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 +2 -3
- package/assets/culpa-compose.yml +4 -4
- package/assets/culpa-relay-fly.toml +70 -0
- package/bin/getculpa.js +34 -6
- package/lib/assets.mjs +39 -36
- package/lib/bootstrap.d.mts +4 -0
- package/lib/bootstrap.mjs +25 -1
- package/lib/dispatch.d.mts +12 -0
- package/lib/dispatch.mjs +59 -0
- package/lib/doctor.mjs +17 -2
- package/lib/install-summary.d.mts +1 -1
- package/lib/install-summary.mjs +22 -10
- package/lib/provision.mjs +329 -318
- package/lib/start.mjs +8 -3
- package/lib/uninstall.mjs +45 -6
- package/package.json +1 -1
- package/scripts/install.js +7 -3
- package/scripts/prepack.js +16 -4
package/README.md
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# getculpa
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
package/assets/culpa-compose.yml
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
}
|
package/lib/bootstrap.d.mts
CHANGED
|
@@ -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;
|
package/lib/dispatch.mjs
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
+
payloadState?: "installed" | "staged" | "missing";
|
|
13
13
|
platform?: string;
|
|
14
14
|
isTTY?: boolean;
|
|
15
15
|
env?: Record<string, string | undefined>;
|
package/lib/install-summary.mjs
CHANGED
|
@@ -46,13 +46,23 @@ function headline(classification, version, previousVersion, dockerReady, colour)
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
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: ${
|
|
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/
|
|
113
|
-
// caller that cannot say whether the payload is
|
|
114
|
-
// "ready", and this function exists precisely
|
|
115
|
-
// claimed without evidence. scripts/install.js
|
|
116
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
144
|
+
lines.push(...inventory(appDir, imagesStaged, collectorStaged, payloadState));
|
|
133
145
|
}
|
|
134
146
|
|
|
135
147
|
if (!dockerReady) {
|