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/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 +117 -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/docker.d.mts +72 -0
- package/lib/docker.mjs +251 -0
- package/lib/doctor.d.mts +29 -0
- package/lib/doctor.mjs +392 -0
- package/lib/fetch.d.mts +17 -0
- package/lib/fetch.mjs +211 -0
- package/lib/paths.d.mts +9 -0
- package/lib/paths.mjs +55 -0
- package/lib/preflight.d.mts +39 -0
- package/lib/preflight.mjs +169 -0
- package/lib/provision.d.mts +25 -0
- package/lib/provision.mjs +237 -0
- package/lib/repair.d.mts +20 -0
- package/lib/repair.mjs +135 -0
- package/lib/start.d.mts +42 -0
- package/lib/start.mjs +392 -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/uninstall.d.mts +17 -0
- package/lib/uninstall.mjs +99 -0
- package/package.json +24 -36
- package/scripts/install.js +135 -0
- package/scripts/prepack.js +41 -0
- package/index.js +0 -3
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
|
+
}
|
package/lib/start.d.mts
ADDED
|
@@ -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
|
+
}
|
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
|
+
}
|