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:
31
README.md
31
README.md
@@ -140,6 +140,37 @@ pnpm dev dashboard -- --engine # Board + AI engine
|
||||
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
|
||||
|
||||
ISC
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "tsx packages/cli/src/bin.ts",
|
||||
"dev:ui": "pnpm --filter @hai/dashboard dev",
|
||||
"build": "pnpm -r build",
|
||||
"build:exe": "pnpm build && pnpm --filter hai build:exe",
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
88
packages/cli/build.ts
Normal file
88
packages/cli/build.ts
Normal 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`);
|
||||
@@ -8,6 +8,7 @@
|
||||
"scripts": {
|
||||
"dev": "tsx src/bin.ts",
|
||||
"build": "tsc",
|
||||
"build:exe": "bun run build.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
|
||||
91
packages/cli/src/__tests__/build-exe.test.ts
Normal file
91
packages/cli/src/__tests__/build-exe.test.ts
Normal 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);
|
||||
});
|
||||
@@ -21,11 +21,22 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
app.use(express.json());
|
||||
|
||||
// Serve built React app
|
||||
const clientDir = existsSync(join(__dirname, "..", "dist", "client"))
|
||||
? join(__dirname, "..", "dist", "client")
|
||||
: existsSync(join(__dirname, "..", "client"))
|
||||
? join(__dirname, "..", "client")
|
||||
: join(__dirname, "..", "public");
|
||||
// Resolution order:
|
||||
// 1. HAI_CLIENT_DIR env override (explicit)
|
||||
// 2. Next to process.execPath (bun-compiled binary: dist/hai + dist/client/)
|
||||
// 3. __dirname/../dist/client (running from src/ via tsx/ts-node)
|
||||
// 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));
|
||||
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user