@crewhaus/single-binary-cli 0.1.3 → 0.1.5
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/dist/cli.d.ts +1 -0
- package/dist/cli.js +50 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.js +256 -0
- package/dist/manifests.d.ts +2 -0
- package/dist/manifests.js +48 -0
- package/package.json +9 -6
- package/src/cli.ts +0 -60
- package/src/index.test.ts +0 -246
- package/src/index.ts +0 -331
- package/src/manifests.ts +0 -63
- package/src/runner.test.ts +0 -142
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun run build:binary` entry point. Builds the requested target, or
|
|
3
|
+
* the full BUILD_MATRIX when no target flag is passed.
|
|
4
|
+
*
|
|
5
|
+
* Examples:
|
|
6
|
+
* bun run build:binary --target macos-arm64
|
|
7
|
+
* bun run build:binary --version 1.0.0 # builds every entry in BUILD_MATRIX
|
|
8
|
+
*/
|
|
9
|
+
import { ARCHES, BUILD_MATRIX, PLATFORMS, buildBinary, formatTarget, } from "./index";
|
|
10
|
+
function parseFlag(name, argv) {
|
|
11
|
+
const idx = argv.findIndex((a) => a === `--${name}`);
|
|
12
|
+
return idx >= 0 ? argv[idx + 1] : undefined;
|
|
13
|
+
}
|
|
14
|
+
function parseTarget(value) {
|
|
15
|
+
const [platform, arch] = value.split("-");
|
|
16
|
+
if (!platform || !arch)
|
|
17
|
+
return undefined;
|
|
18
|
+
if (!PLATFORMS.includes(platform))
|
|
19
|
+
return undefined;
|
|
20
|
+
if (!ARCHES.includes(arch))
|
|
21
|
+
return undefined;
|
|
22
|
+
return { platform: platform, arch: arch };
|
|
23
|
+
}
|
|
24
|
+
async function main() {
|
|
25
|
+
const argv = process.argv.slice(2);
|
|
26
|
+
const version = parseFlag("version", argv) ?? "";
|
|
27
|
+
const targetArg = parseFlag("target", argv);
|
|
28
|
+
const targets = targetArg
|
|
29
|
+
? (() => {
|
|
30
|
+
const t = parseTarget(targetArg);
|
|
31
|
+
if (!t) {
|
|
32
|
+
console.error(`unknown target: ${targetArg}`);
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
return [t];
|
|
36
|
+
})()
|
|
37
|
+
: BUILD_MATRIX;
|
|
38
|
+
for (const t of targets) {
|
|
39
|
+
process.stdout.write(`building crewhaus-${formatTarget(t)}...\n`);
|
|
40
|
+
try {
|
|
41
|
+
const result = await buildBinary({ target: t, version });
|
|
42
|
+
process.stdout.write(` → ${result.outPath}\n`);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
console.error(` ✗ ${formatTarget(t)} failed: ${err.message}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
main();
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 32 — `@crewhaus/single-binary-cli`
|
|
3
|
+
*
|
|
4
|
+
* `bun build --compile` wrapper that produces self-contained
|
|
5
|
+
* `dist/crewhaus-{linux,macos,windows}-{x64,arm64}` binaries for the
|
|
6
|
+
* `crewhaus` CLI. Each binary is ~80 MB; no Bun/Node prereq on the
|
|
7
|
+
* target host.
|
|
8
|
+
*
|
|
9
|
+
* Bun 1.2's `--compile` cross-compile matrix:
|
|
10
|
+
* - linux-x64 ✅
|
|
11
|
+
* - linux-arm64 ✅
|
|
12
|
+
* - macos-x64 ✅
|
|
13
|
+
* - macos-arm64 ✅
|
|
14
|
+
* - windows-x64 ✅ (named "bun-windows-x64" target)
|
|
15
|
+
* - windows-arm64 ❌ (Bun does not produce windows-arm64; documented
|
|
16
|
+
* gap — use linux-arm64 in WSL or skip)
|
|
17
|
+
*
|
|
18
|
+
* The auto-generated package manifests (Homebrew formula, Debian
|
|
19
|
+
* control, Scoop manifest, Winget manifest) live under
|
|
20
|
+
* `packaging/` and are templated against the binaries this package
|
|
21
|
+
* builds. `renderHomebrewFormula({version, sha256ByPlatform})` etc.
|
|
22
|
+
* regenerate them deterministically on every release.
|
|
23
|
+
*/
|
|
24
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
25
|
+
export declare class SingleBinaryError extends CrewhausError {
|
|
26
|
+
readonly name = "SingleBinaryError";
|
|
27
|
+
constructor(message: string, cause?: unknown);
|
|
28
|
+
}
|
|
29
|
+
export declare const PLATFORMS: readonly ["linux", "macos", "windows"];
|
|
30
|
+
export declare const ARCHES: readonly ["x64", "arm64"];
|
|
31
|
+
export type Platform = (typeof PLATFORMS)[number];
|
|
32
|
+
export type Arch = (typeof ARCHES)[number];
|
|
33
|
+
/**
|
|
34
|
+
* The (platform, arch) pairs Bun --compile actually produces. The
|
|
35
|
+
* `windows-arm64` slot is intentionally absent because Bun has no
|
|
36
|
+
* `bun-windows-arm64` target as of Bun 1.2.
|
|
37
|
+
*/
|
|
38
|
+
export type BuildTarget = {
|
|
39
|
+
readonly platform: Platform;
|
|
40
|
+
readonly arch: Arch;
|
|
41
|
+
};
|
|
42
|
+
export declare const BUILD_MATRIX: readonly BuildTarget[];
|
|
43
|
+
/** Bun's --target string for a given (platform, arch) pair. */
|
|
44
|
+
export declare function bunCompileTarget(t: BuildTarget): string;
|
|
45
|
+
/**
|
|
46
|
+
* Output filename of a built binary. Adds `.exe` on windows.
|
|
47
|
+
*/
|
|
48
|
+
export declare function binaryName(t: BuildTarget, version: string): string;
|
|
49
|
+
export type BuildBinaryRunner = (argv: readonly string[], cwd: string) => Promise<{
|
|
50
|
+
exitCode: number;
|
|
51
|
+
stdout: string;
|
|
52
|
+
stderr: string;
|
|
53
|
+
}>;
|
|
54
|
+
export type BuildBinaryOptions = {
|
|
55
|
+
readonly target: BuildTarget;
|
|
56
|
+
readonly version?: string;
|
|
57
|
+
readonly outDir?: string;
|
|
58
|
+
/** Test injection point. Defaults to spawning bun via node:child_process. */
|
|
59
|
+
readonly runner?: BuildBinaryRunner;
|
|
60
|
+
};
|
|
61
|
+
export type BuildBinaryResult = {
|
|
62
|
+
readonly target: BuildTarget;
|
|
63
|
+
readonly outPath: string;
|
|
64
|
+
readonly buildArgv: readonly string[];
|
|
65
|
+
};
|
|
66
|
+
export declare function buildBinary(opts: BuildBinaryOptions): Promise<BuildBinaryResult>;
|
|
67
|
+
export declare function isBuildTarget(t: BuildTarget): boolean;
|
|
68
|
+
export declare function formatTarget(t: BuildTarget): string;
|
|
69
|
+
export type ShaByTarget = Readonly<Partial<Record<string, string>>>;
|
|
70
|
+
export type ManifestInputs = {
|
|
71
|
+
readonly version: string;
|
|
72
|
+
readonly homepage: string;
|
|
73
|
+
readonly downloadBaseUrl: string;
|
|
74
|
+
readonly sha256: ShaByTarget;
|
|
75
|
+
};
|
|
76
|
+
export declare function renderHomebrewFormula(inputs: ManifestInputs): string;
|
|
77
|
+
export declare function renderDebianControl(inputs: ManifestInputs): string;
|
|
78
|
+
export declare function renderScoopManifest(inputs: ManifestInputs): unknown;
|
|
79
|
+
export declare function renderWingetManifest(inputs: ManifestInputs): string;
|
|
80
|
+
export declare function packagingDir(): string;
|
|
81
|
+
export declare function writeAllManifests(inputs: ManifestInputs, root?: string): {
|
|
82
|
+
homebrew: string;
|
|
83
|
+
debian: string;
|
|
84
|
+
scoop: string;
|
|
85
|
+
winget: string;
|
|
86
|
+
};
|
|
87
|
+
/** Read a file's sha256 — used by release tooling to populate manifests. */
|
|
88
|
+
export declare function sha256OfFile(path: string): Promise<string>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/**
|
|
5
|
+
* Section 32 — `@crewhaus/single-binary-cli`
|
|
6
|
+
*
|
|
7
|
+
* `bun build --compile` wrapper that produces self-contained
|
|
8
|
+
* `dist/crewhaus-{linux,macos,windows}-{x64,arm64}` binaries for the
|
|
9
|
+
* `crewhaus` CLI. Each binary is ~80 MB; no Bun/Node prereq on the
|
|
10
|
+
* target host.
|
|
11
|
+
*
|
|
12
|
+
* Bun 1.2's `--compile` cross-compile matrix:
|
|
13
|
+
* - linux-x64 ✅
|
|
14
|
+
* - linux-arm64 ✅
|
|
15
|
+
* - macos-x64 ✅
|
|
16
|
+
* - macos-arm64 ✅
|
|
17
|
+
* - windows-x64 ✅ (named "bun-windows-x64" target)
|
|
18
|
+
* - windows-arm64 ❌ (Bun does not produce windows-arm64; documented
|
|
19
|
+
* gap — use linux-arm64 in WSL or skip)
|
|
20
|
+
*
|
|
21
|
+
* The auto-generated package manifests (Homebrew formula, Debian
|
|
22
|
+
* control, Scoop manifest, Winget manifest) live under
|
|
23
|
+
* `packaging/` and are templated against the binaries this package
|
|
24
|
+
* builds. `renderHomebrewFormula({version, sha256ByPlatform})` etc.
|
|
25
|
+
* regenerate them deterministically on every release.
|
|
26
|
+
*/
|
|
27
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
28
|
+
export class SingleBinaryError extends CrewhausError {
|
|
29
|
+
name = "SingleBinaryError";
|
|
30
|
+
constructor(message, cause) {
|
|
31
|
+
super("config", message, cause);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export const PLATFORMS = ["linux", "macos", "windows"];
|
|
35
|
+
export const ARCHES = ["x64", "arm64"];
|
|
36
|
+
export const BUILD_MATRIX = [
|
|
37
|
+
{ platform: "linux", arch: "x64" },
|
|
38
|
+
{ platform: "linux", arch: "arm64" },
|
|
39
|
+
{ platform: "macos", arch: "x64" },
|
|
40
|
+
{ platform: "macos", arch: "arm64" },
|
|
41
|
+
{ platform: "windows", arch: "x64" },
|
|
42
|
+
];
|
|
43
|
+
/** Bun's --target string for a given (platform, arch) pair. */
|
|
44
|
+
export function bunCompileTarget(t) {
|
|
45
|
+
return `bun-${t.platform}-${t.arch}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Output filename of a built binary. Adds `.exe` on windows.
|
|
49
|
+
*/
|
|
50
|
+
export function binaryName(t, version) {
|
|
51
|
+
const base = `crewhaus-${t.platform}-${t.arch}`;
|
|
52
|
+
const versioned = version ? `${base}-${version}` : base;
|
|
53
|
+
return t.platform === "windows" ? `${versioned}.exe` : versioned;
|
|
54
|
+
}
|
|
55
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
56
|
+
const REPO_ROOT = resolve(PACKAGE_ROOT, "..", "..");
|
|
57
|
+
const CLI_ENTRYPOINT_REL = "apps/cli/src/index.ts";
|
|
58
|
+
export async function buildBinary(opts) {
|
|
59
|
+
const t = opts.target;
|
|
60
|
+
if (!isBuildTarget(t)) {
|
|
61
|
+
throw new SingleBinaryError(`unsupported build target: ${t.platform}-${t.arch} (allowed: ${BUILD_MATRIX.map(formatTarget).join(", ")})`);
|
|
62
|
+
}
|
|
63
|
+
const outDir = opts.outDir ?? join(REPO_ROOT, "dist");
|
|
64
|
+
const version = (opts.version ?? "").trim();
|
|
65
|
+
const outPath = join(outDir, binaryName(t, version));
|
|
66
|
+
const argv = [
|
|
67
|
+
"bun",
|
|
68
|
+
"build",
|
|
69
|
+
"--compile",
|
|
70
|
+
"--target",
|
|
71
|
+
bunCompileTarget(t),
|
|
72
|
+
// Embed the version: inside a compiled binary import.meta.url points
|
|
73
|
+
// into the virtual /$bunfs tree, so the CLI's runtime ../package.json
|
|
74
|
+
// read cannot work — `crewhaus --version` uses this constant instead.
|
|
75
|
+
...(version ? ["--define", `CREWHAUS_EMBEDDED_VERSION=${JSON.stringify(version)}`] : []),
|
|
76
|
+
CLI_ENTRYPOINT_REL,
|
|
77
|
+
"--outfile",
|
|
78
|
+
outPath,
|
|
79
|
+
];
|
|
80
|
+
const runner = opts.runner ?? defaultRunner;
|
|
81
|
+
const { exitCode, stderr } = await runner(argv, REPO_ROOT);
|
|
82
|
+
if (exitCode !== 0) {
|
|
83
|
+
throw new SingleBinaryError(`bun build --compile (${formatTarget(t)}) exited with ${exitCode}: ${stderr.slice(0, 1024)}`);
|
|
84
|
+
}
|
|
85
|
+
return { target: t, outPath, buildArgv: argv };
|
|
86
|
+
}
|
|
87
|
+
export function isBuildTarget(t) {
|
|
88
|
+
return BUILD_MATRIX.some((m) => m.platform === t.platform && m.arch === t.arch);
|
|
89
|
+
}
|
|
90
|
+
export function formatTarget(t) {
|
|
91
|
+
return `${t.platform}-${t.arch}`;
|
|
92
|
+
}
|
|
93
|
+
const defaultRunner = async (argv, cwd) => {
|
|
94
|
+
const { spawn } = await import("node:child_process");
|
|
95
|
+
return new Promise((resolve_) => {
|
|
96
|
+
const head = argv[0] ?? "bun";
|
|
97
|
+
const child = spawn(head, argv.slice(1), { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
98
|
+
const out = [];
|
|
99
|
+
const err = [];
|
|
100
|
+
child.stdout.on("data", (b) => out.push(b));
|
|
101
|
+
child.stderr.on("data", (b) => err.push(b));
|
|
102
|
+
child.on("error", (e) => resolve_({ exitCode: 1, stdout: "", stderr: String(e.message) }));
|
|
103
|
+
child.on("close", (code) => resolve_({
|
|
104
|
+
exitCode: code ?? 1,
|
|
105
|
+
stdout: Buffer.concat(out).toString("utf8"),
|
|
106
|
+
stderr: Buffer.concat(err).toString("utf8"),
|
|
107
|
+
}));
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
export function renderHomebrewFormula(inputs) {
|
|
111
|
+
const { version, homepage, downloadBaseUrl, sha256 } = inputs;
|
|
112
|
+
if (!/^\d+\.\d+\.\d+/.test(version)) {
|
|
113
|
+
throw new SingleBinaryError(`homebrew version must be semver-shaped: ${version}`);
|
|
114
|
+
}
|
|
115
|
+
const macosArm64 = requireSha(sha256, "macos-arm64");
|
|
116
|
+
const macosX64 = requireSha(sha256, "macos-x64");
|
|
117
|
+
const linuxArm64 = requireSha(sha256, "linux-arm64");
|
|
118
|
+
const linuxX64 = requireSha(sha256, "linux-x64");
|
|
119
|
+
return `class Crewhaus < Formula
|
|
120
|
+
desc "Modular meta-harness — compile a single spec into multiple agent runtimes"
|
|
121
|
+
homepage "${homepage}"
|
|
122
|
+
version "${version}"
|
|
123
|
+
license "Apache-2.0"
|
|
124
|
+
|
|
125
|
+
on_macos do
|
|
126
|
+
on_arm do
|
|
127
|
+
url "${downloadBaseUrl}/crewhaus-macos-arm64-${version}"
|
|
128
|
+
sha256 "${macosArm64}"
|
|
129
|
+
end
|
|
130
|
+
on_intel do
|
|
131
|
+
url "${downloadBaseUrl}/crewhaus-macos-x64-${version}"
|
|
132
|
+
sha256 "${macosX64}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
on_linux do
|
|
137
|
+
on_arm do
|
|
138
|
+
url "${downloadBaseUrl}/crewhaus-linux-arm64-${version}"
|
|
139
|
+
sha256 "${linuxArm64}"
|
|
140
|
+
end
|
|
141
|
+
on_intel do
|
|
142
|
+
url "${downloadBaseUrl}/crewhaus-linux-x64-${version}"
|
|
143
|
+
sha256 "${linuxX64}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def install
|
|
148
|
+
bin.install Dir["*"].first => "crewhaus"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
test do
|
|
152
|
+
system "#{bin}/crewhaus", "--version"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
`;
|
|
156
|
+
}
|
|
157
|
+
export function renderDebianControl(inputs) {
|
|
158
|
+
const { version } = inputs;
|
|
159
|
+
return `Package: crewhaus
|
|
160
|
+
Version: ${version}
|
|
161
|
+
Section: utils
|
|
162
|
+
Priority: optional
|
|
163
|
+
Architecture: any
|
|
164
|
+
Maintainer: CrewHaus Maintainers <maintainers@crewhaus.ai>
|
|
165
|
+
Depends: libc6 (>= 2.31)
|
|
166
|
+
Description: Modular meta-harness — compile a single spec into multiple agent runtimes
|
|
167
|
+
CrewHaus compiles a single high-level harness spec into multiple
|
|
168
|
+
runtime targets (graph, workflow, channel bot, eval, batch worker,
|
|
169
|
+
voice service, browser-driver, research-runner). The binary is a
|
|
170
|
+
self-contained Bun bundle requiring no Node/Bun on the target host.
|
|
171
|
+
`;
|
|
172
|
+
}
|
|
173
|
+
export function renderScoopManifest(inputs) {
|
|
174
|
+
const { version, homepage, downloadBaseUrl, sha256 } = inputs;
|
|
175
|
+
const winX64 = requireSha(sha256, "windows-x64");
|
|
176
|
+
return {
|
|
177
|
+
version,
|
|
178
|
+
description: "Modular meta-harness — compile a single spec into multiple agent runtimes",
|
|
179
|
+
homepage,
|
|
180
|
+
license: "Apache-2.0",
|
|
181
|
+
architecture: {
|
|
182
|
+
"64bit": {
|
|
183
|
+
url: `${downloadBaseUrl}/crewhaus-windows-x64-${version}.exe`,
|
|
184
|
+
hash: winX64,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
bin: "crewhaus.exe",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
export function renderWingetManifest(inputs) {
|
|
191
|
+
const { version, homepage, downloadBaseUrl, sha256 } = inputs;
|
|
192
|
+
const winX64 = requireSha(sha256, "windows-x64");
|
|
193
|
+
return `# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.4.0.schema.json
|
|
194
|
+
PackageIdentifier: CrewHaus.CLI
|
|
195
|
+
PackageVersion: ${version}
|
|
196
|
+
Publisher: CrewHaus
|
|
197
|
+
Author: CrewHaus
|
|
198
|
+
PackageName: crewhaus
|
|
199
|
+
PackageUrl: ${homepage}
|
|
200
|
+
License: Apache-2.0
|
|
201
|
+
ShortDescription: Modular meta-harness — compile a single spec into multiple agent runtimes
|
|
202
|
+
Description: |
|
|
203
|
+
CrewHaus compiles a single high-level harness spec into multiple runtime targets.
|
|
204
|
+
Tags:
|
|
205
|
+
- cli
|
|
206
|
+
- llm
|
|
207
|
+
- agent-framework
|
|
208
|
+
Installers:
|
|
209
|
+
- Architecture: x64
|
|
210
|
+
InstallerType: portable
|
|
211
|
+
InstallerUrl: ${downloadBaseUrl}/crewhaus-windows-x64-${version}.exe
|
|
212
|
+
InstallerSha256: ${winX64.toUpperCase()}
|
|
213
|
+
ManifestType: installer
|
|
214
|
+
ManifestVersion: 1.4.0
|
|
215
|
+
`;
|
|
216
|
+
}
|
|
217
|
+
function requireSha(map, target) {
|
|
218
|
+
const sha = map[target];
|
|
219
|
+
if (!sha) {
|
|
220
|
+
throw new SingleBinaryError(`missing sha256 for ${target} in package manifest inputs`);
|
|
221
|
+
}
|
|
222
|
+
if (!/^[0-9a-f]{64}$/i.test(sha)) {
|
|
223
|
+
throw new SingleBinaryError(`malformed sha256 for ${target}: ${sha}`);
|
|
224
|
+
}
|
|
225
|
+
return sha;
|
|
226
|
+
}
|
|
227
|
+
// ─── on-disk manifest writers ────────────────────────────────────────────────
|
|
228
|
+
export function packagingDir() {
|
|
229
|
+
return join(PACKAGE_ROOT, "packaging");
|
|
230
|
+
}
|
|
231
|
+
export function writeAllManifests(inputs, root = packagingDir()) {
|
|
232
|
+
mkdirSync(join(root, "Formula"), { recursive: true });
|
|
233
|
+
mkdirSync(join(root, "debian"), { recursive: true });
|
|
234
|
+
const homebrew = join(root, "Formula", "crewhaus.rb");
|
|
235
|
+
const debian = join(root, "debian", "control");
|
|
236
|
+
const scoop = join(root, "scoop.json");
|
|
237
|
+
const winget = join(root, "winget.yaml");
|
|
238
|
+
writeFileSync(homebrew, renderHomebrewFormula(inputs), { mode: 0o644 });
|
|
239
|
+
writeFileSync(debian, renderDebianControl(inputs), { mode: 0o644 });
|
|
240
|
+
writeFileSync(scoop, `${JSON.stringify(renderScoopManifest(inputs), null, 2)}\n`, {
|
|
241
|
+
mode: 0o644,
|
|
242
|
+
});
|
|
243
|
+
writeFileSync(winget, renderWingetManifest(inputs), { mode: 0o644 });
|
|
244
|
+
return { homebrew, debian, scoop, winget };
|
|
245
|
+
}
|
|
246
|
+
// ─── helpers consumed by the binary CLI ─────────────────────────────────────
|
|
247
|
+
/** Read a file's sha256 — used by release tooling to populate manifests. */
|
|
248
|
+
export async function sha256OfFile(path) {
|
|
249
|
+
if (!existsSync(path)) {
|
|
250
|
+
throw new SingleBinaryError(`file not found: ${path}`);
|
|
251
|
+
}
|
|
252
|
+
const { createHash } = await import("node:crypto");
|
|
253
|
+
const hash = createHash("sha256");
|
|
254
|
+
hash.update(readFileSync(path));
|
|
255
|
+
return hash.digest("hex");
|
|
256
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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 { BUILD_MATRIX, binaryName, formatTarget, packagingDir, sha256OfFile, writeAllManifests, } from "./index";
|
|
17
|
+
function flag(name, argv) {
|
|
18
|
+
const i = argv.indexOf(`--${name}`);
|
|
19
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
20
|
+
}
|
|
21
|
+
async function main() {
|
|
22
|
+
const argv = process.argv.slice(2);
|
|
23
|
+
const version = flag("version", argv);
|
|
24
|
+
if (!version) {
|
|
25
|
+
console.error("✗ --version is required (e.g. --version 1.2.3)");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const homepage = flag("homepage", argv) ?? "https://github.com/crewhaus/factory";
|
|
29
|
+
const downloadBaseUrl = flag("download-base-url", argv) ??
|
|
30
|
+
`https://github.com/crewhaus/factory/releases/download/v${version}`;
|
|
31
|
+
const distDir = resolve(flag("dist", argv) ?? join(import.meta.dir, "..", "..", "..", "dist"));
|
|
32
|
+
const outArg = flag("out", argv);
|
|
33
|
+
const outDir = outArg ? resolve(outArg) : packagingDir();
|
|
34
|
+
// sha256 every built binary. A missing file is a hard error — the manifests
|
|
35
|
+
// must never reference an asset that was not actually built and uploaded.
|
|
36
|
+
const sha256 = {};
|
|
37
|
+
for (const t of BUILD_MATRIX) {
|
|
38
|
+
const file = join(distDir, binaryName(t, version));
|
|
39
|
+
sha256[formatTarget(t)] = await sha256OfFile(file);
|
|
40
|
+
}
|
|
41
|
+
const inputs = { version, homepage, downloadBaseUrl, sha256: sha256 };
|
|
42
|
+
const written = writeAllManifests(inputs, outDir);
|
|
43
|
+
console.log(JSON.stringify({ version, downloadBaseUrl, sha256, written }, null, 2));
|
|
44
|
+
}
|
|
45
|
+
main().catch((err) => {
|
|
46
|
+
console.error(`✗ manifest generation failed: ${err.message}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
});
|
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/single-binary-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src",
|
|
@@ -14,7 +17,7 @@
|
|
|
14
17
|
"manifests": "bun src/manifests.ts"
|
|
15
18
|
},
|
|
16
19
|
"dependencies": {
|
|
17
|
-
"@crewhaus/errors": "0.1.
|
|
20
|
+
"@crewhaus/errors": "0.1.5"
|
|
18
21
|
},
|
|
19
22
|
"license": "Apache-2.0",
|
|
20
23
|
"author": {
|
|
@@ -34,5 +37,5 @@
|
|
|
34
37
|
"publishConfig": {
|
|
35
38
|
"access": "public"
|
|
36
39
|
},
|
|
37
|
-
"files": ["
|
|
40
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
38
41
|
}
|
package/src/cli.ts
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `bun run build:binary` entry point. Builds the requested target, or
|
|
3
|
-
* the full BUILD_MATRIX when no target flag is passed.
|
|
4
|
-
*
|
|
5
|
-
* Examples:
|
|
6
|
-
* bun run build:binary --target macos-arm64
|
|
7
|
-
* bun run build:binary --version 1.0.0 # builds every entry in BUILD_MATRIX
|
|
8
|
-
*/
|
|
9
|
-
import {
|
|
10
|
-
ARCHES,
|
|
11
|
-
type Arch,
|
|
12
|
-
BUILD_MATRIX,
|
|
13
|
-
type BuildTarget,
|
|
14
|
-
PLATFORMS,
|
|
15
|
-
type Platform,
|
|
16
|
-
buildBinary,
|
|
17
|
-
formatTarget,
|
|
18
|
-
} from "./index";
|
|
19
|
-
|
|
20
|
-
function parseFlag(name: string, argv: string[]): string | undefined {
|
|
21
|
-
const idx = argv.findIndex((a) => a === `--${name}`);
|
|
22
|
-
return idx >= 0 ? argv[idx + 1] : undefined;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function parseTarget(value: string): BuildTarget | undefined {
|
|
26
|
-
const [platform, arch] = value.split("-") as [string, string | undefined];
|
|
27
|
-
if (!platform || !arch) return undefined;
|
|
28
|
-
if (!(PLATFORMS as readonly string[]).includes(platform)) return undefined;
|
|
29
|
-
if (!(ARCHES as readonly string[]).includes(arch)) return undefined;
|
|
30
|
-
return { platform: platform as Platform, arch: arch as Arch };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async function main() {
|
|
34
|
-
const argv = process.argv.slice(2);
|
|
35
|
-
const version = parseFlag("version", argv) ?? "";
|
|
36
|
-
const targetArg = parseFlag("target", argv);
|
|
37
|
-
const targets: readonly BuildTarget[] = targetArg
|
|
38
|
-
? (() => {
|
|
39
|
-
const t = parseTarget(targetArg);
|
|
40
|
-
if (!t) {
|
|
41
|
-
console.error(`unknown target: ${targetArg}`);
|
|
42
|
-
process.exit(2);
|
|
43
|
-
}
|
|
44
|
-
return [t];
|
|
45
|
-
})()
|
|
46
|
-
: BUILD_MATRIX;
|
|
47
|
-
|
|
48
|
-
for (const t of targets) {
|
|
49
|
-
process.stdout.write(`building crewhaus-${formatTarget(t)}...\n`);
|
|
50
|
-
try {
|
|
51
|
-
const result = await buildBinary({ target: t, version });
|
|
52
|
-
process.stdout.write(` → ${result.outPath}\n`);
|
|
53
|
-
} catch (err) {
|
|
54
|
-
console.error(` ✗ ${formatTarget(t)} failed: ${(err as Error).message}`);
|
|
55
|
-
process.exit(1);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
main();
|
package/src/index.test.ts
DELETED
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, readFileSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
ARCHES,
|
|
8
|
-
BUILD_MATRIX,
|
|
9
|
-
type BuildBinaryRunner,
|
|
10
|
-
type BuildTarget,
|
|
11
|
-
PLATFORMS,
|
|
12
|
-
SingleBinaryError,
|
|
13
|
-
binaryName,
|
|
14
|
-
buildBinary,
|
|
15
|
-
bunCompileTarget,
|
|
16
|
-
formatTarget,
|
|
17
|
-
isBuildTarget,
|
|
18
|
-
packagingDir,
|
|
19
|
-
renderDebianControl,
|
|
20
|
-
renderHomebrewFormula,
|
|
21
|
-
renderScoopManifest,
|
|
22
|
-
renderWingetManifest,
|
|
23
|
-
sha256OfFile,
|
|
24
|
-
writeAllManifests,
|
|
25
|
-
} from "./index";
|
|
26
|
-
|
|
27
|
-
describe("BUILD_MATRIX shape", () => {
|
|
28
|
-
test("has 5 entries (windows-arm64 intentionally absent)", () => {
|
|
29
|
-
expect(BUILD_MATRIX.length).toBe(5);
|
|
30
|
-
expect(
|
|
31
|
-
BUILD_MATRIX.find((m) => m.platform === "windows" && m.arch === "arm64"),
|
|
32
|
-
).toBeUndefined();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test("PLATFORMS and ARCHES are tightly typed enums", () => {
|
|
36
|
-
expect(PLATFORMS).toEqual(["linux", "macos", "windows"]);
|
|
37
|
-
expect(ARCHES).toEqual(["x64", "arm64"]);
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe("bunCompileTarget", () => {
|
|
42
|
-
test.each(BUILD_MATRIX.map((t): [BuildTarget, string] => [t, `bun-${t.platform}-${t.arch}`]))(
|
|
43
|
-
"%j → %s",
|
|
44
|
-
(t, expected) => {
|
|
45
|
-
expect(bunCompileTarget(t)).toBe(expected);
|
|
46
|
-
},
|
|
47
|
-
);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe("binaryName", () => {
|
|
51
|
-
test("appends version + .exe on windows", () => {
|
|
52
|
-
expect(binaryName({ platform: "windows", arch: "x64" }, "1.0.0")).toBe(
|
|
53
|
-
"crewhaus-windows-x64-1.0.0.exe",
|
|
54
|
-
);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("no .exe on linux/macos", () => {
|
|
58
|
-
expect(binaryName({ platform: "linux", arch: "x64" }, "1.0.0")).toBe(
|
|
59
|
-
"crewhaus-linux-x64-1.0.0",
|
|
60
|
-
);
|
|
61
|
-
expect(binaryName({ platform: "macos", arch: "arm64" }, "0.0.1")).toBe(
|
|
62
|
-
"crewhaus-macos-arm64-0.0.1",
|
|
63
|
-
);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("empty version → no trailing dash", () => {
|
|
67
|
-
expect(binaryName({ platform: "linux", arch: "x64" }, "")).toBe("crewhaus-linux-x64");
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe("isBuildTarget guard", () => {
|
|
72
|
-
test("accepts every entry in BUILD_MATRIX", () => {
|
|
73
|
-
for (const t of BUILD_MATRIX) {
|
|
74
|
-
expect(isBuildTarget(t)).toBe(true);
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("rejects windows-arm64 (not supported by Bun)", () => {
|
|
79
|
-
expect(isBuildTarget({ platform: "windows", arch: "arm64" })).toBe(false);
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
describe("buildBinary() (T2 dry-run)", () => {
|
|
84
|
-
test("happy path — argv is bun build --compile --target=<platform>-<arch> ...", async () => {
|
|
85
|
-
let captured: readonly string[] = [];
|
|
86
|
-
const runner: BuildBinaryRunner = async (argv) => {
|
|
87
|
-
captured = argv;
|
|
88
|
-
return { exitCode: 0, stdout: "", stderr: "" };
|
|
89
|
-
};
|
|
90
|
-
const result = await buildBinary({
|
|
91
|
-
target: { platform: "linux", arch: "x64" },
|
|
92
|
-
version: "1.2.3",
|
|
93
|
-
outDir: "/tmp/dist",
|
|
94
|
-
runner,
|
|
95
|
-
});
|
|
96
|
-
expect(captured[0]).toBe("bun");
|
|
97
|
-
expect(captured).toContain("build");
|
|
98
|
-
expect(captured).toContain("--compile");
|
|
99
|
-
expect(captured).toContain("--target");
|
|
100
|
-
expect(captured).toContain("bun-linux-x64");
|
|
101
|
-
expect(captured).toContain("--outfile");
|
|
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"');
|
|
105
|
-
expect(result.outPath).toBe("/tmp/dist/crewhaus-linux-x64-1.2.3");
|
|
106
|
-
});
|
|
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
|
-
|
|
122
|
-
test("rejects unsupported target windows-arm64", async () => {
|
|
123
|
-
await expect(
|
|
124
|
-
buildBinary({
|
|
125
|
-
target: { platform: "windows", arch: "arm64" },
|
|
126
|
-
runner: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
|
|
127
|
-
}),
|
|
128
|
-
).rejects.toThrow(/unsupported build target/);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test("non-zero exit → SingleBinaryError with stderr", async () => {
|
|
132
|
-
await expect(
|
|
133
|
-
buildBinary({
|
|
134
|
-
target: { platform: "linux", arch: "x64" },
|
|
135
|
-
runner: async () => ({ exitCode: 2, stdout: "", stderr: "bun not found" }),
|
|
136
|
-
}),
|
|
137
|
-
).rejects.toThrow(/bun not found/);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test("formatTarget round-trip", () => {
|
|
141
|
-
expect(formatTarget({ platform: "linux", arch: "arm64" })).toBe("linux-arm64");
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
describe("manifest rendering (T1)", () => {
|
|
146
|
-
const sha256 = {
|
|
147
|
-
"macos-arm64": "a".repeat(64),
|
|
148
|
-
"macos-x64": "b".repeat(64),
|
|
149
|
-
"linux-arm64": "c".repeat(64),
|
|
150
|
-
"linux-x64": "d".repeat(64),
|
|
151
|
-
"windows-x64": "e".repeat(64),
|
|
152
|
-
} as const;
|
|
153
|
-
|
|
154
|
-
const inputs = {
|
|
155
|
-
version: "1.0.0",
|
|
156
|
-
homepage: "https://github.com/crewhaus/factory",
|
|
157
|
-
downloadBaseUrl: "https://github.com/crewhaus/factory/releases/download/v1.0.0",
|
|
158
|
-
sha256,
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
test("Homebrew formula has on_macos / on_linux blocks with correct shas", () => {
|
|
162
|
-
const formula = renderHomebrewFormula(inputs);
|
|
163
|
-
expect(formula).toContain("class Crewhaus < Formula");
|
|
164
|
-
expect(formula).toContain('version "1.0.0"');
|
|
165
|
-
expect(formula).toContain("on_macos");
|
|
166
|
-
expect(formula).toContain("on_linux");
|
|
167
|
-
expect(formula).toContain('license "Apache-2.0"');
|
|
168
|
-
expect(formula).toContain(sha256["macos-arm64"]);
|
|
169
|
-
expect(formula).toContain(sha256["macos-x64"]);
|
|
170
|
-
expect(formula).toContain(sha256["linux-arm64"]);
|
|
171
|
-
expect(formula).toContain(sha256["linux-x64"]);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test("Homebrew rejects non-semver versions", () => {
|
|
175
|
-
expect(() => renderHomebrewFormula({ ...inputs, version: "v1" })).toThrow();
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test("Debian control has correct Architecture/Description", () => {
|
|
179
|
-
const ctl = renderDebianControl(inputs);
|
|
180
|
-
expect(ctl).toContain("Package: crewhaus");
|
|
181
|
-
expect(ctl).toContain("Version: 1.0.0");
|
|
182
|
-
expect(ctl).toContain("Architecture: any");
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
test("Scoop manifest exposes 64bit url + hash", () => {
|
|
186
|
-
const scoop = renderScoopManifest(inputs) as {
|
|
187
|
-
version: string;
|
|
188
|
-
license: string;
|
|
189
|
-
architecture: { "64bit": { url: string; hash: string } };
|
|
190
|
-
};
|
|
191
|
-
expect(scoop.version).toBe("1.0.0");
|
|
192
|
-
expect(scoop.license).toBe("Apache-2.0");
|
|
193
|
-
expect(scoop.architecture["64bit"].hash).toBe(sha256["windows-x64"]);
|
|
194
|
-
expect(scoop.architecture["64bit"].url).toContain("crewhaus-windows-x64-1.0.0.exe");
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
test("Winget manifest is valid YAML-shaped text with InstallerSha256 uppercase", () => {
|
|
198
|
-
const winget = renderWingetManifest(inputs);
|
|
199
|
-
expect(winget).toContain("PackageIdentifier: CrewHaus.CLI");
|
|
200
|
-
expect(winget).toContain("PackageVersion: 1.0.0");
|
|
201
|
-
expect(winget).toContain("License: Apache-2.0");
|
|
202
|
-
expect(winget).toContain(sha256["windows-x64"].toUpperCase());
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test("manifest rendering refuses missing/short shas", () => {
|
|
206
|
-
expect(() =>
|
|
207
|
-
renderHomebrewFormula({ ...inputs, sha256: { "macos-arm64": sha256["macos-arm64"] } }),
|
|
208
|
-
).toThrow(/missing sha256/);
|
|
209
|
-
expect(() => renderScoopManifest({ ...inputs, sha256: { "windows-x64": "short" } })).toThrow(
|
|
210
|
-
/malformed sha256/,
|
|
211
|
-
);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
test("writeAllManifests writes deterministic files", () => {
|
|
215
|
-
const dir = mkdtempSync(join(tmpdir(), "crewhaus-pkg-"));
|
|
216
|
-
const out = writeAllManifests(inputs, dir);
|
|
217
|
-
const formulaText = readFileSync(out.homebrew, "utf8");
|
|
218
|
-
const scoopText = readFileSync(out.scoop, "utf8");
|
|
219
|
-
expect(formulaText).toContain('version "1.0.0"');
|
|
220
|
-
expect(scoopText).toContain(
|
|
221
|
-
'"hash": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"',
|
|
222
|
-
);
|
|
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
|
-
});
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
describe("sha256OfFile", () => {
|
|
232
|
-
test("rejects missing files", async () => {
|
|
233
|
-
await expect(sha256OfFile("/nonexistent/path/to/file")).rejects.toThrow(SingleBinaryError);
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
test("hashes a real file deterministically", async () => {
|
|
237
|
-
const dir = mkdtempSync(join(tmpdir(), "crewhaus-sha-"));
|
|
238
|
-
const path = join(dir, "test.bin");
|
|
239
|
-
const { writeFileSync } = await import("node:fs");
|
|
240
|
-
writeFileSync(path, "deterministic test bytes");
|
|
241
|
-
const a = await sha256OfFile(path);
|
|
242
|
-
const b = await sha256OfFile(path);
|
|
243
|
-
expect(a).toBe(b);
|
|
244
|
-
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
|
245
|
-
});
|
|
246
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,331 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join, resolve } from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
/**
|
|
5
|
-
* Section 32 — `@crewhaus/single-binary-cli`
|
|
6
|
-
*
|
|
7
|
-
* `bun build --compile` wrapper that produces self-contained
|
|
8
|
-
* `dist/crewhaus-{linux,macos,windows}-{x64,arm64}` binaries for the
|
|
9
|
-
* `crewhaus` CLI. Each binary is ~80 MB; no Bun/Node prereq on the
|
|
10
|
-
* target host.
|
|
11
|
-
*
|
|
12
|
-
* Bun 1.2's `--compile` cross-compile matrix:
|
|
13
|
-
* - linux-x64 ✅
|
|
14
|
-
* - linux-arm64 ✅
|
|
15
|
-
* - macos-x64 ✅
|
|
16
|
-
* - macos-arm64 ✅
|
|
17
|
-
* - windows-x64 ✅ (named "bun-windows-x64" target)
|
|
18
|
-
* - windows-arm64 ❌ (Bun does not produce windows-arm64; documented
|
|
19
|
-
* gap — use linux-arm64 in WSL or skip)
|
|
20
|
-
*
|
|
21
|
-
* The auto-generated package manifests (Homebrew formula, Debian
|
|
22
|
-
* control, Scoop manifest, Winget manifest) live under
|
|
23
|
-
* `packaging/` and are templated against the binaries this package
|
|
24
|
-
* builds. `renderHomebrewFormula({version, sha256ByPlatform})` etc.
|
|
25
|
-
* regenerate them deterministically on every release.
|
|
26
|
-
*/
|
|
27
|
-
import { CrewhausError } from "@crewhaus/errors";
|
|
28
|
-
|
|
29
|
-
export class SingleBinaryError extends CrewhausError {
|
|
30
|
-
override readonly name = "SingleBinaryError";
|
|
31
|
-
constructor(message: string, cause?: unknown) {
|
|
32
|
-
super("config", message, cause);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export const PLATFORMS = ["linux", "macos", "windows"] as const;
|
|
37
|
-
export const ARCHES = ["x64", "arm64"] as const;
|
|
38
|
-
export type Platform = (typeof PLATFORMS)[number];
|
|
39
|
-
export type Arch = (typeof ARCHES)[number];
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The (platform, arch) pairs Bun --compile actually produces. The
|
|
43
|
-
* `windows-arm64` slot is intentionally absent because Bun has no
|
|
44
|
-
* `bun-windows-arm64` target as of Bun 1.2.
|
|
45
|
-
*/
|
|
46
|
-
export type BuildTarget = { readonly platform: Platform; readonly arch: Arch };
|
|
47
|
-
export const BUILD_MATRIX: readonly BuildTarget[] = [
|
|
48
|
-
{ platform: "linux", arch: "x64" },
|
|
49
|
-
{ platform: "linux", arch: "arm64" },
|
|
50
|
-
{ platform: "macos", arch: "x64" },
|
|
51
|
-
{ platform: "macos", arch: "arm64" },
|
|
52
|
-
{ platform: "windows", arch: "x64" },
|
|
53
|
-
];
|
|
54
|
-
|
|
55
|
-
/** Bun's --target string for a given (platform, arch) pair. */
|
|
56
|
-
export function bunCompileTarget(t: BuildTarget): string {
|
|
57
|
-
return `bun-${t.platform}-${t.arch}`;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Output filename of a built binary. Adds `.exe` on windows.
|
|
62
|
-
*/
|
|
63
|
-
export function binaryName(t: BuildTarget, version: string): string {
|
|
64
|
-
const base = `crewhaus-${t.platform}-${t.arch}`;
|
|
65
|
-
const versioned = version ? `${base}-${version}` : base;
|
|
66
|
-
return t.platform === "windows" ? `${versioned}.exe` : versioned;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
70
|
-
const REPO_ROOT = resolve(PACKAGE_ROOT, "..", "..");
|
|
71
|
-
const CLI_ENTRYPOINT_REL = "apps/cli/src/index.ts";
|
|
72
|
-
|
|
73
|
-
export type BuildBinaryRunner = (
|
|
74
|
-
argv: readonly string[],
|
|
75
|
-
cwd: string,
|
|
76
|
-
) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
|
|
77
|
-
|
|
78
|
-
export type BuildBinaryOptions = {
|
|
79
|
-
readonly target: BuildTarget;
|
|
80
|
-
readonly version?: string;
|
|
81
|
-
readonly outDir?: string;
|
|
82
|
-
/** Test injection point. Defaults to spawning bun via node:child_process. */
|
|
83
|
-
readonly runner?: BuildBinaryRunner;
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
export type BuildBinaryResult = {
|
|
87
|
-
readonly target: BuildTarget;
|
|
88
|
-
readonly outPath: string;
|
|
89
|
-
readonly buildArgv: readonly string[];
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
export async function buildBinary(opts: BuildBinaryOptions): Promise<BuildBinaryResult> {
|
|
93
|
-
const t = opts.target;
|
|
94
|
-
if (!isBuildTarget(t)) {
|
|
95
|
-
throw new SingleBinaryError(
|
|
96
|
-
`unsupported build target: ${t.platform}-${t.arch} (allowed: ${BUILD_MATRIX.map(formatTarget).join(", ")})`,
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
const outDir = opts.outDir ?? join(REPO_ROOT, "dist");
|
|
100
|
-
const version = (opts.version ?? "").trim();
|
|
101
|
-
const outPath = join(outDir, binaryName(t, version));
|
|
102
|
-
const argv: string[] = [
|
|
103
|
-
"bun",
|
|
104
|
-
"build",
|
|
105
|
-
"--compile",
|
|
106
|
-
"--target",
|
|
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)}`] : []),
|
|
112
|
-
CLI_ENTRYPOINT_REL,
|
|
113
|
-
"--outfile",
|
|
114
|
-
outPath,
|
|
115
|
-
];
|
|
116
|
-
|
|
117
|
-
const runner = opts.runner ?? defaultRunner;
|
|
118
|
-
const { exitCode, stderr } = await runner(argv, REPO_ROOT);
|
|
119
|
-
if (exitCode !== 0) {
|
|
120
|
-
throw new SingleBinaryError(
|
|
121
|
-
`bun build --compile (${formatTarget(t)}) exited with ${exitCode}: ${stderr.slice(0, 1024)}`,
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
return { target: t, outPath, buildArgv: argv };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function isBuildTarget(t: BuildTarget): boolean {
|
|
128
|
-
return BUILD_MATRIX.some((m) => m.platform === t.platform && m.arch === t.arch);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function formatTarget(t: BuildTarget): string {
|
|
132
|
-
return `${t.platform}-${t.arch}`;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const defaultRunner: BuildBinaryRunner = async (argv, cwd) => {
|
|
136
|
-
const { spawn } = await import("node:child_process");
|
|
137
|
-
return new Promise((resolve_) => {
|
|
138
|
-
const head = argv[0] ?? "bun";
|
|
139
|
-
const child = spawn(head, argv.slice(1), { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
140
|
-
const out: Buffer[] = [];
|
|
141
|
-
const err: Buffer[] = [];
|
|
142
|
-
child.stdout.on("data", (b) => out.push(b));
|
|
143
|
-
child.stderr.on("data", (b) => err.push(b));
|
|
144
|
-
child.on("error", (e) =>
|
|
145
|
-
resolve_({ exitCode: 1, stdout: "", stderr: String((e as Error).message) }),
|
|
146
|
-
);
|
|
147
|
-
child.on("close", (code) =>
|
|
148
|
-
resolve_({
|
|
149
|
-
exitCode: code ?? 1,
|
|
150
|
-
stdout: Buffer.concat(out).toString("utf8"),
|
|
151
|
-
stderr: Buffer.concat(err).toString("utf8"),
|
|
152
|
-
}),
|
|
153
|
-
);
|
|
154
|
-
});
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
// ─── package manifest generation ─────────────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
export type ShaByTarget = Readonly<Partial<Record<string, string>>>;
|
|
160
|
-
|
|
161
|
-
export type ManifestInputs = {
|
|
162
|
-
readonly version: string;
|
|
163
|
-
readonly homepage: string;
|
|
164
|
-
readonly downloadBaseUrl: string;
|
|
165
|
-
readonly sha256: ShaByTarget;
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
export function renderHomebrewFormula(inputs: ManifestInputs): string {
|
|
169
|
-
const { version, homepage, downloadBaseUrl, sha256 } = inputs;
|
|
170
|
-
if (!/^\d+\.\d+\.\d+/.test(version)) {
|
|
171
|
-
throw new SingleBinaryError(`homebrew version must be semver-shaped: ${version}`);
|
|
172
|
-
}
|
|
173
|
-
const macosArm64 = requireSha(sha256, "macos-arm64");
|
|
174
|
-
const macosX64 = requireSha(sha256, "macos-x64");
|
|
175
|
-
const linuxArm64 = requireSha(sha256, "linux-arm64");
|
|
176
|
-
const linuxX64 = requireSha(sha256, "linux-x64");
|
|
177
|
-
return `class Crewhaus < Formula
|
|
178
|
-
desc "Modular meta-harness — compile a single spec into multiple agent runtimes"
|
|
179
|
-
homepage "${homepage}"
|
|
180
|
-
version "${version}"
|
|
181
|
-
license "Apache-2.0"
|
|
182
|
-
|
|
183
|
-
on_macos do
|
|
184
|
-
on_arm do
|
|
185
|
-
url "${downloadBaseUrl}/crewhaus-macos-arm64-${version}"
|
|
186
|
-
sha256 "${macosArm64}"
|
|
187
|
-
end
|
|
188
|
-
on_intel do
|
|
189
|
-
url "${downloadBaseUrl}/crewhaus-macos-x64-${version}"
|
|
190
|
-
sha256 "${macosX64}"
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
on_linux do
|
|
195
|
-
on_arm do
|
|
196
|
-
url "${downloadBaseUrl}/crewhaus-linux-arm64-${version}"
|
|
197
|
-
sha256 "${linuxArm64}"
|
|
198
|
-
end
|
|
199
|
-
on_intel do
|
|
200
|
-
url "${downloadBaseUrl}/crewhaus-linux-x64-${version}"
|
|
201
|
-
sha256 "${linuxX64}"
|
|
202
|
-
end
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def install
|
|
206
|
-
bin.install Dir["*"].first => "crewhaus"
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
test do
|
|
210
|
-
system "#{bin}/crewhaus", "--version"
|
|
211
|
-
end
|
|
212
|
-
end
|
|
213
|
-
`;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export function renderDebianControl(inputs: ManifestInputs): string {
|
|
217
|
-
const { version } = inputs;
|
|
218
|
-
return `Package: crewhaus
|
|
219
|
-
Version: ${version}
|
|
220
|
-
Section: utils
|
|
221
|
-
Priority: optional
|
|
222
|
-
Architecture: any
|
|
223
|
-
Maintainer: CrewHaus Maintainers <maintainers@crewhaus.ai>
|
|
224
|
-
Depends: libc6 (>= 2.31)
|
|
225
|
-
Description: Modular meta-harness — compile a single spec into multiple agent runtimes
|
|
226
|
-
CrewHaus compiles a single high-level harness spec into multiple
|
|
227
|
-
runtime targets (graph, workflow, channel bot, eval, batch worker,
|
|
228
|
-
voice service, browser-driver, research-runner). The binary is a
|
|
229
|
-
self-contained Bun bundle requiring no Node/Bun on the target host.
|
|
230
|
-
`;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export function renderScoopManifest(inputs: ManifestInputs): unknown {
|
|
234
|
-
const { version, homepage, downloadBaseUrl, sha256 } = inputs;
|
|
235
|
-
const winX64 = requireSha(sha256, "windows-x64");
|
|
236
|
-
return {
|
|
237
|
-
version,
|
|
238
|
-
description: "Modular meta-harness — compile a single spec into multiple agent runtimes",
|
|
239
|
-
homepage,
|
|
240
|
-
license: "Apache-2.0",
|
|
241
|
-
architecture: {
|
|
242
|
-
"64bit": {
|
|
243
|
-
url: `${downloadBaseUrl}/crewhaus-windows-x64-${version}.exe`,
|
|
244
|
-
hash: winX64,
|
|
245
|
-
},
|
|
246
|
-
},
|
|
247
|
-
bin: "crewhaus.exe",
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
export function renderWingetManifest(inputs: ManifestInputs): string {
|
|
252
|
-
const { version, homepage, downloadBaseUrl, sha256 } = inputs;
|
|
253
|
-
const winX64 = requireSha(sha256, "windows-x64");
|
|
254
|
-
return `# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.4.0.schema.json
|
|
255
|
-
PackageIdentifier: CrewHaus.CLI
|
|
256
|
-
PackageVersion: ${version}
|
|
257
|
-
Publisher: CrewHaus
|
|
258
|
-
Author: CrewHaus
|
|
259
|
-
PackageName: crewhaus
|
|
260
|
-
PackageUrl: ${homepage}
|
|
261
|
-
License: Apache-2.0
|
|
262
|
-
ShortDescription: Modular meta-harness — compile a single spec into multiple agent runtimes
|
|
263
|
-
Description: |
|
|
264
|
-
CrewHaus compiles a single high-level harness spec into multiple runtime targets.
|
|
265
|
-
Tags:
|
|
266
|
-
- cli
|
|
267
|
-
- llm
|
|
268
|
-
- agent-framework
|
|
269
|
-
Installers:
|
|
270
|
-
- Architecture: x64
|
|
271
|
-
InstallerType: portable
|
|
272
|
-
InstallerUrl: ${downloadBaseUrl}/crewhaus-windows-x64-${version}.exe
|
|
273
|
-
InstallerSha256: ${winX64.toUpperCase()}
|
|
274
|
-
ManifestType: installer
|
|
275
|
-
ManifestVersion: 1.4.0
|
|
276
|
-
`;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function requireSha(map: ShaByTarget, target: string): string {
|
|
280
|
-
const sha = map[target];
|
|
281
|
-
if (!sha) {
|
|
282
|
-
throw new SingleBinaryError(`missing sha256 for ${target} in package manifest inputs`);
|
|
283
|
-
}
|
|
284
|
-
if (!/^[0-9a-f]{64}$/i.test(sha)) {
|
|
285
|
-
throw new SingleBinaryError(`malformed sha256 for ${target}: ${sha}`);
|
|
286
|
-
}
|
|
287
|
-
return sha;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// ─── on-disk manifest writers ────────────────────────────────────────────────
|
|
291
|
-
|
|
292
|
-
export function packagingDir(): string {
|
|
293
|
-
return join(PACKAGE_ROOT, "packaging");
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
export function writeAllManifests(
|
|
297
|
-
inputs: ManifestInputs,
|
|
298
|
-
root: string = packagingDir(),
|
|
299
|
-
): {
|
|
300
|
-
homebrew: string;
|
|
301
|
-
debian: string;
|
|
302
|
-
scoop: string;
|
|
303
|
-
winget: string;
|
|
304
|
-
} {
|
|
305
|
-
mkdirSync(join(root, "Formula"), { recursive: true });
|
|
306
|
-
mkdirSync(join(root, "debian"), { recursive: true });
|
|
307
|
-
const homebrew = join(root, "Formula", "crewhaus.rb");
|
|
308
|
-
const debian = join(root, "debian", "control");
|
|
309
|
-
const scoop = join(root, "scoop.json");
|
|
310
|
-
const winget = join(root, "winget.yaml");
|
|
311
|
-
writeFileSync(homebrew, renderHomebrewFormula(inputs), { mode: 0o644 });
|
|
312
|
-
writeFileSync(debian, renderDebianControl(inputs), { mode: 0o644 });
|
|
313
|
-
writeFileSync(scoop, `${JSON.stringify(renderScoopManifest(inputs), null, 2)}\n`, {
|
|
314
|
-
mode: 0o644,
|
|
315
|
-
});
|
|
316
|
-
writeFileSync(winget, renderWingetManifest(inputs), { mode: 0o644 });
|
|
317
|
-
return { homebrew, debian, scoop, winget };
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// ─── helpers consumed by the binary CLI ─────────────────────────────────────
|
|
321
|
-
|
|
322
|
-
/** Read a file's sha256 — used by release tooling to populate manifests. */
|
|
323
|
-
export async function sha256OfFile(path: string): Promise<string> {
|
|
324
|
-
if (!existsSync(path)) {
|
|
325
|
-
throw new SingleBinaryError(`file not found: ${path}`);
|
|
326
|
-
}
|
|
327
|
-
const { createHash } = await import("node:crypto");
|
|
328
|
-
const hash = createHash("sha256");
|
|
329
|
-
hash.update(readFileSync(path));
|
|
330
|
-
return hash.digest("hex");
|
|
331
|
-
}
|
package/src/manifests.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
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
|
-
});
|
package/src/runner.test.ts
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
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
|
-
});
|