getculpa 1.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/assets/culpa-compose.yml +6 -6
- package/assets/uninstall-culpa.ps1 +115 -5
- package/lib/bootstrap.d.mts +25 -0
- package/lib/bootstrap.mjs +73 -0
- package/lib/docker.d.mts +4 -0
- package/lib/docker.mjs +19 -0
- package/lib/doctor.mjs +21 -8
- package/lib/install-summary.d.mts +18 -0
- package/lib/install-summary.mjs +144 -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 +91 -4
- package/lib/tty.d.mts +18 -0
- package/lib/tty.mjs +58 -0
- package/package.json +25 -24
- package/scripts/install.js +31 -12
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;
|
|
@@ -359,6 +440,11 @@ export async function start(opts) {
|
|
|
359
440
|
openBrowser = defaultOpenBrowser,
|
|
360
441
|
isTTY = process.stdout.isTTY,
|
|
361
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 }),
|
|
362
448
|
} = opts;
|
|
363
449
|
|
|
364
450
|
const stagesRun = [STAGES.CHECK_INSTALLATION];
|
|
@@ -369,10 +455,11 @@ export async function start(opts) {
|
|
|
369
455
|
|
|
370
456
|
stagesRun.push(STAGES.CHECK_DOCKER);
|
|
371
457
|
if (platform !== "win32" && !checkDockerPresent(spawnSync)) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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 };
|
|
376
463
|
}
|
|
377
464
|
|
|
378
465
|
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/package.json
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "getculpa",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Culpa CLI: `npm i -g getculpa` provisions the full Culpa install (Windows-installer parity) and leaves it dormant. `getculpa` wakes the stack.",
|
|
5
|
-
"license": "SEE LICENSE IN
|
|
6
|
-
"bin": {
|
|
7
|
-
"getculpa": "bin/getculpa.js",
|
|
8
|
-
"culpa": "bin/culpa.js"
|
|
9
|
-
},
|
|
10
|
-
"scripts": {
|
|
11
|
-
"postinstall": "node scripts/install.js",
|
|
12
|
-
"prepack": "node scripts/prepack.js"
|
|
13
|
-
},
|
|
14
|
-
"files": [
|
|
15
|
-
"bin/",
|
|
16
|
-
"lib/",
|
|
17
|
-
"scripts/",
|
|
18
|
-
"assets/",
|
|
19
|
-
"README.md"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "getculpa",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Culpa CLI: `npm i -g getculpa` provisions the full Culpa install (Windows-installer parity) and leaves it dormant. `getculpa` wakes the stack.",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"bin": {
|
|
7
|
+
"getculpa": "bin/getculpa.js",
|
|
8
|
+
"culpa": "bin/culpa.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"postinstall": "node scripts/install.js",
|
|
12
|
+
"prepack": "node scripts/prepack.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin/",
|
|
16
|
+
"lib/",
|
|
17
|
+
"scripts/",
|
|
18
|
+
"assets/",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/scripts/install.js
CHANGED
|
@@ -111,21 +111,40 @@ async function main() {
|
|
|
111
111
|
);
|
|
112
112
|
provisionOpts.delegateWindowsInstaller = () => true;
|
|
113
113
|
}
|
|
114
|
-
const { classification, previousVersion } =
|
|
114
|
+
const { classification, previousVersion, dockerState, imagesStaged, collectorStaged } =
|
|
115
|
+
await provision(provisionOpts);
|
|
116
|
+
|
|
117
|
+
// T-CF28-4b: stage the CLI payload the launcher actually runs. Without this
|
|
118
|
+
// a fresh install leaves state.json at `current: null` and the first
|
|
119
|
+
// `getculpa` dead-ends — the Mac tester's exact experience. Best-effort by
|
|
120
|
+
// construction: any failure leaves the payload deferred and the summary
|
|
121
|
+
// below names `getculpa update`, which is where we already were.
|
|
122
|
+
const { stageInitialPayload, vendoredLauncherPath } = await import("../lib/bootstrap.mjs");
|
|
123
|
+
const { staged: payloadStaged } = stageInitialPayload({
|
|
124
|
+
launcherPath: vendoredLauncherPath(path.join(__dirname, "..")),
|
|
125
|
+
env: process.env,
|
|
126
|
+
});
|
|
115
127
|
|
|
116
128
|
// CF20-T6: the completion line names what actually happened — an upgrade
|
|
117
129
|
// is STAGED only; the new version applies at the next `getculpa`, not here.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
130
|
+
// T-CF28-5/7: those words now live in lib/install-summary.mjs so they are
|
|
131
|
+
// testable, and they no longer report success without saying whether Docker
|
|
132
|
+
// — which Culpa cannot run without — is actually present.
|
|
133
|
+
const { buildInstallSummary } = await import("../lib/install-summary.mjs");
|
|
134
|
+
const summary = buildInstallSummary({
|
|
135
|
+
classification,
|
|
136
|
+
version: pkg.version,
|
|
137
|
+
previousVersion,
|
|
138
|
+
appDir,
|
|
139
|
+
dockerState,
|
|
140
|
+
imagesStaged,
|
|
141
|
+
collectorStaged,
|
|
142
|
+
payloadStaged,
|
|
143
|
+
platform: process.platform,
|
|
144
|
+
isTTY: Boolean(process.stdout.isTTY),
|
|
145
|
+
env: process.env,
|
|
146
|
+
});
|
|
147
|
+
for (const line of summary) console.log(line);
|
|
129
148
|
}
|
|
130
149
|
|
|
131
150
|
main().catch((e) => {
|