feat(HAI-076): add cross-compilation support for CLI executable builds
- Extend build.ts with cross-compilation targets for multiple platforms/architectures - Add build:exe:all npm script to package.json for building all targets - Add cross-compilation tests for build-exe-cross - Add cross-compilation documentation section to README - Clean up unused agent log, store, and route code across dashboard/core/engine packages
This commit is contained in:
@@ -6,83 +6,186 @@
|
||||
* with the dashboard client assets co-located at packages/cli/dist/client/.
|
||||
*
|
||||
* Usage:
|
||||
* bun run build.ts
|
||||
* bun run build.ts # Build for current platform
|
||||
* bun run build.ts --target bun-linux-x64 # Cross-compile for Linux x64
|
||||
* bun run build.ts --all # Build for all supported platforms
|
||||
*
|
||||
* Prerequisites:
|
||||
* - `pnpm build` must have been run first (dashboard client + tsc)
|
||||
* - Bun >= 1.0
|
||||
* - Bun >= 1.1 (cross-compilation support)
|
||||
*/
|
||||
|
||||
import { join, dirname } from "node:path";
|
||||
import { cpSync, mkdirSync, existsSync, rmSync } from "node:fs";
|
||||
import { cpSync, mkdirSync, existsSync, rmSync, writeFileSync } from "node:fs";
|
||||
|
||||
const cliRoot = dirname(new URL(import.meta.url).pathname);
|
||||
const workspaceRoot = join(cliRoot, "..", "..");
|
||||
const outDir = join(cliRoot, "dist");
|
||||
const outBinary = join(outDir, process.platform === "win32" ? "hai.exe" : "hai");
|
||||
const dashboardClientSrc = join(workspaceRoot, "packages", "dashboard", "dist", "client");
|
||||
const dashboardClientDest = join(outDir, "client");
|
||||
const entryPoint = join(cliRoot, "src", "bin.ts");
|
||||
|
||||
// ── Supported cross-compilation targets ───────────────────────────────
|
||||
const SUPPORTED_TARGETS = [
|
||||
"bun-linux-x64",
|
||||
"bun-linux-arm64",
|
||||
"bun-darwin-x64",
|
||||
"bun-darwin-arm64",
|
||||
"bun-windows-x64",
|
||||
] as const;
|
||||
|
||||
type BunTarget = (typeof SUPPORTED_TARGETS)[number];
|
||||
|
||||
/**
|
||||
* Map a Bun target identifier to the output binary name.
|
||||
* e.g. "bun-linux-x64" → "hai-linux-x64", "bun-windows-x64" → "hai-windows-x64.exe"
|
||||
*/
|
||||
function binaryNameForTarget(target: BunTarget): string {
|
||||
// "bun-linux-x64" → "linux-x64"
|
||||
const suffix = target.replace(/^bun-/, "");
|
||||
const isWindows = target.includes("windows");
|
||||
return `hai-${suffix}${isWindows ? ".exe" : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the default binary name for the current platform (no cross-compile).
|
||||
*/
|
||||
function defaultBinaryName(): string {
|
||||
return process.platform === "win32" ? "hai.exe" : "hai";
|
||||
}
|
||||
|
||||
// ── Parse CLI arguments ───────────────────────────────────────────────
|
||||
function parseArgs(): { targets: BunTarget[] | null } {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes("--all")) {
|
||||
return { targets: [...SUPPORTED_TARGETS] };
|
||||
}
|
||||
|
||||
const targetIdx = args.indexOf("--target");
|
||||
if (targetIdx !== -1) {
|
||||
const target = args[targetIdx + 1];
|
||||
if (!target) {
|
||||
console.error("ERROR: --target requires a value. Supported targets:");
|
||||
SUPPORTED_TARGETS.forEach((t) => console.error(` ${t}`));
|
||||
process.exit(1);
|
||||
}
|
||||
if (!SUPPORTED_TARGETS.includes(target as BunTarget)) {
|
||||
console.error(`ERROR: Unsupported target '${target}'. Supported targets:`);
|
||||
SUPPORTED_TARGETS.forEach((t) => console.error(` ${t}`));
|
||||
process.exit(1);
|
||||
}
|
||||
return { targets: [target as BunTarget] };
|
||||
}
|
||||
|
||||
// Default: no cross-compilation (current platform)
|
||||
return { targets: null };
|
||||
}
|
||||
|
||||
// ── Validate prerequisites ────────────────────────────────────────────
|
||||
if (!existsSync(dashboardClientSrc)) {
|
||||
console.error(
|
||||
`ERROR: Dashboard client not built. Expected: ${dashboardClientSrc}\n` +
|
||||
`Run 'pnpm build' first to build all packages.`,
|
||||
`Run 'pnpm build' first to build all packages.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Clean previous output ─────────────────────────────────────────────
|
||||
if (existsSync(outBinary)) rmSync(outBinary);
|
||||
if (existsSync(dashboardClientDest)) rmSync(dashboardClientDest, { recursive: true });
|
||||
|
||||
// ── Copy dashboard client assets alongside output ─────────────────────
|
||||
// Express.static requires a real filesystem directory, so we co-locate
|
||||
// the pre-built SPA next to the binary rather than embedding blobs.
|
||||
console.log("Copying dashboard client assets...");
|
||||
mkdirSync(dashboardClientDest, { recursive: true });
|
||||
cpSync(dashboardClientSrc, dashboardClientDest, { recursive: true });
|
||||
console.log(` → ${dashboardClientDest}`);
|
||||
|
||||
// ── Compile the CLI binary ────────────────────────────────────────────
|
||||
console.log("Compiling hai executable...");
|
||||
|
||||
const entryPoint = join(cliRoot, "src", "bin.ts");
|
||||
|
||||
const proc = Bun.spawnSync({
|
||||
cmd: [
|
||||
"bun", "build",
|
||||
"--compile",
|
||||
entryPoint,
|
||||
"--outfile", outBinary,
|
||||
"--target", "bun",
|
||||
// Minify for smaller binary
|
||||
"--minify",
|
||||
],
|
||||
cwd: workspaceRoot,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
// Ensure workspace resolution works
|
||||
NODE_PATH: join(workspaceRoot, "node_modules"),
|
||||
},
|
||||
});
|
||||
|
||||
if (proc.exitCode !== 0) {
|
||||
console.error(`\nBun compile failed with exit code ${proc.exitCode}`);
|
||||
process.exit(proc.exitCode ?? 1);
|
||||
function copyClientAssets() {
|
||||
if (existsSync(dashboardClientDest)) rmSync(dashboardClientDest, { recursive: true });
|
||||
console.log("Copying dashboard client assets...");
|
||||
mkdirSync(dashboardClientDest, { recursive: true });
|
||||
cpSync(dashboardClientSrc, dashboardClientDest, { recursive: true });
|
||||
console.log(` → ${dashboardClientDest}`);
|
||||
}
|
||||
|
||||
// ── Write a minimal package.json next to the binary ───────────────────
|
||||
// Some bundled dependencies (e.g. express) probe for package.json at
|
||||
// runtime. Provide a minimal one so the binary can self-resolve.
|
||||
import { writeFileSync } from "node:fs";
|
||||
writeFileSync(
|
||||
join(outDir, "package.json"),
|
||||
JSON.stringify({ name: "hai", version: "0.1.0", type: "module" }, null, 2) + "\n",
|
||||
);
|
||||
function writeDistPackageJson() {
|
||||
writeFileSync(
|
||||
join(outDir, "package.json"),
|
||||
JSON.stringify({ name: "hai", version: "0.1.0", type: "module" }, null, 2) + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n✓ Built: ${outBinary}`);
|
||||
console.log(` Assets: ${dashboardClientDest}`);
|
||||
console.log(`\nRun with: ${outBinary} --help`);
|
||||
// ── Compile a single binary ───────────────────────────────────────────
|
||||
function compileBinary(outFile: string, target: string): boolean {
|
||||
console.log(`Compiling ${outFile} (target: ${target})...`);
|
||||
|
||||
// Clean previous output for this binary
|
||||
if (existsSync(outFile)) rmSync(outFile);
|
||||
|
||||
const proc = Bun.spawnSync({
|
||||
cmd: [
|
||||
"bun",
|
||||
"build",
|
||||
"--compile",
|
||||
entryPoint,
|
||||
"--outfile",
|
||||
outFile,
|
||||
"--target",
|
||||
target,
|
||||
"--minify",
|
||||
],
|
||||
cwd: workspaceRoot,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_PATH: join(workspaceRoot, "node_modules"),
|
||||
},
|
||||
});
|
||||
|
||||
if (proc.exitCode !== 0) {
|
||||
console.error(`\nBun compile failed for ${target} with exit code ${proc.exitCode}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(` ✓ ${outFile}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────
|
||||
const { targets } = parseArgs();
|
||||
|
||||
// Copy assets once (shared across all binaries)
|
||||
copyClientAssets();
|
||||
|
||||
if (targets === null) {
|
||||
// Default: build for current platform → dist/hai
|
||||
const outBinary = join(outDir, defaultBinaryName());
|
||||
const ok = compileBinary(outBinary, "bun");
|
||||
writeDistPackageJson();
|
||||
if (!ok) process.exit(1);
|
||||
console.log(`\n✓ Built: ${outBinary}`);
|
||||
console.log(` Assets: ${dashboardClientDest}`);
|
||||
console.log(`\nRun with: ${outBinary} --help`);
|
||||
} else {
|
||||
// Cross-compilation mode
|
||||
let failed = false;
|
||||
const built: string[] = [];
|
||||
|
||||
for (const target of targets) {
|
||||
const name = binaryNameForTarget(target);
|
||||
const outBinary = join(outDir, name);
|
||||
const ok = compileBinary(outBinary, target);
|
||||
if (!ok) {
|
||||
failed = true;
|
||||
} else {
|
||||
built.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
writeDistPackageJson();
|
||||
|
||||
console.log(`\n${failed ? "⚠" : "✓"} Cross-compilation complete.`);
|
||||
if (built.length > 0) {
|
||||
console.log(` Built ${built.length} binaries:`);
|
||||
built.forEach((b) => console.log(` dist/${b}`));
|
||||
}
|
||||
console.log(` Assets: ${dashboardClientDest}`);
|
||||
|
||||
if (failed) process.exit(1);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev": "tsx src/bin.ts",
|
||||
"build": "tsc",
|
||||
"build:exe": "bun run build.ts",
|
||||
"build:exe:all": "bun run build.ts --all",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
|
||||
134
packages/cli/src/__tests__/build-exe-cross.test.ts
Normal file
134
packages/cli/src/__tests__/build-exe-cross.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const cliRoot = join(import.meta.dirname!, "..", "..");
|
||||
const distDir = join(cliRoot, "dist");
|
||||
const clientDir = join(distDir, "client");
|
||||
|
||||
const SUPPORTED_TARGETS = [
|
||||
"bun-linux-x64",
|
||||
"bun-linux-arm64",
|
||||
"bun-darwin-x64",
|
||||
"bun-darwin-arm64",
|
||||
"bun-windows-x64",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Map target → expected binary filename (mirrors build.ts logic).
|
||||
*/
|
||||
function expectedBinaryName(target: string): string {
|
||||
const suffix = target.replace(/^bun-/, "");
|
||||
const isWindows = target.includes("windows");
|
||||
return `hai-${suffix}${isWindows ? ".exe" : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the native target for the current host so we can run it.
|
||||
*/
|
||||
function nativeTarget(): string | null {
|
||||
const platform = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : null;
|
||||
const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : null;
|
||||
if (!platform || !arch) return null;
|
||||
return `bun-${platform}-${arch}`;
|
||||
}
|
||||
|
||||
describe("build-exe-cross: single target", () => {
|
||||
beforeAll(() => {
|
||||
// Build for linux-x64 specifically
|
||||
execSync("bun run build.ts --target bun-linux-x64", {
|
||||
cwd: cliRoot,
|
||||
stdio: "pipe",
|
||||
timeout: 120_000,
|
||||
});
|
||||
}, 180_000);
|
||||
|
||||
it("produces dist/hai-linux-x64", () => {
|
||||
const bin = join(distDir, "hai-linux-x64");
|
||||
expect(existsSync(bin)).toBe(true);
|
||||
expect(statSync(bin).size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("copies client assets alongside the binary", () => {
|
||||
expect(existsSync(join(clientDir, "index.html"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("build-exe-cross: windows target has .exe extension", () => {
|
||||
beforeAll(() => {
|
||||
execSync("bun run build.ts --target bun-windows-x64", {
|
||||
cwd: cliRoot,
|
||||
stdio: "pipe",
|
||||
timeout: 120_000,
|
||||
});
|
||||
}, 180_000);
|
||||
|
||||
it("produces dist/hai-windows-x64.exe", () => {
|
||||
const bin = join(distDir, "hai-windows-x64.exe");
|
||||
expect(existsSync(bin)).toBe(true);
|
||||
expect(statSync(bin).size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("build-exe-cross: --all builds all platforms", () => {
|
||||
beforeAll(() => {
|
||||
execSync("bun run build.ts --all", {
|
||||
cwd: cliRoot,
|
||||
stdio: "pipe",
|
||||
timeout: 300_000,
|
||||
});
|
||||
}, 360_000);
|
||||
|
||||
for (const target of SUPPORTED_TARGETS) {
|
||||
const name = expectedBinaryName(target);
|
||||
it(`produces dist/${name}`, () => {
|
||||
const bin = join(distDir, name);
|
||||
expect(existsSync(bin)).toBe(true);
|
||||
expect(statSync(bin).size).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
it("copies client assets", () => {
|
||||
expect(existsSync(join(clientDir, "index.html"))).toBe(true);
|
||||
});
|
||||
|
||||
it("native-platform binary runs --help", () => {
|
||||
const target = nativeTarget();
|
||||
if (!target) {
|
||||
// Skip on unsupported host (e.g. Windows in CI)
|
||||
return;
|
||||
}
|
||||
const name = expectedBinaryName(target);
|
||||
const bin = join(distDir, name);
|
||||
if (!existsSync(bin)) return;
|
||||
|
||||
const result = spawnSync(bin, ["--help"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("hai");
|
||||
});
|
||||
});
|
||||
|
||||
describe("build-exe-cross: default (no args) backward compatibility", () => {
|
||||
beforeAll(() => {
|
||||
execSync("bun run build.ts", {
|
||||
cwd: cliRoot,
|
||||
stdio: "pipe",
|
||||
timeout: 120_000,
|
||||
});
|
||||
}, 180_000);
|
||||
|
||||
it("produces dist/hai (no platform suffix)", () => {
|
||||
const defaultName = process.platform === "win32" ? "hai.exe" : "hai";
|
||||
const bin = join(distDir, defaultName);
|
||||
expect(existsSync(bin)).toBe(true);
|
||||
expect(statSync(bin).size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("copies client assets", () => {
|
||||
expect(existsSync(join(clientDir, "index.html"))).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user