import { cp, mkdir, rm, writeFile, chmod } from "node:fs/promises"; import { existsSync } from "node:fs"; import { createRequire } from "node:module"; import { resolve, join, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; import { $ } from "bun"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const cliRoot = resolve(repoRoot, "apps/cli"); const webRoot = resolve(repoRoot, "apps/local"); const distDir = resolve(cliRoot, "dist"); const ONEPASSWORD_CORE_WASM_FILENAME = "onepassword-core_bg.wasm"; const WORKERD_VERSION = "1.20260708.1"; const WORKER_BUNDLER_DIRNAME = "worker-bundler"; const resolveQuickJsWasmPath = (): string => { const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json")); const quickJsPkg = req.resolve("quickjs-emscripten/package.json"); const wasmPath = resolve( dirname(quickJsPkg), "../@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm", ); if (!existsSync(wasmPath)) throw new Error(`QuickJS WASM not found at ${wasmPath}`); return wasmPath; }; // --------------------------------------------------------------------------- // MCP-Apps shell (mcp-app.html) // // The MCP host serves the shell as the `ui://executor/shell.html` resource, and // `@executor-js/mcp-apps-shell` reads it from disk at runtime. That runtime // `fs.readFile` is invisible to `bun build --compile`, so without this the // packaged binary would fail every artifact resource read. Build it if missing // and copy it next to the executable, where the loader finds it via // `process.execPath` — the same colocation trick used for `libsql.node` / // `emscripten-module.wasm`. // --------------------------------------------------------------------------- const mcpAppsShellDir = resolve(repoRoot, "packages/hosts/mcp-apps-shell"); const mcpAppsShellHtml = join(mcpAppsShellDir, "dist/mcp-app.html"); const ensureMcpAppsShell = async (): Promise => { if (!existsSync(mcpAppsShellHtml)) { console.log("Building MCP-Apps shell (mcp-app.html)..."); await $`bun run --cwd ${mcpAppsShellDir} build:shell`.quiet(); } if (!existsSync(mcpAppsShellHtml)) { throw new Error(`MCP-Apps shell missing at ${mcpAppsShellHtml} after build:shell`); } return mcpAppsShellHtml; }; const resolveOnePasswordCoreWasmPath = (): string => { const req = createRequire(join(repoRoot, "packages/plugins/onepassword/package.json")); const sdkPkg = req.resolve("@1password/sdk/package.json"); const sdkReq = createRequire(sdkPkg); const sdkCorePkg = sdkReq.resolve("@1password/sdk-core/package.json"); const wasmPath = join(dirname(sdkCorePkg), "nodejs/core_bg.wasm"); if (!existsSync(wasmPath)) throw new Error(`1Password SDK core WASM not found at ${wasmPath}`); return wasmPath; }; const resolveWorkerBundlerDistPath = (): string => { const req = createRequire(join(repoRoot, "apps/cli/package.json")); const pkgJson = req.resolve("@cloudflare/worker-bundler/package.json"); const distPath = join(dirname(pkgJson), "dist"); if (!existsSync(join(distPath, "index.js")) || !existsSync(join(distPath, "esbuild.wasm"))) { throw new Error(`@cloudflare/worker-bundler dist files not found at ${distPath}`); } return distPath; }; const createPackedWorkerBundlerSource = async (distPath: string): Promise => { const esbuildPath = join(repoRoot, "node_modules/.bun/node_modules/esbuild/lib/main.js"); if (!existsSync(esbuildPath)) throw new Error(`esbuild not found at ${esbuildPath}`); const { build } = await import(pathToFileURL(esbuildPath).href); const result = await build({ entryPoints: [join(distPath, "index.js")], bundle: true, format: "esm", platform: "browser", external: ["./esbuild.wasm"], logLevel: "silent", write: false, }); const source = result.outputFiles[0]?.text; if (source === undefined) throw new Error("failed to bundle @cloudflare/worker-bundler"); return source; }; // --------------------------------------------------------------------------- // Metadata // --------------------------------------------------------------------------- const readMetadata = async () => { const rootPkg = await Bun.file(join(repoRoot, "package.json")).json(); const cliPkg = await Bun.file(join(cliRoot, "package.json")).json(); return { name: "executor", version: process.env.EXECUTOR_VERSION ?? cliPkg.version ?? rootPkg.version ?? "0.0.0", description: rootPkg.description ?? "Local AI executor with a CLI, local API server, and web UI.", keywords: rootPkg.keywords ?? [], homepage: rootPkg.homepage, bugs: rootPkg.bugs, repository: rootPkg.repository, license: rootPkg.license ?? "MIT", }; }; // --------------------------------------------------------------------------- // Targets // --------------------------------------------------------------------------- type Target = { os: "linux" | "darwin" | "win32"; arch: "x64" | "arm64"; abi?: "musl"; }; const ALL_TARGETS: Target[] = [ { os: "linux", arch: "x64" }, { os: "linux", arch: "arm64" }, { os: "linux", arch: "x64", abi: "musl" }, { os: "linux", arch: "arm64", abi: "musl" }, { os: "darwin", arch: "x64" }, { os: "darwin", arch: "arm64" }, { os: "win32", arch: "x64" }, { os: "win32", arch: "arm64" }, ]; const platformName = (t: Target) => (t.os === "win32" ? "windows" : t.os); /** Per-platform suffix used in dist directory names, npm dist-tags, and the * semver prerelease segment of variant package versions. e.g. "linux-x64", * "linux-x64-musl", "darwin-arm64". */ const platformTag = (t: Target) => [platformName(t), t.arch, t.abi].filter(Boolean).join("-"); /** Dist directory name (e.g. dist/executor-linux-x64). Only used as a build * artifact convention; the actual npm package name inside is `executor`. */ const targetPackageName = (t: Target) => `executor-${platformTag(t)}`; const bunTargetKeys = [ "linux-x64", "linux-arm64", "linux-x64-musl", "linux-arm64-musl", "darwin-x64", "darwin-arm64", "win32-x64", "win32-arm64", ] as const; type BunTargetKey = (typeof bunTargetKeys)[number]; const bunTargets = { "linux-x64": "bun-linux-x64", "linux-arm64": "bun-linux-arm64", "linux-x64-musl": "bun-linux-x64-musl", "linux-arm64-musl": "bun-linux-arm64-musl", "darwin-x64": "bun-darwin-x64", "darwin-arm64": "bun-darwin-arm64", "win32-x64": "bun-windows-x64", "win32-arm64": "bun-windows-arm64", } satisfies Record; const isBunTargetKey = (key: string): key is BunTargetKey => bunTargetKeys.includes(key as BunTargetKey); const bunTarget = (t: Target): Bun.Build.CompileTarget => { const key = [t.os, t.arch, t.abi].filter(Boolean).join("-"); if (!isBunTargetKey(key)) throw new Error(`Unsupported Bun compile target: ${key}`); const target = bunTargets[key]; return target; }; const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor"); const workerdBinaryName = (t: Target) => (t.os === "win32" ? "workerd.exe" : "workerd"); const isCurrentPlatform = (t: Target) => t.os === process.platform && t.arch === process.arch && !t.abi; /** * Resolve the platform-specific @napi-rs/keyring native binding for a target. * * `bun build --compile` doesn't include `.node` modules in bunfs, so the * keyring loader's dynamic `require('@napi-rs/keyring--')` fails * at runtime. We copy the `.node` file next to the executor and main.ts sets * `NAPI_RS_NATIVE_LIBRARY_PATH` so the loader's env-var escape hatch picks it * up instead of trying to walk node_modules. */ const resolveKeyringNative = (t: Target): string | null => { const platformMap: Record = { "darwin-arm64": { pkg: "@napi-rs/keyring-darwin-arm64", node: "keyring.darwin-arm64.node" }, "darwin-x64": { pkg: "@napi-rs/keyring-darwin-x64", node: "keyring.darwin-x64.node" }, "linux-arm64": { pkg: "@napi-rs/keyring-linux-arm64-gnu", node: "keyring.linux-arm64-gnu.node", }, "linux-x64": { pkg: "@napi-rs/keyring-linux-x64-gnu", node: "keyring.linux-x64-gnu.node" }, "linux-arm64-musl": { pkg: "@napi-rs/keyring-linux-arm64-musl", node: "keyring.linux-arm64-musl.node", }, "linux-x64-musl": { pkg: "@napi-rs/keyring-linux-x64-musl", node: "keyring.linux-x64-musl.node", }, "win32-arm64": { pkg: "@napi-rs/keyring-win32-arm64-msvc", node: "keyring.win32-arm64-msvc.node", }, "win32-x64": { pkg: "@napi-rs/keyring-win32-x64-msvc", node: "keyring.win32-x64-msvc.node" }, }; const key = [t.os, t.arch, t.abi].filter(Boolean).join("-"); const entry = platformMap[key]; if (!entry) return null; try { const req = createRequire(join(repoRoot, "node_modules", "@napi-rs/keyring", "package.json")); const pkgJson = req.resolve(`${entry.pkg}/package.json`); return join(dirname(pkgJson), entry.node); } catch { const bunPath = join( repoRoot, `node_modules/.bun/${entry.pkg.replace("/", "+")}@1.2.0/node_modules/${entry.pkg}/${entry.node}`, ); if (existsSync(bunPath)) return bunPath; return null; } }; /** * Resolve the platform-specific `@libsql/` native binding for a target. * * The local server's SQLite driver (libSQL) loads its `.node` via a dynamic * `require('@libsql/')`, which `bun build --compile` can't bundle into * bunfs (same limitation as keyring). We copy the right `.node` next to the * executor as `libsql.node`; main.ts redirects the bare require to it. */ const LIBSQL_NATIVE_VERSION = "0.5.29"; const resolveLibsqlNative = (t: Target): string | null => { const platformMap: Record = { "darwin-arm64": "darwin-arm64", "darwin-x64": "darwin-x64", // The compiled binary runs on Bun, which libSQL's loader treats as glibc // (its musl->gnu workaround), so non-musl linux targets need the -gnu binding. "linux-arm64": "linux-arm64-gnu", "linux-x64": "linux-x64-gnu", "linux-arm64-musl": "linux-arm64-musl", "linux-x64-musl": "linux-x64-musl", "win32-arm64": "win32-arm64-msvc", "win32-x64": "win32-x64-msvc", }; const key = [t.os, t.arch, t.abi].filter(Boolean).join("-"); const target = platformMap[key]; if (!target) return null; const pkg = `@libsql/${target}`; try { const req = createRequire(join(repoRoot, "apps/local", "package.json")); const pkgJson = req.resolve(`${pkg}/package.json`); return join(dirname(pkgJson), "index.node"); } catch { const bunPath = join( repoRoot, `node_modules/.bun/${pkg.replace("/", "+")}@${LIBSQL_NATIVE_VERSION}/node_modules/${pkg}/index.node`, ); return existsSync(bunPath) ? bunPath : null; } }; const resolveWorkerdBinary = (t: Target): string | null => { const platformMap: Record = { "darwin-arm64": "@cloudflare/workerd-darwin-arm64", "darwin-x64": "@cloudflare/workerd-darwin-64", "linux-arm64": "@cloudflare/workerd-linux-arm64", "linux-x64": "@cloudflare/workerd-linux-64", "win32-x64": "@cloudflare/workerd-windows-64", }; const key = [t.os, t.arch, t.abi].filter(Boolean).join("-"); const pkg = platformMap[key]; if (!pkg) return null; const binary = workerdBinaryName(t); try { const req = createRequire( join(repoRoot, "packages/kernel/runtime-workerd-subprocess/package.json"), ); const pkgJson = req.resolve(`${pkg}/package.json`); for (const candidate of [ join(dirname(pkgJson), "bin", binary), join(dirname(pkgJson), binary), ]) { if (existsSync(candidate)) return candidate; } } catch { const packageRoot = join( repoRoot, `node_modules/.bun/${pkg.replace("/", "+")}@${WORKERD_VERSION}/node_modules/${pkg}`, ); for (const candidate of [join(packageRoot, "bin", binary), join(packageRoot, binary)]) { if (existsSync(candidate)) return candidate; } } return null; }; // --------------------------------------------------------------------------- // Build mode // --------------------------------------------------------------------------- type BuildMode = "production" | "development"; // --------------------------------------------------------------------------- // Build web app // --------------------------------------------------------------------------- const buildWeb = async (mode: BuildMode) => { const webDist = join(webRoot, "dist"); await rm(webDist, { recursive: true, force: true }); console.log(`Building web app (${mode})...`); const proc = Bun.spawn(["bun", "run", "build", "--mode", mode], { cwd: webRoot, stdio: ["ignore", "inherit", "inherit"], env: { ...process.env, NODE_ENV: mode }, }); if ((await proc.exited) !== 0) throw new Error("Web build failed"); return webDist; }; // --------------------------------------------------------------------------- // Embedded web UI — generates a virtual module that imports all web assets // using `with { type: "file" }` so Bun bakes them into the compiled binary. // --------------------------------------------------------------------------- const createEmbeddedWebUISource = async (mode: BuildMode) => { const webDist = await buildWeb(mode); const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: webDist }))) .map((f) => f.replaceAll("\\", "/")) .sort(); const imports = files.map((file, i) => { const spec = join(webDist, file).replaceAll("\\", "/"); return `import file_${i} from ${JSON.stringify(spec)} with { type: "file" };`; }); const entries = files.map((file, i) => ` ${JSON.stringify(file)}: file_${i},`); return [ "// Auto-generated — maps web UI paths to embedded file references", ...imports, "export default {", ...entries, "} as Record;", ].join("\n"); }; // --------------------------------------------------------------------------- // Embedded legacy v1 drizzle migrations — inlined as text imports so drizzle's // `migrate()` (which reads a folder from disk) can be given a tmpdir // populated from the inlined contents at runtime. The v1→v2 data migration // replays this chain to bring an older v1 database up to v1-final before // reading it (v2's own schema is created from FumaDB DDL, not this folder). // --------------------------------------------------------------------------- const createEmbeddedMigrationsSource = async () => { const migrationsDir = resolve(webRoot, "drizzle-legacy-v1"); const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: migrationsDir }))) .map((f) => f.replaceAll("\\", "/")) .sort(); const imports = files.map((file, i) => { const spec = join(migrationsDir, file).replaceAll("\\", "/"); return `import file_${i} from ${JSON.stringify(spec)} with { type: "text" };`; }); const entries = files.map((file, i) => ` ${JSON.stringify(file)}: file_${i},`); return [ "// Auto-generated — maps migration paths to inlined file contents", ...imports, "export default {", ...entries, "} as Record;", ].join("\n"); }; // --------------------------------------------------------------------------- // Build platform binaries // --------------------------------------------------------------------------- const EMBEDDED_WEB_UI_STUB = `const files: Record | null = null;\n\nexport default files;\n`; const EMBEDDED_MIGRATIONS_STUB = `const migrations: Record | null = null;\n\nexport default migrations;\n`; const buildBinaries = async (targets: Target[], mode: BuildMode) => { const meta = await readMetadata(); const binaries: Record = {}; const embeddedWebUIPath = join(cliRoot, "src/embedded-web-ui.gen.ts"); const embeddedMigrationsPath = join(webRoot, "src/db/embedded-migrations.gen.ts"); await rm(distDir, { recursive: true, force: true }); // Cross-platform builds need every target's optional native packages // (e.g. @napi-rs/keyring-darwin-arm64) so we can copy the right .node next // to each target's executor. `bun install --frozen-lockfile --cpu=* --os=*` // extracts them all without modifying the lockfile. const needsCrossPlatform = targets.some((t) => !isCurrentPlatform(t)); if (needsCrossPlatform) { console.log("Installing optional native deps for all platforms..."); const proc = Bun.spawn(["bun", "install", "--frozen-lockfile", "--cpu=*", "--os=*"], { cwd: repoRoot, stdio: ["ignore", "inherit", "inherit"], }); if ((await proc.exited) !== 0) { throw new Error("bun install --cpu=* --os=* failed"); } } console.log(`Generating embedded web UI bundle (${mode})...`); const embeddedWebUI = await createEmbeddedWebUISource(mode); await writeFile(embeddedWebUIPath, `${embeddedWebUI}\n`); console.log("Generating embedded drizzle migrations..."); const embeddedMigrations = await createEmbeddedMigrationsSource(); await writeFile(embeddedMigrationsPath, `${embeddedMigrations}\n`); const quickJsWasmPath = resolveQuickJsWasmPath(); const mcpAppsShellPath = await ensureMcpAppsShell(); const onePasswordCoreWasmPath = resolveOnePasswordCoreWasmPath(); const workerBundlerDistPath = resolveWorkerBundlerDistPath(); const packedWorkerBundlerSource = await createPackedWorkerBundlerSource(workerBundlerDistPath); try { for (const target of targets) { const name = targetPackageName(target); const outDir = join(distDir, name); const binDir = join(outDir, "bin"); await mkdir(binDir, { recursive: true }); console.log(`Building ${name}...`); await Bun.build({ entrypoints: [join(cliRoot, "src/main.ts")], minify: mode === "production", compile: { target: bunTarget(target), outfile: join(binDir, binaryName(target)), }, }); // Copy QuickJS WASM next to binary — loaded at runtime by the server await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm")); // Copy the MCP-Apps shell next to the binary. Platform-independent HTML, // read from execDir at runtime by @executor-js/mcp-apps-shell. await cp(mcpAppsShellPath, join(binDir, "mcp-app.html")); // Copy 1Password's sdk-core WASM next to the binary. The patched // sdk-core loader falls back to this sibling file when bun --compile // bakes a build-machine node_modules path into __dirname. await cp(onePasswordCoreWasmPath, join(binDir, ONEPASSWORD_CORE_WASM_FILENAME)); // Copy @napi-rs/keyring native binding next to executor — bun --compile // doesn't bundle .node files, so the loader needs to find it on disk // via NAPI_RS_NATIVE_LIBRARY_PATH (set in main.ts). const keyringNative = resolveKeyringNative(target); if (keyringNative && existsSync(keyringNative)) { await cp(keyringNative, join(binDir, "keyring.node")); } // Copy the libSQL native binding next to executor — same bunfs limitation // as keyring; main.ts redirects `require('@libsql/')` to it. const libsqlNative = resolveLibsqlNative(target); if (libsqlNative && existsSync(libsqlNative)) { await cp(libsqlNative, join(binDir, "libsql.node")); } const workerdBinary = resolveWorkerdBinary(target); if (workerdBinary && existsSync(workerdBinary)) { await cp(workerdBinary, join(binDir, workerdBinaryName(target))); if (target.os !== "win32") await chmod(join(binDir, workerdBinaryName(target)), 0o755); } await cp(workerBundlerDistPath, join(binDir, WORKER_BUNDLER_DIRNAME, "dist"), { recursive: true, }); await writeFile( join(binDir, WORKER_BUNDLER_DIRNAME, "dist", "index.bundled.js"), packedWorkerBundlerSource, ); // Smoke test on current platform if (isCurrentPlatform(target)) { const bin = join(binDir, binaryName(target)); console.log(` Smoke test: ${bin} --version`); const version = await $`${bin} --version`.text(); console.log(` OK: ${version.trim()}`); } // Variant package.json. All variants publish to the SAME npm package // name (`executor`) under platform-tagged versions (e.g. // `1.4.14-linux-x64`). The wrapper references each variant via an // `npm:` alias in its optionalDependencies. This mirrors codex's // pattern and avoids ever having to claim a new npm package name when // a new platform is added — a single trusted-publishing config on the // `executor` package covers everything. // // No `bin` field on purpose — the wrapper's launcher resolves the // platform binary via require.resolve on the alias name and execs it. const tag = platformTag(target); const variantVersion = `${meta.version}-${tag}`; await writeFile( join(outDir, "package.json"), JSON.stringify( { name: meta.name, version: variantVersion, description: `${meta.description} (${tag})`, os: [target.os], cpu: [target.arch], homepage: meta.homepage, bugs: meta.bugs, repository: meta.repository, license: meta.license, }, null, 2, ) + "\n", ); // The local alias name (`executor-linux-x64`, ...) is what the // wrapper's launcher passes to require.resolve at runtime. It's // bound at install time by npm's `npm:executor@` // alias spec in optionalDependencies. const aliasName = `${meta.name}-${tag}`; binaries[aliasName] = `npm:${meta.name}@${variantVersion}`; } return binaries; } finally { await writeFile(embeddedWebUIPath, EMBEDDED_WEB_UI_STUB); await writeFile(embeddedMigrationsPath, EMBEDDED_MIGRATIONS_STUB); } }; // --------------------------------------------------------------------------- // Build wrapper npm package // --------------------------------------------------------------------------- const buildWrapperPackage = async (binaries: Record) => { const meta = await readMetadata(); const wrapperDir = join(distDir, meta.name); const binDir = join(wrapperDir, "bin"); await mkdir(binDir, { recursive: true }); // Node.js launcher — resolves the platform binary via require.resolve // against optionalDependencies and execs it. No postinstall: the binary // ships as an os/cpu-filtered optional dep, the launcher resolves it at // runtime. This works whether or not the package manager runs postinstalls // (bun blocks them by default). await writeFile(join(binDir, "executor"), NODE_SHIM); await chmod(join(binDir, "executor"), 0o755); await writeFile( join(wrapperDir, "package.json"), JSON.stringify( { name: meta.name, version: meta.version, description: meta.description, keywords: meta.keywords, homepage: meta.homepage, bugs: meta.bugs, repository: meta.repository, license: meta.license, bin: { executor: "bin/executor" }, // Per-platform compiled binaries published as platform-tagged // versions of `executor` itself, referenced via npm:alias specs: // "executor-linux-x64": "npm:executor@1.4.14-linux-x64" // npm/bun fetch only the variant matching the current os/cpu (set // on each variant's package.json); everything else is a no-op. // The local alias name on the left is what the launcher passes to // require.resolve at runtime — npm puts the variant at // `node_modules/executor--/` so resolution just works. optionalDependencies: binaries, engines: { node: ">=20", }, }, null, 2, ) + "\n", ); const readmePath = join(repoRoot, "README.md"); if (existsSync(readmePath)) { await cp(readmePath, join(wrapperDir, "README.md")); } console.log(`\nWrapper package: ${wrapperDir}`); console.log(` ${meta.name}@${meta.version}`); console.log(` optionalDependencies: ${Object.keys(binaries).join(", ")}`); }; // --------------------------------------------------------------------------- // Preview wrapper — a slim npm package for pkg.pr.new previews that fetches // the platform binary from our R2 bucket at install time. // // The release wrapper points postinstall at GitHub Releases. For previews // there's no release, so we upload per-PR tarballs to R2 (keyed by commit // SHA) and have a dedicated postinstall download from there. // // CI splits this in two: // 1. A matrix job per platform runs `buildPreviewTarballs` to produce // dist/previews/.tar.gz, which it uploads to R2. // 2. A single publish job runs `buildPreviewWrapperPackage` with an // explicit list of platforms and hands the wrapper to pkg.pr.new. // // Both paths require EXECUTOR_PREVIEW_CDN_URL and EXECUTOR_PREVIEW_SHA so // the postinstall URL embeds the right commit. // --------------------------------------------------------------------------- const buildPreviewTarballs = async (binaries: Record) => { const previewDir = join(distDir, "previews"); await mkdir(previewDir, { recursive: true }); for (const platformPkg of Object.keys(binaries)) { const srcBinDir = join(distDir, platformPkg, "bin"); const tarPath = join(previewDir, `${platformPkg}.tar.gz`); await $`tar -czf ${tarPath} .`.cwd(srcBinDir).quiet(); console.log(`Preview tarball: ${tarPath}`); } }; // Resolve a comma-separated list of target package names (e.g. // "executor-windows-x64") to Targets. Shared by `--target` and the // EXECUTOR_PREVIEW_TARGETS env used by the preview-wrapper CI job. const resolveTargetsFromEnv = (env: string | undefined): Target[] => { if (!env) throw new Error("No build targets given (comma-separated package names)"); const names = env .split(",") .map((s) => s.trim()) .filter(Boolean); const resolved = names.map((name) => { const match = ALL_TARGETS.find((t) => targetPackageName(t) === name); if (!match) { const valid = ALL_TARGETS.map(targetPackageName).join(", "); throw new Error(`Unknown build target: ${name}. Expected one of: ${valid}`); } return match; }); if (resolved.length === 0) throw new Error("Build target list resolved to empty"); return resolved; }; const buildPreviewWrapperPackage = async (targets: Target[]) => { const meta = await readMetadata(); const cdnUrl = process.env.EXECUTOR_PREVIEW_CDN_URL?.replace(/\/+$/, ""); const sha = process.env.EXECUTOR_PREVIEW_SHA; if (!cdnUrl || !sha) { throw new Error( "preview build requires EXECUTOR_PREVIEW_CDN_URL and EXECUTOR_PREVIEW_SHA to be set", ); } const wrapperDir = join(distDir, meta.name); const binDir = join(wrapperDir, "bin"); await mkdir(binDir, { recursive: true }); await writeFile(join(binDir, "executor"), NODE_SHIM); await chmod(join(binDir, "executor"), 0o755); const postinstall = PREVIEW_POSTINSTALL_SCRIPT.replaceAll("__CDN_BASE_URL__", `${cdnUrl}/${sha}`); await writeFile(join(wrapperDir, "postinstall.cjs"), postinstall); // Restrict os/cpu to platforms we actually built this run so npm refuses // the install on anything else — a clear error beats a cryptic 404 from // postinstall on an unbuilt platform. const osList = Array.from(new Set(targets.map((t) => t.os))); const cpuList = Array.from(new Set(targets.map((t) => t.arch))); await writeFile( join(wrapperDir, "package.json"), JSON.stringify( { name: meta.name, version: meta.version, description: meta.description, keywords: meta.keywords, homepage: meta.homepage, bugs: meta.bugs, repository: meta.repository, license: meta.license, bin: { executor: "bin/executor" }, files: ["bin", "postinstall.cjs", "README.md"], scripts: { postinstall: "node ./postinstall.cjs" }, os: osList, cpu: cpuList, engines: { node: ">=20" }, }, null, 2, ) + "\n", ); const readmePath = join(repoRoot, "README.md"); if (existsSync(readmePath)) { await cp(readmePath, join(wrapperDir, "README.md")); } console.log(`\nPreview wrapper: ${wrapperDir}`); console.log(` ${meta.name}@${meta.version} — CDN ${cdnUrl}/${sha}`); console.log(` targets: ${targets.map(targetPackageName).join(", ")}`); }; // --------------------------------------------------------------------------- // Publish // --------------------------------------------------------------------------- const packageAlreadyPublished = async (pkgDir: string) => { const pkg = (await Bun.file(join(pkgDir, "package.json")).json()) as { name?: string; version?: string; }; if (!pkg.name || !pkg.version) { throw new Error(`Missing name/version in ${join(pkgDir, "package.json")}`); } const proc = Bun.spawn(["npm", "view", `${pkg.name}@${pkg.version}`, "version"], { cwd: pkgDir, stdio: ["ignore", "ignore", "ignore"], }); return (await proc.exited) === 0; }; /** Recognize npm error output for "this version already exists." Used to * make publish retries idempotent — if a previous workflow run got partway * through and crashed, we should skip what's already on the registry and * push the rest. Mirrors codex's `rust-release.yml` skip pattern. */ const ALREADY_PUBLISHED_RE = /previously published|cannot publish over|version already exists/i; const publishPackedPackage = async (pkgDir: string, channel: string) => { if (await packageAlreadyPublished(pkgDir)) { console.log(`Skipping ${pkgDir}; package version already exists on npm.`); return; } await $`bun pm pack`.cwd(pkgDir); const provenance = process.env.GITHUB_ACTIONS === "true" ? ["--provenance"] : []; const result = await $`npm publish *.tgz --access public --tag ${channel} ${provenance}` .cwd(pkgDir) .nothrow() .quiet(); const stdout = result.stdout.toString(); const stderr = result.stderr.toString(); process.stdout.write(stdout); process.stderr.write(stderr); if (result.exitCode === 0) return; // The pre-check via `npm view` has a propagation race — even when an // earlier publish in this same run committed, `npm view` may 404 for a few // seconds. Treat "already published" errors as success on retry. if (ALREADY_PUBLISHED_RE.test(stderr) || ALREADY_PUBLISHED_RE.test(stdout)) { console.log(`Skipping ${pkgDir}; npm reported version already published.`); return; } throw new Error(`npm publish failed for ${pkgDir} (exit ${result.exitCode})`); }; /** Extract the platform-tag suffix from a variant version string. * e.g. "1.4.14-linux-x64" -> "linux-x64". * Variants get a per-platform npm dist-tag (not `latest` or `beta`) so * publishing them doesn't move the channel pointer. The wrapper alone * drives `latest`/`beta`. */ const variantTagFromVersion = (version: string): string => { const idx = version.indexOf("-"); if (idx === -1) { throw new Error( `Variant version missing platform-tag suffix (expected '-'): ${version}`, ); } return version.slice(idx + 1); }; const publish = async (channel: string) => { const meta = await readMetadata(); // Variants publish first so the wrapper's optionalDependencies resolve. // All variants and the wrapper publish to the same npm package // (`executor`) — variants under platform-tagged versions, wrapper under // the channel tag. Single trusted-publishing config covers everything. const platformDirs: string[] = []; for (const entry of new Bun.Glob("executor-*/package.json").scanSync({ cwd: distDir })) { platformDirs.push(join(distDir, dirname(entry))); } platformDirs.sort(); // Publish variants sequentially. They all PUT to the same npm package // (`executor`), and the registry rejects concurrent packument updates with // 409 Conflict ("Failed to save packument. A common cause is if you try to // publish a new package before the previous package has been fully // processed.") — that's exactly what happened on v1.4.14's first attempt, // where 3 of 8 raced through and the rest 409'd. Serial is plenty fast // (~5s per variant × 8 ≈ 40s). console.log(`Publishing ${platformDirs.length} platform variant(s)...`); for (const dir of platformDirs) { const pkg = (await Bun.file(join(dir, "package.json")).json()) as { version: string }; const tag = variantTagFromVersion(pkg.version); await publishPackedPackage(dir, tag); } const wrapperDir = join(distDir, meta.name); console.log(`Publishing wrapper ${wrapperDir} under @${channel}...`); await publishPackedPackage(wrapperDir, channel); }; // --------------------------------------------------------------------------- // GitHub release assets // --------------------------------------------------------------------------- const ZIP_ASSET_SCRIPT = [ "import pathlib, sys, zipfile", "output = pathlib.Path(sys.argv[1])", "with zipfile.ZipFile(output, 'w', compression=zipfile.ZIP_DEFLATED) as archive:", " for path in pathlib.Path('.').rglob('*'):", " if path.is_file():", " archive.write(path, path.as_posix())", ].join("\n"); const createReleaseAssets = async () => { // The dir name (e.g. "executor-linux-x64") still encodes the platform tag. // Don't read `pkg.name` here — under the npm:alias pattern every variant's // package.json has the same `name: "executor"`, so all assets would collide // on the same filename. for (const entry of new Bun.Glob("executor-*/package.json").scanSync({ cwd: distDir })) { const dirName = dirname(entry); const pkgDir = join(distDir, dirName); if (dirName.includes("linux")) { await $`tar -czf ${join(distDir, `${dirName}.tar.gz`)} *`.cwd(join(pkgDir, "bin")); } else { await $`python3 -c ${ZIP_ASSET_SCRIPT} ${join(distDir, `${dirName}.zip`)}`.cwd( join(pkgDir, "bin"), ); } console.log(`Created release asset: ${dirName}`); } }; // --------------------------------------------------------------------------- // Node.js launcher — resolves the platform binary shipped as an // optionalDependency and execs it. Resolution order: // 1. EXECUTOR_BIN_PATH override // 2. require.resolve("executor--/package.json") // 3. bin/runtime/ (preview wrapper compat — pkg.pr.new previews // download the binary here at install time instead of via // optionalDependencies) // // Signals (SIGINT/SIGTERM/SIGHUP) are forwarded to the child so long-running // commands like `executor web` shut down cleanly under Ctrl-C. // --------------------------------------------------------------------------- const NODE_SHIM = `#!/usr/bin/env node const childProcess = require("child_process"); const fs = require("fs"); const path = require("path"); const os = require("os"); function spawnAndExit(target) { const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" }); child.on("error", (err) => { console.error(err.message); process.exit(1); }); const forward = (signal) => { if (!child.killed) { try { child.kill(signal); } catch {} } }; ["SIGINT", "SIGTERM", "SIGHUP"].forEach((sig) => process.on(sig, () => forward(sig))); child.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exit(typeof code === "number" ? code : 0); }); } if (process.env.EXECUTOR_BIN_PATH) { spawnAndExit(process.env.EXECUTOR_BIN_PATH); return; } const isWin = process.platform === "win32"; const binary = isWin ? "executor.exe" : "executor"; // Package names use "windows" (not "win32") to match what the build script emits. const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" }; const archMap = { x64: "x64", arm64: "arm64" }; const platform = platformMap[os.platform()] || os.platform(); const arch = archMap[os.arch()] || os.arch(); const isMusl = (() => { if (platform !== "linux") return false; try { if (fs.existsSync("/etc/alpine-release")) return true; } catch {} try { const r = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }); if (((r.stdout || "") + (r.stderr || "")).toLowerCase().includes("musl")) return true; } catch {} return false; })(); // Candidate package names in preference order. On linux a system might match // glibc or musl; try musl first if detected, glibc first otherwise. const candidates = (() => { const base = "executor-" + platform + "-" + arch; if (platform === "linux") { return isMusl ? [base + "-musl", base] : [base, base + "-musl"]; } return [base]; })(); function detectPackageManager() { const ua = process.env.npm_config_user_agent || ""; if (/\\bbun\\//.test(ua)) return "bun"; const execPath = process.env.npm_execpath || ""; if (execPath.includes("bun")) return "bun"; if (__dirname.includes(".bun/install/global") || __dirname.includes(".bun\\\\install\\\\global")) { return "bun"; } return ua ? "npm" : null; } for (const name of candidates) { try { const pkgJson = require.resolve(name + "/package.json"); const candidate = path.join(path.dirname(pkgJson), "bin", binary); if (fs.existsSync(candidate)) { spawnAndExit(candidate); return; } } catch { // package not installed for this platform; try next candidate } } // Preview wrapper compat: pkg.pr.new previews download the platform binary // to bin/runtime/ rather than via optionalDependencies. const scriptDir = path.dirname(fs.realpathSync(__filename)); const previewBinary = path.join(scriptDir, "runtime", binary); if (fs.existsSync(previewBinary)) { spawnAndExit(previewBinary); return; } const pm = detectPackageManager(); const reinstall = pm === "bun" ? "bun install -g executor" : pm === "npm" ? "npm install -g executor" : "reinstall executor"; console.error( "executor: could not locate a platform binary for " + os.platform() + "-" + os.arch() + ".\\n" + "Tried optionalDependencies: " + candidates.map((n) => '"' + n + '"').join(", ") + "\\n" + "To fix: " + reinstall ); process.exit(1); `; // --------------------------------------------------------------------------- // Preview postinstall — download per-PR tarballs from our R2 CDN instead of // GitHub Releases. The __CDN_BASE_URL__ placeholder is replaced at build time // with `${cdnBase}/${sha}` so each preview fetches its own commit's binary. // --------------------------------------------------------------------------- const PREVIEW_POSTINSTALL_SCRIPT = `#!/usr/bin/env node const childProcess = require("child_process"); const fs = require("fs"); const path = require("path"); const os = require("os"); const CDN_BASE = "__CDN_BASE_URL__"; const packageDir = path.dirname(fs.realpathSync(__filename)); const binDir = path.join(packageDir, "bin"); const runtimeDir = path.join(binDir, "runtime"); const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" }; const platform = platformMap[os.platform()] || os.platform(); const arch = os.arch() === "arm64" ? "arm64" : "x64"; const binary = platform === "windows" ? "executor.exe" : "executor"; const cachedBinary = path.join(runtimeDir, binary); const isMusl = (() => { if (platform !== "linux") return false; try { if (fs.existsSync("/etc/alpine-release")) return true; } catch {} try { const r = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }); if (((r.stdout || "") + (r.stderr || "")).toLowerCase().includes("musl")) return true; } catch {} return false; })(); const assetBase = (() => { const base = "executor-" + platform + "-" + arch; return platform === "linux" && isMusl ? base + "-musl" : base; })(); const archiveName = assetBase + ".tar.gz"; const downloadUrl = CDN_BASE + "/" + archiveName; const archivePath = path.join(packageDir, archiveName); const run = (command, args) => { const result = childProcess.spawnSync(command, args, { stdio: "inherit" }); if (result.error) throw result.error; if (typeof result.status === "number" && result.status !== 0) { throw new Error(command + " exited with code " + result.status); } }; const download = async () => { const response = await fetch(downloadUrl, { redirect: "follow" }); if (!response.ok) { throw new Error( "Failed to download " + downloadUrl + " (status " + response.status + "). " + "This preview build may not cover your platform (" + assetBase + ")." ); } const arrayBuffer = await response.arrayBuffer(); fs.writeFileSync(archivePath, Buffer.from(arrayBuffer)); }; (async () => { try { await download(); fs.mkdirSync(binDir, { recursive: true }); fs.rmSync(runtimeDir, { recursive: true, force: true }); fs.mkdirSync(runtimeDir, { recursive: true }); run("tar", ["-xzf", archivePath, "-C", runtimeDir]); if (!fs.existsSync(cachedBinary)) { throw new Error("Expected extracted binary at " + cachedBinary); } if (platform !== "windows") fs.chmodSync(cachedBinary, 0o755); fs.rmSync(archivePath, { force: true }); console.log("executor: installed preview " + assetBase + " from " + CDN_BASE); } catch (error) { console.error("executor preview postinstall failed:", error && error.message ? error.message : error); process.exit(1); } })(); `; // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- const { values, positionals } = parseArgs({ args: process.argv.slice(2), options: { single: { type: "boolean", default: false }, // Build a specific target (or comma-separated set) by package name, e.g. // `--target executor-windows-x64`. Used by the e2e VM harness to compile // the guest's binary; without it `binary` builds the current platform // (`--single`) or all targets. target: { type: "string" }, mode: { type: "string", default: "production" }, }, allowPositionals: true, strict: true, }); const command = positionals[0]; const mode = values.mode as BuildMode; if (mode !== "production" && mode !== "development") { throw new Error(`Invalid --mode: ${mode}. Must be "production" or "development".`); } if (command === "binary") { const targets = values.target ? resolveTargetsFromEnv(values.target) : values.single ? ALL_TARGETS.filter(isCurrentPlatform) : ALL_TARGETS; const binaries = await buildBinaries(targets, mode); await buildWrapperPackage(binaries); } else if (command === "preview") { // End-to-end preview build for local testing: binary for the current // platform + tarball + wrapper, all in one step. const targets = ALL_TARGETS.filter(isCurrentPlatform); const binaries = await buildBinaries(targets, mode); await buildPreviewTarballs(binaries); await buildPreviewWrapperPackage(targets); } else if (command === "preview-tarball") { // CI matrix job: build the current runner's binary + tarball only. // The wrapper is built separately once all matrix entries have uploaded. const targets = ALL_TARGETS.filter(isCurrentPlatform); const binaries = await buildBinaries(targets, mode); await buildPreviewTarballs(binaries); } else if (command === "preview-wrapper") { // CI publish job: build just the npm wrapper, with os/cpu restricted to // the platforms listed in EXECUTOR_PREVIEW_TARGETS (comma-separated). const targets = resolveTargetsFromEnv(process.env.EXECUTOR_PREVIEW_TARGETS); await buildPreviewWrapperPackage(targets); } else if (command === "release-assets") { await createReleaseAssets(); } else if (command === "publish") { const channel = positionals[1] ?? "latest"; await publish(channel); } else { console.log(`Usage: bun run build.ts binary [--single] [--mode production|development] bun run build.ts preview [--mode production|development] bun run build.ts preview-tarball [--mode production|development] bun run build.ts preview-wrapper bun run build.ts release-assets bun run build.ts publish [channel]`); process.exit(1); }