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
|
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
|
||||||
|
|||||||
@@ -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
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": {
|
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
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());
|
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));
|
||||||
|
|
||||||
|
|||||||
@@ -4,5 +4,6 @@
|
|||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "src"
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user