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/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
- 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
- };
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.1",
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 https://getculpa.com",
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
- "engines": {
22
- "node": ">=20"
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
+ }
@@ -111,21 +111,40 @@ async function main() {
111
111
  );
112
112
  provisionOpts.delegateWindowsInstaller = () => true;
113
113
  }
114
- const { classification, previousVersion } = await provision(provisionOpts);
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
- if (classification === "upgrade") {
119
- console.log(`Culpa updated: ${previousVersion} -> ${pkg.version} (staged; applies at next \`getculpa\`).`);
120
- } else if (classification === "downgrade-package") {
121
- console.log(`Culpa ${previousVersion} is already installed; this package (${pkg.version}) is older and was not applied.`);
122
- } else if (classification === "same-version" || classification === "repair") {
123
- console.log("Culpa installation repaired.");
124
- } else {
125
- console.log("Culpa installed successfully.");
126
- }
127
- console.log("Run:");
128
- console.log(" getculpa");
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) => {