feat(HAI-071): add Bun single-binary compile support for CLI

- Create build script (packages/cli/build.ts) using Bun.build compile target
- Update asset resolution in dashboard server for bundled context
- Add npm scripts to wire up the build process
- Add build-exe tests to verify compiled binary output
- Fix pre-existing build errors in engine and core packages
- Document build and compile workflow in README
This commit is contained in:
Dustin Byrne
2026-03-26 00:32:57 -04:00
parent 96bacb5d65
commit fbecff7255
7 changed files with 230 additions and 6 deletions

View File

@@ -140,6 +140,37 @@ pnpm dev dashboard -- --engine # Board + AI engine
pnpm dev task list # CLI commands pnpm dev task list # CLI commands
``` ```
## Building a standalone executable
You can build a single self-contained `hai` binary using [Bun](https://bun.sh/):
```bash
pnpm build:exe
```
This compiles all TypeScript, builds the dashboard client, and produces:
- `packages/cli/dist/hai` — the standalone binary
- `packages/cli/dist/client/` — co-located dashboard assets
Run the binary directly — no Node.js, pnpm, or workspace setup needed:
```bash
./packages/cli/dist/hai --help
./packages/cli/dist/hai task list
./packages/cli/dist/hai dashboard
```
To distribute, copy both the `hai` binary and the `client/` directory together.
You can override the dashboard asset path via the `HAI_CLIENT_DIR` environment variable:
```bash
HAI_CLIENT_DIR=/path/to/client ./hai dashboard
```
**Prerequisites:** Bun ≥ 1.0 (`bun --version`)
## License ## License
ISC ISC

View File

@@ -8,6 +8,7 @@
"dev": "tsx packages/cli/src/bin.ts", "dev": "tsx packages/cli/src/bin.ts",
"dev:ui": "pnpm --filter @hai/dashboard dev", "dev:ui": "pnpm --filter @hai/dashboard dev",
"build": "pnpm -r build", "build": "pnpm -r build",
"build:exe": "pnpm build && pnpm --filter hai build:exe",
"typecheck": "pnpm -r typecheck" "typecheck": "pnpm -r typecheck"
}, },
"pnpm": { "pnpm": {

88
packages/cli/build.ts Normal file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bun
/**
* Bun compile build script for the `hai` CLI.
*
* Produces a single self-contained executable at packages/cli/dist/hai
* with the dashboard client assets co-located at packages/cli/dist/client/.
*
* Usage:
* bun run build.ts
*
* Prerequisites:
* - `pnpm build` must have been run first (dashboard client + tsc)
* - Bun >= 1.0
*/
import { join, dirname } from "node:path";
import { cpSync, mkdirSync, existsSync, rmSync } 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");
// ── Validate prerequisites ────────────────────────────────────────────
if (!existsSync(dashboardClientSrc)) {
console.error(
`ERROR: Dashboard client not built. Expected: ${dashboardClientSrc}\n` +
`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);
}
// ── 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",
);
console.log(`\n✓ Built: ${outBinary}`);
console.log(` Assets: ${dashboardClientDest}`);
console.log(`\nRun with: ${outBinary} --help`);

View File

@@ -8,6 +8,7 @@
"scripts": { "scripts": {
"dev": "tsx src/bin.ts", "dev": "tsx src/bin.ts",
"build": "tsc", "build": "tsc",
"build:exe": "bun run build.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run" "test": "vitest run"
}, },

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, beforeAll } from "vitest";
import { execSync, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
const cliRoot = join(import.meta.dirname!, "..", "..");
const outBinary = join(cliRoot, "dist", process.platform === "win32" ? "hai.exe" : "hai");
const clientDir = join(cliRoot, "dist", "client");
describe("build-exe", () => {
beforeAll(() => {
// Build the executable (skip if already built to speed up re-runs)
if (!existsSync(outBinary)) {
execSync("bun run build.ts", {
cwd: cliRoot,
stdio: "pipe",
timeout: 120_000,
});
}
}, 180_000);
it("build script produces the binary", () => {
expect(existsSync(outBinary)).toBe(true);
});
it("build produces co-located client assets", () => {
expect(existsSync(join(clientDir, "index.html"))).toBe(true);
});
it("binary runs --help and prints expected output", () => {
const result = spawnSync(outBinary, ["--help"], {
encoding: "utf-8",
timeout: 15_000,
});
expect(result.status).toBe(0);
expect(result.stdout).toContain("hai — AI-orchestrated task board");
expect(result.stdout).toContain("dashboard");
expect(result.stdout).toContain("task create");
expect(result.stdout).toContain("task list");
});
it("binary runs 'task list' without crashing", () => {
const tmpDir = mkdtempSync(join(tmpdir(), "hai-test-"));
try {
const result = spawnSync(outBinary, ["task", "list"], {
cwd: tmpDir,
encoding: "utf-8",
timeout: 15_000,
});
expect(result.status).toBe(0);
expect(result.stdout).toContain("No tasks yet");
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
it("binary starts dashboard and serves client assets", async () => {
const { spawn } = await import("node:child_process");
const tmpDir = mkdtempSync(join(tmpdir(), "hai-dash-test-"));
const port = 14040 + Math.floor(Math.random() * 1000);
try {
const output = await new Promise<string>((resolve, reject) => {
const child = spawn(outBinary, ["dashboard", "--no-open", "-p", String(port)], {
cwd: tmpDir,
stdio: ["ignore", "pipe", "pipe"],
});
let out = "";
child.stdout.on("data", (d: Buffer) => { out += d.toString(); });
child.stderr.on("data", (d: Buffer) => { out += d.toString(); });
// Wait for the startup banner, then kill
const timer = setTimeout(() => {
child.kill("SIGTERM");
resolve(out);
}, 3_000);
child.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
child.on("close", () => {
clearTimeout(timer);
resolve(out);
});
});
expect(output).toContain("hai board");
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
}, 15_000);
});

View File

@@ -21,11 +21,22 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
app.use(express.json()); app.use(express.json());
// Serve built React app // Serve built React app
const clientDir = existsSync(join(__dirname, "..", "dist", "client")) // Resolution order:
? join(__dirname, "..", "dist", "client") // 1. HAI_CLIENT_DIR env override (explicit)
: existsSync(join(__dirname, "..", "client")) // 2. Next to process.execPath (bun-compiled binary: dist/hai + dist/client/)
? join(__dirname, "..", "client") // 3. __dirname/../dist/client (running from src/ via tsx/ts-node)
: join(__dirname, "..", "public"); // 4. __dirname/../client (running from dist/ after tsc)
// 5. __dirname/../public (fallback for dev)
const execDir = dirname(process.execPath);
const clientDir = process.env.HAI_CLIENT_DIR
? process.env.HAI_CLIENT_DIR
: existsSync(join(execDir, "client", "index.html"))
? join(execDir, "client")
: existsSync(join(__dirname, "..", "dist", "client"))
? join(__dirname, "..", "dist", "client")
: existsSync(join(__dirname, "..", "client"))
? join(__dirname, "..", "client")
: join(__dirname, "..", "public");
app.use(express.static(clientDir)); app.use(express.static(clientDir));

View File

@@ -4,5 +4,6 @@
"outDir": "dist", "outDir": "dist",
"rootDir": "src" "rootDir": "src"
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/**/*.test.ts"]
} }