Skip to content

Commit d8564a6

Browse files
wormeymanclaude
andcommitted
feat(scripts): loud preflight for the scripts that need Docker
A stopped daemon surfaced as a wrangler build error several screens deep that never says "start Docker". This turns it into a red banner naming the exact command for whichever runtime you actually have installed. Wired into `preview:dev` and `preview:deploy` only - the two that build the Factorio image. Deliberately NOT into `preview:test` or `verify`: CLAUDE.md documents that those run on CI runners with no container runtime at all, and a daemon dependency there would break that property. It reports rather than acts. The runtime is the developer's choice, not the repo's - OrbStack, Docker Desktop, colima and podman all satisfy the Dockerfile, and hardcoding `orb start` would strand everyone else. Auto-start is available behind an explicit FMW_AUTO_START_DOCKER=1 opt-in, which polls for the daemon rather than trusting the start command's exit code. Exit 0 daemon ready / 1 CLI present but no daemon / 2 no CLI at all, and the no-CLI banner says INSTALL rather than START. Colour is decoration, never the message: NO_COLOR and non-TTY drop the escapes, and the glyph and wording still carry it. All three paths were run by hand (DOCKER_HOST to a dead socket, and PATH stripped of docker). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AB7J1qK6kSBnJJmgDqMst
1 parent 5c3db0c commit d8564a6

3 files changed

Lines changed: 413 additions & 2 deletions

File tree

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@
1818
"verify:shard": "vp test",
1919
"verify": "pnpm run verify:lint && vp run --cache test && pnpm run preview:test",
2020
"localpreview": "pnpm run preview:dev",
21-
"preview:dev": "concurrently -k -n worker,app -c blue,green \"pnpm run preview:worker\" \"pnpm run preview:app\"",
21+
"require:docker": "node scripts/require-docker.ts",
22+
"preview:dev": "pnpm run require:docker && concurrently -k -n worker,app -c blue,green \"pnpm run preview:worker\" \"pnpm run preview:app\"",
2223
"preview:worker": "pnpm --filter @fmw/preview-worker exec wrangler dev --var ALLOWED_ORIGIN:http://localhost:5173",
2324
"preview:app": "VITE_PREVIEW_SERVICE_URL=http://localhost:8787 vp dev --port 5173 --strictPort",
2425
"preview:test": "pnpm --filter @fmw/preview-worker test && pnpm --filter @fmw/preview-container test",
25-
"preview:deploy": "pnpm --filter @fmw/preview-worker run deploy",
26+
"preview:deploy": "pnpm run require:docker && pnpm --filter @fmw/preview-worker run deploy",
2627
"deploy:app": "pnpm run verify && pnpm build && pnpm --filter @fmw/preview-worker exec wrangler pages deploy dist --cwd ../.. --project-name factoriomapwebui --branch main --commit-dirty=true",
2728
"deploy": "pnpm run deploy:app"
2829
},

