@crewhaus/single-binary-cli 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -12
- package/src/index.test.ts +26 -0
- package/src/index.ts +8 -4
- package/src/manifests.ts +63 -0
- package/src/runner.test.ts +142 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/single-binary-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "bun build --compile wrapper producing self-contained crewhaus binaries for linux/macos/windows × x64/arm64; auto-generated Homebrew/apt/Scoop/Winget manifests (Section 32)",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -10,16 +10,17 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"test": "bun test src",
|
|
13
|
-
"build:binary": "bun src/cli.ts"
|
|
13
|
+
"build:binary": "bun src/cli.ts",
|
|
14
|
+
"manifests": "bun src/manifests.ts"
|
|
14
15
|
},
|
|
15
16
|
"dependencies": {
|
|
16
|
-
"@crewhaus/errors": "0.1.
|
|
17
|
+
"@crewhaus/errors": "0.1.3"
|
|
17
18
|
},
|
|
18
19
|
"license": "Apache-2.0",
|
|
19
20
|
"author": {
|
|
20
21
|
"name": "Max Meier",
|
|
21
|
-
"email": "max@
|
|
22
|
-
"url": "https://
|
|
22
|
+
"email": "max@crewhaus.ai",
|
|
23
|
+
"url": "https://crewhaus.ai"
|
|
23
24
|
},
|
|
24
25
|
"repository": {
|
|
25
26
|
"type": "git",
|
|
@@ -31,12 +32,7 @@
|
|
|
31
32
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
32
33
|
},
|
|
33
34
|
"publishConfig": {
|
|
34
|
-
"access": "
|
|
35
|
+
"access": "public"
|
|
35
36
|
},
|
|
36
|
-
"files": [
|
|
37
|
-
"src",
|
|
38
|
-
"README.md",
|
|
39
|
-
"LICENSE",
|
|
40
|
-
"NOTICE"
|
|
41
|
-
]
|
|
37
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
42
38
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
bunCompileTarget,
|
|
16
16
|
formatTarget,
|
|
17
17
|
isBuildTarget,
|
|
18
|
+
packagingDir,
|
|
18
19
|
renderDebianControl,
|
|
19
20
|
renderHomebrewFormula,
|
|
20
21
|
renderScoopManifest,
|
|
@@ -99,9 +100,25 @@ describe("buildBinary() (T2 dry-run)", () => {
|
|
|
99
100
|
expect(captured).toContain("bun-linux-x64");
|
|
100
101
|
expect(captured).toContain("--outfile");
|
|
101
102
|
expect(captured).toContain("/tmp/dist/crewhaus-linux-x64-1.2.3");
|
|
103
|
+
expect(captured).toContain("--define");
|
|
104
|
+
expect(captured).toContain('CREWHAUS_EMBEDDED_VERSION="1.2.3"');
|
|
102
105
|
expect(result.outPath).toBe("/tmp/dist/crewhaus-linux-x64-1.2.3");
|
|
103
106
|
});
|
|
104
107
|
|
|
108
|
+
test("no version → no --define (the CLI falls back to its package.json read)", async () => {
|
|
109
|
+
let captured: readonly string[] = [];
|
|
110
|
+
const runner: BuildBinaryRunner = async (argv) => {
|
|
111
|
+
captured = argv;
|
|
112
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
113
|
+
};
|
|
114
|
+
await buildBinary({
|
|
115
|
+
target: { platform: "linux", arch: "x64" },
|
|
116
|
+
outDir: "/tmp/dist",
|
|
117
|
+
runner,
|
|
118
|
+
});
|
|
119
|
+
expect(captured).not.toContain("--define");
|
|
120
|
+
});
|
|
121
|
+
|
|
105
122
|
test("rejects unsupported target windows-arm64", async () => {
|
|
106
123
|
await expect(
|
|
107
124
|
buildBinary({
|
|
@@ -147,6 +164,7 @@ describe("manifest rendering (T1)", () => {
|
|
|
147
164
|
expect(formula).toContain('version "1.0.0"');
|
|
148
165
|
expect(formula).toContain("on_macos");
|
|
149
166
|
expect(formula).toContain("on_linux");
|
|
167
|
+
expect(formula).toContain('license "Apache-2.0"');
|
|
150
168
|
expect(formula).toContain(sha256["macos-arm64"]);
|
|
151
169
|
expect(formula).toContain(sha256["macos-x64"]);
|
|
152
170
|
expect(formula).toContain(sha256["linux-arm64"]);
|
|
@@ -167,9 +185,11 @@ describe("manifest rendering (T1)", () => {
|
|
|
167
185
|
test("Scoop manifest exposes 64bit url + hash", () => {
|
|
168
186
|
const scoop = renderScoopManifest(inputs) as {
|
|
169
187
|
version: string;
|
|
188
|
+
license: string;
|
|
170
189
|
architecture: { "64bit": { url: string; hash: string } };
|
|
171
190
|
};
|
|
172
191
|
expect(scoop.version).toBe("1.0.0");
|
|
192
|
+
expect(scoop.license).toBe("Apache-2.0");
|
|
173
193
|
expect(scoop.architecture["64bit"].hash).toBe(sha256["windows-x64"]);
|
|
174
194
|
expect(scoop.architecture["64bit"].url).toContain("crewhaus-windows-x64-1.0.0.exe");
|
|
175
195
|
});
|
|
@@ -178,6 +198,7 @@ describe("manifest rendering (T1)", () => {
|
|
|
178
198
|
const winget = renderWingetManifest(inputs);
|
|
179
199
|
expect(winget).toContain("PackageIdentifier: CrewHaus.CLI");
|
|
180
200
|
expect(winget).toContain("PackageVersion: 1.0.0");
|
|
201
|
+
expect(winget).toContain("License: Apache-2.0");
|
|
181
202
|
expect(winget).toContain(sha256["windows-x64"].toUpperCase());
|
|
182
203
|
});
|
|
183
204
|
|
|
@@ -200,6 +221,11 @@ describe("manifest rendering (T1)", () => {
|
|
|
200
221
|
'"hash": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"',
|
|
201
222
|
);
|
|
202
223
|
});
|
|
224
|
+
|
|
225
|
+
test("packagingDir() points at the package's packaging/ directory (default root)", () => {
|
|
226
|
+
// The default on-disk root used by writeAllManifests when no dir is given.
|
|
227
|
+
expect(packagingDir()).toMatch(/single-binary-cli[/\\]packaging$/);
|
|
228
|
+
});
|
|
203
229
|
});
|
|
204
230
|
|
|
205
231
|
describe("sha256OfFile", () => {
|
package/src/index.ts
CHANGED
|
@@ -105,6 +105,10 @@ export async function buildBinary(opts: BuildBinaryOptions): Promise<BuildBinary
|
|
|
105
105
|
"--compile",
|
|
106
106
|
"--target",
|
|
107
107
|
bunCompileTarget(t),
|
|
108
|
+
// Embed the version: inside a compiled binary import.meta.url points
|
|
109
|
+
// into the virtual /$bunfs tree, so the CLI's runtime ../package.json
|
|
110
|
+
// read cannot work — `crewhaus --version` uses this constant instead.
|
|
111
|
+
...(version ? ["--define", `CREWHAUS_EMBEDDED_VERSION=${JSON.stringify(version)}`] : []),
|
|
108
112
|
CLI_ENTRYPOINT_REL,
|
|
109
113
|
"--outfile",
|
|
110
114
|
outPath,
|
|
@@ -174,7 +178,7 @@ export function renderHomebrewFormula(inputs: ManifestInputs): string {
|
|
|
174
178
|
desc "Modular meta-harness — compile a single spec into multiple agent runtimes"
|
|
175
179
|
homepage "${homepage}"
|
|
176
180
|
version "${version}"
|
|
177
|
-
license "
|
|
181
|
+
license "Apache-2.0"
|
|
178
182
|
|
|
179
183
|
on_macos do
|
|
180
184
|
on_arm do
|
|
@@ -216,7 +220,7 @@ Version: ${version}
|
|
|
216
220
|
Section: utils
|
|
217
221
|
Priority: optional
|
|
218
222
|
Architecture: any
|
|
219
|
-
Maintainer: CrewHaus Maintainers <maintainers@crewhaus.
|
|
223
|
+
Maintainer: CrewHaus Maintainers <maintainers@crewhaus.ai>
|
|
220
224
|
Depends: libc6 (>= 2.31)
|
|
221
225
|
Description: Modular meta-harness — compile a single spec into multiple agent runtimes
|
|
222
226
|
CrewHaus compiles a single high-level harness spec into multiple
|
|
@@ -233,7 +237,7 @@ export function renderScoopManifest(inputs: ManifestInputs): unknown {
|
|
|
233
237
|
version,
|
|
234
238
|
description: "Modular meta-harness — compile a single spec into multiple agent runtimes",
|
|
235
239
|
homepage,
|
|
236
|
-
license: "
|
|
240
|
+
license: "Apache-2.0",
|
|
237
241
|
architecture: {
|
|
238
242
|
"64bit": {
|
|
239
243
|
url: `${downloadBaseUrl}/crewhaus-windows-x64-${version}.exe`,
|
|
@@ -254,7 +258,7 @@ Publisher: CrewHaus
|
|
|
254
258
|
Author: CrewHaus
|
|
255
259
|
PackageName: crewhaus
|
|
256
260
|
PackageUrl: ${homepage}
|
|
257
|
-
License:
|
|
261
|
+
License: Apache-2.0
|
|
258
262
|
ShortDescription: Modular meta-harness — compile a single spec into multiple agent runtimes
|
|
259
263
|
Description: |
|
|
260
264
|
CrewHaus compiles a single high-level harness spec into multiple runtime targets.
|
package/src/manifests.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Generate the Homebrew / apt / Scoop / Winget package manifests from the
|
|
4
|
+
* binaries already built into `dist/` by `build:binary`. Run AFTER the build.
|
|
5
|
+
* Used by the release workflow (.github/workflows/release.yml) so the manifest
|
|
6
|
+
* generation is a single, unit-tested command rather than inline YAML.
|
|
7
|
+
*
|
|
8
|
+
* bun src/manifests.ts --version 1.2.3 \
|
|
9
|
+
* --download-base-url https://github.com/crewhaus/factory/releases/download/v1.2.3 \
|
|
10
|
+
* --dist <repo>/dist --out <repo>/packaging
|
|
11
|
+
*
|
|
12
|
+
* Prints a JSON summary ({version, downloadBaseUrl, sha256, written}) to stdout
|
|
13
|
+
* so CI can capture the sha map and the written paths.
|
|
14
|
+
*/
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
BUILD_MATRIX,
|
|
18
|
+
type ShaByTarget,
|
|
19
|
+
binaryName,
|
|
20
|
+
formatTarget,
|
|
21
|
+
packagingDir,
|
|
22
|
+
sha256OfFile,
|
|
23
|
+
writeAllManifests,
|
|
24
|
+
} from "./index";
|
|
25
|
+
|
|
26
|
+
function flag(name: string, argv: string[]): string | undefined {
|
|
27
|
+
const i = argv.indexOf(`--${name}`);
|
|
28
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function main() {
|
|
32
|
+
const argv = process.argv.slice(2);
|
|
33
|
+
const version = flag("version", argv);
|
|
34
|
+
if (!version) {
|
|
35
|
+
console.error("✗ --version is required (e.g. --version 1.2.3)");
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const homepage = flag("homepage", argv) ?? "https://github.com/crewhaus/factory";
|
|
39
|
+
const downloadBaseUrl =
|
|
40
|
+
flag("download-base-url", argv) ??
|
|
41
|
+
`https://github.com/crewhaus/factory/releases/download/v${version}`;
|
|
42
|
+
const distDir = resolve(flag("dist", argv) ?? join(import.meta.dir, "..", "..", "..", "dist"));
|
|
43
|
+
const outArg = flag("out", argv);
|
|
44
|
+
const outDir = outArg ? resolve(outArg) : packagingDir();
|
|
45
|
+
|
|
46
|
+
// sha256 every built binary. A missing file is a hard error — the manifests
|
|
47
|
+
// must never reference an asset that was not actually built and uploaded.
|
|
48
|
+
const sha256: Record<string, string> = {};
|
|
49
|
+
for (const t of BUILD_MATRIX) {
|
|
50
|
+
const file = join(distDir, binaryName(t, version));
|
|
51
|
+
sha256[formatTarget(t)] = await sha256OfFile(file);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const inputs = { version, homepage, downloadBaseUrl, sha256: sha256 as ShaByTarget };
|
|
55
|
+
const written = writeAllManifests(inputs, outDir);
|
|
56
|
+
|
|
57
|
+
console.log(JSON.stringify({ version, downloadBaseUrl, sha256, written }, null, 2));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
main().catch((err) => {
|
|
61
|
+
console.error(`✗ manifest generation failed: ${(err as Error).message}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage for the default `child_process`-backed build runner.
|
|
3
|
+
*
|
|
4
|
+
* Every other test injects a fake `runner`, so the real `defaultRunner`
|
|
5
|
+
* (which spawns `bun build --compile`) is never exercised. Here we drive it
|
|
6
|
+
* by calling `buildBinary` WITHOUT a runner and mocking `node:child_process`
|
|
7
|
+
* so `spawn` returns a fake child emitter — no real process, no real timers.
|
|
8
|
+
*
|
|
9
|
+
* The fake child fires its stdout/stderr/close|error listeners on a
|
|
10
|
+
* microtask (queueMicrotask), which is deterministic and needs no fake clock.
|
|
11
|
+
* NOTE: `mock.restore()` does NOT undo `mock.module`, and Bun shares one
|
|
12
|
+
* module registry across all test files (nondeterministic order) — the
|
|
13
|
+
* `afterAll` below reinstalls the real `node:child_process` so the per-test
|
|
14
|
+
* spawn fakes can't leak into the sibling suite.
|
|
15
|
+
*/
|
|
16
|
+
import { afterAll, afterEach, describe, expect, mock, test } from "bun:test";
|
|
17
|
+
import { EventEmitter } from "node:events";
|
|
18
|
+
import { resolve } from "node:path";
|
|
19
|
+
import { SingleBinaryError, buildBinary } from "./index";
|
|
20
|
+
|
|
21
|
+
// Mirror REPO_ROOT from ./index — two levels above the package root,
|
|
22
|
+
// whatever the checkout or worktree happens to be named. (`tsc -b` also
|
|
23
|
+
// runs this file from dist/, hence the src|dist strip.)
|
|
24
|
+
const REPO_ROOT = resolve(import.meta.dir.replace(/[/\\](src|dist)$/, ""), "..", "..");
|
|
25
|
+
|
|
26
|
+
// Captured before any mock.module call so afterAll can reinstall the real module.
|
|
27
|
+
const realChildProcess = require("node:child_process") as typeof import("node:child_process");
|
|
28
|
+
|
|
29
|
+
afterAll(() => {
|
|
30
|
+
mock.module("node:child_process", () => realChildProcess);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
type FakeOpts = {
|
|
34
|
+
readonly stdout?: string;
|
|
35
|
+
readonly stderr?: string;
|
|
36
|
+
readonly code?: number | null;
|
|
37
|
+
readonly emitError?: Error;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function fakeChild(opts: FakeOpts): EventEmitter & { stdout: EventEmitter; stderr: EventEmitter } {
|
|
41
|
+
const child = new EventEmitter() as EventEmitter & {
|
|
42
|
+
stdout: EventEmitter;
|
|
43
|
+
stderr: EventEmitter;
|
|
44
|
+
};
|
|
45
|
+
child.stdout = new EventEmitter();
|
|
46
|
+
child.stderr = new EventEmitter();
|
|
47
|
+
// Emit after the caller has attached its listeners (next microtask).
|
|
48
|
+
queueMicrotask(() => {
|
|
49
|
+
if (opts.stdout) child.stdout.emit("data", Buffer.from(opts.stdout, "utf8"));
|
|
50
|
+
if (opts.stderr) child.stderr.emit("data", Buffer.from(opts.stderr, "utf8"));
|
|
51
|
+
if (opts.emitError) {
|
|
52
|
+
child.emit("error", opts.emitError);
|
|
53
|
+
} else {
|
|
54
|
+
// Emit the code verbatim — including an explicit `null` — so the
|
|
55
|
+
// `code ?? 1` branch in defaultRunner is exercised, not masked here.
|
|
56
|
+
child.emit("close", opts.code === undefined ? 0 : opts.code);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return child;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("defaultRunner (real child_process path, mocked spawn)", () => {
|
|
63
|
+
afterEach(() => mock.restore());
|
|
64
|
+
|
|
65
|
+
test("spawns `bun` with the compile argv and resolves on clean exit", async () => {
|
|
66
|
+
let captured: { head?: string; rest?: string[]; cwd?: string } = {};
|
|
67
|
+
mock.module("node:child_process", () => ({
|
|
68
|
+
spawn: (head: string, rest: string[], optsArg: { cwd: string }) => {
|
|
69
|
+
captured = { head, rest, cwd: optsArg.cwd };
|
|
70
|
+
return fakeChild({ stdout: "compiled ok", code: 0 });
|
|
71
|
+
},
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
const result = await buildBinary({
|
|
75
|
+
target: { platform: "linux", arch: "x64" },
|
|
76
|
+
version: "9.9.9",
|
|
77
|
+
outDir: "/tmp/sbc-runner-dist",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(captured.head).toBe("bun");
|
|
81
|
+
expect(captured.rest).toContain("build");
|
|
82
|
+
expect(captured.rest).toContain("--compile");
|
|
83
|
+
expect(captured.rest).toContain("bun-linux-x64");
|
|
84
|
+
// cwd is REPO_ROOT (two levels above the package).
|
|
85
|
+
expect(captured.cwd).toBe(REPO_ROOT);
|
|
86
|
+
expect(result.outPath).toBe("/tmp/sbc-runner-dist/crewhaus-linux-x64-9.9.9");
|
|
87
|
+
expect(result.buildArgv[0]).toBe("bun");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("accumulates multiple stdout/stderr chunks before close", async () => {
|
|
91
|
+
mock.module("node:child_process", () => ({
|
|
92
|
+
spawn: () => {
|
|
93
|
+
const child = new EventEmitter() as EventEmitter & {
|
|
94
|
+
stdout: EventEmitter;
|
|
95
|
+
stderr: EventEmitter;
|
|
96
|
+
};
|
|
97
|
+
child.stdout = new EventEmitter();
|
|
98
|
+
child.stderr = new EventEmitter();
|
|
99
|
+
queueMicrotask(() => {
|
|
100
|
+
child.stdout.emit("data", Buffer.from("part1-", "utf8"));
|
|
101
|
+
child.stdout.emit("data", Buffer.from("part2", "utf8"));
|
|
102
|
+
child.stderr.emit("data", Buffer.from("warn", "utf8"));
|
|
103
|
+
child.emit("close", 0);
|
|
104
|
+
});
|
|
105
|
+
return child;
|
|
106
|
+
},
|
|
107
|
+
}));
|
|
108
|
+
// exit 0 → resolves; the multi-chunk buffers exercise the data handlers.
|
|
109
|
+
const result = await buildBinary({
|
|
110
|
+
target: { platform: "macos", arch: "x64" },
|
|
111
|
+
outDir: "/tmp/sbc-runner-dist",
|
|
112
|
+
});
|
|
113
|
+
expect(result.target.platform).toBe("macos");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("non-zero exit code surfaces stderr in a SingleBinaryError", async () => {
|
|
117
|
+
mock.module("node:child_process", () => ({
|
|
118
|
+
spawn: () => fakeChild({ stderr: "bun: compile failed", code: 7 }),
|
|
119
|
+
}));
|
|
120
|
+
await expect(
|
|
121
|
+
buildBinary({ target: { platform: "macos", arch: "arm64" }, outDir: "/tmp/sbc-runner-dist" }),
|
|
122
|
+
).rejects.toThrow(/exited with 7: bun: compile failed/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a null exit code is treated as failure (exitCode 1)", async () => {
|
|
126
|
+
mock.module("node:child_process", () => ({
|
|
127
|
+
spawn: () => fakeChild({ stderr: "killed", code: null }),
|
|
128
|
+
}));
|
|
129
|
+
await expect(
|
|
130
|
+
buildBinary({ target: { platform: "windows", arch: "x64" }, outDir: "/tmp/sbc-runner-dist" }),
|
|
131
|
+
).rejects.toBeInstanceOf(SingleBinaryError);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a spawn `error` event resolves to exitCode 1 → SingleBinaryError", async () => {
|
|
135
|
+
mock.module("node:child_process", () => ({
|
|
136
|
+
spawn: () => fakeChild({ emitError: new Error("spawn ENOENT: bun not on PATH") }),
|
|
137
|
+
}));
|
|
138
|
+
await expect(
|
|
139
|
+
buildBinary({ target: { platform: "linux", arch: "arm64" }, outDir: "/tmp/sbc-runner-dist" }),
|
|
140
|
+
).rejects.toThrow(/spawn ENOENT: bun not on PATH/);
|
|
141
|
+
});
|
|
142
|
+
});
|