@crewhaus/docker-images 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.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Section 32 — `@crewhaus/docker-images`
3
+ *
4
+ * Per-target-shape Dockerfiles (one per target shape: cli, workflow, channel,
5
+ * graph, managed, pipeline, crew, research, batch, voice, browser, eval) plus
6
+ * a `buildImage()` wrapper around `docker buildx build` that the
7
+ * `crewhaus build-image <target>` CLI subcommand (apps/cli/src/index.ts) calls.
8
+ *
9
+ * v0 ships:
10
+ * - 12 multi-stage Dockerfiles under `docker/<target>/Dockerfile`,
11
+ * each with non-root user, read-only root, HEALTHCHECK, and
12
+ * target-specific system deps baked in (chromium for browser,
13
+ * libopus for voice, curl+ca-certs for daemon shapes).
14
+ * - `buildImage()` shells to `docker buildx build` (gated on `docker
15
+ * version` succeeding); injectable `runner` for tests.
16
+ * - `loadDigests()` / `recordDigest()` over a `docker/digests.json`
17
+ * lockfile so reproducible builds work offline.
18
+ */
19
+ import { CrewhausError } from "@crewhaus/errors";
20
+ export declare class DockerImagesError extends CrewhausError {
21
+ readonly name = "DockerImagesError";
22
+ constructor(message: string, cause?: unknown);
23
+ }
24
+ /**
25
+ * The 12 target shapes that ship a Dockerfile. Sourced from
26
+ * `packages/spec/src/index.ts` discriminator literals; we keep the
27
+ * canonical list locally because this package owns the "which shapes
28
+ * have a deployable image" question.
29
+ */
30
+ export declare const TARGET_SHAPES: readonly ["cli", "workflow", "channel", "graph", "managed", "pipeline", "crew", "research", "batch", "voice", "browser", "eval"];
31
+ export type TargetShape = (typeof TARGET_SHAPES)[number];
32
+ export declare function isTargetShape(value: unknown): value is TargetShape;
33
+ export declare function dockerRoot(): string;
34
+ export declare function digestsPath(): string;
35
+ export declare function dockerfilePath(target: TargetShape): string;
36
+ export declare function readDockerfile(target: TargetShape): string;
37
+ export declare function dockerfileSize(target: TargetShape): number;
38
+ export declare function listAvailableTargets(): readonly TargetShape[];
39
+ export type BuildRunner = (argv: readonly string[], cwd: string) => Promise<{
40
+ exitCode: number;
41
+ stdout: string;
42
+ stderr: string;
43
+ }>;
44
+ export type BuildImageOptions = {
45
+ readonly target: TargetShape;
46
+ readonly tag: string;
47
+ /** Optional build context path; defaults to the workspace root. */
48
+ readonly contextPath?: string;
49
+ /** Optional buildx platform string, e.g. `linux/amd64,linux/arm64`. */
50
+ readonly platform?: string;
51
+ /** Whether to push to a registry after build. Defaults to false. */
52
+ readonly push?: boolean;
53
+ /** Test injection point — defaults to a node:child_process spawn of `docker`. */
54
+ readonly runner?: BuildRunner;
55
+ };
56
+ export type BuildImageResult = {
57
+ readonly tag: string;
58
+ readonly target: TargetShape;
59
+ readonly buildArgv: readonly string[];
60
+ };
61
+ export declare function buildImage(opts: BuildImageOptions): Promise<BuildImageResult>;
62
+ export declare const defaultRunner: BuildRunner;
63
+ export type DigestEntry = {
64
+ readonly sha256: string;
65
+ readonly builtAt: string;
66
+ };
67
+ export type DigestsFile = {
68
+ _format_version: number;
69
+ [target: string]: unknown;
70
+ };
71
+ export declare function loadDigests(path?: string): DigestsFile;
72
+ export declare function recordDigest(target: TargetShape, tag: string, sha256: string, path?: string): void;
73
+ export type DockerfileFingerprint = {
74
+ readonly stages: ReadonlyArray<{
75
+ readonly image: string;
76
+ readonly stage?: string;
77
+ }>;
78
+ readonly hasHealthcheck: boolean;
79
+ readonly nonRootUser?: string;
80
+ readonly workdir?: string;
81
+ readonly entrypoint?: string;
82
+ readonly cmd?: string;
83
+ readonly exposedPorts: readonly number[];
84
+ readonly envs: readonly string[];
85
+ };
86
+ export declare function fingerprintDockerfile(text: string): DockerfileFingerprint;
package/dist/index.js ADDED
@@ -0,0 +1,177 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /**
5
+ * Section 32 — `@crewhaus/docker-images`
6
+ *
7
+ * Per-target-shape Dockerfiles (one per target shape: cli, workflow, channel,
8
+ * graph, managed, pipeline, crew, research, batch, voice, browser, eval) plus
9
+ * a `buildImage()` wrapper around `docker buildx build` that the
10
+ * `crewhaus build-image <target>` CLI subcommand (apps/cli/src/index.ts) calls.
11
+ *
12
+ * v0 ships:
13
+ * - 12 multi-stage Dockerfiles under `docker/<target>/Dockerfile`,
14
+ * each with non-root user, read-only root, HEALTHCHECK, and
15
+ * target-specific system deps baked in (chromium for browser,
16
+ * libopus for voice, curl+ca-certs for daemon shapes).
17
+ * - `buildImage()` shells to `docker buildx build` (gated on `docker
18
+ * version` succeeding); injectable `runner` for tests.
19
+ * - `loadDigests()` / `recordDigest()` over a `docker/digests.json`
20
+ * lockfile so reproducible builds work offline.
21
+ */
22
+ import { CrewhausError } from "@crewhaus/errors";
23
+ export class DockerImagesError extends CrewhausError {
24
+ name = "DockerImagesError";
25
+ constructor(message, cause) {
26
+ super("config", message, cause);
27
+ }
28
+ }
29
+ /**
30
+ * The 12 target shapes that ship a Dockerfile. Sourced from
31
+ * `packages/spec/src/index.ts` discriminator literals; we keep the
32
+ * canonical list locally because this package owns the "which shapes
33
+ * have a deployable image" question.
34
+ */
35
+ export const TARGET_SHAPES = [
36
+ "cli",
37
+ "workflow",
38
+ "channel",
39
+ "graph",
40
+ "managed",
41
+ "pipeline",
42
+ "crew",
43
+ "research",
44
+ "batch",
45
+ "voice",
46
+ "browser",
47
+ "eval",
48
+ ];
49
+ export function isTargetShape(value) {
50
+ return typeof value === "string" && TARGET_SHAPES.includes(value);
51
+ }
52
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
53
+ const DOCKER_ROOT = join(PACKAGE_ROOT, "docker");
54
+ const DIGESTS_PATH = join(DOCKER_ROOT, "digests.json");
55
+ export function dockerRoot() {
56
+ return DOCKER_ROOT;
57
+ }
58
+ export function digestsPath() {
59
+ return DIGESTS_PATH;
60
+ }
61
+ export function dockerfilePath(target) {
62
+ return join(DOCKER_ROOT, target, "Dockerfile");
63
+ }
64
+ export function readDockerfile(target) {
65
+ const path = dockerfilePath(target);
66
+ if (!existsSync(path)) {
67
+ throw new DockerImagesError(`dockerfile missing for target ${target} (expected at ${path})`);
68
+ }
69
+ return readFileSync(path, "utf8");
70
+ }
71
+ export function dockerfileSize(target) {
72
+ return statSync(dockerfilePath(target)).size;
73
+ }
74
+ export function listAvailableTargets() {
75
+ return TARGET_SHAPES.filter((t) => existsSync(dockerfilePath(t)));
76
+ }
77
+ export async function buildImage(opts) {
78
+ if (!isTargetShape(opts.target)) {
79
+ throw new DockerImagesError(`unknown target shape: ${String(opts.target)}`);
80
+ }
81
+ if (!opts.tag || /[\s\n\r]/.test(opts.tag)) {
82
+ throw new DockerImagesError(`invalid tag: ${JSON.stringify(opts.tag)} — must be non-empty and contain no whitespace`);
83
+ }
84
+ const dockerfile = dockerfilePath(opts.target);
85
+ if (!existsSync(dockerfile)) {
86
+ throw new DockerImagesError(`dockerfile missing for target ${opts.target} (expected at ${dockerfile})`);
87
+ }
88
+ const contextPath = opts.contextPath ?? resolve(PACKAGE_ROOT, "..", "..");
89
+ const argv = [
90
+ "docker",
91
+ "buildx",
92
+ "build",
93
+ "--file",
94
+ dockerfile,
95
+ "--tag",
96
+ `crewhaus/${opts.target}:${opts.tag}`,
97
+ ];
98
+ if (opts.platform) {
99
+ argv.push("--platform", opts.platform);
100
+ }
101
+ argv.push(opts.push ? "--push" : "--load");
102
+ argv.push(contextPath);
103
+ const runner = opts.runner ?? defaultRunner;
104
+ const { exitCode, stderr } = await runner(argv, contextPath);
105
+ if (exitCode !== 0) {
106
+ throw new DockerImagesError(`docker buildx build exited with code ${exitCode}: ${stderr.slice(0, 1024)}`);
107
+ }
108
+ return { tag: opts.tag, target: opts.target, buildArgv: argv };
109
+ }
110
+ export const defaultRunner = async (argv, cwd) => {
111
+ const { spawn } = await import("node:child_process");
112
+ return new Promise((resolve_) => {
113
+ const head = argv[0] ?? "docker";
114
+ const child = spawn(head, argv.slice(1), { cwd, stdio: ["ignore", "pipe", "pipe"] });
115
+ const out = [];
116
+ const err = [];
117
+ child.stdout.on("data", (b) => out.push(b));
118
+ child.stderr.on("data", (b) => err.push(b));
119
+ child.on("error", (e) => resolve_({ exitCode: 1, stdout: "", stderr: String(e.message) }));
120
+ child.on("close", (code) => resolve_({
121
+ exitCode: code ?? 1,
122
+ stdout: Buffer.concat(out).toString("utf8"),
123
+ stderr: Buffer.concat(err).toString("utf8"),
124
+ }));
125
+ });
126
+ };
127
+ export function loadDigests(path = DIGESTS_PATH) {
128
+ if (!existsSync(path)) {
129
+ return { _format_version: 1 };
130
+ }
131
+ const raw = readFileSync(path, "utf8");
132
+ try {
133
+ return JSON.parse(raw);
134
+ }
135
+ catch (cause) {
136
+ throw new DockerImagesError(`failed to parse digests.json at ${path}`, cause);
137
+ }
138
+ }
139
+ export function recordDigest(target, tag, sha256, path = DIGESTS_PATH) {
140
+ if (!/^sha256:[0-9a-f]{64}$/.test(sha256)) {
141
+ throw new DockerImagesError(`invalid sha256: ${sha256}`);
142
+ }
143
+ const current = loadDigests(path);
144
+ const targetEntry = current[target] ?? {};
145
+ targetEntry[tag] = { sha256, builtAt: new Date().toISOString() };
146
+ current[target] = targetEntry;
147
+ current._format_version = 1;
148
+ mkdirSync(dirname(path), { recursive: true });
149
+ writeFileSync(path, `${JSON.stringify(current, null, 2)}\n`, { mode: 0o644 });
150
+ }
151
+ export function fingerprintDockerfile(text) {
152
+ const lines = text.split(/\r?\n/);
153
+ const fromStages = lines
154
+ .map((l) => /^FROM\s+(\S+)(?:\s+AS\s+(\S+))?/i.exec(l.trim()))
155
+ .filter((m) => Boolean(m))
156
+ .map((m) => ({ image: m[1] ?? "", stage: (m[2] ?? "").toLowerCase() || undefined }));
157
+ const exposedPorts = lines
158
+ .filter((l) => /^EXPOSE\s+\d+/i.test(l.trim()))
159
+ .map((l) => Number(l.trim().split(/\s+/)[1]));
160
+ return {
161
+ stages: fromStages,
162
+ hasHealthcheck: /^HEALTHCHECK\s+/im.test(text),
163
+ nonRootUser: /^USER\s+(\S+)/im.exec(text)?.[1],
164
+ workdir: /^WORKDIR\s+(\S+)/im.exec(text)?.[1],
165
+ entrypoint: /^ENTRYPOINT\s+(.+)$/im.exec(text)?.[1]?.trim(),
166
+ cmd: /^CMD\s+(.+)$/im.exec(text)?.[1]?.trim(),
167
+ exposedPorts,
168
+ envs: lines
169
+ .filter((l) => /^ENV\s+/i.test(l.trim()))
170
+ .flatMap((l) => l
171
+ .trim()
172
+ .replace(/^ENV\s+/i, "")
173
+ .split(/\s+/)
174
+ .map((kv) => (kv.split("=")[0] ?? "").trim())
175
+ .filter((k) => k.length > 0 && /^[A-Z_][A-Z0-9_]*$/.test(k))),
176
+ };
177
+ }
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@crewhaus/docker-images",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Per-target-shape Docker images (multi-stage, non-root, healthchecked) + crewhaus build-image CLI wrapper (Section 32)",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.3"
18
+ "@crewhaus/errors": "0.1.5"
16
19
  },
