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/LICENSE +41 -0
- package/README.md +37 -18
- package/assets/culpa-collector.ps1 +137 -0
- package/assets/culpa-compose.yml +144 -0
- package/assets/install-culpa.ps1 +310 -0
- package/assets/launch-culpa.ps1 +350 -0
- package/assets/register.mjs +713 -0
- package/assets/uninstall-culpa.ps1 +227 -0
- package/bin/culpa.js +21 -10
- package/bin/getculpa.js +147 -0
- package/lib/assets.d.mts +2 -0
- package/lib/assets.mjs +36 -0
- package/lib/bootstrap.d.mts +25 -0
- package/lib/bootstrap.mjs +73 -0
- package/lib/docker.d.mts +76 -0
- package/lib/docker.mjs +270 -0
- package/lib/doctor.d.mts +29 -0
- package/lib/doctor.mjs +405 -0
- package/lib/fetch.d.mts +17 -0
- package/lib/fetch.mjs +211 -0
- package/lib/install-summary.d.mts +18 -0
- package/lib/install-summary.mjs +144 -0
- package/lib/paths.d.mts +9 -0
- package/lib/paths.mjs +61 -0
- package/lib/preflight.d.mts +39 -0
- package/lib/preflight.mjs +169 -0
- package/lib/provision.d.mts +54 -0
- package/lib/provision.mjs +318 -0
- package/lib/repair.d.mts +21 -0
- package/lib/repair.mjs +174 -0
- package/lib/start.d.mts +44 -0
- package/lib/start.mjs +479 -0
- package/lib/status.d.mts +23 -0
- package/lib/status.mjs +74 -0
- package/lib/stop.d.mts +15 -0
- package/lib/stop.mjs +46 -0
- package/lib/tty.d.mts +18 -0
- package/lib/tty.mjs +58 -0
- package/lib/uninstall.d.mts +17 -0
- package/lib/uninstall.mjs +99 -0
- package/package.json +14 -25
- package/scripts/install.js +154 -0
- package/scripts/prepack.js +41 -0
- package/index.js +0 -3
package/lib/start.mjs
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
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
|
+
installDockerDarwin,
|
|
38
|
+
startEngineIfPossible,
|
|
39
|
+
waitForDockerEngine,
|
|
40
|
+
waitForHealth,
|
|
41
|
+
waitForPgReady,
|
|
42
|
+
} from "./docker.mjs";
|
|
43
|
+
import { checkDockerPresent, compareVersions, parsePinnedServerVersion } from "./preflight.mjs";
|
|
44
|
+
import { askYesNo as realAskYesNo } from "./tty.mjs";
|
|
45
|
+
|
|
46
|
+
export const STAGES = Object.freeze({
|
|
47
|
+
CHECK_INSTALLATION: "CHECK_INSTALLATION",
|
|
48
|
+
CHECK_DOCKER: "CHECK_DOCKER",
|
|
49
|
+
IDEMPOTENCY_CHECK: "IDEMPOTENCY_CHECK",
|
|
50
|
+
PORT_CHECK: "PORT_CHECK",
|
|
51
|
+
START_DOCKER_IF_REQUIRED: "START_DOCKER_IF_REQUIRED",
|
|
52
|
+
WAIT_FOR_DOCKER: "WAIT_FOR_DOCKER",
|
|
53
|
+
PREPARE_RUNTIME: "PREPARE_RUNTIME",
|
|
54
|
+
START_CULPA_DEPENDENCIES: "START_CULPA_DEPENDENCIES",
|
|
55
|
+
RUN_REQUIRED_MIGRATIONS: "RUN_REQUIRED_MIGRATIONS",
|
|
56
|
+
HEALTH_CHECK: "HEALTH_CHECK",
|
|
57
|
+
START_CULPA: "START_CULPA",
|
|
58
|
+
OPEN_UI: "OPEN_UI",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const DASHBOARD_URL = "http://127.0.0.1:3000";
|
|
62
|
+
const DASHBOARD_URL_FRIENDLY = "http://localhost:3000";
|
|
63
|
+
const PORTS_TO_CHECK = [3000, 4545];
|
|
64
|
+
|
|
65
|
+
function fail(stage, message, recovery) {
|
|
66
|
+
return { ok: false, stage, message, recovery };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readInstallState(appDir) {
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(readFileSync(path.join(appDir, "install-state.json"), "utf8"));
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeInstallState(appDir, state) {
|
|
78
|
+
writeFileSync(path.join(appDir, "install-state.json"), `${JSON.stringify(state, null, 2)}\n`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// PREPARE_RUNTIME's deferred-pull half (both platforms). DECISION (documented
|
|
82
|
+
// per task instructions): `docker compose up` DOES auto-pull any image
|
|
83
|
+
// missing locally by default (pull_policy: missing), so skipping this call
|
|
84
|
+
// entirely would still work correctly — the reason to keep it is the
|
|
85
|
+
// MESSAGE, not correctness. provision.mjs stages images with `docker compose
|
|
86
|
+
// pull` when the engine is reachable at npm-install time; when it wasn't,
|
|
87
|
+
// install-state.json is left with imagesStaged:false and the pull is caught
|
|
88
|
+
// up HERE, on the first `getculpa`, labelled honestly, so a first-run
|
|
89
|
+
// multi-GB pull is never silently absorbed into `compose up`'s own output or
|
|
90
|
+
// (on win32) into launch-culpa.ps1's health-wait window. Pulls the SHIPPED
|
|
91
|
+
// compose (culpa-compose.yml), matching provision.mjs's own stageImages() —
|
|
92
|
+
// on an upgrade this pre-pulls the version that is about to become live via
|
|
93
|
+
// QW-1, rather than the (possibly stale) currently-live pin.
|
|
94
|
+
function deferredPullCatchUp({ appDir, spawnSync, log, warn }) {
|
|
95
|
+
const state = readInstallState(appDir);
|
|
96
|
+
if (state?.imagesStaged === true) return;
|
|
97
|
+
const shipped = path.join(appDir, "culpa-compose.yml");
|
|
98
|
+
if (!existsSync(shipped)) return;
|
|
99
|
+
log("==> Pulling Culpa's container images (first run - this can take a few minutes)");
|
|
100
|
+
const r = spawnSync("docker", ["compose", "-f", shipped, "-p", "culpa", "pull"], { stdio: "inherit" });
|
|
101
|
+
if (r.status !== 0) {
|
|
102
|
+
warn("getculpa: image pull did not complete cleanly - `docker compose up` will retry it.");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (state) writeInstallState(appDir, { ...state, imagesStaged: true });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Ported from Test-BackwardPin (launch-culpa.ps1:97-103) byte-for-byte:
|
|
109
|
+
// a malformed pin against a well-formed known baseline fails CLOSED (refuse);
|
|
110
|
+
// both-unparsable and no-baseline stay permissive.
|
|
111
|
+
function isBackwardPin(pinnedVersion, lastBootVersion) {
|
|
112
|
+
if (!pinnedVersion || !pinnedVersion.trim()) return false;
|
|
113
|
+
if (!lastBootVersion || !lastBootVersion.trim()) return false;
|
|
114
|
+
const rx = /^v?(\d+)\.(\d+)\.(\d+)/;
|
|
115
|
+
if (!rx.test(pinnedVersion) && rx.test(lastBootVersion)) return true;
|
|
116
|
+
return compareVersions(pinnedVersion, lastBootVersion) === -1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// QW-1/3/4, ported from launch-culpa.ps1:257-316. Returns { pinnedVersion }
|
|
120
|
+
// to continue, or a fail() result to abort BEFORE anything else is started —
|
|
121
|
+
// same fail-closed contract as the .ps1. non-win32 only (win32 delegates
|
|
122
|
+
// these three guards to the .ps1 itself).
|
|
123
|
+
async function runVersionGuards({ appDir, liveCompose, spawnSync, env, log, warn, sleepFn }) {
|
|
124
|
+
const shippedPath = path.join(appDir, "culpa-compose.yml");
|
|
125
|
+
if (existsSync(shippedPath)) {
|
|
126
|
+
const shippedVer = parsePinnedServerVersion(readFileSync(shippedPath, "utf8"));
|
|
127
|
+
const liveVer = parsePinnedServerVersion(readFileSync(liveCompose, "utf8"));
|
|
128
|
+
if (shippedVer && liveVer && compareVersions(shippedVer, liveVer) === 1) {
|
|
129
|
+
copyFileSync(shippedPath, liveCompose);
|
|
130
|
+
log("==> Upgrade detected: live compose re-pinned from the shipped culpa-compose.yml");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const pinnedVersion = parsePinnedServerVersion(readFileSync(liveCompose, "utf8"));
|
|
135
|
+
const lastBootFile = path.join(appDir, "last-boot-version.txt");
|
|
136
|
+
const lastBootVersion = existsSync(lastBootFile) ? readFileSync(lastBootFile, "utf8").trim() : "";
|
|
137
|
+
|
|
138
|
+
if (isBackwardPin(pinnedVersion, lastBootVersion)) {
|
|
139
|
+
if (env.CULPA_ALLOW_DOWNGRADE === "1") {
|
|
140
|
+
warn(
|
|
141
|
+
`WARNING: CULPA_ALLOW_DOWNGRADE=1 - launching an OLDER server (${pinnedVersion}) against a database last used by ${lastBootVersion}.`,
|
|
142
|
+
);
|
|
143
|
+
} else {
|
|
144
|
+
return fail(
|
|
145
|
+
STAGES.PREPARE_RUNTIME,
|
|
146
|
+
"Culpa couldn't complete the database upgrade.",
|
|
147
|
+
`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.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const dbExists = dbContainerExists(spawnSync);
|
|
153
|
+
const baseline = resolveBackupBaseline(lastBootVersion, spawnSync);
|
|
154
|
+
if (!isBackupNeeded(dbExists, pinnedVersion, baseline)) return { pinnedVersion };
|
|
155
|
+
|
|
156
|
+
if (env.CULPA_SKIP_BACKUP === "1") {
|
|
157
|
+
warn(`WARNING: CULPA_SKIP_BACKUP=1 - upgrading ${baseline || "unknown"} -> ${pinnedVersion} WITHOUT a database backup.`);
|
|
158
|
+
return { pinnedVersion };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
log(`==> Version change (${baseline || "unknown"} -> ${pinnedVersion}): backing up the database first`);
|
|
162
|
+
const up = spawnSync("docker", ["compose", "-f", liveCompose, "-p", "culpa", "up", "-d", "db"]);
|
|
163
|
+
if (up.status !== 0) {
|
|
164
|
+
return fail(
|
|
165
|
+
STAGES.PREPARE_RUNTIME,
|
|
166
|
+
"Culpa couldn't reach its local database.",
|
|
167
|
+
"Could not start the database container to take the pre-upgrade backup. Check `docker logs culpa-db`, then run `getculpa` again.",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const pgReady = await waitForPgReady({ spawnSync, sleepFn });
|
|
171
|
+
if (!pgReady) {
|
|
172
|
+
return fail(
|
|
173
|
+
STAGES.PREPARE_RUNTIME,
|
|
174
|
+
"Culpa couldn't reach its local database.",
|
|
175
|
+
"The database did not become ready within 60s, so the pre-upgrade backup could not be taken. Nothing was upgraded. Run `getculpa` again.",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const dump = preMigrationBackup({ backupDir: path.join(appDir, "backups"), tag: baseline || "unknown", spawnSync });
|
|
180
|
+
log(`==> Backup verified: ${dump}`);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
return fail(
|
|
183
|
+
STAGES.PREPARE_RUNTIME,
|
|
184
|
+
"Culpa couldn't complete the database upgrade.",
|
|
185
|
+
`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.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return { pinnedVersion };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// T-CF28-7 — "The install fails to ask for docker and other dependencies. IT
|
|
192
|
+
// MUST ASK AND INSTALL THESE" (founder). This is the ASK half; the DETECT half
|
|
193
|
+
// runs at npm-install time (lib/install-summary.mjs), because postinstall
|
|
194
|
+
// cannot prompt (scripts/install.js:13-15).
|
|
195
|
+
//
|
|
196
|
+
// Reached only when Docker is genuinely absent on a non-win32 platform. Every
|
|
197
|
+
// branch ends in a fail() — even a SUCCESSFUL install, because Docker Desktop
|
|
198
|
+
// still needs one manual launch before its engine answers. That mirrors
|
|
199
|
+
// launch-culpa.ps1:233, which likewise stops after installing rather than
|
|
200
|
+
// pretending the stack is coming up.
|
|
201
|
+
const DOCKER_DESKTOP_URL = "https://www.docker.com/products/docker-desktop/";
|
|
202
|
+
const DOCKER_ENGINE_DOCS_URL = "https://docs.docker.com/engine/install/";
|
|
203
|
+
const DOCKER_MISSING = "Culpa couldn't start: Docker is not installed.";
|
|
204
|
+
|
|
205
|
+
// The consent text IS the disclosure. It names the exact command, that a
|
|
206
|
+
// password will be asked for, and that a manual first launch is still
|
|
207
|
+
// required — so nobody agrees to a "one-click install" that isn't one.
|
|
208
|
+
const DARWIN_CONSENT_QUESTION =
|
|
209
|
+
"Culpa needs Docker to run, and it was not found on this machine.\n" +
|
|
210
|
+
"Install it now with `brew install --cask docker`?\n" +
|
|
211
|
+
" Homebrew will ask for your password, and afterwards you must open Docker once\n" +
|
|
212
|
+
" from Applications - this cannot be a fully hands-off install.\n" +
|
|
213
|
+
" Nothing is downloaded unless you answer yes.\n" +
|
|
214
|
+
"Install Docker now? (y/N) ";
|
|
215
|
+
|
|
216
|
+
async function offerDockerInstall({ platform, spawnSync, log, askYesNo, isInteractive }) {
|
|
217
|
+
// linux: installing a system package without explicit consent is out of
|
|
218
|
+
// bounds — the same rule startEngineIfPossible already follows for merely
|
|
219
|
+
// STARTING the engine (docker.mjs:73-79). Guidance only, and no prompt: a
|
|
220
|
+
// yes here could not be honoured anyway.
|
|
221
|
+
if (platform !== "darwin") {
|
|
222
|
+
return fail(
|
|
223
|
+
STAGES.CHECK_DOCKER,
|
|
224
|
+
DOCKER_MISSING,
|
|
225
|
+
`Culpa runs its database and services in containers. Install Docker Engine (${DOCKER_ENGINE_DOCS_URL}), then run \`getculpa\` again. Culpa will not install system packages for you.`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!isInteractive) {
|
|
230
|
+
return fail(
|
|
231
|
+
STAGES.CHECK_DOCKER,
|
|
232
|
+
DOCKER_MISSING,
|
|
233
|
+
`Culpa runs its database and services in containers. Install it with \`brew install --cask docker\` (or download it from ${DOCKER_DESKTOP_URL}), open Docker once, then run \`getculpa\` again.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!(await askYesNo(DARWIN_CONSENT_QUESTION))) {
|
|
238
|
+
return fail(
|
|
239
|
+
STAGES.CHECK_DOCKER,
|
|
240
|
+
DOCKER_MISSING,
|
|
241
|
+
`The Docker install was not authorized, so nothing was installed and nothing was changed. Install it yourself with \`brew install --cask docker\` or from ${DOCKER_DESKTOP_URL}, then run \`getculpa\` again.`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
log("==> Installing Docker Desktop with Homebrew (brew install --cask docker)");
|
|
246
|
+
const outcome = installDockerDarwin(spawnSync);
|
|
247
|
+
|
|
248
|
+
if (outcome === "no-brew") {
|
|
249
|
+
return fail(
|
|
250
|
+
STAGES.CHECK_DOCKER,
|
|
251
|
+
DOCKER_MISSING,
|
|
252
|
+
`Homebrew is not available, so Docker could not be installed automatically. Nothing was changed. Download Docker Desktop from ${DOCKER_DESKTOP_URL}, open it once, then run \`getculpa\` again.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (outcome !== "installed") {
|
|
256
|
+
const code = outcome.replace(/^failed:/, "");
|
|
257
|
+
return fail(
|
|
258
|
+
STAGES.CHECK_DOCKER,
|
|
259
|
+
DOCKER_MISSING,
|
|
260
|
+
`Homebrew could not install Docker (exit code ${code}). Nothing else was changed. Install Docker Desktop from ${DOCKER_DESKTOP_URL}, open it once, then run \`getculpa\` again.`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
return fail(
|
|
264
|
+
STAGES.CHECK_DOCKER,
|
|
265
|
+
"Docker was installed. Culpa has not started yet.",
|
|
266
|
+
"Open Docker once from Applications and wait for it to finish starting, then run `getculpa` again.",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function maybeOpenBrowser({ env, isTTY, openBrowser }) {
|
|
271
|
+
if (!isTTY) return;
|
|
272
|
+
if (env.CULPA_NO_BROWSER) return;
|
|
273
|
+
openBrowser(DASHBOARD_URL_FRIENDLY);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function defaultOpenBrowser(url) {
|
|
277
|
+
if (process.platform === "darwin") return void realSpawnSync("open", [url]);
|
|
278
|
+
if (process.platform === "linux") return void realSpawnSync("xdg-open", [url]);
|
|
279
|
+
realSpawnSync("cmd.exe", ["/c", "start", "", url]);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Real win32 delegate: no flags — the launched script is the one the
|
|
283
|
+
// Windows installer's own shortcut runs, unmodified. Captures output (rather
|
|
284
|
+
// than pure stdio:"inherit") so mapWindowsFailure() can name the failing
|
|
285
|
+
// layer on a non-zero exit; the captured text is still echoed to the caller.
|
|
286
|
+
function defaultDelegateWindowsLaunch(appDir) {
|
|
287
|
+
const script = path.join(appDir, "launch-culpa.ps1");
|
|
288
|
+
const result = realSpawnSync("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script], {
|
|
289
|
+
encoding: "utf8",
|
|
290
|
+
});
|
|
291
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
292
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
293
|
+
return { ok: result.status === 0, output: `${result.stdout ?? ""}\n${result.stderr ?? ""}` };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Best-effort text match against launch-culpa.ps1's own Fail() messages
|
|
297
|
+
// (verbatim strings at :223/227/231/243/251/276/301/308/313/323/340) so a
|
|
298
|
+
// win32 failure still gets ONE of the canonical layer-naming messages
|
|
299
|
+
// instead of a raw non-zero exit code. Order matters — first match wins.
|
|
300
|
+
const WINDOWS_FAILURE_MAP = [
|
|
301
|
+
[/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."],
|
|
302
|
+
[/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)."],
|
|
303
|
+
[/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."],
|
|
304
|
+
[/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."],
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
function mapWindowsFailure(output) {
|
|
308
|
+
for (const [re, message, recovery] of WINDOWS_FAILURE_MAP) {
|
|
309
|
+
if (re.test(output)) return { stage: STAGES.START_CULPA_DEPENDENCIES, message, recovery };
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
stage: STAGES.START_CULPA_DEPENDENCIES,
|
|
313
|
+
message: "Culpa couldn't start.",
|
|
314
|
+
recovery: "See the output above for details, then run `getculpa` again (or `getculpa status` for diagnostics).",
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function runIdempotencyCheck({ spawnSync, fetchFn, log, env, isTTY, openBrowser }) {
|
|
319
|
+
const running = isProjectRunning(spawnSync);
|
|
320
|
+
const healthy = running && (await probeHealth(DASHBOARD_URL, { fetchFn }));
|
|
321
|
+
if (!healthy) return { running };
|
|
322
|
+
log(`Culpa is already running - ${DASHBOARD_URL_FRIENDLY}`);
|
|
323
|
+
maybeOpenBrowser({ env, isTTY, openBrowser });
|
|
324
|
+
return { running, alreadyHealthy: true };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function checkPortConflicts({ running, platform, spawnSync }) {
|
|
328
|
+
if (running) return null; // our own project may legitimately hold these ports
|
|
329
|
+
for (const port of PORTS_TO_CHECK) {
|
|
330
|
+
const owner = findPortOwner(port, { platform, spawnSync });
|
|
331
|
+
if (owner) {
|
|
332
|
+
return fail(
|
|
333
|
+
STAGES.PORT_CHECK,
|
|
334
|
+
`Culpa couldn't start because port ${port} is already in use by ${owner.name} (pid ${owner.pid}).`,
|
|
335
|
+
`Stop ${owner.name} yourself (Culpa will never do this for you), then run \`getculpa\` again.`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function runWindowsDelegate({ appDir, spawnSync, log, warn, delegateWindowsLaunch, stagesRun }) {
|
|
343
|
+
stagesRun.push(STAGES.PREPARE_RUNTIME);
|
|
344
|
+
deferredPullCatchUp({ appDir, spawnSync, log, warn });
|
|
345
|
+
|
|
346
|
+
stagesRun.push(
|
|
347
|
+
STAGES.START_CULPA_DEPENDENCIES,
|
|
348
|
+
STAGES.RUN_REQUIRED_MIGRATIONS,
|
|
349
|
+
STAGES.HEALTH_CHECK,
|
|
350
|
+
STAGES.START_CULPA,
|
|
351
|
+
STAGES.OPEN_UI,
|
|
352
|
+
);
|
|
353
|
+
log("==> Delegating to launch-culpa.ps1 (Docker ensure, compose up, collector, health wait, browser)");
|
|
354
|
+
const result = delegateWindowsLaunch(appDir);
|
|
355
|
+
if (!result.ok) {
|
|
356
|
+
const mapped = mapWindowsFailure(result.output ?? "");
|
|
357
|
+
return { ok: false, stage: mapped.stage, message: mapped.message, recovery: mapped.recovery, stagesRun };
|
|
358
|
+
}
|
|
359
|
+
return { ok: true, stage: STAGES.OPEN_UI, stagesRun };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function runPosixStateMachine({ appDir, liveCompose, platform, spawnSync, fetchFn, sleepFn, env, log, warn, openBrowser, isTTY, stagesRun }) {
|
|
363
|
+
stagesRun.push(STAGES.START_DOCKER_IF_REQUIRED);
|
|
364
|
+
let engineReady = checkDockerEngineReachable(spawnSync);
|
|
365
|
+
if (!engineReady) {
|
|
366
|
+
startEngineIfPossible(platform, spawnSync, log);
|
|
367
|
+
stagesRun.push(STAGES.WAIT_FOR_DOCKER);
|
|
368
|
+
engineReady = await waitForDockerEngine({ spawnSync, sleepFn });
|
|
369
|
+
if (!engineReady) {
|
|
370
|
+
return {
|
|
371
|
+
...fail(
|
|
372
|
+
STAGES.WAIT_FOR_DOCKER,
|
|
373
|
+
"Culpa couldn't start Docker.",
|
|
374
|
+
"Start Docker (or Docker Desktop) yourself, wait for it to finish starting, then run `getculpa` again.",
|
|
375
|
+
),
|
|
376
|
+
stagesRun,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
stagesRun.push(STAGES.WAIT_FOR_DOCKER);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
stagesRun.push(STAGES.PREPARE_RUNTIME);
|
|
384
|
+
deferredPullCatchUp({ appDir, spawnSync, log, warn });
|
|
385
|
+
const guardResult = await runVersionGuards({ appDir, liveCompose, spawnSync, env, log, warn, sleepFn });
|
|
386
|
+
if (guardResult.ok === false) return { ...guardResult, stagesRun };
|
|
387
|
+
const { pinnedVersion } = guardResult;
|
|
388
|
+
|
|
389
|
+
stagesRun.push(STAGES.START_CULPA_DEPENDENCIES);
|
|
390
|
+
const up = spawnSync("docker", ["compose", "-f", liveCompose, "-p", "culpa", "up", "-d"], { stdio: "inherit" });
|
|
391
|
+
if (up.status !== 0) {
|
|
392
|
+
return {
|
|
393
|
+
...fail(STAGES.START_CULPA_DEPENDENCIES, "Culpa couldn't start its services.", "Docker's own error is above. Fix it, then run `getculpa` again."),
|
|
394
|
+
stagesRun,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// RUN_REQUIRED_MIGRATIONS is compose-internal: the culpa-server image's
|
|
399
|
+
// container CMD runs culpa-migrate BEFORE the server binary starts (no
|
|
400
|
+
// compose override) — verified in native/culpa-api/src/main.rs:5-10. There
|
|
401
|
+
// is nothing for Node to invoke; HEALTH_CHECK below is the real signal
|
|
402
|
+
// that migrations finished, since the server never answers until they do.
|
|
403
|
+
stagesRun.push(STAGES.RUN_REQUIRED_MIGRATIONS);
|
|
404
|
+
|
|
405
|
+
stagesRun.push(STAGES.HEALTH_CHECK);
|
|
406
|
+
const healthy = await waitForHealth(DASHBOARD_URL, { sleepFn, fetchFn });
|
|
407
|
+
if (!healthy) {
|
|
408
|
+
return {
|
|
409
|
+
...fail(
|
|
410
|
+
STAGES.HEALTH_CHECK,
|
|
411
|
+
"Culpa's dashboard didn't come up in time.",
|
|
412
|
+
"Run `docker logs culpa-dashboard` (or `getculpa status`) to see why, then run `getculpa` again.",
|
|
413
|
+
),
|
|
414
|
+
stagesRun,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// No collectord build/staging exists for macOS/Linux yet (task scope) —
|
|
419
|
+
// degrade honestly rather than fail the whole launch over capture.
|
|
420
|
+
stagesRun.push(STAGES.START_CULPA);
|
|
421
|
+
log("getculpa: capture is OFF on this platform for now (no collector build ships for macOS/Linux yet) - everything else works normally.");
|
|
422
|
+
|
|
423
|
+
stagesRun.push(STAGES.OPEN_UI);
|
|
424
|
+
if (pinnedVersion) writeFileSync(path.join(appDir, "last-boot-version.txt"), `${pinnedVersion}\n`);
|
|
425
|
+
maybeOpenBrowser({ env, isTTY, openBrowser });
|
|
426
|
+
|
|
427
|
+
return { ok: true, stage: STAGES.OPEN_UI, stagesRun };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function start(opts) {
|
|
431
|
+
const {
|
|
432
|
+
appDir,
|
|
433
|
+
platform = process.platform,
|
|
434
|
+
spawnSync = realSpawnSync,
|
|
435
|
+
fetchFn,
|
|
436
|
+
sleepFn,
|
|
437
|
+
env = process.env,
|
|
438
|
+
log = console.log,
|
|
439
|
+
warn = console.warn,
|
|
440
|
+
openBrowser = defaultOpenBrowser,
|
|
441
|
+
isTTY = process.stdout.isTTY,
|
|
442
|
+
delegateWindowsLaunch = defaultDelegateWindowsLaunch,
|
|
443
|
+
// T-CF28-7: consent needs BOTH ends of the terminal — stdin to read the
|
|
444
|
+
// answer, stdout for the question. isTTY above is stdout-only (it gates
|
|
445
|
+
// the browser open), so it cannot stand in for this.
|
|
446
|
+
isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
447
|
+
askYesNo = (question) => realAskYesNo(question, { isInteractive }),
|
|
448
|
+
} = opts;
|
|
449
|
+
|
|
450
|
+
const stagesRun = [STAGES.CHECK_INSTALLATION];
|
|
451
|
+
const liveCompose = path.join(appDir, "docker-compose.yml");
|
|
452
|
+
if (!existsSync(appDir) || !existsSync(liveCompose)) {
|
|
453
|
+
return { ...fail(STAGES.CHECK_INSTALLATION, "Culpa is not installed.", "Run `npm i -g getculpa` (or reinstall it) first."), stagesRun };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
stagesRun.push(STAGES.CHECK_DOCKER);
|
|
457
|
+
if (platform !== "win32" && !checkDockerPresent(spawnSync)) {
|
|
458
|
+
// T-CF28-7: was a dead end with a bare URL. Still a hard gate — nothing
|
|
459
|
+
// below can work without Docker — but on darwin it now offers to install
|
|
460
|
+
// it first. win32 is deliberately excluded (see this file's header):
|
|
461
|
+
// launch-culpa.ps1:179-234 owns that flow and must not be short-circuited.
|
|
462
|
+
return { ...(await offerDockerInstall({ platform, spawnSync, log, askYesNo, isInteractive })), stagesRun };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
stagesRun.push(STAGES.IDEMPOTENCY_CHECK);
|
|
466
|
+
const idempotency = await runIdempotencyCheck({ spawnSync, fetchFn, log, env, isTTY, openBrowser });
|
|
467
|
+
if (idempotency.alreadyHealthy) {
|
|
468
|
+
return { ok: true, stage: STAGES.IDEMPOTENCY_CHECK, alreadyRunning: true, stagesRun };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
stagesRun.push(STAGES.PORT_CHECK);
|
|
472
|
+
const portConflict = checkPortConflicts({ running: idempotency.running, platform, spawnSync });
|
|
473
|
+
if (portConflict) return { ...portConflict, stagesRun };
|
|
474
|
+
|
|
475
|
+
if (platform === "win32") {
|
|
476
|
+
return runWindowsDelegate({ appDir, spawnSync, log, warn, delegateWindowsLaunch, stagesRun });
|
|
477
|
+
}
|
|
478
|
+
return runPosixStateMachine({ appDir, liveCompose, platform, spawnSync, fetchFn, sleepFn, env, log, warn, openBrowser, isTTY, stagesRun });
|
|
479
|
+
}
|
package/lib/status.d.mts
ADDED
|
@@ -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
|
+
}
|
package/lib/stop.d.mts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SpawnSyncLike } from "./docker.d.mts";
|
|
2
|
+
|
|
3
|
+
export interface StopOptions {
|
|
4
|
+
appDir?: string;
|
|
5
|
+
platform?: string;
|
|
6
|
+
spawnSync?: SpawnSyncLike;
|
|
7
|
+
log?: (msg: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface StopResult {
|
|
11
|
+
ok: boolean;
|
|
12
|
+
stopped: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function stop(opts?: StopOptions): Promise<StopResult>;
|
package/lib/stop.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// CF20-T4 — `getculpa stop`.
|
|
2
|
+
//
|
|
3
|
+
// DECISION (see task report): uses `docker compose stop`, NOT `down`.
|
|
4
|
+
// uninstall-culpa.ps1:60/65 uses `down` because uninstall is a full teardown
|
|
5
|
+
// (containers + the compose network removed; named volumes are kept — no
|
|
6
|
+
// `-v` flag there either). `stop` is a distinct, resumable verb: a founder
|
|
7
|
+
// pausing Culpa should get a fast `getculpa` back — Compose's own paired
|
|
8
|
+
// verb for `stop` is `start`/`up`, not a recreate — without losing the
|
|
9
|
+
// container-level state `down` discards. The collector is a host process
|
|
10
|
+
// (win32 only, D-082 — it binds 127.0.0.1 outside Docker) so it is always
|
|
11
|
+
// stopped explicitly first, exactly as uninstall-culpa.ps1:43-44 does.
|
|
12
|
+
|
|
13
|
+
import { spawnSync as realSpawnSync } from "node:child_process";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { getAppDir } from "./paths.mjs";
|
|
17
|
+
|
|
18
|
+
export async function stop(opts = {}) {
|
|
19
|
+
const appDir = opts.appDir ?? getAppDir();
|
|
20
|
+
const platform = opts.platform ?? process.platform;
|
|
21
|
+
const spawnSync = opts.spawnSync ?? realSpawnSync;
|
|
22
|
+
const log = opts.log ?? console.log;
|
|
23
|
+
|
|
24
|
+
if (platform === "win32") {
|
|
25
|
+
const collectorCtl = path.join(appDir, "culpa-collector.ps1");
|
|
26
|
+
if (existsSync(collectorCtl)) {
|
|
27
|
+
spawnSync("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", collectorCtl, "-Stop"], {
|
|
28
|
+
stdio: "inherit",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const compose = path.join(appDir, "docker-compose.yml");
|
|
34
|
+
if (!existsSync(compose)) {
|
|
35
|
+
log("Culpa is not installed here - nothing to stop.");
|
|
36
|
+
return { ok: true, stopped: false };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const result = spawnSync("docker", ["compose", "-f", compose, "-p", "culpa", "stop"], { stdio: "inherit" });
|
|
40
|
+
if (result.status !== 0) {
|
|
41
|
+
log("getculpa: Culpa did not stop cleanly - see the output above.");
|
|
42
|
+
return { ok: false, stopped: false };
|
|
43
|
+
}
|
|
44
|
+
log("Culpa stopped.");
|
|
45
|
+
return { ok: true, stopped: true };
|
|
46
|
+
}
|
package/lib/tty.d.mts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface ColourOptions {
|
|
2
|
+
isTTY?: boolean;
|
|
3
|
+
env?: Record<string, string | undefined>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function colourEnabled(opts?: ColourOptions): boolean;
|
|
7
|
+
export function green(text: string, opts?: ColourOptions): string;
|
|
8
|
+
|
|
9
|
+
export interface AskYesNoOptions {
|
|
10
|
+
isInteractive?: boolean;
|
|
11
|
+
createInterface?: (opts: { input: unknown; output: unknown }) => {
|
|
12
|
+
question: (q: string, cb: (answer: string) => void) => void;
|
|
13
|
+
once: (event: string, cb: () => void) => void;
|
|
14
|
+
close: () => void;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function askYesNo(question: string, opts?: AskYesNoOptions): Promise<boolean>;
|