scripts/require-docker.ts

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/**
2+
* Preflight for the two scripts that genuinely need a container runtime:
3+
* `preview:dev` (wrangler dev builds the Factorio image) and `preview:deploy`
4+
* (wrangler deploy builds and pushes it). Without this, a stopped daemon
5+
* surfaces as a wrangler build error several screens deep that does not say
6+
* "start Docker".
7+
*
8+
* Exit 0 = a daemon answered; 1 = the CLI exists but no daemon answered
9+
* (actionable - start it); 2 = no docker CLI at all (install something).
10+
*
11+
* Deliberately NOT wired into `preview:test` or `verify`. Those run on CI
12+
* runners with no container runtime at all and must keep working there - see
13+
* CLAUDE.md. Adding a daemon dependency to them would break that property.
14+
*
15+
* It reports rather than acts, because the runtime is the developer's choice,
16+
* not the repo's: OrbStack, Docker Desktop, colima and podman all satisfy the
17+
* Dockerfile and only the human knows which they installed. Set
18+
* `FMW_AUTO_START_DOCKER=1` to opt into having it run the start command for
19+
* you.
20+
*
21+
* Node runs this `.ts` directly (type stripping); there is deliberately no
22+
* compile step and no `tsc` involved - see CLAUDE.md.
23+
*/
24+
25+
import { spawnSync } from "node:child_process";
26+
import { existsSync } from "node:fs";
27+
import { join, resolve } from "node:path";
28+
import { fileURLToPath } from "node:url";
29+
30+
/** How long to wait on a probe before calling the daemon unreachable. */
31+
const PROBE_TIMEOUT_MS = 10_000;
32+
/** How long to wait for a daemon to answer after an opt-in auto-start. */
33+
const START_TIMEOUT_MS = 90_000;
34+
/** Inner width of the banner box, in characters. */
35+
const BOX_WIDTH = 70;
36+
37+
export type ProbeResult =
38+
| { status: "ok"; version: string }
39+
| { status: "no-daemon"; detail: string }
40+
| { status: "no-cli"; detail: string };
41+
42+
export interface Runtime {
43+
/** Display name, e.g. "OrbStack". */
44+
name: string;
45+
/** Shell command that starts it. */
46+
start: string;
47+
/** True when this one looks installed on the current machine. */
48+
installed: boolean;
49+
}
50+
51+
// ============================================================================
52+
// Colour
53+
// ============================================================================
54+
55+
const CODES = {
56+
reset: "\x1b[0m",
57+
bold: "\x1b[1m",
58+
dim: "\x1b[2m",
59+
red: "\x1b[31m",
60+
green: "\x1b[32m",
61+
yellow: "\x1b[33m",
62+
cyan: "\x1b[36m",
63+
} as const;
64+
65+
export type Style = keyof Omit<typeof CODES, "reset">;
66+
67+
/**
68+
* Wrap text in ANSI styles when colour is on.
69+
*
70+
* Colour is a decoration, never the message: every banner below also carries a
71+
* glyph and a word, so piping to a file or running under NO_COLOR loses nothing
72+
* but the paint.
73+
*/
74+
export function paint(text: string, styles: Style[], enabled: boolean): string {
75+
if (!enabled || styles.length === 0) return text;
76+
return styles.map((s) => CODES[s]).join("") + text + CODES.reset;
77+
}
78+
79+
/** Honour the NO_COLOR convention, and never emit escapes into a pipe. */
80+
export function colorEnabled(env: NodeJS.ProcessEnv, isTty: boolean): boolean {
81+
if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") return false;
82+
if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0")
83+
return true;
84+
return isTty;
85+
}
86+
87+
// ============================================================================
88+
// Banner
89+
// ============================================================================
90+
91+
/**
92+
* Render a heavy box around `title`.
93+
*
94+
* Padding is computed from the PLAIN text and the styling applied afterwards,
95+
* so the right-hand border stays flush - measuring a string that already has
96+
* escape codes in it is how boxes come out ragged.
97+
*/
98+
export function buildBox(title: string, styles: Style[], enabled: boolean): string[] {
99+
const bar = "═".repeat(BOX_WIDTH);
100+
const padded = ` ${title} `.padEnd(BOX_WIDTH, " ");
101+
return [
102+
paint(`╔${bar}╗`, styles, enabled),
103+
paint(`║${padded}║`, styles, enabled),
104+
paint(`╚${bar}╝`, styles, enabled),
105+
];
106+
}
107+
108+
/** Clip a one-line detail so it cannot wrap the banner into unreadability. */
109+
export function truncate(text: string, max: number): string {
110+
const flat = text.replace(/\s+/g, " ").trim();
111+
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
112+
}
113+
114+
/**
115+
* Turn a failed probe into the exact lines a human needs, loudest first.
116+
*
117+
* Split out from `main` so `test/requireDocker.spec.ts` can assert the advice
118+
* without a container runtime anywhere near the test runner.
119+
*/
120+
export function describeFailure(
121+
probe: Exclude<ProbeResult, { status: "ok" }>,
122+
runtimes: Runtime[],
123+
enabled: boolean,
124+
): { code: number; lines: string[] } {
125+
const installed = runtimes.filter((r) => r.installed);
126+
const noCli = probe.status === "no-cli";
127+
const title = noCli ? "✖ NO CONTAINER RUNTIME FOUND" : "✖ DOCKER IS NOT RUNNING";
128+
129+
const lines = [
130+
"",
131+
...buildBox(title, ["bold", "red"], enabled),
132+
"",
133+
noCli
134+
? ` This build needs a Docker-compatible CLI and none is on your PATH.`
135+
: ` A ${paint("docker", ["cyan"], enabled)} CLI is installed, but no daemon answered.`,
136+
// Truncated: docker's own connect errors run past 150 characters, and a
137+
// wrapped wall of dim text buries the actionable part below it.
138+
` ${paint(truncate(probe.detail, 110), ["dim"], enabled)}`,
139+
"",
140+
paint(noCli ? ` ▶ INSTALL ONE OF:` : ` ▶ START IT WITH:`, ["bold", "yellow"], enabled),
141+
"",
142+
];
143+
144+
const suggestions = installed.length > 0 ? installed : runtimes;
145+
for (const r of suggestions) {
146+
const mark = r.installed ? paint("●", ["green"], enabled) : paint("○", ["dim"], enabled);
147+
const note = r.installed ? "" : paint(" (not detected)", ["dim"], enabled);
148+
lines.push(
149+
` ${mark} ${r.name.padEnd(16)} ${paint(r.start, ["bold", "cyan"], enabled)}${note}`,
150+
);
151+
}
152+
153+
lines.push(
154+
"",
155+
paint(
156+
noCli
157+
? ` Then re-run. Any Docker-compatible runtime satisfies the Dockerfile.`
158+
: ` Then re-run. Or set FMW_AUTO_START_DOCKER=1 to have this start it for you.`,
159+
["dim"],
160+
enabled,
161+
),
162+
"",
163+
);
164+
return { code: noCli ? 2 : 1, lines };
165+
}
166+
167+
/** The one-line all-clear. Quiet on purpose - noise on success trains people to ignore it. */
168+
export function describeSuccess(version: string, enabled: boolean): string {
169+
return `${paint("✔", ["bold", "green"], enabled)} Docker daemon ready ${paint(`(${version})`, ["dim"], enabled)}`;
170+
}
171+
172+
// ============================================================================
173+
// Probing
174+
// ============================================================================
175+
176+
/** Ask the daemon for its version. Anything other than a clean answer is a failure. */
177+
export function probeDocker(timeoutMs = PROBE_TIMEOUT_MS): ProbeResult {
178+
const res = spawnSync("docker", ["info", "--format", "{{.ServerVersion}}"], {
179+
encoding: "utf8",
180+
timeout: timeoutMs,
181+
});
182+
if (res.error) {
183+
const code = (res.error as NodeJS.ErrnoException).code;
184+
if (code === "ENOENT") return { status: "no-cli", detail: "`docker` is not on your PATH." };
185+
return { status: "no-daemon", detail: `docker info failed: ${res.error.message}` };
186+
}
187+
if (res.status === 0 && res.stdout.trim()) {
188+
return { status: "ok", version: res.stdout.trim() };
189+
}
190+
const stderr = (res.stderr || "").trim().split("\n")[0] || "docker info returned no version.";
191+
return { status: "no-daemon", detail: stderr };
192+
}
193+
194+
/**
195+
* Which runtimes look installed here. Order is the order they get suggested in.
196+
*
197+
* PATH is scanned directly rather than shelling out to `command -v`: spawning
198+
* with `shell: true` AND an args array is deprecated in Node 26 (DEP0190) and
199+
* prints a warning that would land in the middle of the banner.
200+
*/
201+
export function detectRuntimes(): Runtime[] {
202+
const dirs = (process.env.PATH ?? "").split(":").filter(Boolean);
203+
const onPath = (bin: string): boolean => dirs.some((d) => existsSync(join(d, bin)));
204+
return [
205+
{ name: "OrbStack", start: "orb start", installed: onPath("orb") },
206+
{
207+
name: "Docker Desktop",
208+
start: "open -a Docker",
209+
installed: existsSync("/Applications/Docker.app"),
210+
},
211+
{ name: "colima", start: "colima start", installed: onPath("colima") },
212+
{ name: "podman", start: "podman machine start", installed: onPath("podman") },
213+
];
214+
}
215+
216+
// ============================================================================
217+
// Entrypoint
218+
// ============================================================================
219+
220+
async function main(): Promise<number> {
221+
const enabled = colorEnabled(process.env, process.stdout.isTTY === true);
222+
223+
let probe = probeDocker();
224+
if (probe.status === "ok") {
225+
console.log(describeSuccess(probe.version, enabled));
226+
return 0;
227+
}
228+
229+
const runtimes = detectRuntimes();
230+
const auto = process.env.FMW_AUTO_START_DOCKER;
231+
const target = runtimes.find((r) => r.installed);
232+
233+
if (auto === "1" && target && probe.status !== "no-cli") {
234+
console.log(
235+
paint(`⏳ Starting ${target.name} (FMW_AUTO_START_DOCKER=1) ...`, ["yellow"], enabled),
236+
);
237+
spawnSync(target.start, { shell: true, stdio: "inherit", timeout: START_TIMEOUT_MS });
238+
// The start command returning does not mean the daemon is accepting
239+
// connections yet, so poll rather than trusting its exit code.
240+
const deadline = Date.now() + START_TIMEOUT_MS;
241+
while (Date.now() < deadline) {
242+
probe = probeDocker(5_000);
243+
if (probe.status === "ok") {
244+
console.log(describeSuccess(probe.version, enabled));
245+
return 0;
246+
}
247+
spawnSync("sleep", ["2"]);
248+
}
249+
}
250+
251+
const { code, lines } = describeFailure(probe, runtimes, enabled);
252+
for (const line of lines) console.error(line);
253+
return code;
254+
}
255+
256+
// Only run when executed directly - `test/requireDocker.spec.ts` imports the
257+
// pure helpers from here and must not shell out to docker.
258+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
259+
process.exitCode = await main();
260+
}

0 commit comments

Comments
 (0)