getculpa 1.0.2 → 1.0.4

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/README.md CHANGED
@@ -1,8 +1,7 @@
1
1
  # getculpa
2
2
 
3
- `packaging/npm-getculpa` supersedes the `installers/npm@0.0.1` name-claim
4
- placeholder (that package's contents are frozen; this one is the real
5
- package published as `getculpa` on npm).
3
+ **Culpa LLM spend forensics and forecasting, local-first.** See where the
4
+ money went, across models, features and users, on your own machine.
6
5
 
7
6
  **`npm i -g getculpa` provisions the full Culpa install — the same files,
8
7
  shortcuts, and registration the Windows installer lays down — and starts
@@ -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.4 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.4
131
131
  container_name: culpa-dashboard
132
132
  environment:
133
133
  CULPA_API_BASE: http://server:4545
@@ -0,0 +1,70 @@
1
+ # Culpa Relay — Fly.io deploy (T-C7, D-048; Rust binary since T-157/D8-11:
2
+ # the image carries ONE compiled binary, nothing readable).
3
+ # The relay is PUBLIC BY DESIGN: serverless and multi-cloud backends push to a
4
+ # URL + token, so this app has [http_service] with force_https — the exact
5
+ # OPPOSITE of the private-only companion (deploy/companion/fly.toml). Security
6
+ # is the required bearer tokens: the relay refuses to boot without BOTH (the
7
+ # write token appends, the read token reads — never crossed), and every
8
+ # endpoint except /health demands one.
9
+ #
10
+ # The relay is DB-less: its only state is the object mailbox on the volume
11
+ # below. Your LOCAL Culpa pulls that feed (live tail + catch-up) and is
12
+ # the only place pricing/normalization ever happen.
13
+ #
14
+ # Deploy (from the REPO ROOT; the build context is native/relay):
15
+ # flyctl launch --config deploy/relay/fly.toml --no-deploy
16
+ # flyctl volumes create culpa_relay_data --app culpa-relay --region <same-as-backend>
17
+ # flyctl secrets set --app culpa-relay CULPA_INGEST_TOKEN=$(openssl rand -hex 32)
18
+ # flyctl secrets set --app culpa-relay CULPA_READ_TOKEN=$(openssl rand -hex 32)
19
+ # flyctl deploy --config deploy/relay/fly.toml
20
+ #
21
+ # Then wire the two sides (see docs/relay-deploy-runbook.md):
22
+ # backend: CULPA_BASE_URL=https://culpa-relay.fly.dev CULPA_INGEST_TOKEN=<the write token>
23
+ # local Culpa: relay config url=https://culpa-relay.fly.dev, token=<the read token>
24
+
25
+ app = "culpa-relay" # rename per install
26
+ primary_region = "jnb" # put this in the SAME region as your backend
27
+
28
+ [build]
29
+ # resolved relative to this file; run flyctl from the repo root so the
30
+ # build context carries native/relay/ (same footgun as the license server)
31
+ dockerfile = "../../native/relay/Dockerfile"
32
+
33
+ [env]
34
+ RELAY_PORT = "4747"
35
+ RELAY_DATA_DIR = "/data"
36
+ # CULPA_INGEST_TOKEN + CULPA_READ_TOKEN arrive via `flyctl secrets set` —
37
+ # NEVER in this file. The relay exits at boot if either is missing or they
38
+ # are equal (it never runs open or role-collapsed).
39
+
40
+ [http_service]
41
+ internal_port = 4747
42
+ force_https = true
43
+ # ALWAYS-ON is the point: the relay accumulates the log while your laptop is
44
+ # off. Never let the platform stop the machine.
45
+ auto_stop_machines = "off"
46
+ auto_start_machines = true
47
+ min_machines_running = 1
48
+
49
+ [[http_service.checks]]
50
+ interval = "30s"
51
+ timeout = "5s"
52
+ grace_period = "10s"
53
+ method = "GET"
54
+ path = "/health"
55
+
56
+ [mounts]
57
+ # the object mailbox lives here — losing this volume loses any entries your
58
+ # local Culpa has not pulled yet
59
+ #
60
+ # ⚠ SINGLE MACHINE ONLY (CodeRabbit full review): Fly volumes are one-per-
61
+ # machine and never replicated, so `fly scale count 2` gives each machine its
62
+ # OWN /data and silently FORKS the mailbox — the puller would only see
63
+ # whichever fork the edge routes it to. fly.toml cannot cap machine count;
64
+ # never scale this app past 1 (check with `fly scale show -a <app>`), or move
65
+ # the mailbox to shared storage first.
66
+ source = "culpa_relay_data"
67
+ destination = "/data"
68
+
69
+ [[vm]]
70
+ size = "shared-cpu-1x" # the relay only appends and serves a file
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);
package/lib/assets.mjs CHANGED
@@ -1,36 +1,39 @@
1
- // CF20-T3 — resolves the canonical shared install assets. Packed installs
2
- // read from assets/ (staged by scripts/prepack.js at `npm pack`/publish
3
- // time). Dev/test runs (no pack step) fall back to the repo-relative
4
- // canonical paths, so nothing here is ever forked — there is exactly one
5
- // source of truth for each file, and assets/ is a generated copy of it.
6
-
7
- import { existsSync } from "node:fs";
8
- import path from "node:path";
9
- import { fileURLToPath } from "node:url";
10
-
11
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
- const packageRoot = path.join(__dirname, "..");
13
- // packaging/npm-getculpa/lib -> packaging/npm-getculpa -> packaging -> repo root
14
- const repoRoot = path.join(packageRoot, "..", "..");
15
-
16
- // name -> repo-relative canonical source (dev/test fallback only)
17
- export const ASSET_MANIFEST = {
18
- "culpa-compose.yml": path.join(repoRoot, "installers", "culpa-compose.yml"),
19
- "install-culpa.ps1": path.join(repoRoot, "installers", "windows", "install-culpa.ps1"),
20
- "launch-culpa.ps1": path.join(repoRoot, "installers", "windows", "launch-culpa.ps1"),
21
- "uninstall-culpa.ps1": path.join(repoRoot, "installers", "windows", "uninstall-culpa.ps1"),
22
- "culpa-collector.ps1": path.join(repoRoot, "installers", "windows", "culpa-collector.ps1"),
23
- "register.mjs": path.join(repoRoot, "collector", "node", "register.mjs"),
24
- };
25
-
26
- export function resolveAssetPath(name) {
27
- const fallback = ASSET_MANIFEST[name];
28
- if (!fallback) throw new Error(`unknown shared install asset: ${name}`);
29
-
30
- const packed = path.join(packageRoot, "assets", name);
31
- if (existsSync(packed)) return packed;
32
- if (existsSync(fallback)) return fallback;
33
- throw new Error(
34
- `asset '${name}' not found in packed assets/ (${packed}) or the repo-relative fallback (${fallback})`,
35
- );
36
- }
1
+ // CF20-T3 — resolves the canonical shared install assets. Packed installs
2
+ // read from assets/ (staged by scripts/prepack.js at `npm pack`/publish
3
+ // time). Dev/test runs (no pack step) fall back to the repo-relative
4
+ // canonical paths, so nothing here is ever forked — there is exactly one
5
+ // source of truth for each file, and assets/ is a generated copy of it.
6
+
7
+ import { existsSync } from "node:fs";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const packageRoot = path.join(__dirname, "..");
13
+ // packaging/npm-getculpa/lib -> packaging/npm-getculpa -> packaging -> repo root
14
+ const repoRoot = path.join(packageRoot, "..", "..");
15
+
16
+ // name -> repo-relative canonical source (dev/test fallback only)
17
+ export const ASSET_MANIFEST = {
18
+ "culpa-compose.yml": path.join(repoRoot, "installers", "culpa-compose.yml"),
19
+ "install-culpa.ps1": path.join(repoRoot, "installers", "windows", "install-culpa.ps1"),
20
+ "launch-culpa.ps1": path.join(repoRoot, "installers", "windows", "launch-culpa.ps1"),
21
+ "uninstall-culpa.ps1": path.join(repoRoot, "installers", "windows", "uninstall-culpa.ps1"),
22
+ "culpa-collector.ps1": path.join(repoRoot, "installers", "windows", "culpa-collector.ps1"),
23
+ "register.mjs": path.join(repoRoot, "collector", "node", "register.mjs"),
24
+ // ISS-V103-9: staged under an explicit name; the canonical basename
25
+ // (fly.toml) would read as the customer's own app config.
26
+ "culpa-relay-fly.toml": path.join(repoRoot, "deploy", "relay", "fly.toml"),
27
+ };
28
+
29
+ export function resolveAssetPath(name) {
30
+ const fallback = ASSET_MANIFEST[name];
31
+ if (!fallback) throw new Error(`unknown shared install asset: ${name}`);
32
+
33
+ const packed = path.join(packageRoot, "assets", name);
34
+ if (existsSync(packed)) return packed;
35
+ if (existsSync(fallback)) return fallback;
36
+ throw new Error(
37
+ `asset '${name}' not found in packed assets/ (${packed}) or the repo-relative fallback (${fallback})`,
38
+ );
39
+ }
@@ -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) {