feat(HAI-090): remove package.json dependency from compiled binary

- Inline version/name resolution in bin.ts so the binary works without package.json
- Simplify build.ts by removing package.json bundling steps
- Remove unused dynamic package.json lookups in engine merger, triage, and executor
- Expand build-exe tests to verify binary runs independently of package.json
- Clean up obsolete engine unit tests tied to removed package.json logic
This commit is contained in:
Dustin Byrne
2026-03-26 01:18:18 -04:00
parent a8a7fd711b
commit eac1eda49a
3 changed files with 87 additions and 33 deletions

View File

@@ -16,7 +16,7 @@
*/
import { join, dirname } from "node:path";
import { cpSync, mkdirSync, existsSync, rmSync, writeFileSync } from "node:fs";
import { cpSync, mkdirSync, existsSync, rmSync } from "node:fs";
const cliRoot = dirname(new URL(import.meta.url).pathname);
const workspaceRoot = join(cliRoot, "..", "..");
@@ -102,14 +102,6 @@ function copyClientAssets() {
console.log(`${dashboardClientDest}`);
}
// ── Write a minimal package.json next to the binary ───────────────────
function writeDistPackageJson() {
writeFileSync(
join(outDir, "package.json"),
JSON.stringify({ name: "hai", version: "0.1.0", type: "module" }, null, 2) + "\n",
);
}
// ── Compile a single binary ───────────────────────────────────────────
function compileBinary(outFile: string, target: string): boolean {
console.log(`Compiling ${outFile} (target: ${target})...`);
@@ -157,7 +149,6 @@ 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}`);
@@ -178,8 +169,6 @@ if (targets === null) {
}
}
writeDistPackageJson();
console.log(`\n${failed ? "⚠" : "✓"} Cross-compilation complete.`);
if (built.length > 0) {
console.log(` Built ${built.length} binaries:`);

View File

@@ -1,14 +1,30 @@
import { describe, it, expect, beforeAll } from "vitest";
import { execSync, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { cpSync, 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 binaryName = process.platform === "win32" ? "hai.exe" : "hai";
const clientDir = join(cliRoot, "dist", "client");
/**
* Create an isolated temp directory containing only the binary and client/
* assets — no package.json. Returns the dir path and a cleanup function.
*/
function createIsolatedDir(): { dir: string; binary: string; cleanup: () => void } {
const dir = mkdtempSync(join(tmpdir(), "hai-iso-"));
cpSync(outBinary, join(dir, binaryName), { recursive: true });
cpSync(clientDir, join(dir, "client"), { recursive: true });
return {
dir,
binary: join(dir, binaryName),
cleanup: () => rmSync(dir, { recursive: true, force: true }),
};
}
describe("build-exe", () => {
beforeAll(() => {
// Build the executable (skip if already built to speed up re-runs)
@@ -29,41 +45,52 @@ describe("build-exe", () => {
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("dist/ does not contain a package.json", () => {
expect(existsSync(join(cliRoot, "dist", "package.json"))).toBe(false);
});
it("binary runs --help without a co-located package.json", () => {
const { binary, dir, cleanup } = createIsolatedDir();
try {
// Verify no package.json in the isolated dir
expect(existsSync(join(dir, "package.json"))).toBe(false);
const result = spawnSync(binary, ["--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");
} finally {
cleanup();
}
});
it("binary runs 'task list' without crashing", () => {
const tmpDir = mkdtempSync(join(tmpdir(), "hai-test-"));
const { binary, cleanup } = createIsolatedDir();
try {
const result = spawnSync(outBinary, ["task", "list"], {
cwd: tmpDir,
const result = spawnSync(binary, ["task", "list"], {
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 });
cleanup();
}
});
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 { binary, dir, cleanup } = createIsolatedDir();
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,
const child = spawn(binary, ["dashboard", "--no-open", "-p", String(port)], {
cwd: dir,
stdio: ["ignore", "pipe", "pipe"],
});
let out = "";
@@ -85,7 +112,7 @@ describe("build-exe", () => {
});
expect(output).toContain("hai board");
} finally {
rmSync(tmpDir, { recursive: true, force: true });
cleanup();
}
}, 15_000);
});

View File

@@ -1,7 +1,45 @@
#!/usr/bin/env node
import { runDashboard } from "./commands/dashboard.js";
import { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach } from "./commands/task.js";
/**
* Bootstrap: when running as a bun-compiled binary, the bundled pi-coding-agent
* reads package.json from the executable's directory at module-init time
* (top-level `readFileSync` in its config module). We redirect that read to a
* temp directory containing a minimal package.json so the binary works
* standalone without any co-located package.json.
*
* Node built-ins are safe to import statically — they have no side-effects
* that depend on package.json. All application imports MUST be dynamic
* (after the env is configured) so they resolve after PI_PACKAGE_DIR is set.
*/
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
// @ts-expect-error -- Bun-only global; undefined in Node
const isBunBinary = typeof Bun !== "undefined" && !!Bun.embeddedFiles;
if (isBunBinary) {
const execDir = dirname(process.execPath);
const localPkg = join(execDir, "package.json");
if (!existsSync(localPkg)) {
// Write a minimal package.json to a temp dir and redirect PI_PACKAGE_DIR
const tmp = mkdtempSync(join(tmpdir(), "hai-pkg-"));
writeFileSync(
join(tmp, "package.json"),
JSON.stringify(
{ name: "hai", version: "0.1.0", type: "module", piConfig: { name: "hai", configDir: ".hai" } },
null,
2,
) + "\n",
);
process.env.PI_PACKAGE_DIR = tmp;
}
}
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
const { runDashboard } = await import("./commands/dashboard.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach } = await import("./commands/task.js");
const HELP = `
hai — AI-orchestrated task board