getculpa 1.0.1 → 1.0.3
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/assets/culpa-compose.yml +6 -6
- package/assets/uninstall-culpa.ps1 +115 -5
- package/bin/getculpa.js +34 -6
- package/lib/bootstrap.d.mts +29 -0
- package/lib/bootstrap.mjs +97 -0
- package/lib/dispatch.d.mts +12 -0
- package/lib/dispatch.mjs +59 -0
- package/lib/docker.d.mts +4 -0
- package/lib/docker.mjs +19 -0
- package/lib/doctor.mjs +38 -10
- package/lib/install-summary.d.mts +18 -0
- package/lib/install-summary.mjs +156 -0
- package/lib/paths.mjs +6 -0
- package/lib/provision.d.mts +30 -1
- package/lib/provision.mjs +86 -5
- package/lib/repair.d.mts +1 -0
- package/lib/repair.mjs +174 -135
- package/lib/start.d.mts +2 -0
- package/lib/start.mjs +99 -7
- package/lib/tty.d.mts +18 -0
- package/lib/tty.mjs +58 -0
- package/lib/uninstall.mjs +45 -6
- package/package.json +25 -24
- package/scripts/install.js +35 -12
package/lib/repair.mjs
CHANGED
|
@@ -1,135 +1,174 @@
|
|
|
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 {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 {
|
|
18
|
+
collectorBinaryName,
|
|
19
|
+
parityAssetsFor,
|
|
20
|
+
stageCollectorBinary,
|
|
21
|
+
stageLiveComposeIfAbsent,
|
|
22
|
+
stageParityFiles,
|
|
23
|
+
} from "./provision.mjs";
|
|
24
|
+
import { fetchCollectord, fetchLauncher } from "./fetch.mjs";
|
|
25
|
+
import { checkDockerEngineReachable, checkDockerPresent } from "./preflight.mjs";
|
|
26
|
+
|
|
27
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const packageRoot = path.join(__dirname, "..");
|
|
29
|
+
|
|
30
|
+
function readJsonSafe(filePath) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function detectDockerState(dockerSpawnSync) {
|
|
39
|
+
if (!checkDockerPresent(dockerSpawnSync)) return "missing";
|
|
40
|
+
if (!checkDockerEngineReachable(dockerSpawnSync)) return "installed-not-running";
|
|
41
|
+
return "ready";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function vendorBinaryPath(vendorDir, name, platform) {
|
|
45
|
+
const exe = platform === "win32" ? ".exe" : "";
|
|
46
|
+
return path.join(vendorDir, `${name}${exe}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function repair(opts) {
|
|
50
|
+
const {
|
|
51
|
+
appDir,
|
|
52
|
+
currentVersion,
|
|
53
|
+
platform = process.platform,
|
|
54
|
+
arch = process.arch,
|
|
55
|
+
vendorDir = path.join(packageRoot, "vendor"),
|
|
56
|
+
dockerSpawnSync = realSpawnSync,
|
|
57
|
+
fetchLauncherFn = fetchLauncher,
|
|
58
|
+
fetchCollectordFn = fetchCollectord,
|
|
59
|
+
log = console.log,
|
|
60
|
+
} = opts;
|
|
61
|
+
|
|
62
|
+
const actions = [];
|
|
63
|
+
|
|
64
|
+
if (!existsSync(appDir)) {
|
|
65
|
+
mkdirSync(appDir, { recursive: true });
|
|
66
|
+
actions.push(`created the missing app directory (${appDir})`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 1. Parity files — re-stage anything missing. stageParityFiles never
|
|
70
|
+
// touches docker-compose.yml (it doesn't reference that name at all),
|
|
71
|
+
// so this step alone already satisfies "never touch a live compose".
|
|
72
|
+
// T-CF28-6: platform-scoped, or repair would restore the four Windows .ps1
|
|
73
|
+
// files provisioning deliberately skips on darwin/linux — and report each
|
|
74
|
+
// one as a "restored missing parity file" action while doing it.
|
|
75
|
+
const missingBefore = parityAssetsFor(platform).filter((name) => !existsSync(path.join(appDir, name)));
|
|
76
|
+
stageParityFiles(appDir, process.env, platform);
|
|
77
|
+
for (const name of missingBefore) {
|
|
78
|
+
actions.push(`restored missing parity file: ${name}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 2. Live compose — created ONLY if entirely absent; an existing one is
|
|
82
|
+
// never overwritten (stageLiveComposeIfAbsent's own contract).
|
|
83
|
+
const composeCreated = stageLiveComposeIfAbsent(appDir);
|
|
84
|
+
if (composeCreated) {
|
|
85
|
+
actions.push("live docker-compose.yml was missing - recreated from the shipped culpa-compose.yml");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 3. install-state.json — regenerate only when missing/corrupt. The
|
|
89
|
+
// regenerated state is a conservative snapshot of what is actually on
|
|
90
|
+
// disk right now: dockerState/collectorStaged are re-detected,
|
|
91
|
+
// imagesStaged is set false (safe default — worst case is one extra
|
|
92
|
+
// `docker compose pull` message at the next `getculpa`, never data
|
|
93
|
+
// loss, mirroring deferredPullCatchUp's own contract in lib/start.mjs).
|
|
94
|
+
const existingState = readJsonSafe(path.join(appDir, "install-state.json"));
|
|
95
|
+
if (!existingState) {
|
|
96
|
+
const dockerState = detectDockerState(dockerSpawnSync);
|
|
97
|
+
// Review of 0e4b2e2: this derived collectorStaged from the PACKAGE
|
|
98
|
+
// vendor dir, which is not what culpa-collector.ps1:35 reads. It could
|
|
99
|
+
// therefore regenerate install-state.json saying capture was staged
|
|
100
|
+
// while the app dir held no collector at all — the same false positive
|
|
101
|
+
// T-CF29-8 removed from provision(). One invariant, both writers.
|
|
102
|
+
const collectorStaged = existsSync(path.join(appDir, collectorBinaryName(platform, arch)));
|
|
103
|
+
const regenerated = {
|
|
104
|
+
packageVersion: currentVersion,
|
|
105
|
+
provisionedAt: new Date().toISOString(),
|
|
106
|
+
dockerState,
|
|
107
|
+
imagesStaged: false,
|
|
108
|
+
collectorStaged,
|
|
109
|
+
platform,
|
|
110
|
+
};
|
|
111
|
+
writeFileSync(path.join(appDir, "install-state.json"), `${JSON.stringify(regenerated, null, 2)}\n`);
|
|
112
|
+
actions.push("install-state.json was missing or unreadable - regenerated");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 4. Launcher / collectord — re-fetch only when the vendored binary is
|
|
116
|
+
// genuinely absent. Never re-fetches when present (idempotent, and the
|
|
117
|
+
// task's explicit invocation-recording fact). The launcher is REQUIRED
|
|
118
|
+
// (doctor.mjs's checkVendoredLauncher fails the same way when it's
|
|
119
|
+
// missing) — a failed re-fetch must make repair's own result say so,
|
|
120
|
+
// not just log and report ok:true (CF20-R review). The collector stays
|
|
121
|
+
// optional/non-fatal: capture-off is a valid degraded state.
|
|
122
|
+
const launcherPath = vendorBinaryPath(vendorDir, "culpa-launcher", platform);
|
|
123
|
+
if (!existsSync(launcherPath)) {
|
|
124
|
+
try {
|
|
125
|
+
await fetchLauncherFn({ vendorDir });
|
|
126
|
+
actions.push("the launcher was missing - re-fetched and verified");
|
|
127
|
+
} catch (e) {
|
|
128
|
+
log(`getculpa repair: could not re-fetch the launcher (${e.message}). Run \`getculpa repair\` again once online.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const collectordPath = vendorBinaryPath(vendorDir, "culpa-collectord", platform);
|
|
133
|
+
if (!existsSync(collectordPath)) {
|
|
134
|
+
try {
|
|
135
|
+
await fetchCollectordFn({ vendorDir });
|
|
136
|
+
actions.push("the capture collector was missing - re-fetched and verified");
|
|
137
|
+
} catch (e) {
|
|
138
|
+
// fetchCollectord's own contract: "not published for this release yet"
|
|
139
|
+
// is an expected, non-fatal outcome (see lib/fetch.mjs) — repair must
|
|
140
|
+
// degrade the same way, never abort over an optional component.
|
|
141
|
+
log(`getculpa repair: capture collector not re-fetched (${e.message}).`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Review of 0e4b2e2: re-fetching only refilled the PACKAGE vendor dir, so
|
|
146
|
+
// "re-fetched and verified" claimed a repair that left capture exactly as
|
|
147
|
+
// broken as it found it. The app-dir copy is the one that matters.
|
|
148
|
+
const appCollector = path.join(appDir, collectorBinaryName(platform, arch));
|
|
149
|
+
if (!existsSync(appCollector) && stageCollectorBinary({ appDir, vendorDir, platform, arch })) {
|
|
150
|
+
actions.push("the capture collector was missing from the app directory - staged it");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The state file may have been regenerated BEFORE the staging above, so
|
|
154
|
+
// correct it to what is now true rather than leaving a stale answer.
|
|
155
|
+
const statePath = path.join(appDir, "install-state.json");
|
|
156
|
+
const finalState = readJsonSafe(statePath);
|
|
157
|
+
const collectorTruth = existsSync(appCollector);
|
|
158
|
+
// Review of 4e45f81: this used to correct the file and say NOTHING, so a
|
|
159
|
+
// run that really did change something reported "nothing to do - this
|
|
160
|
+
// install is already healthy". Same class of false claim the rest of this
|
|
161
|
+
// commit removes, just relocated into the action log.
|
|
162
|
+
if (finalState && finalState.collectorStaged !== collectorTruth) {
|
|
163
|
+
actions.push(
|
|
164
|
+
`install-state.json claimed capture was ${finalState.collectorStaged ? "staged" : "absent"} - corrected to match the app directory`,
|
|
165
|
+
);
|
|
166
|
+
writeFileSync(statePath, `${JSON.stringify({ ...finalState, collectorStaged: collectorTruth }, null, 2)}\n`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (actions.length === 0) log("getculpa repair: nothing to do - this install is already healthy.");
|
|
170
|
+
else for (const a of actions) log(`getculpa repair: ${a}`);
|
|
171
|
+
|
|
172
|
+
const launcherOk = existsSync(launcherPath);
|
|
173
|
+
return { ok: launcherOk, actions };
|
|
174
|
+
}
|
package/lib/start.d.mts
CHANGED
|
@@ -28,6 +28,8 @@ export interface StartOptions {
|
|
|
28
28
|
openBrowser?: (url: string) => void;
|
|
29
29
|
isTTY?: boolean;
|
|
30
30
|
delegateWindowsLaunch?: (appDir: string) => { ok: boolean; output?: string };
|
|
31
|
+
isInteractive?: boolean;
|
|
32
|
+
askYesNo?: (question: string) => Promise<boolean>;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export interface StartResult {
|
package/lib/start.mjs
CHANGED
|
@@ -34,12 +34,14 @@ import {
|
|
|
34
34
|
preMigrationBackup,
|
|
35
35
|
probeHealth,
|
|
36
36
|
resolveBackupBaseline,
|
|
37
|
+
installDockerDarwin,
|
|
37
38
|
startEngineIfPossible,
|
|
38
39
|
waitForDockerEngine,
|
|
39
40
|
waitForHealth,
|
|
40
41
|
waitForPgReady,
|
|
41
42
|
} from "./docker.mjs";
|
|
42
43
|
import { checkDockerPresent, compareVersions, parsePinnedServerVersion } from "./preflight.mjs";
|
|
44
|
+
import { askYesNo as realAskYesNo } from "./tty.mjs";
|
|
43
45
|
|
|
44
46
|
export const STAGES = Object.freeze({
|
|
45
47
|
CHECK_INSTALLATION: "CHECK_INSTALLATION",
|
|
@@ -186,6 +188,85 @@ async function runVersionGuards({ appDir, liveCompose, spawnSync, env, log, warn
|
|
|
186
188
|
return { pinnedVersion };
|
|
187
189
|
}
|
|
188
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
|
+
|
|
189
270
|
function maybeOpenBrowser({ env, isTTY, openBrowser }) {
|
|
190
271
|
if (!isTTY) return;
|
|
191
272
|
if (env.CULPA_NO_BROWSER) return;
|
|
@@ -334,10 +415,15 @@ async function runPosixStateMachine({ appDir, liveCompose, platform, spawnSync,
|
|
|
334
415
|
};
|
|
335
416
|
}
|
|
336
417
|
|
|
337
|
-
//
|
|
338
|
-
//
|
|
418
|
+
// V102 T-V102-14 (M4, D-113): the old line here said "no collector build
|
|
419
|
+
// ships for macOS/Linux yet" — verified FALSE on real hardware (the arm64
|
|
420
|
+
// collectord ships, was staged executable, install-state recorded
|
|
421
|
+
// collectorStaged: true). What IS true: the collector is not auto-started
|
|
422
|
+
// on this platform yet, while the server-side coding-tool log sweep works
|
|
423
|
+
// (proven live on the Mac: calls landed unattended, ~45 min cadence). Say
|
|
424
|
+
// exactly that — neither direction of the old overclaim.
|
|
339
425
|
stagesRun.push(STAGES.START_CULPA);
|
|
340
|
-
log("getculpa:
|
|
426
|
+
log("getculpa: your local coding-tool usage is captured by the built-in log sweep. The production traffic collector is not started automatically on this platform yet - everything else works normally.");
|
|
341
427
|
|
|
342
428
|
stagesRun.push(STAGES.OPEN_UI);
|
|
343
429
|
if (pinnedVersion) writeFileSync(path.join(appDir, "last-boot-version.txt"), `${pinnedVersion}\n`);
|
|
@@ -359,6 +445,11 @@ export async function start(opts) {
|
|
|
359
445
|
openBrowser = defaultOpenBrowser,
|
|
360
446
|
isTTY = process.stdout.isTTY,
|
|
361
447
|
delegateWindowsLaunch = defaultDelegateWindowsLaunch,
|
|
448
|
+
// T-CF28-7: consent needs BOTH ends of the terminal — stdin to read the
|
|
449
|
+
// answer, stdout for the question. isTTY above is stdout-only (it gates
|
|
450
|
+
// the browser open), so it cannot stand in for this.
|
|
451
|
+
isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
452
|
+
askYesNo = (question) => realAskYesNo(question, { isInteractive }),
|
|
362
453
|
} = opts;
|
|
363
454
|
|
|
364
455
|
const stagesRun = [STAGES.CHECK_INSTALLATION];
|
|
@@ -369,10 +460,11 @@ export async function start(opts) {
|
|
|
369
460
|
|
|
370
461
|
stagesRun.push(STAGES.CHECK_DOCKER);
|
|
371
462
|
if (platform !== "win32" && !checkDockerPresent(spawnSync)) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
463
|
+
// T-CF28-7: was a dead end with a bare URL. Still a hard gate — nothing
|
|
464
|
+
// below can work without Docker — but on darwin it now offers to install
|
|
465
|
+
// it first. win32 is deliberately excluded (see this file's header):
|
|
466
|
+
// launch-culpa.ps1:179-234 owns that flow and must not be short-circuited.
|
|
467
|
+
return { ...(await offerDockerInstall({ platform, spawnSync, log, askYesNo, isInteractive })), stagesRun };
|
|
376
468
|
}
|
|
377
469
|
|
|
378
470
|
stagesRun.push(STAGES.IDEMPOTENCY_CHECK);
|
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>;
|
package/lib/tty.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// T-CF28-5 / T-CF28-7 — terminal helpers: colour, and the yes/no consent
|
|
2
|
+
// prompt. Both are gated on there actually being a human attached.
|
|
3
|
+
//
|
|
4
|
+
// The founder's ruling for the postinstall message is "Culpa is installed and
|
|
5
|
+
// ready." with "ready" in GREEN. Colour is emitted ONLY when the destination
|
|
6
|
+
// is a real terminal and NO_COLOR is unset: `npm i -g` output is routinely
|
|
7
|
+
// piped, redirected to a log, or captured by CI, and a raw ANSI escape there
|
|
8
|
+
// is visible garbage in the one place a human later reads to find out what
|
|
9
|
+
// went wrong. Plain text is the fallback, never a degraded message.
|
|
10
|
+
//
|
|
11
|
+
// isTTY and env are parameters rather than reads of the live process, for the
|
|
12
|
+
// same reason lib/start.mjs:360 and lib/uninstall.mjs:77 inject theirs: a test
|
|
13
|
+
// asserts both branches deliberately instead of depending on how the runner
|
|
14
|
+
// happens to be attached.
|
|
15
|
+
//
|
|
16
|
+
// NO_COLOR follows the https://no-color.org convention: any value, including
|
|
17
|
+
// an empty string, disables colour. Only an ABSENT variable allows it.
|
|
18
|
+
|
|
19
|
+
// String.fromCharCode(27) rather than a literal escape: this file is edited by
|
|
20
|
+
// tools that have silently converted the six-character sequence into a raw
|
|
21
|
+
// control byte, which is invisible in review and breaks on the next edit.
|
|
22
|
+
const ESC = String.fromCharCode(27);
|
|
23
|
+
const GREEN = `${ESC}[32m`;
|
|
24
|
+
const DEFAULT_FG = `${ESC}[39m`;
|
|
25
|
+
|
|
26
|
+
export function colourEnabled({ isTTY, env = {} } = {}) {
|
|
27
|
+
if (!isTTY) return false;
|
|
28
|
+
return env.NO_COLOR === undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function green(text, opts = {}) {
|
|
32
|
+
if (!colourEnabled(opts)) return text;
|
|
33
|
+
return `${GREEN}${text}${DEFAULT_FG}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// T-CF28-7 — explicit consent before Culpa installs anything on the user's
|
|
37
|
+
// machine. Same safety posture as lib/uninstall.mjs:61-69: a non-interactive
|
|
38
|
+
// caller is never prompted and always gets NO, so a scripted or CI run can
|
|
39
|
+
// neither hang on a stdin read nor have an install decided for it by default.
|
|
40
|
+
// The caller supplies the whole question, including the "(y/N)" — the wording
|
|
41
|
+
// of what is about to happen is part of the consent, not decoration.
|
|
42
|
+
export async function askYesNo(question, opts = {}) {
|
|
43
|
+
const { isInteractive = false, createInterface } = opts;
|
|
44
|
+
if (!isInteractive) return false;
|
|
45
|
+
const { createInterface: realCreateInterface } = await import("node:readline");
|
|
46
|
+
const rl = (createInterface ?? realCreateInterface)({ input: process.stdin, output: process.stdout });
|
|
47
|
+
const answer = await new Promise((resolve) => {
|
|
48
|
+
// EOF (Ctrl-D, or a stdin that ends mid-prompt) fires `close` and NEVER
|
|
49
|
+
// invokes question()'s callback. Without this listener the promise stays
|
|
50
|
+
// pending and `getculpa` hangs forever at the consent prompt with no
|
|
51
|
+
// error and no way out. An unanswered question is a NO, exactly as the
|
|
52
|
+
// non-interactive path above already decides.
|
|
53
|
+
rl.once("close", () => resolve(""));
|
|
54
|
+
rl.question(question, resolve);
|
|
55
|
+
});
|
|
56
|
+
rl.close();
|
|
57
|
+
return answer.trim().toLowerCase() === "y";
|
|
58
|
+
}
|
package/lib/uninstall.mjs
CHANGED
|
@@ -62,12 +62,36 @@ async function promptPgdataRemoval(createInterface, isTTY) {
|
|
|
62
62
|
if (!isTTY) return false;
|
|
63
63
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
64
64
|
const answer = await new Promise((resolve) => {
|
|
65
|
+
// V102 T-V102-16 (D-113): EOF (Ctrl-D, or a stdin that ends mid-prompt)
|
|
66
|
+
// fires `close` and NEVER invokes question()'s callback — this promise
|
|
67
|
+
// used to hang forever at the one prompt guarding DELETION of the cost
|
|
68
|
+
// database. An unanswered question is a NO, exactly like tty.mjs.
|
|
69
|
+
// Optional-call: older injected fakes without `once` keep working.
|
|
70
|
+
rl.once?.("close", () => resolve(""));
|
|
65
71
|
rl.question("Also DELETE all recorded cost data? This cannot be undone. (y/N) ", resolve);
|
|
66
72
|
});
|
|
67
73
|
rl.close();
|
|
68
74
|
return answer.trim().toLowerCase() === "y";
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
// V102 T-V102-16 (M11/W2, D-113): macOS uninstall exited 0 leaving ~7.3 MB
|
|
78
|
+
// (launcher, collector, compose, register.mjs) silently behind — the Windows
|
|
79
|
+
// ps1 removes the app dir and is "the more complete of the two" (W2). Remove
|
|
80
|
+
// the dir ONLY when it is provably a Culpa install dir (install-state.json,
|
|
81
|
+
// which every install writes); a mistargeted CULPA_APP_DIR is kept and said.
|
|
82
|
+
// Truthful beats a surprise rm -rf in both directions.
|
|
83
|
+
function removeAppDir(appDir) {
|
|
84
|
+
if (!existsSync(appDir)) return;
|
|
85
|
+
if (existsSync(path.join(appDir, "install-state.json"))) {
|
|
86
|
+
rmSync(appDir, { recursive: true, force: true });
|
|
87
|
+
console.log(`Removed: ${appDir} (launcher, collector, compose and capture shims).`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
console.log(
|
|
91
|
+
`Kept: ${appDir} - no install-state.json found there, so it is not provably a Culpa install directory. Remove it yourself if you are sure.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
71
95
|
export async function uninstall(opts = {}) {
|
|
72
96
|
const appDir = opts.appDir ?? getAppDir();
|
|
73
97
|
const platform = opts.platform ?? process.platform;
|
|
@@ -82,13 +106,28 @@ export async function uninstall(opts = {}) {
|
|
|
82
106
|
if (!ok) console.error("getculpa: uninstall did not complete cleanly - see the output above.");
|
|
83
107
|
} else {
|
|
84
108
|
ok = composeDown(appDir, spawnSync);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
109
|
+
// Review-4 IMPORTANT + CodeRabbit PR#19 [7] (Major): a FAILED stop skips
|
|
110
|
+
// EVERY destructive step — the ps1's $stackStopped gate, whose comment
|
|
111
|
+
// records the prior real incident (CR-PR5 round 3). Not even the pgdata
|
|
112
|
+
// PROMPT is offered: after a partial teardown the volume can be
|
|
113
|
+
// unreferenced, so a user confirming deletion here could lose the cost
|
|
114
|
+
// database while being told the stack is still running. Nothing is
|
|
115
|
+
// removed; the one honest instruction is to stop the stack and rerun.
|
|
116
|
+
if (!ok) {
|
|
117
|
+
console.log(
|
|
118
|
+
`Files kept: ${appDir} - the stack is still running (\`docker compose down\` failed above). Nothing was removed. Stop the stack, then run this again.`,
|
|
119
|
+
);
|
|
90
120
|
} else {
|
|
91
|
-
|
|
121
|
+
removeShortcutEquivalents(homedir);
|
|
122
|
+
const wipe = await promptPgdataRemoval(createInterface, isTTY);
|
|
123
|
+
if (wipe) {
|
|
124
|
+
spawnSync("docker", ["volume", "rm", "culpa_pgdata"], { stdio: "inherit" });
|
|
125
|
+
console.log("Data volume removed.");
|
|
126
|
+
} else {
|
|
127
|
+
console.log("Data kept (volume culpa_pgdata). Reinstalling later will find it again.");
|
|
128
|
+
}
|
|
129
|
+
// M11/W2: after the data decision, remove the app dir itself (ps1 parity)
|
|
130
|
+
removeAppDir(appDir);
|
|
92
131
|
}
|
|
93
132
|
}
|
|
94
133
|
|