17
20
  "license": "Apache-2.0",
18
21
  "author": {
@@ -32,5 +35,5 @@
32
35
  "publishConfig": {
33
36
  "access": "public"
34
37
  },
35
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
38
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
36
39
  }
package/src/index.test.ts DELETED
@@ -1,307 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
-
6
- import {
7
- type BuildRunner,
8
- DockerImagesError,
9
- TARGET_SHAPES,
10
- buildImage,
11
- defaultRunner,
12
- digestsPath,
13
- dockerRoot,
14
- dockerfilePath,
15
- dockerfileSize,
16
- fingerprintDockerfile,
17
- isTargetShape,
18
- listAvailableTargets,
19
- loadDigests,
20
- readDockerfile,
21
- recordDigest,
22
- } from "./index";
23
-
24
- describe("TARGET_SHAPES", () => {
25
- test("contains exactly the 12 shipped target shapes", () => {
26
- expect(TARGET_SHAPES).toEqual([
27
- "cli",
28
- "workflow",
29
- "channel",
30
- "graph",
31
- "managed",
32
- "pipeline",
33
- "crew",
34
- "research",
35
- "batch",
36
- "voice",
37
- "browser",
38
- "eval",
39
- ]);
40
- });
41
-
42
- test("isTargetShape accepts every shape and rejects unknowns", () => {
43
- for (const t of TARGET_SHAPES) {
44
- expect(isTargetShape(t)).toBe(true);
45
- }
46
- expect(isTargetShape("realtime")).toBe(false);
47
- expect(isTargetShape("")).toBe(false);
48
- expect(isTargetShape(42)).toBe(false);
49
- });
50
- });
51
-
52
- describe("Dockerfiles on disk", () => {
53
- test("every shape has a Dockerfile under docker/<target>/Dockerfile", () => {
54
- for (const t of TARGET_SHAPES) {
55
- expect(dockerfileSize(t)).toBeGreaterThan(100);
56
- }
57
- });
58
-
59
- test("listAvailableTargets returns all 12 shapes", () => {
60
- expect(listAvailableTargets()).toEqual(TARGET_SHAPES);
61
- });
62
-
63
- test("dockerRoot points inside the package", () => {
64
- expect(dockerRoot()).toMatch(/packages\/docker-images\/docker$/);
65
- });
66
-
67
- test("digestsPath resolves to docker/digests.json inside the package", () => {
68
- expect(digestsPath()).toMatch(/packages\/docker-images\/docker\/digests\.json$/);
69
- expect(digestsPath()).toBe(join(dockerRoot(), "digests.json"));
70
- });
71
- });
72
-
73
- describe("Dockerfile structural contract (T2)", () => {
74
- for (const target of TARGET_SHAPES) {
75
- test(`${target} → multi-stage, non-root, healthchecked, has bun runtime`, () => {
76
- const text = readDockerfile(target);
77
- const fp = fingerprintDockerfile(text);
78
- expect(fp.stages.length).toBeGreaterThanOrEqual(2);
79
- expect(fp.hasHealthcheck).toBe(true);
80
- expect(fp.nonRootUser).toBe("crewhaus");
81
- expect(fp.workdir).toBe("/app");
82
- expect(fp.envs).toContain("CREWHAUS_TARGET");
83
- expect(fp.entrypoint?.includes("bun")).toBe(true);
84
- // multi-stage: at least one named stage (deps/build/runtime)
85
- expect(fp.stages.some((s) => s.stage === "deps" || s.stage === "build")).toBe(true);
86
- });
87
- }
88
-
89
- test("daemon shapes (channel/managed/crew/voice/browser) expose a port and curl healthcheck", () => {
90
- const daemonShapes = ["channel", "managed", "crew", "voice", "browser"] as const;
91
- for (const t of daemonShapes) {
92
- const text = readDockerfile(t);
93
- const fp = fingerprintDockerfile(text);
94
- expect(fp.exposedPorts.length).toBeGreaterThan(0);
95
- expect(text).toContain("curl");
96
- }
97
- });
98
-
99
- test("voice image installs libopus", () => {
100
- expect(readDockerfile("voice")).toContain("libopus");
101
- });
102
-
103
- test("browser image installs chromium and Playwright env vars", () => {
104
- const text = readDockerfile("browser");
105
- expect(text).toContain("chromium");
106
- expect(text).toContain("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH");
107
- });
108
- });
109
-
110
- describe("buildImage()", () => {
111
- test("rejects unknown targets", async () => {
112
- await expect(buildImage({ target: "unknown" as never, tag: "smoke" })).rejects.toThrow(
113
- DockerImagesError,
114
- );
115
- });
116
-
117
- test("rejects empty/whitespace tags", async () => {
118
- await expect(buildImage({ target: "cli", tag: "" })).rejects.toThrow(/invalid tag/);
119
- await expect(buildImage({ target: "cli", tag: "bad tag" })).rejects.toThrow(/invalid tag/);
120
- });
121
-
122
- test("happy path emits the documented argv shape", async () => {
123
- const captured: string[][] = [];
124
- const runner: BuildRunner = async (argv) => {
125
- captured.push([...argv]);
126
- return { exitCode: 0, stdout: "", stderr: "" };
127
- };
128
- const result = await buildImage({
129
- target: "cli",
130
- tag: "smoke",
131
- runner,
132
- });
133
- expect(result.tag).toBe("smoke");
134
- expect(result.target).toBe("cli");
135
- expect(captured.length).toBe(1);
136
- const argv = captured[0] ?? [];
137
- expect(argv[0]).toBe("docker");
138
- expect(argv).toContain("buildx");
139
- expect(argv).toContain("build");
140
- expect(argv).toContain("--tag");
141
- expect(argv).toContain("crewhaus/cli:smoke");
142
- expect(argv).toContain("--load");
143
- expect(argv.includes("--push")).toBe(false);
144
- expect(argv).toContain(dockerfilePath("cli"));
145
- });
146
-
147
- test("--push instead of --load when push:true", async () => {
148
- let argv: readonly string[] = [];
149
- await buildImage({
150
- target: "channel",
151
- tag: "v1",
152
- push: true,
153
- runner: async (a) => {
154
- argv = a;
155
- return { exitCode: 0, stdout: "", stderr: "" };
156
- },
157
- });
158
- expect(argv).toContain("--push");
159
- expect(argv.includes("--load")).toBe(false);
160
- });
161
-
162
- test("non-zero exit propagates as DockerImagesError with stderr captured", async () => {
163
- const runner: BuildRunner = async () => ({
164
- exitCode: 1,
165
- stdout: "",
166
- stderr: "no docker daemon",
167
- });
168
- await expect(buildImage({ target: "cli", tag: "smoke", runner })).rejects.toThrow(
169
- /no docker daemon/,
170
- );
171
- });
172
-
173
- test("platform passes through to argv", async () => {
174
- let argv: readonly string[] = [];
175
- await buildImage({
176
- target: "graph",
177
- tag: "multi",
178
- platform: "linux/amd64,linux/arm64",
179
- runner: async (a) => {
180
- argv = a;
181
- return { exitCode: 0, stdout: "", stderr: "" };
182
- },
183
- });
184
- expect(argv).toContain("--platform");
185
- expect(argv).toContain("linux/amd64,linux/arm64");
186
- });
187
- });
188
-
189
- describe("digests lockfile", () => {
190
- test("loadDigests returns _format_version: 1 when file missing", () => {
191
- const dir = mkdtempSync(join(tmpdir(), "crewhaus-digests-"));
192
- const path = join(dir, "missing.json");
193
- expect(loadDigests(path)).toEqual({ _format_version: 1 });
194
- });
195
-
196
- test("recordDigest writes and round-trips", () => {
197
- const dir = mkdtempSync(join(tmpdir(), "crewhaus-digests-"));
198
- const path = join(dir, "digests.json");
199
- const sha = `sha256:${"a".repeat(64)}`;
200
- recordDigest("cli", "smoke", sha, path);
201
- const loaded = loadDigests(path) as { cli?: Record<string, { sha256: string }> };
202
- expect(loaded.cli?.["smoke"]?.sha256).toBe(sha);
203
- });
204
-
205
- test("recordDigest rejects malformed digests", () => {
206
- const dir = mkdtempSync(join(tmpdir(), "crewhaus-digests-"));
207
- const path = join(dir, "digests.json");
208
- expect(() => recordDigest("cli", "tag", "not-a-digest", path)).toThrow(/invalid sha256/);
209
- expect(() => recordDigest("cli", "tag", "sha256:short", path)).toThrow();
210
- });
211
-
212
- test("loadDigests propagates JSON parse errors as DockerImagesError", () => {
213
- const dir = mkdtempSync(join(tmpdir(), "crewhaus-digests-"));
214
- const path = join(dir, "bad.json");
215
- writeFileSync(path, "not json");
216
- expect(() => loadDigests(path)).toThrow(DockerImagesError);
217
- });
218
-
219
- test("recordDigest preserves prior entries across multiple records", () => {
220
- const dir = mkdtempSync(join(tmpdir(), "crewhaus-digests-"));
221
- const path = join(dir, "digests.json");
222
- const shaA = `sha256:${"a".repeat(64)}`;
223
- const shaB = `sha256:${"b".repeat(64)}`;
224
- recordDigest("cli", "v1", shaA, path);
225
- recordDigest("channel", "v1", shaB, path);
226
- const final = JSON.parse(readFileSync(path, "utf8"));
227
- expect(final.cli.v1.sha256).toBe(shaA);
228
- expect(final.channel.v1.sha256).toBe(shaB);
229
- });
230
- });
231
-
232
- describe("fingerprintDockerfile (T1 unit)", () => {
233
- test("parses a minimal multi-stage Dockerfile", () => {
234
- const text = `FROM alpine AS base
235
- FROM base AS runtime
236
- WORKDIR /work
237
- ENV FOO=bar BAZ=qux
238
- USER nobody
239
- EXPOSE 9000
240
- HEALTHCHECK CMD echo
241
- ENTRYPOINT ["bun", "x"]
242
- CMD ["--help"]
243
- `;
244
- const fp = fingerprintDockerfile(text);
245
- expect(fp.stages.map((s) => s.stage)).toEqual(["base", "runtime"]);
246
- expect(fp.hasHealthcheck).toBe(true);
247
- expect(fp.nonRootUser).toBe("nobody");
248
- expect(fp.workdir).toBe("/work");
249
- expect(fp.exposedPorts).toEqual([9000]);
250
- expect([...fp.envs].sort()).toEqual(["BAZ", "FOO"]);
251
- expect(fp.entrypoint).toContain("bun");
252
- expect(fp.cmd).toContain("--help");
253
- });
254
-
255
- test("absent fields surface as undefined / empty", () => {
256
- const fp = fingerprintDockerfile("FROM alpine\n");
257
- expect(fp.hasHealthcheck).toBe(false);
258
- expect(fp.nonRootUser).toBeUndefined();
259
- expect(fp.exposedPorts).toEqual([]);
260
- });
261
- });
262
-
263
- describe("defaultRunner (spawn integration, deterministic)", () => {
264
- // We drive the real spawn-based runner using the running JS binary
265
- // (`process.execPath`) with `-e` — no docker, no network, no external
266
- // service. argv[0] is the program; the rest are passed verbatim.
267
- const exe = process.execPath;
268
-
269
- test("captures stdout, stderr, and a zero exit code", async () => {
270
- const result = await defaultRunner(
271
- [exe, "-e", 'process.stdout.write("hello-out");process.stderr.write("hello-err")'],
272
- process.cwd(),
273
- );
274
- expect(result.exitCode).toBe(0);
275
- expect(result.stdout).toBe("hello-out");
276
- expect(result.stderr).toBe("hello-err");
277
- });
278
-
279
- test("propagates a non-zero exit code", async () => {
280
- const result = await defaultRunner(
281
- [exe, "-e", 'process.stderr.write("boom");process.exit(7)'],
282
- process.cwd(),
283
- );
284
- expect(result.exitCode).toBe(7);
285
- expect(result.stderr).toBe("boom");
286
- });
287
-
288
- test("resolves with exitCode 1 when the program cannot be spawned (error event)", async () => {
289
- const result = await defaultRunner(
290
- ["./crewhaus-nonexistent-binary-9f3a2c", "--version"],
291
- process.cwd(),
292
- );
293
- expect(result.exitCode).toBe(1);
294
- expect(result.stdout).toBe("");
295
- expect(result.stderr.length).toBeGreaterThan(0);
296
- });
297
-
298
- test("falls back to exitCode 1 when the child is killed by a signal (null code)", async () => {
299
- // A child that SIGKILLs itself terminates via signal, so `close` fires with
300
- // code === null — exercising the `code ?? 1` fallback deterministically.
301
- const result = await defaultRunner(
302
- [exe, "-e", "process.kill(process.pid, 'SIGKILL')"],
303
- process.cwd(),
304
- );
305
- expect(result.exitCode).toBe(1);
306
- });
307
- });
package/src/index.ts DELETED
@@ -1,256 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- /**
5
- * Section 32 — `@crewhaus/docker-images`
6
- *
7
- * Per-target-shape Dockerfiles (one per target shape: cli, workflow, channel,
8
- * graph, managed, pipeline, crew, research, batch, voice, browser, eval) plus
9
- * a `buildImage()` wrapper around `docker buildx build` that the
10
- * `crewhaus build-image <target>` CLI subcommand (apps/cli/src/index.ts) calls.
11
- *
12
- * v0 ships:
13
- * - 12 multi-stage Dockerfiles under `docker/<target>/Dockerfile`,
14
- * each with non-root user, read-only root, HEALTHCHECK, and
15
- * target-specific system deps baked in (chromium for browser,
16
- * libopus for voice, curl+ca-certs for daemon shapes).
17
- * - `buildImage()` shells to `docker buildx build` (gated on `docker
18
- * version` succeeding); injectable `runner` for tests.
19
- * - `loadDigests()` / `recordDigest()` over a `docker/digests.json`
20
- * lockfile so reproducible builds work offline.
21
- */
22
- import { CrewhausError } from "@crewhaus/errors";
23
-
24
- export class DockerImagesError extends CrewhausError {
25
- override readonly name = "DockerImagesError";
26
- constructor(message: string, cause?: unknown) {
27
- super("config", message, cause);
28
- }
29
- }
30
-
31
- /**
32
- * The 12 target shapes that ship a Dockerfile. Sourced from
33
- * `packages/spec/src/index.ts` discriminator literals; we keep the
34
- * canonical list locally because this package owns the "which shapes
35
- * have a deployable image" question.
36
- */
37
- export const TARGET_SHAPES = [
38
- "cli",
39
- "workflow",
40
- "channel",
41
- "graph",
42
- "managed",
43
- "pipeline",
44
- "crew",
45
- "research",
46
- "batch",
47
- "voice",
48
- "browser",
49
- "eval",
50
- ] as const;
51
-
52
- export type TargetShape = (typeof TARGET_SHAPES)[number];
53
-
54
- export function isTargetShape(value: unknown): value is TargetShape {
55
- return typeof value === "string" && (TARGET_SHAPES as readonly string[]).includes(value);
56
- }
57
-
58
- const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
59
- const DOCKER_ROOT = join(PACKAGE_ROOT, "docker");
60
- const DIGESTS_PATH = join(DOCKER_ROOT, "digests.json");
61
-
62
- export function dockerRoot(): string {
63
- return DOCKER_ROOT;
64
- }
65
-
66
- export function digestsPath(): string {
67
- return DIGESTS_PATH;
68
- }
69
-
70
- export function dockerfilePath(target: TargetShape): string {
71
- return join(DOCKER_ROOT, target, "Dockerfile");
72
- }
73
-
74
- export function readDockerfile(target: TargetShape): string {
75
- const path = dockerfilePath(target);
76
- if (!existsSync(path)) {
77
- throw new DockerImagesError(`dockerfile missing for target ${target} (expected at ${path})`);
78
- }
79
- return readFileSync(path, "utf8");
80
- }
81
-
82
- export function dockerfileSize(target: TargetShape): number {
83
- return statSync(dockerfilePath(target)).size;
84
- }
85
-
86
- export function listAvailableTargets(): readonly TargetShape[] {
87
- return TARGET_SHAPES.filter((t) => existsSync(dockerfilePath(t)));
88
- }
89
-
90
- export type BuildRunner = (
91
- argv: readonly string[],
92
- cwd: string,
93
- ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
94
-
95
- export type BuildImageOptions = {
96
- readonly target: TargetShape;
97
- readonly tag: string;
98
- /** Optional build context path; defaults to the workspace root. */
99
- readonly contextPath?: string;
100
- /** Optional buildx platform string, e.g. `linux/amd64,linux/arm64`. */
101
- readonly platform?: string;
102
- /** Whether to push to a registry after build. Defaults to false. */
103
- readonly push?: boolean;
104
- /** Test injection point — defaults to a node:child_process spawn of `docker`. */
105
- readonly runner?: BuildRunner;
106
- };
107
-
108
- export type BuildImageResult = {
109
- readonly tag: string;
110
- readonly target: TargetShape;
111
- readonly buildArgv: readonly string[];
112
- };
113
-
114
- export async function buildImage(opts: BuildImageOptions): Promise<BuildImageResult> {
115
- if (!isTargetShape(opts.target)) {
116
- throw new DockerImagesError(`unknown target shape: ${String(opts.target)}`);
117
- }
118
- if (!opts.tag || /[\s\n\r]/.test(opts.tag)) {
119
- throw new DockerImagesError(
120
- `invalid tag: ${JSON.stringify(opts.tag)} — must be non-empty and contain no whitespace`,
121
- );
122
- }
123
- const dockerfile = dockerfilePath(opts.target);
124
- if (!existsSync(dockerfile)) {
125
- throw new DockerImagesError(
126
- `dockerfile missing for target ${opts.target} (expected at ${dockerfile})`,
127
- );
128
- }
129
- const contextPath = opts.contextPath ?? resolve(PACKAGE_ROOT, "..", "..");
130
- const argv: string[] = [
131
- "docker",
132
- "buildx",
133
- "build",
134
- "--file",
135
- dockerfile,
136
- "--tag",
137
- `crewhaus/${opts.target}:${opts.tag}`,
138
- ];
139
- if (opts.platform) {
140
- argv.push("--platform", opts.platform);
141
- }
142
- argv.push(opts.push ? "--push" : "--load");
143
- argv.push(contextPath);
144
-
145
- const runner = opts.runner ?? defaultRunner;
146
- const { exitCode, stderr } = await runner(argv, contextPath);
147
- if (exitCode !== 0) {
148
- throw new DockerImagesError(
149
- `docker buildx build exited with code ${exitCode}: ${stderr.slice(0, 1024)}`,
150
- );
151
- }
152
- return { tag: opts.tag, target: opts.target, buildArgv: argv };
153
- }
154
-
155
- export const defaultRunner: BuildRunner = async (argv, cwd) => {
156
- const { spawn } = await import("node:child_process");
157
- return new Promise((resolve_) => {
158
- const head = argv[0] ?? "docker";
159
- const child = spawn(head, argv.slice(1), { cwd, stdio: ["ignore", "pipe", "pipe"] });
160
- const out: Buffer[] = [];
161
- const err: Buffer[] = [];
162
- child.stdout.on("data", (b) => out.push(b));
163
- child.stderr.on("data", (b) => err.push(b));
164
- child.on("error", (e) =>
165
- resolve_({ exitCode: 1, stdout: "", stderr: String((e as Error).message) }),
166
- );
167
- child.on("close", (code) =>
168
- resolve_({
169
- exitCode: code ?? 1,
170
- stdout: Buffer.concat(out).toString("utf8"),
171
- stderr: Buffer.concat(err).toString("utf8"),
172
- }),
173
- );
174
- });
175
- };
176
-
177
- // ─── digests lockfile ────────────────────────────────────────────────────────
178
-
179
- export type DigestEntry = { readonly sha256: string; readonly builtAt: string };
180
- export type DigestsFile = {
181
- _format_version: number;
182
- [target: string]: unknown;
183
- };
184
-
185
- export function loadDigests(path: string = DIGESTS_PATH): DigestsFile {
186
- if (!existsSync(path)) {
187
- return { _format_version: 1 };
188
- }
189
- const raw = readFileSync(path, "utf8");
190
- try {
191
- return JSON.parse(raw) as DigestsFile;
192
- } catch (cause) {
193
- throw new DockerImagesError(`failed to parse digests.json at ${path}`, cause);
194
- }
195
- }
196
-
197
- export function recordDigest(
198
- target: TargetShape,
199
- tag: string,
200
- sha256: string,
201
- path: string = DIGESTS_PATH,
202
- ): void {
203
- if (!/^sha256:[0-9a-f]{64}$/.test(sha256)) {
204
- throw new DockerImagesError(`invalid sha256: ${sha256}`);
205
- }
206
- const current = loadDigests(path);
207
- const targetEntry = (current[target] as Record<string, DigestEntry> | undefined) ?? {};
208
- targetEntry[tag] = { sha256, builtAt: new Date().toISOString() };
209
- current[target] = targetEntry;
210
- current._format_version = 1;
211
- mkdirSync(dirname(path), { recursive: true });
212
- writeFileSync(path, `${JSON.stringify(current, null, 2)}\n`, { mode: 0o644 });
213
- }
214
-
215
- // ─── Dockerfile structural fingerprint (consumed by tests) ───────────────────
216
-
217
- export type DockerfileFingerprint = {
218
- readonly stages: ReadonlyArray<{ readonly image: string; readonly stage?: string }>;
219
- readonly hasHealthcheck: boolean;
220
- readonly nonRootUser?: string;
221
- readonly workdir?: string;
222
- readonly entrypoint?: string;
223
- readonly cmd?: string;
224
- readonly exposedPorts: readonly number[];
225
- readonly envs: readonly string[];
226
- };
227
-
228
- export function fingerprintDockerfile(text: string): DockerfileFingerprint {
229
- const lines = text.split(/\r?\n/);
230
- const fromStages = lines
231
- .map((l) => /^FROM\s+(\S+)(?:\s+AS\s+(\S+))?/i.exec(l.trim()))
232
- .filter((m): m is RegExpExecArray => Boolean(m))
233
- .map((m) => ({ image: m[1] ?? "", stage: (m[2] ?? "").toLowerCase() || undefined }));
234
- const exposedPorts = lines
235
- .filter((l) => /^EXPOSE\s+\d+/i.test(l.trim()))
236
- .map((l) => Number(l.trim().split(/\s+/)[1]));
237
- return {
238
- stages: fromStages,
239
- hasHealthcheck: /^HEALTHCHECK\s+/im.test(text),
240
- nonRootUser: /^USER\s+(\S+)/im.exec(text)?.[1],
241
- workdir: /^WORKDIR\s+(\S+)/im.exec(text)?.[1],
242
- entrypoint: /^ENTRYPOINT\s+(.+)$/im.exec(text)?.[1]?.trim(),
243
- cmd: /^CMD\s+(.+)$/im.exec(text)?.[1]?.trim(),
244
- exposedPorts,
245
- envs: lines
246
- .filter((l) => /^ENV\s+/i.test(l.trim()))
247
- .flatMap((l) =>
248
- l
249
- .trim()
250
- .replace(/^ENV\s+/i, "")
251
- .split(/\s+/)
252
- .map((kv) => (kv.split("=")[0] ?? "").trim())
253
- .filter((k) => k.length > 0 && /^[A-Z_][A-Z0-9_]*$/.test(k)),
254
- ),
255
- };
256
- }