getculpa 0.0.1 → 1.0.1

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/repair.mjs ADDED
@@ -0,0 +1,135 @@
1
+ // CF20-T5 — `getculpa repair`. Re-stages missing/corrupt parity files from
2
+ // the packaged assets, regenerates install-state.json if unreadable,
3
+ // re-fetches a missing launcher/collectord, and does none of this
4
+ // destructively: an EXISTING docker-compose.yml (the live pin) is never
5
+ // touched (the edcf97f invariant this task's reviewer condition names), no
6
+ // `docker volume` command is ever issued (data is never touched), and a
7
+ // second run changes nothing that was already healthy (idempotent).
8
+ //
9
+ // Reuses lib/provision.mjs's own stageParityFiles/stageLiveComposeIfAbsent
10
+ // (exported additively for this purpose — see that file's comment) instead
11
+ // of re-implementing file staging here.
12
+
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { spawnSync as realSpawnSync } from "node:child_process";
17
+ import { stageLiveComposeIfAbsent, stageParityFiles, PARITY_ASSETS } from "./provision.mjs";
18
+ import { fetchCollectord, fetchLauncher } from "./fetch.mjs";
19
+ import { checkDockerEngineReachable, checkDockerPresent } from "./preflight.mjs";
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ const packageRoot = path.join(__dirname, "..");
23
+
24
+ function readJsonSafe(filePath) {
25
+ try {
26
+ return JSON.parse(readFileSync(filePath, "utf8"));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function detectDockerState(dockerSpawnSync) {
33
+ if (!checkDockerPresent(dockerSpawnSync)) return "missing";
34
+ if (!checkDockerEngineReachable(dockerSpawnSync)) return "installed-not-running";
35
+ return "ready";
36
+ }
37
+
38
+ function vendorBinaryPath(vendorDir, name, platform) {
39
+ const exe = platform === "win32" ? ".exe" : "";
40
+ return path.join(vendorDir, `${name}${exe}`);
41
+ }
42
+
43
+ export async function repair(opts) {
44
+ const {
45
+ appDir,
46
+ currentVersion,
47
+ platform = process.platform,
48
+ vendorDir = path.join(packageRoot, "vendor"),
49
+ dockerSpawnSync = realSpawnSync,
50
+ fetchLauncherFn = fetchLauncher,
51
+ fetchCollectordFn = fetchCollectord,
52
+ log = console.log,
53
+ } = opts;
54
+
55
+ const actions = [];
56
+
57
+ if (!existsSync(appDir)) {
58
+ mkdirSync(appDir, { recursive: true });
59
+ actions.push(`created the missing app directory (${appDir})`);
60
+ }
61
+
62
+ // 1. Parity files — re-stage anything missing. stageParityFiles never
63
+ // touches docker-compose.yml (it doesn't reference that name at all),
64
+ // so this step alone already satisfies "never touch a live compose".
65
+ const missingBefore = PARITY_ASSETS.filter((name) => !existsSync(path.join(appDir, name)));
66
+ stageParityFiles(appDir);
67
+ for (const name of missingBefore) {
68
+ actions.push(`restored missing parity file: ${name}`);
69
+ }
70
+
71
+ // 2. Live compose — created ONLY if entirely absent; an existing one is
72
+ // never overwritten (stageLiveComposeIfAbsent's own contract).
73
+ const composeCreated = stageLiveComposeIfAbsent(appDir);
74
+ if (composeCreated) {
75
+ actions.push("live docker-compose.yml was missing - recreated from the shipped culpa-compose.yml");
76
+ }
77
+
78
+ // 3. install-state.json — regenerate only when missing/corrupt. The
79
+ // regenerated state is a conservative snapshot of what is actually on
80
+ // disk right now: dockerState/collectorStaged are re-detected,
81
+ // imagesStaged is set false (safe default — worst case is one extra
82
+ // `docker compose pull` message at the next `getculpa`, never data
83
+ // loss, mirroring deferredPullCatchUp's own contract in lib/start.mjs).
84
+ const existingState = readJsonSafe(path.join(appDir, "install-state.json"));
85
+ if (!existingState) {
86
+ const dockerState = detectDockerState(dockerSpawnSync);
87
+ const collectorStaged = existsSync(vendorBinaryPath(vendorDir, "culpa-collectord", platform));
88
+ const regenerated = {
89
+ packageVersion: currentVersion,
90
+ provisionedAt: new Date().toISOString(),
91
+ dockerState,
92
+ imagesStaged: false,
93
+ collectorStaged,
94
+ platform,
95
+ };
96
+ writeFileSync(path.join(appDir, "install-state.json"), `${JSON.stringify(regenerated, null, 2)}\n`);
97
+ actions.push("install-state.json was missing or unreadable - regenerated");
98
+ }
99
+
100
+ // 4. Launcher / collectord — re-fetch only when the vendored binary is
101
+ // genuinely absent. Never re-fetches when present (idempotent, and the
102
+ // task's explicit invocation-recording fact). The launcher is REQUIRED
103
+ // (doctor.mjs's checkVendoredLauncher fails the same way when it's
104
+ // missing) — a failed re-fetch must make repair's own result say so,
105
+ // not just log and report ok:true (CF20-R review). The collector stays
106
+ // optional/non-fatal: capture-off is a valid degraded state.
107
+ const launcherPath = vendorBinaryPath(vendorDir, "culpa-launcher", platform);
108
+ if (!existsSync(launcherPath)) {
109
+ try {
110
+ await fetchLauncherFn({ vendorDir });
111
+ actions.push("the launcher was missing - re-fetched and verified");
112
+ } catch (e) {
113
+ log(`getculpa repair: could not re-fetch the launcher (${e.message}). Run \`getculpa repair\` again once online.`);
114
+ }
115
+ }
116
+
117
+ const collectordPath = vendorBinaryPath(vendorDir, "culpa-collectord", platform);
118
+ if (!existsSync(collectordPath)) {
119
+ try {
120
+ await fetchCollectordFn({ vendorDir });
121
+ actions.push("the capture collector was missing - re-fetched and verified");
122
+ } catch (e) {
123
+ // fetchCollectord's own contract: "not published for this release yet"
124
+ // is an expected, non-fatal outcome (see lib/fetch.mjs) — repair must
125
+ // degrade the same way, never abort over an optional component.
126
+ log(`getculpa repair: capture collector not re-fetched (${e.message}).`);
127
+ }
128
+ }
129
+
130
+ if (actions.length === 0) log("getculpa repair: nothing to do - this install is already healthy.");
131
+ else for (const a of actions) log(`getculpa repair: ${a}`);
132
+
133
+ const launcherOk = existsSync(launcherPath);
134
+ return { ok: launcherOk, actions };
135
+ }
@@ -0,0 +1,42 @@
1
+ import type { SpawnSyncLike, FetchLike } from "./docker.d.mts";
2
+
3
+ export const STAGES: {
4
+ readonly CHECK_INSTALLATION: "CHECK_INSTALLATION";
5
+ readonly CHECK_DOCKER: "CHECK_DOCKER";
6
+ readonly IDEMPOTENCY_CHECK: "IDEMPOTENCY_CHECK";
7
+ readonly PORT_CHECK: "PORT_CHECK";
8
+ readonly START_DOCKER_IF_REQUIRED: "START_DOCKER_IF_REQUIRED";
9
+ readonly WAIT_FOR_DOCKER: "WAIT_FOR_DOCKER";
10
+ readonly PREPARE_RUNTIME: "PREPARE_RUNTIME";
11
+ readonly START_CULPA_DEPENDENCIES: "START_CULPA_DEPENDENCIES";
12
+ readonly RUN_REQUIRED_MIGRATIONS: "RUN_REQUIRED_MIGRATIONS";
13
+ readonly HEALTH_CHECK: "HEALTH_CHECK";
14
+ readonly START_CULPA: "START_CULPA";
15
+ readonly OPEN_UI: "OPEN_UI";
16
+ };
17
+
18
+ export interface StartOptions {
19
+ appDir: string;
20
+ currentVersion?: string;
21
+ platform?: string;
22
+ spawnSync?: SpawnSyncLike;
23
+ fetchFn?: FetchLike;
24
+ sleepFn?: (ms: number) => Promise<void>;
25
+ env?: Record<string, string | undefined>;
26
+ log?: (msg: string) => void;
27
+ warn?: (msg: string) => void;
28
+ openBrowser?: (url: string) => void;
29
+ isTTY?: boolean;
30
+ delegateWindowsLaunch?: (appDir: string) => { ok: boolean; output?: string };
31
+ }
32
+
33
+ export interface StartResult {
34
+ ok: boolean;
35
+ stage: string;
36
+ message?: string;
37
+ recovery?: string;
38
+ alreadyRunning?: boolean;
39
+ stagesRun: string[];
40
+ }
41
+
42
+ export function start(opts: StartOptions): Promise<StartResult>;
package/lib/start.mjs ADDED
@@ -0,0 +1,392 @@
1
+ // CF20-T4 — `getculpa` / `getculpa start`: wakes the dormant stack.
2
+ //
3
+ // Windows: Node runs the platform-agnostic pre-checks (installed? docker
4
+ // present? already running? ports free?) then DELEGATES everything else —
5
+ // Docker-engine ensure/authorize/start+poll, QW-1/3/4 guards, `compose up`,
6
+ // collector restart, health wait, registry sync, browser open — to the
7
+ // STAGED launch-culpa.ps1 (installers/windows/launch-culpa.ps1, copied into
8
+ // the app dir by provision.mjs's PARITY_ASSETS). That script is the
9
+ // semantics source (see the task's own instruction); this module must never
10
+ // re-implement or diverge from what it does. In particular,
11
+ // launch-culpa.ps1:179-234 ALSO owns "Docker isn't installed" (its own
12
+ // WPF authorize-then-winget-install flow) — so CHECK_DOCKER is
13
+ // informational-only on win32, never a hard gate: failing fast here would
14
+ // skip that flow and regress existing UX for a founder on a fresh machine.
15
+ //
16
+ // macOS/Linux: no launch-culpa.ps1 exists there, so this module implements
17
+ // the SAME state machine directly, reusing lib/docker.mjs's helpers and
18
+ // lib/preflight.mjs's version-parsing (the same functions QW-1/3 use, so
19
+ // the two guard implementations can never disagree about what "a pin" is).
20
+ // CHECK_DOCKER IS a hard gate there: there is no interactive installer flow
21
+ // on these platforms in this task's scope (linux especially — no
22
+ // `systemctl start docker` without explicit consent, per the task's hard
23
+ // rules), so a missing/unreachable engine fails fast with one recovery step.
24
+
25
+ import { spawnSync as realSpawnSync } from "node:child_process";
26
+ import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
27
+ import path from "node:path";
28
+ import {
29
+ checkDockerEngineReachable,
30
+ dbContainerExists,
31
+ findPortOwner,
32
+ isBackupNeeded,
33
+ isProjectRunning,
34
+ preMigrationBackup,
35
+ probeHealth,
36
+ resolveBackupBaseline,
37
+ startEngineIfPossible,
38
+ waitForDockerEngine,
39
+ waitForHealth,
40
+ waitForPgReady,
41
+ } from "./docker.mjs";
42
+ import { checkDockerPresent, compareVersions, parsePinnedServerVersion } from "./preflight.mjs";
43
+
44
+ export const STAGES = Object.freeze({
45
+ CHECK_INSTALLATION: "CHECK_INSTALLATION",
46
+ CHECK_DOCKER: "CHECK_DOCKER",
47
+ IDEMPOTENCY_CHECK: "IDEMPOTENCY_CHECK",
48
+ PORT_CHECK: "PORT_CHECK",
49
+ START_DOCKER_IF_REQUIRED: "START_DOCKER_IF_REQUIRED",
50
+ WAIT_FOR_DOCKER: "WAIT_FOR_DOCKER",
51
+ PREPARE_RUNTIME: "PREPARE_RUNTIME",
52
+ START_CULPA_DEPENDENCIES: "START_CULPA_DEPENDENCIES",
53
+ RUN_REQUIRED_MIGRATIONS: "RUN_REQUIRED_MIGRATIONS",
54
+ HEALTH_CHECK: "HEALTH_CHECK",
55
+ START_CULPA: "START_CULPA",
56
+ OPEN_UI: "OPEN_UI",
57
+ });
58
+
59
+ const DASHBOARD_URL = "http://127.0.0.1:3000";
60
+ const DASHBOARD_URL_FRIENDLY = "http://localhost:3000";
61
+ const PORTS_TO_CHECK = [3000, 4545];
62
+
63
+ function fail(stage, message, recovery) {
64
+ return { ok: false, stage, message, recovery };
65
+ }
66
+
67
+ function readInstallState(appDir) {
68
+ try {
69
+ return JSON.parse(readFileSync(path.join(appDir, "install-state.json"), "utf8"));
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function writeInstallState(appDir, state) {
76
+ writeFileSync(path.join(appDir, "install-state.json"), `${JSON.stringify(state, null, 2)}\n`);
77
+ }
78
+
79
+ // PREPARE_RUNTIME's deferred-pull half (both platforms). DECISION (documented
80
+ // per task instructions): `docker compose up` DOES auto-pull any image
81
+ // missing locally by default (pull_policy: missing), so skipping this call
82
+ // entirely would still work correctly — the reason to keep it is the
83
+ // MESSAGE, not correctness. provision.mjs stages images with `docker compose
84
+ // pull` when the engine is reachable at npm-install time; when it wasn't,
85
+ // install-state.json is left with imagesStaged:false and the pull is caught
86
+ // up HERE, on the first `getculpa`, labelled honestly, so a first-run
87
+ // multi-GB pull is never silently absorbed into `compose up`'s own output or
88
+ // (on win32) into launch-culpa.ps1's health-wait window. Pulls the SHIPPED
89
+ // compose (culpa-compose.yml), matching provision.mjs's own stageImages() —
90
+ // on an upgrade this pre-pulls the version that is about to become live via
91
+ // QW-1, rather than the (possibly stale) currently-live pin.
92
+ function deferredPullCatchUp({ appDir, spawnSync, log, warn }) {
93
+ const state = readInstallState(appDir);
94
+ if (state?.imagesStaged === true) return;
95
+ const shipped = path.join(appDir, "culpa-compose.yml");
96
+ if (!existsSync(shipped)) return;
97
+ log("==> Pulling Culpa's container images (first run - this can take a few minutes)");
98
+ const r = spawnSync("docker", ["compose", "-f", shipped, "-p", "culpa", "pull"], { stdio: "inherit" });
99
+ if (r.status !== 0) {
100
+ warn("getculpa: image pull did not complete cleanly - `docker compose up` will retry it.");
101
+ return;
102
+ }
103
+ if (state) writeInstallState(appDir, { ...state, imagesStaged: true });
104
+ }
105
+
106
+ // Ported from Test-BackwardPin (launch-culpa.ps1:97-103) byte-for-byte:
107
+ // a malformed pin against a well-formed known baseline fails CLOSED (refuse);
108
+ // both-unparsable and no-baseline stay permissive.
109
+ function isBackwardPin(pinnedVersion, lastBootVersion) {
110
+ if (!pinnedVersion || !pinnedVersion.trim()) return false;
111
+ if (!lastBootVersion || !lastBootVersion.trim()) return false;
112
+ const rx = /^v?(\d+)\.(\d+)\.(\d+)/;
113
+ if (!rx.test(pinnedVersion) && rx.test(lastBootVersion)) return true;
114
+ return compareVersions(pinnedVersion, lastBootVersion) === -1;
115
+ }
116
+
117
+ // QW-1/3/4, ported from launch-culpa.ps1:257-316. Returns { pinnedVersion }
118
+ // to continue, or a fail() result to abort BEFORE anything else is started —
119
+ // same fail-closed contract as the .ps1. non-win32 only (win32 delegates
120
+ // these three guards to the .ps1 itself).
121
+ async function runVersionGuards({ appDir, liveCompose, spawnSync, env, log, warn, sleepFn }) {
122
+ const shippedPath = path.join(appDir, "culpa-compose.yml");
123
+ if (existsSync(shippedPath)) {
124
+ const shippedVer = parsePinnedServerVersion(readFileSync(shippedPath, "utf8"));
125
+ const liveVer = parsePinnedServerVersion(readFileSync(liveCompose, "utf8"));
126
+ if (shippedVer && liveVer && compareVersions(shippedVer, liveVer) === 1) {
127
+ copyFileSync(shippedPath, liveCompose);
128
+ log("==> Upgrade detected: live compose re-pinned from the shipped culpa-compose.yml");
129
+ }
130
+ }
131
+
132
+ const pinnedVersion = parsePinnedServerVersion(readFileSync(liveCompose, "utf8"));
133
+ const lastBootFile = path.join(appDir, "last-boot-version.txt");
134
+ const lastBootVersion = existsSync(lastBootFile) ? readFileSync(lastBootFile, "utf8").trim() : "";
135
+
136
+ if (isBackwardPin(pinnedVersion, lastBootVersion)) {
137
+ if (env.CULPA_ALLOW_DOWNGRADE === "1") {
138
+ warn(
139
+ `WARNING: CULPA_ALLOW_DOWNGRADE=1 - launching an OLDER server (${pinnedVersion}) against a database last used by ${lastBootVersion}.`,
140
+ );
141
+ } else {
142
+ return fail(
143
+ STAGES.PREPARE_RUNTIME,
144
+ "Culpa couldn't complete the database upgrade.",
145
+ `The compose file pins culpa-server ${pinnedVersion}, but this machine last ran ${lastBootVersion}. Restore the ${lastBootVersion} pin, or set CULPA_ALLOW_DOWNGRADE=1 only after restoring a matching database backup, then run \`getculpa\` again.`,
146
+ );
147
+ }
148
+ }
149
+
150
+ const dbExists = dbContainerExists(spawnSync);
151
+ const baseline = resolveBackupBaseline(lastBootVersion, spawnSync);
152
+ if (!isBackupNeeded(dbExists, pinnedVersion, baseline)) return { pinnedVersion };
153
+
154
+ if (env.CULPA_SKIP_BACKUP === "1") {
155
+ warn(`WARNING: CULPA_SKIP_BACKUP=1 - upgrading ${baseline || "unknown"} -> ${pinnedVersion} WITHOUT a database backup.`);
156
+ return { pinnedVersion };
157
+ }
158
+
159
+ log(`==> Version change (${baseline || "unknown"} -> ${pinnedVersion}): backing up the database first`);
160
+ const up = spawnSync("docker", ["compose", "-f", liveCompose, "-p", "culpa", "up", "-d", "db"]);
161
+ if (up.status !== 0) {
162
+ return fail(
163
+ STAGES.PREPARE_RUNTIME,
164
+ "Culpa couldn't reach its local database.",
165
+ "Could not start the database container to take the pre-upgrade backup. Check `docker logs culpa-db`, then run `getculpa` again.",
166
+ );
167
+ }
168
+ const pgReady = await waitForPgReady({ spawnSync, sleepFn });
169
+ if (!pgReady) {
170
+ return fail(
171
+ STAGES.PREPARE_RUNTIME,
172
+ "Culpa couldn't reach its local database.",
173
+ "The database did not become ready within 60s, so the pre-upgrade backup could not be taken. Nothing was upgraded. Run `getculpa` again.",
174
+ );
175
+ }
176
+ try {
177
+ const dump = preMigrationBackup({ backupDir: path.join(appDir, "backups"), tag: baseline || "unknown", spawnSync });
178
+ log(`==> Backup verified: ${dump}`);
179
+ } catch (e) {
180
+ return fail(
181
+ STAGES.PREPARE_RUNTIME,
182
+ "Culpa couldn't complete the database upgrade.",
183
+ `Pre-upgrade backup failed (${e.message}). Nothing was upgraded. Set CULPA_SKIP_BACKUP=1 to launch without a backup (not recommended), then run \`getculpa\` again.`,
184
+ );
185
+ }
186
+ return { pinnedVersion };
187
+ }
188
+
189
+ function maybeOpenBrowser({ env, isTTY, openBrowser }) {
190
+ if (!isTTY) return;
191
+ if (env.CULPA_NO_BROWSER) return;
192
+ openBrowser(DASHBOARD_URL_FRIENDLY);
193
+ }
194
+
195
+ function defaultOpenBrowser(url) {
196
+ if (process.platform === "darwin") return void realSpawnSync("open", [url]);
197
+ if (process.platform === "linux") return void realSpawnSync("xdg-open", [url]);
198
+ realSpawnSync("cmd.exe", ["/c", "start", "", url]);
199
+ }
200
+
201
+ // Real win32 delegate: no flags — the launched script is the one the
202
+ // Windows installer's own shortcut runs, unmodified. Captures output (rather
203
+ // than pure stdio:"inherit") so mapWindowsFailure() can name the failing
204
+ // layer on a non-zero exit; the captured text is still echoed to the caller.
205
+ function defaultDelegateWindowsLaunch(appDir) {
206
+ const script = path.join(appDir, "launch-culpa.ps1");
207
+ const result = realSpawnSync("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script], {
208
+ encoding: "utf8",
209
+ });
210
+ if (result.stdout) process.stdout.write(result.stdout);
211
+ if (result.stderr) process.stderr.write(result.stderr);
212
+ return { ok: result.status === 0, output: `${result.stdout ?? ""}\n${result.stderr ?? ""}` };
213
+ }
214
+
215
+ // Best-effort text match against launch-culpa.ps1's own Fail() messages
216
+ // (verbatim strings at :223/227/231/243/251/276/301/308/313/323/340) so a
217
+ // win32 failure still gets ONE of the canonical layer-naming messages
218
+ // instead of a raw non-zero exit code. Order matters — first match wins.
219
+ const WINDOWS_FAILURE_MAP = [
220
+ [/docker desktop|winget|docker.*not (running|found)/i, "Culpa couldn't start Docker.", "Start Docker Desktop yourself, wait for the whale icon to settle, then run `getculpa` again."],
221
+ [/pre-upgrade backup|pg_dump|OLDER server|pinned server/i, "Culpa couldn't complete the database upgrade.", "See the output above for the exact reason, then run `getculpa` again (set CULPA_SKIP_BACKUP=1 or CULPA_ALLOW_DOWNGRADE=1 only if you understand the risk)."],
222
+ [/dashboard did not answer/i, "Culpa's dashboard didn't come up in time.", "Run `docker logs culpa-dashboard` to see why, then run `getculpa` again."],
223
+ [/could not start the culpa services/i, "Culpa couldn't start its services.", "See the output above for the exact Docker error, then run `getculpa` again."],
224
+ ];
225
+
226
+ function mapWindowsFailure(output) {
227
+ for (const [re, message, recovery] of WINDOWS_FAILURE_MAP) {
228
+ if (re.test(output)) return { stage: STAGES.START_CULPA_DEPENDENCIES, message, recovery };
229
+ }
230
+ return {
231
+ stage: STAGES.START_CULPA_DEPENDENCIES,
232
+ message: "Culpa couldn't start.",
233
+ recovery: "See the output above for details, then run `getculpa` again (or `getculpa status` for diagnostics).",
234
+ };
235
+ }
236
+
237
+ async function runIdempotencyCheck({ spawnSync, fetchFn, log, env, isTTY, openBrowser }) {
238
+ const running = isProjectRunning(spawnSync);
239
+ const healthy = running && (await probeHealth(DASHBOARD_URL, { fetchFn }));
240
+ if (!healthy) return { running };
241
+ log(`Culpa is already running - ${DASHBOARD_URL_FRIENDLY}`);
242
+ maybeOpenBrowser({ env, isTTY, openBrowser });
243
+ return { running, alreadyHealthy: true };
244
+ }
245
+
246
+ function checkPortConflicts({ running, platform, spawnSync }) {
247
+ if (running) return null; // our own project may legitimately hold these ports
248
+ for (const port of PORTS_TO_CHECK) {
249
+ const owner = findPortOwner(port, { platform, spawnSync });
250
+ if (owner) {
251
+ return fail(
252
+ STAGES.PORT_CHECK,
253
+ `Culpa couldn't start because port ${port} is already in use by ${owner.name} (pid ${owner.pid}).`,
254
+ `Stop ${owner.name} yourself (Culpa will never do this for you), then run \`getculpa\` again.`,
255
+ );
256
+ }
257
+ }
258
+ return null;
259
+ }
260
+
261
+ async function runWindowsDelegate({ appDir, spawnSync, log, warn, delegateWindowsLaunch, stagesRun }) {
262
+ stagesRun.push(STAGES.PREPARE_RUNTIME);
263
+ deferredPullCatchUp({ appDir, spawnSync, log, warn });
264
+
265
+ stagesRun.push(
266
+ STAGES.START_CULPA_DEPENDENCIES,
267
+ STAGES.RUN_REQUIRED_MIGRATIONS,
268
+ STAGES.HEALTH_CHECK,
269
+ STAGES.START_CULPA,
270
+ STAGES.OPEN_UI,
271
+ );
272
+ log("==> Delegating to launch-culpa.ps1 (Docker ensure, compose up, collector, health wait, browser)");
273
+ const result = delegateWindowsLaunch(appDir);
274
+ if (!result.ok) {
275
+ const mapped = mapWindowsFailure(result.output ?? "");
276
+ return { ok: false, stage: mapped.stage, message: mapped.message, recovery: mapped.recovery, stagesRun };
277
+ }
278
+ return { ok: true, stage: STAGES.OPEN_UI, stagesRun };
279
+ }
280
+
281
+ async function runPosixStateMachine({ appDir, liveCompose, platform, spawnSync, fetchFn, sleepFn, env, log, warn, openBrowser, isTTY, stagesRun }) {
282
+ stagesRun.push(STAGES.START_DOCKER_IF_REQUIRED);
283
+ let engineReady = checkDockerEngineReachable(spawnSync);
284
+ if (!engineReady) {
285
+ startEngineIfPossible(platform, spawnSync, log);
286
+ stagesRun.push(STAGES.WAIT_FOR_DOCKER);
287
+ engineReady = await waitForDockerEngine({ spawnSync, sleepFn });
288
+ if (!engineReady) {
289
+ return {
290
+ ...fail(
291
+ STAGES.WAIT_FOR_DOCKER,
292
+ "Culpa couldn't start Docker.",
293
+ "Start Docker (or Docker Desktop) yourself, wait for it to finish starting, then run `getculpa` again.",
294
+ ),
295
+ stagesRun,
296
+ };
297
+ }
298
+ } else {
299
+ stagesRun.push(STAGES.WAIT_FOR_DOCKER);
300
+ }
301
+
302
+ stagesRun.push(STAGES.PREPARE_RUNTIME);
303
+ deferredPullCatchUp({ appDir, spawnSync, log, warn });
304
+ const guardResult = await runVersionGuards({ appDir, liveCompose, spawnSync, env, log, warn, sleepFn });
305
+ if (guardResult.ok === false) return { ...guardResult, stagesRun };
306
+ const { pinnedVersion } = guardResult;
307
+
308
+ stagesRun.push(STAGES.START_CULPA_DEPENDENCIES);
309
+ const up = spawnSync("docker", ["compose", "-f", liveCompose, "-p", "culpa", "up", "-d"], { stdio: "inherit" });
310
+ if (up.status !== 0) {
311
+ return {
312
+ ...fail(STAGES.START_CULPA_DEPENDENCIES, "Culpa couldn't start its services.", "Docker's own error is above. Fix it, then run `getculpa` again."),
313
+ stagesRun,
314
+ };
315
+ }
316
+
317
+ // RUN_REQUIRED_MIGRATIONS is compose-internal: the culpa-server image's
318
+ // container CMD runs culpa-migrate BEFORE the server binary starts (no
319
+ // compose override) — verified in native/culpa-api/src/main.rs:5-10. There
320
+ // is nothing for Node to invoke; HEALTH_CHECK below is the real signal
321
+ // that migrations finished, since the server never answers until they do.
322
+ stagesRun.push(STAGES.RUN_REQUIRED_MIGRATIONS);
323
+
324
+ stagesRun.push(STAGES.HEALTH_CHECK);
325
+ const healthy = await waitForHealth(DASHBOARD_URL, { sleepFn, fetchFn });
326
+ if (!healthy) {
327
+ return {
328
+ ...fail(
329
+ STAGES.HEALTH_CHECK,
330
+ "Culpa's dashboard didn't come up in time.",
331
+ "Run `docker logs culpa-dashboard` (or `getculpa status`) to see why, then run `getculpa` again.",
332
+ ),
333
+ stagesRun,
334
+ };
335
+ }
336
+
337
+ // No collectord build/staging exists for macOS/Linux yet (task scope) —
338
+ // degrade honestly rather than fail the whole launch over capture.
339
+ stagesRun.push(STAGES.START_CULPA);
340
+ log("getculpa: capture is OFF on this platform for now (no collector build ships for macOS/Linux yet) - everything else works normally.");
341
+
342
+ stagesRun.push(STAGES.OPEN_UI);
343
+ if (pinnedVersion) writeFileSync(path.join(appDir, "last-boot-version.txt"), `${pinnedVersion}\n`);
344
+ maybeOpenBrowser({ env, isTTY, openBrowser });
345
+
346
+ return { ok: true, stage: STAGES.OPEN_UI, stagesRun };
347
+ }
348
+
349
+ export async function start(opts) {
350
+ const {
351
+ appDir,
352
+ platform = process.platform,
353
+ spawnSync = realSpawnSync,
354
+ fetchFn,
355
+ sleepFn,
356
+ env = process.env,
357
+ log = console.log,
358
+ warn = console.warn,
359
+ openBrowser = defaultOpenBrowser,
360
+ isTTY = process.stdout.isTTY,
361
+ delegateWindowsLaunch = defaultDelegateWindowsLaunch,
362
+ } = opts;
363
+
364
+ const stagesRun = [STAGES.CHECK_INSTALLATION];
365
+ const liveCompose = path.join(appDir, "docker-compose.yml");
366
+ if (!existsSync(appDir) || !existsSync(liveCompose)) {
367
+ return { ...fail(STAGES.CHECK_INSTALLATION, "Culpa is not installed.", "Run `npm i -g getculpa` (or reinstall it) first."), stagesRun };
368
+ }
369
+
370
+ stagesRun.push(STAGES.CHECK_DOCKER);
371
+ if (platform !== "win32" && !checkDockerPresent(spawnSync)) {
372
+ return {
373
+ ...fail(STAGES.CHECK_DOCKER, "Culpa couldn't start Docker.", "Install Docker (https://docs.docker.com/get-docker/), then run `getculpa` again."),
374
+ stagesRun,
375
+ };
376
+ }
377
+
378
+ stagesRun.push(STAGES.IDEMPOTENCY_CHECK);
379
+ const idempotency = await runIdempotencyCheck({ spawnSync, fetchFn, log, env, isTTY, openBrowser });
380
+ if (idempotency.alreadyHealthy) {
381
+ return { ok: true, stage: STAGES.IDEMPOTENCY_CHECK, alreadyRunning: true, stagesRun };
382
+ }
383
+
384
+ stagesRun.push(STAGES.PORT_CHECK);
385
+ const portConflict = checkPortConflicts({ running: idempotency.running, platform, spawnSync });
386
+ if (portConflict) return { ...portConflict, stagesRun };
387
+
388
+ if (platform === "win32") {
389
+ return runWindowsDelegate({ appDir, spawnSync, log, warn, delegateWindowsLaunch, stagesRun });
390
+ }
391
+ return runPosixStateMachine({ appDir, liveCompose, platform, spawnSync, fetchFn, sleepFn, env, log, warn, openBrowser, isTTY, stagesRun });
392
+ }
@@ -0,0 +1,23 @@
1
+ import type { SpawnSyncLike, FetchLike } from "./docker.d.mts";
2
+
3
+ export interface StatusOptions {
4
+ appDir?: string;
5
+ platform?: string;
6
+ spawnSync?: SpawnSyncLike;
7
+ fetchFn?: FetchLike;
8
+ }
9
+
10
+ export interface StatusResult {
11
+ installed: boolean;
12
+ packageVersion: string | null;
13
+ platform: string;
14
+ dockerPresent: boolean;
15
+ dockerReachable: boolean;
16
+ running: boolean;
17
+ dashboardHealthy: boolean;
18
+ serverHealthy: boolean;
19
+ licenseConfigured: boolean | null;
20
+ }
21
+
22
+ export function status(opts?: StatusOptions): Promise<StatusResult>;
23
+ export function formatStatus(s: StatusResult): string;
package/lib/status.mjs ADDED
@@ -0,0 +1,74 @@
1
+ // CF20-T4 — `getculpa status`. Every probe degrades to a known-unknown
2
+ // value on failure; this command must never throw, and must never touch a
3
+ // live process (detection only — starting/stopping is start.mjs/stop.mjs's
4
+ // job). License presence is surfaced as the `configured` BOOLEAN ONLY, and
5
+ // ONLY when the server already answered 200 — never key material, never
6
+ // speculative against a stopped stack (native/culpa-api/src/server.rs:1695-1711).
7
+
8
+ import { spawnSync as realSpawnSync } from "node:child_process";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { checkDockerEngineReachable, isProjectRunning, probeHealth } from "./docker.mjs";
12
+ import { checkDockerPresent } from "./preflight.mjs";
13
+ import { getAppDir } from "./paths.mjs";
14
+
15
+ function readInstallState(appDir) {
16
+ try {
17
+ return JSON.parse(readFileSync(path.join(appDir, "install-state.json"), "utf8"));
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ async function probeServerEntitlement(fetchFn) {
24
+ try {
25
+ const res = await fetchFn("http://127.0.0.1:4545/internal/entitlement", { signal: AbortSignal.timeout(3_000) });
26
+ if (res.status !== 200) return { serverHealthy: false, licenseConfigured: null };
27
+ const body = await res.json();
28
+ const configured = typeof body?.configured === "boolean" ? body.configured : null;
29
+ return { serverHealthy: true, licenseConfigured: configured };
30
+ } catch {
31
+ return { serverHealthy: false, licenseConfigured: null };
32
+ }
33
+ }
34
+
35
+ export async function status(opts = {}) {
36
+ const appDir = opts.appDir ?? getAppDir();
37
+ const platform = opts.platform ?? process.platform;
38
+ const spawnSync = opts.spawnSync ?? realSpawnSync;
39
+ const fetchFn = opts.fetchFn ?? fetch;
40
+
41
+ const installed = existsSync(path.join(appDir, "docker-compose.yml"));
42
+ const state = installed ? readInstallState(appDir) : null;
43
+ const dockerPresent = checkDockerPresent(spawnSync);
44
+ const dockerReachable = dockerPresent && checkDockerEngineReachable(spawnSync);
45
+ const running = dockerReachable && isProjectRunning(spawnSync);
46
+
47
+ const dashboardHealthy = running ? await probeHealth("http://127.0.0.1:3000", { fetchFn }) : false;
48
+ const { serverHealthy, licenseConfigured } = running
49
+ ? await probeServerEntitlement(fetchFn)
50
+ : { serverHealthy: false, licenseConfigured: null };
51
+
52
+ return {
53
+ installed,
54
+ packageVersion: state?.packageVersion ?? null,
55
+ platform,
56
+ dockerPresent,
57
+ dockerReachable,
58
+ running,
59
+ dashboardHealthy,
60
+ serverHealthy,
61
+ licenseConfigured,
62
+ };
63
+ }
64
+
65
+ export function formatStatus(s) {
66
+ const line = (label, value) => `${label.padEnd(18)} ${value}`;
67
+ return [
68
+ line("Installed:", s.installed ? `yes (v${s.packageVersion ?? "unknown"})` : "no"),
69
+ line("Docker:", !s.dockerPresent ? "not found" : s.dockerReachable ? "running" : "installed, not running"),
70
+ line("Culpa stack:", s.running ? "running" : "stopped"),
71
+ line("Dashboard:", s.dashboardHealthy ? "healthy (http://localhost:3000)" : "not responding"),
72
+ line("License:", s.licenseConfigured === null ? "unknown" : s.licenseConfigured ? "configured" : "not configured"),
73
+ ].join("\n");
74
+ }