getculpa 1.0.2 → 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.
@@ -5,7 +5,7 @@
5
5
  # name is used when present, which is how these pins were verified before
6
6
  # publication.
7
7
  #
8
- # ── RELEASE STATE: v1.0.2 PUBLISHED AND SIGNED (2026-08-17) ─────────
8
+ # ── RELEASE STATE: v1.0.3 PUBLISHED AND SIGNED (2026-08-20) ─────────
9
9
  # The line above is REWRITTEN BY installers/publish.sh at real-publish time —
10
10
  # this file has shipped a hand-edited, wrong publication claim twice, so the
11
11
  # claim is now mechanical, never prose. While it reads NOT PUBLISHED, the pins
@@ -26,7 +26,7 @@
26
26
  # cosign verify \
27
27
  # --certificate-identity 'info@myaigi.ai' \
28
28
  # --certificate-oidc-issuer 'https://github.com/login/oauth' \
29
- # ghcr.io/myaigidev/culpa-server:v1.0.2
29
+ # ghcr.io/myaigidev/culpa-server:v1.0.3
30
30
  # Repeat for culpa-dashboard. Both must report VERIFIED.
31
31
  #
32
32
  # If a pull fails with an authentication or "denied" error rather than a
@@ -55,7 +55,7 @@ services:
55
55
  restart: unless-stopped
56
56
 
57
57
  server:
58
- image: ghcr.io/myaigidev/culpa-server:v1.0.2
58
+ image: ghcr.io/myaigidev/culpa-server:v1.0.3
59
59
  container_name: culpa-server
60
60
  environment:
61
61
  DATABASE_URL: postgres://culpa:culpa@db:5432/culpa
@@ -127,7 +127,7 @@ services:
127
127
  # black-surface palette — no server, migration or API change, so the server
128
128
  # above deliberately stays at v0.11.0 rather than being re-tagged for a
129
129
  # release it has no diff in (D-072: never rebuild a tag that already exists).
130
- image: ghcr.io/myaigidev/culpa-dashboard:v1.0.2
130
+ image: ghcr.io/myaigidev/culpa-dashboard:v1.0.3
131
131
  container_name: culpa-dashboard
132
132
  environment:
133
133
  CULPA_API_BASE: http://server:4545
