|
| 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