getculpa 0.0.1 → 1.0.2

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/lib/docker.mjs ADDED
@@ -0,0 +1,270 @@
1
+ // CF20-T4 — docker engine + running-stack + guard helpers shared by
2
+ // lib/start.mjs, lib/stop.mjs and lib/status.mjs. Every docker-touching
3
+ // function takes an injectable spawnSync (default: the real one) — the same
4
+ // pattern lib/preflight.mjs and lib/provision.mjs already use, and for the
5
+ // same reason: an explicit dependency is more reliable to stub in a test
6
+ // than a PATH shim (see tests/npm-core-provision.test.ts's header comment).
7
+ // NOTHING in this file touches a real container, port, or filesystem
8
+ // location outside a caller-supplied path — bounded polls only, no sleeps
9
+ // beyond the poll interval, and every poll's interval/budget is injectable
10
+ // so a test never actually waits.
11
+
12
+ import { spawnSync as realSpawnSync } from "node:child_process";
13
+ import { mkdirSync, statSync } from "node:fs";
14
+ import path from "node:path";
15
+
16
+ const PROJECT_LABEL = "com.docker.compose.project=culpa";
17
+
18
+ // CF20-R: every docker CLI probe below is a `docker info`/`ps`/`inspect`-class
19
+ // call that normally returns in well under a second. Without a timeout, a
20
+ // hung docker CLI (Desktop mid-restart, a stuck named pipe, an orphaned
21
+ // process holding the socket) stalls npm install / `getculpa doctor`
22
+ // forever. 15s is generous headroom above any healthy probe's real latency
23
+ // while still being short enough that an install or doctor run visibly
24
+ // degrades instead of hanging. NOT applied to preMigrationBackup's pg_dump /
25
+ // docker cp calls below — those are genuinely long-running for a real
26
+ // database and a 15s cap would abort a legitimate backup, not just a hang;
27
+ // the bounded WAIT loops (waitForDockerEngine/waitForHealth/waitForPgReady)
28
+ // keep their own maxWaitMs/intervalMs budgets unchanged — this constant only
29
+ // bounds each individual spawnSync call inside them.
30
+ export const PROBE_TIMEOUT_MS = 15_000;
31
+
32
+ function realSleep(ms) {
33
+ return new Promise((resolve) => setTimeout(resolve, ms));
34
+ }
35
+
36
+ // Bounded poll: calls `probe()` up to `Math.ceil(maxWaitMs / intervalMs)`
37
+ // times, sleeping `intervalMs` BETWEEN attempts only (never after the last
38
+ // one) — mirrors launch-culpa.ps1's `for ($i = 0; $i -lt N; $i++)` loops
39
+ // (2s x 60 = 120s for the engine/dashboard, 2s x 30 = 60s for pg_isready).
40
+ export async function pollUntil(probe, { maxWaitMs, intervalMs, sleepFn = realSleep }) {
41
+ const attempts = Math.max(1, Math.ceil(maxWaitMs / intervalMs));
42
+ for (let i = 0; i < attempts; i++) {
43
+ if (await probe()) return true;
44
+ if (i < attempts - 1) await sleepFn(intervalMs);
45
+ }
46
+ return false;
47
+ }
48
+
49
+ // --- engine reachability + start -------------------------------------------
50
+
51
+ export function checkDockerEngineReachable(spawnSync = realSpawnSync) {
52
+ const r = spawnSync("docker", ["info", "--format", "ok"], { timeout: PROBE_TIMEOUT_MS });
53
+ return r.status === 0;
54
+ }
55
+
56
+ // 120s / 2s — identical budget to launch-culpa.ps1:246-251.
57
+ export async function waitForDockerEngine(opts = {}) {
58
+ const { spawnSync = realSpawnSync, maxWaitMs = 120_000, intervalMs = 2_000, sleepFn } = opts;
59
+ return pollUntil(() => checkDockerEngineReachable(spawnSync), { maxWaitMs, intervalMs, sleepFn });
60
+ }
61
+
62
+ // darwin: mirrors installers/macos/install-culpa.command's `open -a Docker`.
63
+ // linux: starting a system service without explicit consent is out of
64
+ // bounds for this task (no systemctl) — this reports the two common
65
+ // commands and returns unattempted; the caller's WAIT_FOR_DOCKER stage then
66
+ // times out with the standard "couldn't start Docker" message, same as if
67
+ // the founder had ignored the launch-culpa.ps1 prompt.
68
+ export function startEngineIfPossible(platform, spawnSync = realSpawnSync, log = console.log) {
69
+ if (platform === "darwin") {
70
+ const r = spawnSync("open", ["-a", "Docker"]);
71
+ return { attempted: true, ok: r.status === 0 };
72
+ }
73
+ if (platform === "linux") {
74
+ log("getculpa: Docker's engine is not running. Start it yourself, for example:");
75
+ log(" sudo systemctl start docker (systemd hosts)");
76
+ log(" or start Docker Desktop from your applications menu,");
77
+ log("then run `getculpa` again.");
78
+ return { attempted: false, ok: false };
79
+ }
80
+ return { attempted: false, ok: false };
81
+ }
82
+
83
+ // T-CF28-7 — the macOS half of the ask-and-install flow Windows already
84
+ // ships (installers/windows/launch-culpa.ps1:208-219). Called ONLY after the
85
+ // user has explicitly agreed; this function does not ask. It reports WHICH
86
+ // path it took rather than a bare boolean, for the same reason
87
+ // Install-DockerDesktop does: a bare false made the .ps1's caller claim a
88
+ // download page had been opened when the package manager was present and the
89
+ // install had simply failed. Returns "installed" | "no-brew" | "failed:<code>".
90
+ //
91
+ // Homebrew is the only automatic path offered on darwin: it is the user's own
92
+ // auditable package manager, and `--cask docker` is the official cask. There
93
+ // is deliberately no curl-and-run fallback.
94
+ export function installDockerDarwin(spawnSync = realSpawnSync) {
95
+ const brew = spawnSync("brew", ["--version"], { timeout: PROBE_TIMEOUT_MS });
96
+ if (brew.status !== 0) return "no-brew";
97
+ const result = spawnSync("brew", ["install", "--cask", "docker"], { stdio: "inherit" });
98
+ if (result.status === 0) return "installed";
99
+ return `failed:${result.status}`;
100
+ }
101
+
102
+ // --- IDEMPOTENCY (spec sec 23) ----------------------------------------------
103
+ //
104
+ // `docker ps` (RUNNING containers only — a `docker compose stop`ped stack
105
+ // must read as "not running" so a later `getculpa` recreates it) filtered by
106
+ // the compose project label. Docker Compose stamps
107
+ // com.docker.compose.project=<project name> on every container it creates,
108
+ // independent of which compose file path was used to create it — this is
109
+ // why the check needs no `-f` and cannot be fooled by a QW-1 re-pin between
110
+ // checks. `docker compose -p culpa ps` was considered and rejected: current
111
+ // Docker Compose still needs a compose file (-f, or a matching working
112
+ // directory) to resolve service definitions for `ps`, which would
113
+ // re-introduce a path dependency this check does not need — raw `docker ps`
114
+ // with a label filter answers "is culpa's project running" without one.
115
+ export function isProjectRunning(spawnSync = realSpawnSync) {
116
+ const r = spawnSync("docker", ["ps", "--filter", `label=${PROJECT_LABEL}`, "--format", "{{.ID}}"], {
117
+ encoding: "utf8",
118
+ timeout: PROBE_TIMEOUT_MS,
119
+ });
120
+ if (r.status !== 0) return false;
121
+ return (r.stdout ?? "").trim().length > 0;
122
+ }
123
+
124
+ // --- HTTP health -------------------------------------------------------------
125
+
126
+ export async function probeHealth(url, { fetchFn = fetch } = {}) {
127
+ try {
128
+ const res = await fetchFn(url, { signal: AbortSignal.timeout(3_000) });
129
+ return res.status === 200;
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ // 120s / 2s — identical budget to launch-culpa.ps1:331-340.
136
+ export async function waitForHealth(url, opts = {}) {
137
+ const { maxWaitMs = 120_000, intervalMs = 2_000, sleepFn, fetchFn } = opts;
138
+ return pollUntil(() => probeHealth(url, { fetchFn }), { maxWaitMs, intervalMs, sleepFn });
139
+ }
140
+
141
+ // --- QW-4 port: pg_isready + pg_dump backup, ported from
142
+ // launch-culpa.ps1:284-316 (Invoke-PreMigrationBackup / the pg_isready
143
+ // loop). Same fail-closed contract: no verified dump, no launch. -------------
144
+
145
+ export function dbContainerExists(spawnSync = realSpawnSync) {
146
+ const r = spawnSync("docker", ["ps", "-a", "--format", "{{.Names}}"], {
147
+ encoding: "utf8",
148
+ timeout: PROBE_TIMEOUT_MS,
149
+ });
150
+ if (r.status !== 0) return false;
151
+ return /culpa-db/.test(r.stdout ?? "");
152
+ }
153
+
154
+ // 60s / 2s — identical budget to launch-culpa.ps1:303-307. PROBE_TIMEOUT_MS
155
+ // bounds each individual `pg_isready` call inside the poll; it does not
156
+ // change the poll's own maxWaitMs/intervalMs budget.
157
+ export async function waitForPgReady(opts = {}) {
158
+ const { spawnSync = realSpawnSync, maxWaitMs = 60_000, intervalMs = 2_000, sleepFn } = opts;
159
+ return pollUntil(
160
+ () =>
161
+ spawnSync("docker", ["exec", "culpa-db", "pg_isready", "-U", "culpa"], { timeout: PROBE_TIMEOUT_MS }).status ===
162
+ 0,
163
+ { maxWaitMs, intervalMs, sleepFn },
164
+ );
165
+ }
166
+
167
+ export function resolveBackupBaseline(recorded, spawnSync = realSpawnSync) {
168
+ if (recorded && recorded.trim()) return recorded.trim();
169
+ const r = spawnSync("docker", ["inspect", "culpa-server", "--format", "{{.Config.Image}}"], {
170
+ encoding: "utf8",
171
+ timeout: PROBE_TIMEOUT_MS,
172
+ });
173
+ const image = (r.stdout ?? "").trim();
174
+ const m = /:(v[0-9][^:\s]*)$/.exec(image);
175
+ return m ? m[1] : "";
176
+ }
177
+
178
+ // Ported verbatim from Test-BackupNeeded (launch-culpa.ps1:160-165): data
179
+ // present + an UNKNOWN baseline means BACK UP, never skip; only a baseline
180
+ // that is known AND equal to the pin skips the backup.
181
+ export function isBackupNeeded(dbExists, pinnedVersion, baseline) {
182
+ if (!dbExists) return false;
183
+ if (!baseline || !baseline.trim()) return true;
184
+ if (!pinnedVersion || !pinnedVersion.trim()) return true;
185
+ return pinnedVersion !== baseline;
186
+ }
187
+
188
+ function formatStamp(d) {
189
+ const p = (n) => String(n).padStart(2, "0");
190
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
191
+ }
192
+
193
+ // Throws on any failure (fail-closed); returns the verified dump path on
194
+ // success. Same three checks as the .ps1: dump exit code, copy exit code,
195
+ // and a >=1024-byte size floor on the copied file.
196
+ export function preMigrationBackup(opts) {
197
+ const { backupDir, tag, spawnSync = realSpawnSync, now = new Date() } = opts;
198
+ mkdirSync(backupDir, { recursive: true });
199
+ const dest = path.join(backupDir, `pre-${tag}-${formatStamp(now)}.dump`);
200
+
201
+ const dump = spawnSync("docker", [
202
+ "exec",
203
+ "culpa-db",
204
+ "sh",
205
+ "-c",
206
+ "pg_dump -Fc -U culpa culpa > /tmp/culpa-pre-upgrade.dump",
207
+ ]);
208
+ if (dump.status !== 0) throw new Error("pg_dump inside culpa-db failed");
209
+
210
+ const copy = spawnSync("docker", ["cp", "culpa-db:/tmp/culpa-pre-upgrade.dump", dest]);
211
+ if (copy.status !== 0) throw new Error("copying the dump out of culpa-db failed");
212
+
213
+ let size = 0;
214
+ try {
215
+ size = statSync(dest).size;
216
+ } catch {
217
+ size = 0;
218
+ }
219
+ if (size < 1024) {
220
+ throw new Error(`the dump at '${dest}' is missing or implausibly small (${size} bytes)`);
221
+ }
222
+ return dest;
223
+ }
224
+
225
+ // --- PORT HANDLING (spec sec 24) --------------------------------------------
226
+ //
227
+ // Only ever called to NAME an owner — never to act on one. Returns
228
+ // { pid, name } for the process holding `port` in LISTEN state, or null when
229
+ // the port is free (or the lookup itself failed — a lookup failure must
230
+ // never be reported as a conflict).
231
+ export function findPortOwner(port, opts = {}) {
232
+ const { platform = process.platform, spawnSync = realSpawnSync } = opts;
233
+ return platform === "win32" ? findPortOwnerWin32(port, spawnSync) : findPortOwnerPosix(port, spawnSync);
234
+ }
235
+
236
+ function findPortOwnerWin32(port, spawnSync) {
237
+ const conn = spawnSync(
238
+ "powershell.exe",
239
+ [
240
+ "-NoProfile",
241
+ "-Command",
242
+ `(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess)`,
243
+ ],
244
+ { encoding: "utf8" },
245
+ );
246
+ const pidText = (conn.stdout ?? "").trim();
247
+ const pid = Number(pidText.split(/\s+/)[0]);
248
+ if (!pidText || !Number.isFinite(pid) || pid <= 0) return null;
249
+
250
+ const proc = spawnSync(
251
+ "powershell.exe",
252
+ ["-NoProfile", "-Command", `(Get-Process -Id ${pid} -ErrorAction SilentlyContinue).ProcessName`],
253
+ { encoding: "utf8" },
254
+ );
255
+ const name = (proc.stdout ?? "").trim() || `pid ${pid}`;
256
+ return { pid, name };
257
+ }
258
+
259
+ function findPortOwnerPosix(port, spawnSync) {
260
+ const r = spawnSync("lsof", ["-i", `:${port}`, "-sTCP:LISTEN", "-P", "-n"], { encoding: "utf8" });
261
+ if (r.status !== 0) return null;
262
+ const lines = (r.stdout ?? "").split("\n").filter((l) => l.trim().length > 0);
263
+ const dataLine = lines[1]; // lines[0] is the COMMAND/PID/... header
264
+ if (!dataLine) return null;
265
+ const cols = dataLine.trim().split(/\s+/);
266
+ const name = cols[0];
267
+ const pid = Number(cols[1]);
268
+ if (!name || !Number.isFinite(pid) || pid <= 0) return null;
269
+ return { pid, name };
270
+ }
@@ -0,0 +1,29 @@
1
+ import type { SpawnSyncLike, FetchLike } from "./docker.d.mts";
2
+
3
+ export type CheckStatus = "ok" | "warn" | "fail" | "info";
4
+
5
+ export interface DoctorCheck {
6
+ section: string;
7
+ label: string;
8
+ status: CheckStatus;
9
+ detail: string;
10
+ }
11
+
12
+ export interface DoctorOptions {
13
+ appDir: string;
14
+ currentVersion: string;
15
+ platform?: string;
16
+ arch?: string;
17
+ spawnSync?: SpawnSyncLike;
18
+ fetchFn?: FetchLike;
19
+ env?: Record<string, string | undefined>;
20
+ vendorDir?: string;
21
+ }
22
+
23
+ export interface DoctorResult {
24
+ ok: boolean;
25
+ checks: DoctorCheck[];
26
+ }
27
+
28
+ export function doctor(opts: DoctorOptions): Promise<DoctorResult>;
29
+ export function formatDoctorReport(result: DoctorResult): string;