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