843 lines · 31.0 KB · TypeScript
RawDownload
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 } 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 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;}; // ---------------------------------------------------------------------------// 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); const targetPackageName = (t: Target) =>  ["executor", platformName(t), t.arch, t.abi].filter(Boolean).join("-"); 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<BunTargetKey, Bun.Build.CompileTarget>; 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 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-<plat>-<arch>')` 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<string, { pkg: string; node: string }> = {    "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;  }}; // ---------------------------------------------------------------------------// 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<string, string>;",  ].join("\n");}; // ---------------------------------------------------------------------------// Embedded 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.// --------------------------------------------------------------------------- const createEmbeddedMigrationsSource = async () => {  const migrationsDir = resolve(webRoot, "drizzle");  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<string, string>;",  ].join("\n");}; // ---------------------------------------------------------------------------// Build platform binaries// --------------------------------------------------------------------------- const EMBEDDED_WEB_UI_STUB = `const files: Record<string, string> | null = null;\n\nexport default files;\n`;const EMBEDDED_MIGRATIONS_STUB = `const migrations: Record<string, string> | null = null;\n\nexport default migrations;\n`; const buildBinaries = async (targets: Target[], mode: BuildMode) => {  const meta = await readMetadata();  const binaries: Record<string, string> = {};  const embeddedWebUIPath = join(cliRoot, "src/embedded-web-ui.gen.ts");  const embeddedMigrationsPath = join(webRoot, "src/server/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();   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 @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"));      }       // 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()}`);      }       // Platform package.json. No `bin` field on purpose — the wrapper's      // launcher walks node_modules to locate `bin/executor` directly. Adding      // a bin here would race with the wrapper for the global `executor`      // entry under flat installs.      await writeFile(        join(outDir, "package.json"),        JSON.stringify(          {            name,            version: meta.version,            description: `${meta.description} (${name})`,            os: [target.os],            cpu: [target.arch],            homepage: meta.homepage,            bugs: meta.bugs,            repository: meta.repository,            license: meta.license,          },          null,          2,        ) + "\n",      );       binaries[name] = meta.version;    }     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<string, string>) => {  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. npm/bun install only the entry        // matching the current os/cpu; everything else is a no-op.        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/<platform>.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<string, string>) => {  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}`);  }}; const resolveTargetsFromEnv = (env: string | undefined): Target[] => {  if (!env) throw new Error("EXECUTOR_PREVIEW_TARGETS must be set (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) throw new Error(`Unknown preview target: ${name}`);    return match;  });  if (resolved.length === 0) throw new Error("EXECUTOR_PREVIEW_TARGETS resolved to an empty list");  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}`);

Lines 1–500 of 843.