package/bin/getculpa.js CHANGED
@@ -121,21 +121,49 @@ async function runUninstall() {
121
121
 
122
122
  async function main(argv) {
123
123
  const [cmd] = argv;
124
- if (cmd === undefined || cmd === "start") return runStart();
125
- if (cmd === "-h" || cmd === "--help" || cmd === "help") {
126
- console.log(HELP);
127
- return process.exit(0);
128
- }
129
124
  if (cmd === "-v" || cmd === "--version") {
130
125
  printVersion();
131
126
  return process.exit(0);
132
127
  }
128
+ // V102 T-V102-04 (M8, D-113): `getculpa uninstall --help` once EXECUTED the
129
+ // uninstall — the help flag was only recognised as argv[0]. All help and
130
+ // uninstall-consent decisions now happen here, BEFORE anything is imported
131
+ // or spawned. Passthrough verbs fall through untouched (the launcher owns
132
+ // their help).
133
+ const { decideDispatch, verbHelp } = await import("../lib/dispatch.mjs");
134
+ const decision = decideDispatch(argv, {
135
+ isTTY: process.stdin.isTTY === true && process.stdout.isTTY === true,
136
+ });
137
+ if (decision.kind === "help") {
138
+ console.log(decision.verb === null ? HELP : verbHelp(decision.verb));
139
+ return process.exit(0);
140
+ }
141
+ if (decision.kind === "refuse-uninstall") {
142
+ console.error(
143
+ "getculpa: uninstall removes Culpa's containers and network. Run it from a terminal to confirm interactively, or pass --yes to consent from a script. Nothing was changed.",
144
+ );
145
+ return process.exit(1);
146
+ }
147
+ if (decision.kind === "confirm-uninstall") {
148
+ const { askYesNo } = await import("../lib/tty.mjs");
149
+ const consented = await askYesNo(
150
+ "This stops and removes Culpa's containers and network (recorded cost data is kept unless you separately confirm deleting it). Continue? (y/N) ",
151
+ { isInteractive: true },
152
+ );
153
+ if (!consented) {
154
+ console.log("getculpa: uninstall cancelled. Nothing was changed.");
155
+ return process.exit(0);
156
+ }
157
+ return runUninstall();
158
+ }
159
+ if (decision.kind === "run-uninstall") return runUninstall();
160
+ if (cmd === undefined || cmd === "start") return runStart();
133
161
  if (cmd === "stop") return runStop();
134
162
  if (cmd === "restart") return runRestart();
135
163
  if (cmd === "status") return runStatus();
136
164
  if (cmd === "doctor") return runDoctor();
137
165
  if (cmd === "repair") return runRepair();
138
- if (cmd === "uninstall") return runUninstall();
166
+ // `uninstall` never reaches here — every argv shape is decided above.
139
167
  // scan / connect / update / config / anything unrecognized: the launcher
140
168
  // owns USAGE and exit-code semantics for its own surface, same as culpa.js.
141
169
  return runLauncherPassthrough(argv);
@@ -23,3 +23,7 @@ export interface StageInitialPayloadResult {
23
23
  }
24
24
 
25
25
  export function stageInitialPayload(opts: StageInitialPayloadOptions): StageInitialPayloadResult;
26
+
27
+ export type PayloadState = "installed" | "staged" | "missing";
28
+
29
+ export function readPayloadState(opts?: { env?: Record<string, string | undefined> }): PayloadState;
package/lib/bootstrap.mjs CHANGED
@@ -26,8 +26,9 @@
26
26
  // command. The install never claims more than it did.
27
27
 
28
28
  import { spawnSync as realSpawnSync } from "node:child_process";
29
- import { existsSync } from "node:fs";
29
+ import { existsSync, readFileSync } from "node:fs";
30
30
  import path from "node:path";
31
+ import { getAppDir } from "./paths.mjs";
31
32
 
32
33
  // A real payload is tens of MB over a network Culpa does not control. Long
33
34
  // enough not to abort a slow-but-working download; short enough that a hung
@@ -44,6 +45,29 @@ export function vendoredLauncherPath(packageRoot, platform = process.platform) {
44
45
  return existsSync(candidate) ? candidate : null;
45
46
  }
46
47
 
48
+ /** V102 T-V102-15 (M3, D-113): what the launcher's updates/state.json
49
+ * actually says — EVIDENCE for the install summary, never the updater's
50
+ * exit code (which claimed "Culpa CLI: installed" on a Mac whose state read
51
+ * current: null while doctor FAILed the same install).
52
+ * "installed": current is set. "staged": pending only — activates at the
53
+ * next `getculpa`. "missing": neither, no file, or unreadable.
54
+ * NB (review-3): getAppDir honours CULPA_APP_DIR but not CULPA_HOME — the
55
+ * known-open T-CF28-3c asymmetry (see paths.mjs:17-27 and open-loops
56
+ * §T-CF28-3c). A CULPA_HOME-only relocation therefore reads the default
57
+ * location and reports "missing" — underselling, never a false claim. */
58
+ export function readPayloadState(opts = {}) {
59
+ const { env = process.env } = opts;
60
+ try {
61
+ const raw = readFileSync(path.join(getAppDir({ env }), "updates", "state.json"), "utf8");
62
+ const state = JSON.parse(raw);
63
+ if (typeof state.current === "string" && state.current.length > 0) return "installed";
64
+ if (typeof state.pending === "string" && state.pending.length > 0) return "staged";
65
+ return "missing";
66
+ } catch {
67
+ return "missing"; // absent or corrupt: no evidence, no claim
68
+ }
69
+ }
70
+
47
71
  export function stageInitialPayload(opts) {
48
72
  const { launcherPath, spawnSync = realSpawnSync, log = console.log, env = process.env } = opts;
49
73
 
@@ -0,0 +1,12 @@
1
+ export type DispatchDecision =
2
+ | { kind: "help"; verb: string | null }
3
+ | { kind: "refuse-uninstall" }
4
+ | { kind: "confirm-uninstall" }
5
+ | { kind: "run-uninstall" }
6
+ | { kind: "dispatch" };
7
+
8
+ export const LOCAL_VERBS: string[];
9
+
10
+ export function verbHelp(verb: string): string;
11
+
12
+ export function decideDispatch(argv: string[], opts?: { isTTY?: boolean }): DispatchDecision;
@@ -0,0 +1,59 @@
1
+ // V102 T-V102-04 (M8/P0-2, D-113) — dispatch decisions for bin/getculpa.js.
2
+ //
3
+ // Executed on real hardware: `getculpa uninstall --help` stopped and removed
4
+ // all three containers and the network, because the router recognised a help
5
+ // flag only as argv[0] and dispatched on argv[0] without reading argv[1].
6
+ // `stop`, `restart` and `repair` shared the shape. The rule now: for every
7
+ // verb THIS bin routes locally, a help flag anywhere in argv decides HELP
8
+ // before anything is imported or spawned. Passthrough verbs (scan, connect,
9
+ // update, config, …) keep their argv untouched — the vendored launcher owns
10
+ // their help and exit-code semantics.
11
+ //
12
+ // Pure decisions, no side effects: the bin acts on the returned kind, and the
13
+ // facts in tests/npm-cli-dispatch.test.ts assert the decisions directly.
14
+
15
+ const HELP_FLAGS = new Set(["-h", "--help", "help"]);
16
+
17
+ /** Every verb bin/getculpa.js routes locally (everything else passes through). */
18
+ export const LOCAL_VERBS = ["start", "stop", "restart", "status", "doctor", "repair", "uninstall"];
19
+ const LOCAL = new Set(LOCAL_VERBS);
20
+
21
+ const VERB_HELP = {
22
+ start: "Start (or resume) the Culpa stack. Data is kept.",
23
+ stop: "Stop the running stack. Containers stop, nothing is removed, all data is kept. Start again with `getculpa`.",
24
+ restart: "Stop, then start the stack. No data is touched.",
25
+ status: "Show install/docker/stack/license status. Read-only.",
26
+ doctor: "Diagnose this install. Read-only.",
27
+ repair: "Re-stage missing or corrupt install files. No data touched.",
28
+ uninstall:
29
+ "Stop and REMOVE Culpa's containers and network. Asks for confirmation first; pass --yes to consent from a script (a non-interactive run without --yes refuses). Recorded cost data is kept unless you separately confirm deleting it.",
30
+ };
31
+
32
+ /** One verb's help block, prefixed with its usage line. */
33
+ export function verbHelp(verb) {
34
+ const text = VERB_HELP[verb] ?? "No help recorded for this verb.";
35
+ return `Usage: getculpa ${verb}\n\n ${text}\n`;
36
+ }
37
+
38
+ /**
39
+ * Decide what argv means BEFORE any dispatch. Returns one of:
40
+ * { kind: "help", verb: string | null } print help (general when verb null)
41
+ * { kind: "refuse-uninstall" } non-interactive, no --yes: refuse
42
+ * { kind: "confirm-uninstall" } interactive: ask, then uninstall
43
+ * { kind: "run-uninstall" } consent given via --yes/-y
44
+ * { kind: "dispatch" } fall through to the existing router
45
+ */
46
+ export function decideDispatch(argv, { isTTY = false } = {}) {
47
+ const [cmd] = argv;
48
+ if (cmd === undefined) return { kind: "dispatch" };
49
+ if (HELP_FLAGS.has(cmd)) return { kind: "help", verb: null };
50
+ if (LOCAL.has(cmd) && argv.slice(1).some((a) => HELP_FLAGS.has(a))) {
51
+ return { kind: "help", verb: cmd };
52
+ }
53
+ if (cmd === "uninstall") {
54
+ if (argv.includes("--yes") || argv.includes("-y")) return { kind: "run-uninstall" };
55
+ if (!isTTY) return { kind: "refuse-uninstall" };
56
+ return { kind: "confirm-uninstall" };
57
+ }
58
+ return { kind: "dispatch" };
59
+ }
package/lib/doctor.mjs CHANGED
@@ -244,9 +244,24 @@ async function checkHealth(fetchFn, running) {
244
244
  }
245
245
 
246
246
  function scanLicenseKeyPresence(env, appDir) {
247
- if (env.CULPA_LICENSE_KEY) return true;
247
+ // CodeRabbit PR#19 [6]: " " is truthy — trim before believing the env
248
+ if ((env.CULPA_LICENSE_KEY ?? "").trim() !== "") return true;
248
249
  const compose = readTextSafe(path.join(appDir, "docker-compose.yml"));
249
- return /CULPA_LICENSE_KEY/.test(compose);
250
+ // V102 T-V102-15 (M6/W3, D-113): the old bare /CULPA_LICENSE_KEY/ regex
251
+ // matched a COMMENT line in the shipped compose, so doctor reported
252
+ // "[OK] present (redacted)" with no key set — on real hardware, both
253
+ // platforms, while `status` said "License: unknown" beside it. Only an
254
+ // uncommented env ENTRY carrying a non-empty value counts.
255
+ return compose.split(/\r?\n/).some((line) => {
256
+ const t = line.trim();
257
+ if (t.startsWith("#")) return false;
258
+ const m = /^-?\s*["']?CULPA_LICENSE_KEY["']?\s*[:=]\s*(.+)$/.exec(t);
259
+ if (m === null) return false;
260
+ // PR#19 [6]: "KEY: # configured elsewhere" is YAML for an EMPTY value
261
+ // plus a trailing comment — never a configured key
262
+ const value = m[1].trim().replace(/^["']|["']$/g, "").trim();
263
+ return value.length > 0 && !value.startsWith("#");
264
+ });
250
265
  }
251
266
 
252
267
  // Extracts ONLY configured/state/plan — even if the (possibly stubbed, in
@@ -9,7 +9,7 @@ export interface InstallSummaryOptions {
9
9
  dockerState: DockerState;
10
10
  imagesStaged?: boolean;
11
11
  collectorStaged?: boolean;
12
- payloadStaged?: boolean;
12
+ payloadState?: "installed" | "staged" | "missing";
13
13
  platform?: string;
14
14
  isTTY?: boolean;
15
15
  env?: Record<string, string | undefined>;
@@ -46,13 +46,23 @@ function headline(classification, version, previousVersion, dockerReady, colour)
46
46
  }
47
47
  }
48
48
 
49
- function inventory(appDir, imagesStaged, collectorStaged, payloadStaged) {
49
+ // V102 T-V102-15 (M3, D-113): the row reports the three REAL states read
50
+ // from updates/state.json (readPayloadState), never the updater's exit code
51
+ // — the shipped 1.0.2 printed "Culpa CLI: installed" on a Mac whose
52
+ // state.json read current: null, while doctor FAILed the same install.
53
+ const PAYLOAD_ROW = {
54
+ installed: "installed",
55
+ staged: "staged - activates at your next `getculpa`",
56
+ missing: "deferred - run `getculpa update` to finish",
57
+ };
58
+
59
+ function inventory(appDir, imagesStaged, collectorStaged, payloadState) {
50
60
  return [
51
61
  ` Location: ${appDir}`,
52
62
  // T-CF28-4b: the CLI payload is the program `getculpa` actually runs. When
53
63
  // it is missing the install is not usable at all, so this row names the
54
64
  // one command that finishes the job rather than leaving a dead first run.
55
- ` Culpa CLI: ${payloadStaged ? "installed" : "deferred - run `getculpa update` to finish"}`,
65
+ ` Culpa CLI: ${PAYLOAD_ROW[payloadState] ?? PAYLOAD_ROW.missing}`,
56
66
  ` Images: ${imagesStaged ? "staged" : "deferred to first run"}`,
57
67
  ` Capture: ${collectorStaged ? "collector installed" : "off (collector not installed)"}`,
58
68
  ];
@@ -109,27 +119,29 @@ export function buildInstallSummary(opts) {
109
119
  dockerState,
110
120
  imagesStaged = false,
111
121
  collectorStaged = false,
112
- // T-CF28-4b. Fail-CLOSED, matching imagesStaged/collectorStaged above: a
113
- // caller that cannot say whether the payload is there has no evidence for
114
- // "ready", and this function exists precisely to stop readiness being
115
- // claimed without evidence. scripts/install.js always passes the real value.
116
- payloadStaged = false,
122
+ // T-CF28-4b / V102 T-V102-15. Fail-CLOSED, matching imagesStaged/
123
+ // collectorStaged above: a caller that cannot say whether the payload is
124
+ // there has no evidence for "ready", and this function exists precisely
125
+ // to stop readiness being claimed without evidence. scripts/install.js
126
+ // passes the real state read from updates/state.json (readPayloadState).
127
+ payloadState = "missing",
117
128
  platform = process.platform,
118
129
  isTTY = false,
119
130
  env = {},
120
131
  } = opts;
121
132
 
122
133
  const dockerReady = dockerState === "ready";
123
- // "ready" means the next command will work. A missing CLI payload breaks
134
+ // "ready" means the next command will work. A MISSING CLI payload breaks
124
135
  // that just as completely as a missing Docker, so it gates the word too.
125
- const ready = dockerReady && payloadStaged;
136
+ // "staged" keeps it: the pending version activates at that very launch.
137
+ const ready = dockerReady && payloadState !== "missing";
126
138
  const colour = { isTTY, env };
127
139
  const lines = [headline(classification, version, previousVersion, ready, colour)];
128
140
 
129
141
  // A refused downgrade staged nothing — an inventory there would describe a
130
142
  // state this run did not produce (lib/provision.mjs:175-183).
131
143
  if (classification !== "downgrade-package") {
132
- lines.push(...inventory(appDir, imagesStaged, collectorStaged, payloadStaged));
144
+ lines.push(...inventory(appDir, imagesStaged, collectorStaged, payloadState));
133
145
  }
134
146
 
135
147
  if (!dockerReady) {
package/lib/start.mjs CHANGED
@@ -415,10 +415,15 @@ async function runPosixStateMachine({ appDir, liveCompose, platform, spawnSync,
415
415
  };
416
416
  }
417
417
 
418
- // No collectord build/staging exists for macOS/Linux yet (task scope)
419
- // degrade honestly rather than fail the whole launch over capture.
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.
420
425
  stagesRun.push(STAGES.START_CULPA);
421
- log("getculpa: capture is OFF on this platform for now (no collector build ships for macOS/Linux yet) - everything else works normally.");
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.");
422
427
 
423
428
  stagesRun.push(STAGES.OPEN_UI);
424
429
  if (pinnedVersion) writeFileSync(path.join(appDir, "last-boot-version.txt"), `${pinnedVersion}\n`);
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
- removeShortcutEquivalents(homedir);
86
- const wipe = await promptPgdataRemoval(createInterface, isTTY);
87
- if (wipe) {
88
- spawnSync("docker", ["volume", "rm", "culpa_pgdata"], { stdio: "inherit" });
89
- console.log("Data volume removed.");
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
- console.log("Data kept (volume culpa_pgdata). Reinstalling later will find it again.");
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getculpa",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
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
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "bin": {
@@ -119,11 +119,15 @@ async function main() {
119
119
  // `getculpa` dead-ends — the Mac tester's exact experience. Best-effort by
120
120
  // construction: any failure leaves the payload deferred and the summary
121
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({
122
+ const { readPayloadState, stageInitialPayload, vendoredLauncherPath } = await import("../lib/bootstrap.mjs");
123
+ stageInitialPayload({
124
124
  launcherPath: vendoredLauncherPath(path.join(__dirname, "..")),
125
125
  env: process.env,
126
126
  });
127
+ // V102 T-V102-15 (M3): the summary reports what updates/state.json actually
128
+ // says, never the updater's exit code — "installed" was claimed on a Mac
129
+ // whose state read current: null while doctor FAILed the same install.
130
+ const payloadState = readPayloadState({ env: process.env });
127
131
 
128
132
  // CF20-T6: the completion line names what actually happened — an upgrade
129
133
  // is STAGED only; the new version applies at the next `getculpa`, not here.
@@ -139,7 +143,7 @@ async function main() {
139
143
  dockerState,
140
144
  imagesStaged,
141
145
  collectorStaged,
142
- payloadStaged,
146
+ payloadState,
143
147
  platform: process.platform,
144
148
  isTTY: Boolean(process.stdout.isTTY),
145
149
  env: process.env,