getculpa 1.0.3 → 1.0.5
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 +2 -2
- package/assets/culpa-relay-fly.toml +70 -0
- package/lib/assets.mjs +39 -36
- package/lib/provision.mjs +329 -318
- package/package.json +1 -1
- 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.5 PUBLISHED AND SIGNED (2026-08-23) ─────────
|
|
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
|
|
@@ -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.5
|
|
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/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/provision.mjs
CHANGED
|
@@ -1,318 +1,329 @@
|
|
|
1
|
-
// CF20-T3 — the defer-start provisioner. Lays down everything the Windows
|
|
2
|
-
// installer lays down; starts nothing (compose up, collector start, and a
|
|
3
|
-
// browser open are all launch-time work — CF20-T4).
|
|
4
|
-
//
|
|
5
|
-
// Docker interaction (presence, engine reachability, image pull) and the
|
|
6
|
-
// win32 installer delegation both take injectable dependencies, defaulting
|
|
7
|
-
// to the real ones. Tests MUST override them: this module runs on the
|
|
8
|
-
// founder's own dev machine, and the real win32 delegate writes to the real
|
|
9
|
-
// Desktop, Start Menu and HKCU — never acceptable as a side effect of a test
|
|
10
|
-
// run. Docker-command recording (for the "nothing started" fact) happens
|
|
11
|
-
// through the injected dockerSpawnSync rather than a PATH shim, for the same
|
|
12
|
-
// reason install-culpa.ps1 injects -Probe into Test-DockerInstalled: an
|
|
13
|
-
// explicit dependency is more reliable than relying on OS PATH resolution
|
|
14
|
-
// rules for a fake executable.
|
|
15
|
-
|
|
16
|
-
import { spawnSync as realSpawnSync } from "node:child_process";
|
|
17
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
-
import path from "node:path";
|
|
19
|
-
import { fileURLToPath } from "node:url";
|
|
20
|
-
import { checkDockerEngineReachable, checkDockerPresent, classifyUpgrade, isIncomingComposeOlder } from "./preflight.mjs";
|
|
21
|
-
import { resolveAssetPath } from "./assets.mjs";
|
|
22
|
-
import { canonicalAppDir } from "./paths.mjs";
|
|
23
|
-
|
|
24
|
-
// T-CF29-8: where lib/fetch.mjs stages the vendored binaries. Same derivation
|
|
25
|
-
// lib/repair.mjs already uses, so the two agree on one vendor directory.
|
|
26
|
-
const packageRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
|
-
|
|
28
|
-
// The full parity file-set (Windows inventory: culpa-setup.iss:43-54, minus
|
|
29
|
-
// the collectord binary, which lib/fetch.mjs stages separately by triple).
|
|
30
|
-
// This is the WINDOWS inventory and stays the canonical full list — use
|
|
31
|
-
// parityAssetsFor(platform) to get what a given platform should actually
|
|
32
|
-
// receive.
|
|
33
|
-
export const PARITY_ASSETS = [
|
|
34
|
-
"culpa-compose.yml",
|
|
35
|
-
"install-culpa.ps1",
|
|
36
|
-
"launch-culpa.ps1",
|
|
37
|
-
"uninstall-culpa.ps1",
|
|
38
|
-
"culpa-collector.ps1",
|
|
39
|
-
"register.mjs",
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"culpa
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
export function
|
|
118
|
-
const
|
|
119
|
-
return
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
case "
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
1
|
+
// CF20-T3 — the defer-start provisioner. Lays down everything the Windows
|
|
2
|
+
// installer lays down; starts nothing (compose up, collector start, and a
|
|
3
|
+
// browser open are all launch-time work — CF20-T4).
|
|
4
|
+
//
|
|
5
|
+
// Docker interaction (presence, engine reachability, image pull) and the
|
|
6
|
+
// win32 installer delegation both take injectable dependencies, defaulting
|
|
7
|
+
// to the real ones. Tests MUST override them: this module runs on the
|
|
8
|
+
// founder's own dev machine, and the real win32 delegate writes to the real
|
|
9
|
+
// Desktop, Start Menu and HKCU — never acceptable as a side effect of a test
|
|
10
|
+
// run. Docker-command recording (for the "nothing started" fact) happens
|
|
11
|
+
// through the injected dockerSpawnSync rather than a PATH shim, for the same
|
|
12
|
+
// reason install-culpa.ps1 injects -Probe into Test-DockerInstalled: an
|
|
13
|
+
// explicit dependency is more reliable than relying on OS PATH resolution
|
|
14
|
+
// rules for a fake executable.
|
|
15
|
+
|
|
16
|
+
import { spawnSync as realSpawnSync } from "node:child_process";
|
|
17
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { checkDockerEngineReachable, checkDockerPresent, classifyUpgrade, isIncomingComposeOlder } from "./preflight.mjs";
|
|
21
|
+
import { resolveAssetPath } from "./assets.mjs";
|
|
22
|
+
import { canonicalAppDir } from "./paths.mjs";
|
|
23
|
+
|
|
24
|
+
// T-CF29-8: where lib/fetch.mjs stages the vendored binaries. Same derivation
|
|
25
|
+
// lib/repair.mjs already uses, so the two agree on one vendor directory.
|
|
26
|
+
const packageRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
|
+
|
|
28
|
+
// The full parity file-set (Windows inventory: culpa-setup.iss:43-54, minus
|
|
29
|
+
// the collectord binary, which lib/fetch.mjs stages separately by triple).
|
|
30
|
+
// This is the WINDOWS inventory and stays the canonical full list — use
|
|
31
|
+
// parityAssetsFor(platform) to get what a given platform should actually
|
|
32
|
+
// receive.
|
|
33
|
+
export const PARITY_ASSETS = [
|
|
34
|
+
"culpa-compose.yml",
|
|
35
|
+
"install-culpa.ps1",
|
|
36
|
+
"launch-culpa.ps1",
|
|
37
|
+
"uninstall-culpa.ps1",
|
|
38
|
+
"culpa-collector.ps1",
|
|
39
|
+
"register.mjs",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Shipped, but NOT parity and NOT graded by doctor — see stageParityFiles.
|
|
43
|
+
export const RELAY_FLY_CONFIG = "culpa-relay-fly.toml";
|
|
44
|
+
|
|
45
|
+
// T-CF28-6 — four of the six are PowerShell scripts that only win32 can run:
|
|
46
|
+
// lib/start.mjs delegates to launch-culpa.ps1 only when platform === "win32"
|
|
47
|
+
// (start.mjs:457), lib/uninstall.mjs only delegates to uninstall-culpa.ps1 on
|
|
48
|
+
// win32 (uninstall.mjs:80-82), and install-culpa.ps1/culpa-collector.ps1 are
|
|
49
|
+
// invoked by the Windows installer path alone. Staging them on darwin/linux
|
|
50
|
+
// put four inert files in the user's app dir and made `getculpa doctor` grade
|
|
51
|
+
// them as part of a healthy install.
|
|
52
|
+
export const WINDOWS_ONLY_ASSETS = Object.freeze([
|
|
53
|
+
"install-culpa.ps1",
|
|
54
|
+
"launch-culpa.ps1",
|
|
55
|
+
"uninstall-culpa.ps1",
|
|
56
|
+
"culpa-collector.ps1",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// The single source of truth for "which parity files belong on this platform".
|
|
60
|
+
// provision(), repair() and doctor() ALL read it — if any one of them used the
|
|
61
|
+
// flat list instead, repair would restore what provision skipped, or doctor
|
|
62
|
+
// would fail an install for a file that is correctly absent.
|
|
63
|
+
export function parityAssetsFor(platform = process.platform) {
|
|
64
|
+
if (platform === "win32") return PARITY_ASSETS;
|
|
65
|
+
return PARITY_ASSETS.filter((name) => !WINDOWS_ONLY_ASSETS.includes(name));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// CF20-T6 — protects the SHIPPED culpa-compose.yml the same way
|
|
69
|
+
// stageLiveComposeIfAbsent already protects the live one, mirroring
|
|
70
|
+
// launch-culpa.ps1's QW-1 one-direction rule: an older incoming pin must
|
|
71
|
+
// never silently overwrite a newer one already staged (the
|
|
72
|
+
// downgrade-package case — npm delivered an older getculpa than what is
|
|
73
|
+
// already here). CULPA_ALLOW_DOWNGRADE=1 is the same escape hatch T4's
|
|
74
|
+
// start.mjs already honors for QW-3 at launch time.
|
|
75
|
+
function stageShippedCompose(appDir, env) {
|
|
76
|
+
const src = resolveAssetPath("culpa-compose.yml");
|
|
77
|
+
const dest = path.join(appDir, "culpa-compose.yml");
|
|
78
|
+
const allowDowngrade = env.CULPA_ALLOW_DOWNGRADE === "1";
|
|
79
|
+
if (existsSync(dest) && !allowDowngrade) {
|
|
80
|
+
const incoming = readFileSync(src, "utf8");
|
|
81
|
+
const staged = readFileSync(dest, "utf8");
|
|
82
|
+
if (isIncomingComposeOlder(incoming, staged)) {
|
|
83
|
+
console.warn(
|
|
84
|
+
"getculpa: the incoming package's culpa-compose.yml pin is older than the one already staged - skipped (set CULPA_ALLOW_DOWNGRADE=1 to override).",
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
copyFileSync(src, dest);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// CF20-T5 (repair): exported so lib/repair.mjs can re-stage the parity
|
|
93
|
+
// file-set and (only-if-absent) the live compose without duplicating this
|
|
94
|
+
// logic — same reason the task instructions call out reuse over a second
|
|
95
|
+
// copy. Purely additive: behavior and call sites inside this file are
|
|
96
|
+
// unchanged.
|
|
97
|
+
export function stageParityFiles(appDir, env = process.env, platform = process.platform) {
|
|
98
|
+
mkdirSync(appDir, { recursive: true });
|
|
99
|
+
stageShippedCompose(appDir, env);
|
|
100
|
+
for (const name of parityAssetsFor(platform)) {
|
|
101
|
+
if (name === "culpa-compose.yml") continue;
|
|
102
|
+
copyFileSync(resolveAssetPath(name), path.join(appDir, name));
|
|
103
|
+
}
|
|
104
|
+
// ISS-V103-9: the Cloud Relay page's Fly tab prints
|
|
105
|
+
// `flyctl deploy --config culpa-relay-fly.toml` and tells the customer to
|
|
106
|
+
// run it from THIS folder, so the file has to be here. Staged OUTSIDE
|
|
107
|
+
// PARITY_ASSETS deliberately: that list is the Windows-installer parity
|
|
108
|
+
// set and doctor grades every member as load-bearing, but a relay config
|
|
109
|
+
// is optional — most installs never deploy one, and a missing relay
|
|
110
|
+
// config must never make `getculpa doctor` unhappy.
|
|
111
|
+
copyFileSync(resolveAssetPath(RELAY_FLY_CONFIG), path.join(appDir, RELAY_FLY_CONFIG));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The live compose is staged ONLY when absent. An existing live compose
|
|
115
|
+
// belongs to launch-time's QW-1 sync (launch-culpa.ps1) — provisioning must
|
|
116
|
+
// never re-pin a running install out from under it.
|
|
117
|
+
export function stageLiveComposeIfAbsent(appDir) {
|
|
118
|
+
const live = path.join(appDir, "docker-compose.yml");
|
|
119
|
+
if (existsSync(live)) return false;
|
|
120
|
+
copyFileSync(path.join(appDir, "culpa-compose.yml"), live);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// T-CF29-8 — the name culpa-collector.ps1:35 reads from the app dir, and the
|
|
125
|
+
// same name culpa-setup.iss:52 installs there. NOT the Rust target triple
|
|
126
|
+
// lib/fetch.mjs uses for the release asset (x86_64-pc-windows-msvc): this is
|
|
127
|
+
// the Node-style platform-arch pair.
|
|
128
|
+
export function collectorBinaryName(platform = process.platform, arch = process.arch) {
|
|
129
|
+
const exe = platform === "win32" ? ".exe" : "";
|
|
130
|
+
return `culpa-collectord-${platform}-${arch}${exe}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// T-CF29-8 — capture was silently OFF on every Windows npm install:
|
|
134
|
+
// "culpa-collectord-win32-x64.exe not present in this package - skipping
|
|
135
|
+
// capture setup". lib/fetch.mjs stages the binary into the PACKAGE's vendor/
|
|
136
|
+
// as culpa-collectord.exe, the .ps1 reads it from the APP DIR under the
|
|
137
|
+
// triple-suffixed name, and nothing bridged the two. The Windows installer
|
|
138
|
+
// path has always put it in the app dir, so this is the npm path catching up
|
|
139
|
+
// to installer parity, not a new convention.
|
|
140
|
+
//
|
|
141
|
+
// Returns whether the binary is really there afterwards — never a hopeful
|
|
142
|
+
// echo of the caller's collectorFetched flag, because "capture is on" is
|
|
143
|
+
// exactly the kind of claim that must be backed by a file on disk.
|
|
144
|
+
export function stageCollectorBinary({ appDir, vendorDir, platform, arch }) {
|
|
145
|
+
const exe = platform === "win32" ? ".exe" : "";
|
|
146
|
+
const src = path.join(vendorDir, `culpa-collectord${exe}`);
|
|
147
|
+
if (!existsSync(src)) return false;
|
|
148
|
+
const dest = path.join(appDir, collectorBinaryName(platform, arch));
|
|
149
|
+
try {
|
|
150
|
+
copyFileSync(src, dest);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.warn(`getculpa: capture collector could not be staged (${e.message}) - capture OFF, everything else works.`);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
return existsSync(dest);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function detectDockerState(dockerSpawnSync) {
|
|
159
|
+
if (!checkDockerPresent(dockerSpawnSync)) return "missing";
|
|
160
|
+
if (!checkDockerEngineReachable(dockerSpawnSync)) return "installed-not-running";
|
|
161
|
+
return "ready";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Staging only — `pull`, never `up`. This is the defer invariant's other
|
|
165
|
+
// half: even with a reachable engine, provisioning must not start anything.
|
|
166
|
+
function stageImages(appDir, dockerSpawnSync) {
|
|
167
|
+
const shipped = path.join(appDir, "culpa-compose.yml");
|
|
168
|
+
const result = dockerSpawnSync("docker", ["compose", "-f", shipped, "-p", "culpa", "pull"], {
|
|
169
|
+
encoding: "utf8",
|
|
170
|
+
});
|
|
171
|
+
return result.status === 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Real win32 delegate: -DeferStart -NonInteractive is what makes this safe
|
|
175
|
+
// to run unattended from postinstall (no dialogs, no engine start, no
|
|
176
|
+
// compose up, no browser) while still producing the same shortcuts + HKCU
|
|
177
|
+
// registration the Windows installer produces.
|
|
178
|
+
function defaultDelegateWindowsInstaller(appDir) {
|
|
179
|
+
const script = path.join(appDir, "install-culpa.ps1");
|
|
180
|
+
const result = realSpawnSync(
|
|
181
|
+
"powershell.exe",
|
|
182
|
+
["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-DeferStart", "-NonInteractive"],
|
|
183
|
+
{ stdio: "inherit" },
|
|
184
|
+
);
|
|
185
|
+
return result.status === 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function writeState(appDir, state) {
|
|
189
|
+
writeFileSync(path.join(appDir, "install-state.json"), `${JSON.stringify(state, null, 2)}\n`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readExistingState(appDir) {
|
|
193
|
+
try {
|
|
194
|
+
return JSON.parse(readFileSync(path.join(appDir, "install-state.json"), "utf8"));
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// CF20-T6 — the honest, human-facing line for each classifyUpgrade outcome.
|
|
201
|
+
// "upgrade" is explicit that the new version is STAGED, not running yet —
|
|
202
|
+
// it applies at the next `getculpa` (start.mjs's QW-1-equivalent sync + T4's
|
|
203
|
+
// launch orchestration), never here. Postinstall stays silent about
|
|
204
|
+
// migrations by design: those are QW-4's job at that next `getculpa`, not
|
|
205
|
+
// this one.
|
|
206
|
+
function describeClassification(classification, previousVersion, currentVersion) {
|
|
207
|
+
switch (classification) {
|
|
208
|
+
case "fresh":
|
|
209
|
+
return `getculpa: installing Culpa ${currentVersion}.`;
|
|
210
|
+
case "upgrade":
|
|
211
|
+
return `getculpa: upgrading Culpa ${previousVersion} -> ${currentVersion} (staged; applies at next \`getculpa\`).`;
|
|
212
|
+
case "same-version":
|
|
213
|
+
return `getculpa: repairing the existing Culpa ${currentVersion} installation (same version reinstalled).`;
|
|
214
|
+
case "downgrade-package":
|
|
215
|
+
return (
|
|
216
|
+
`getculpa: this package (${currentVersion}) is older than the installed Culpa ${previousVersion} - ` +
|
|
217
|
+
"refusing to downgrade (set CULPA_ALLOW_DOWNGRADE=1 to override)."
|
|
218
|
+
);
|
|
219
|
+
case "repair":
|
|
220
|
+
default:
|
|
221
|
+
return "getculpa: repairing the existing Culpa installation.";
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function provision(opts) {
|
|
226
|
+
const {
|
|
227
|
+
appDir,
|
|
228
|
+
currentVersion,
|
|
229
|
+
collectorFetched = false,
|
|
230
|
+
platform = process.platform,
|
|
231
|
+
arch = process.arch,
|
|
232
|
+
vendorDir = path.join(packageRoot, "vendor"),
|
|
233
|
+
dockerSpawnSync = realSpawnSync,
|
|
234
|
+
delegateWindowsInstaller = defaultDelegateWindowsInstaller,
|
|
235
|
+
env = process.env,
|
|
236
|
+
} = opts;
|
|
237
|
+
|
|
238
|
+
// Classify + capture the pre-existing state BEFORE anything below mutates
|
|
239
|
+
// appDir — both read the install-state.json / live compose that staging
|
|
240
|
+
// is about to overwrite.
|
|
241
|
+
const { classification, previousVersion } = classifyUpgrade({ appDir, currentVersion });
|
|
242
|
+
console.log(describeClassification(classification, previousVersion, currentVersion));
|
|
243
|
+
const existingState = readExistingState(appDir);
|
|
244
|
+
|
|
245
|
+
// CF20-R: a REFUSED downgrade (classification === "downgrade-package",
|
|
246
|
+
// CULPA_ALLOW_DOWNGRADE not set) used to still restage every OTHER parity
|
|
247
|
+
// file (the ps1 scripts, register.mjs) with the older package's copies —
|
|
248
|
+
// only culpa-compose.yml was protected (stageShippedCompose's own guard).
|
|
249
|
+
// That left a mixed-version app dir: a newer compose pin beside older
|
|
250
|
+
// scripts. Skip the WHOLE parity re-stage in this case instead, so the app
|
|
251
|
+
// dir stays wholly at the newer version; dir/state handling below is
|
|
252
|
+
// unaffected.
|
|
253
|
+
const allowDowngrade = env.CULPA_ALLOW_DOWNGRADE === "1";
|
|
254
|
+
const refusedDowngrade = classification === "downgrade-package" && !allowDowngrade;
|
|
255
|
+
if (refusedDowngrade) {
|
|
256
|
+
console.warn(
|
|
257
|
+
`getculpa: this package is older than the installed Culpa ${previousVersion} - skipping the parity file re-stage entirely (compose, scripts, register.mjs) to avoid a mixed-version app dir (set CULPA_ALLOW_DOWNGRADE=1 to override).`,
|
|
258
|
+
);
|
|
259
|
+
} else {
|
|
260
|
+
stageParityFiles(appDir, env, platform);
|
|
261
|
+
}
|
|
262
|
+
stageLiveComposeIfAbsent(appDir);
|
|
263
|
+
|
|
264
|
+
// T-CF29-8: only attempt it when the fetch reported success; the result is
|
|
265
|
+
// what gets recorded, not the intent.
|
|
266
|
+
const collectorStaged = collectorFetched && stageCollectorBinary({ appDir, vendorDir, platform, arch });
|
|
267
|
+
if (collectorFetched && !collectorStaged) {
|
|
268
|
+
console.warn(
|
|
269
|
+
"getculpa: the capture collector was downloaded but could not be placed in the app directory - capture OFF, everything else works.",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const dockerState = detectDockerState(dockerSpawnSync);
|
|
274
|
+
let imagesStaged = false;
|
|
275
|
+
if (dockerState === "ready") {
|
|
276
|
+
imagesStaged = stageImages(appDir, dockerSpawnSync);
|
|
277
|
+
if (!imagesStaged) {
|
|
278
|
+
console.warn("getculpa: image staging failed - images will be pulled at first `getculpa`.");
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
const why = dockerState === "missing" ? "Docker was not found" : "Docker is not running";
|
|
282
|
+
console.warn(`getculpa: ${why} - image staging deferred to first \`getculpa\`.`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (platform === "win32") {
|
|
286
|
+
// CF20-R: the Windows delegate registers the ONE fixed, per-machine HKCU
|
|
287
|
+
// uninstall key (install-culpa.ps1 / culpa-setup.iss's AppId). A second
|
|
288
|
+
// app dir on the same machine (CULPA_APP_DIR pointed somewhere
|
|
289
|
+
// non-default) must never overwrite that shared key — the gate's
|
|
290
|
+
// historical incident class. Only delegate when this appDir IS the
|
|
291
|
+
// canonical one (CULPA_APP_DIR's override stripped); otherwise skip
|
|
292
|
+
// registration and say so, once, honestly.
|
|
293
|
+
if (appDir === canonicalAppDir({ platform, env })) {
|
|
294
|
+
const ok = delegateWindowsInstaller(appDir);
|
|
295
|
+
if (!ok) {
|
|
296
|
+
console.warn("getculpa: Windows shortcut/registry setup did not complete cleanly - shortcuts may be missing.");
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
console.log("getculpa: non-canonical app dir: shortcuts/registry registration skipped.");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// T6 review (Important): a REFUSED downgrade must not record the rejected,
|
|
304
|
+
// older currentVersion as installed — the protected artifacts still carry
|
|
305
|
+
// the newer version, and this field is the ground truth the next run's
|
|
306
|
+
// classification reads. Persist the true installed version instead.
|
|
307
|
+
const recordedPackageVersion =
|
|
308
|
+
classification === "downgrade-package" && previousVersion ? previousVersion : currentVersion;
|
|
309
|
+
writeState(appDir, {
|
|
310
|
+
packageVersion: recordedPackageVersion,
|
|
311
|
+
previousVersion,
|
|
312
|
+
classification,
|
|
313
|
+
// provisionedAt is the FIRST install's timestamp, preserved across every
|
|
314
|
+
// subsequent provision() run; updatedAt is this run's.
|
|
315
|
+
provisionedAt: existingState?.provisionedAt ?? new Date().toISOString(),
|
|
316
|
+
updatedAt: new Date().toISOString(),
|
|
317
|
+
dockerState,
|
|
318
|
+
imagesStaged,
|
|
319
|
+
collectorStaged,
|
|
320
|
+
platform,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// T-CF28-5/7: the Docker and staging facts were already computed above and
|
|
324
|
+
// written to install-state.json, but the caller that has to REPORT them
|
|
325
|
+
// (scripts/install.js) could not see them — which is how a Docker-less
|
|
326
|
+
// install still printed "Culpa installed successfully." Returning them
|
|
327
|
+
// changes no behavior here; it just stops postinstall from having to guess.
|
|
328
|
+
return { classification, previousVersion, dockerState, imagesStaged, collectorStaged };
|
|
329
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "getculpa",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
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": {
|
package/scripts/prepack.js
CHANGED
|
@@ -14,6 +14,9 @@ const pkgRoot = path.join(__dirname, "..");
|
|
|
14
14
|
const repoRoot = path.join(pkgRoot, "..", "..");
|
|
15
15
|
const assetsDir = path.join(pkgRoot, "assets");
|
|
16
16
|
|
|
17
|
+
// An entry is either the repo-relative path parts (staged under its own
|
|
18
|
+
// basename) or { src: [...parts], as: "name" } when the canonical file's
|
|
19
|
+
// basename is too generic to sit in a customer's install folder.
|
|
17
20
|
const SOURCES = [
|
|
18
21
|
["installers", "culpa-compose.yml"],
|
|
19
22
|
["installers", "windows", "install-culpa.ps1"],
|
|
@@ -21,6 +24,14 @@ const SOURCES = [
|
|
|
21
24
|
["installers", "windows", "uninstall-culpa.ps1"],
|
|
22
25
|
["installers", "windows", "culpa-collector.ps1"],
|
|
23
26
|
["collector", "node", "register.mjs"],
|
|
27
|
+
// ISS-V103-9: the dashboard's Fly relay tab prints
|
|
28
|
+
// `flyctl deploy --config <this file>` and tells the customer to run it from
|
|
29
|
+
// their Culpa install folder. It shipped in NEITHER the install folder nor
|
|
30
|
+
// the npm package — it existed only in the source repo — so the Fly relay
|
|
31
|
+
// path could not be completed by anyone who installed via npm. Staged under
|
|
32
|
+
// an explicit name because a bare `fly.toml` in the install folder would
|
|
33
|
+
// read as the customer's OWN app config.
|
|
34
|
+
{ src: ["deploy", "relay", "fly.toml"], as: "culpa-relay-fly.toml" },
|
|
24
35
|
];
|
|
25
36
|
|
|
26
37
|
// CF20-R: clear assets/ before restaging — otherwise a canonical source file
|
|
@@ -29,13 +40,14 @@ const SOURCES = [
|
|
|
29
40
|
// hand-edited, so nothing else would ever remove it).
|
|
30
41
|
fs.rmSync(assetsDir, { recursive: true, force: true });
|
|
31
42
|
fs.mkdirSync(assetsDir, { recursive: true });
|
|
32
|
-
for (const
|
|
43
|
+
for (const entry of SOURCES) {
|
|
44
|
+
const parts = Array.isArray(entry) ? entry : entry.src;
|
|
33
45
|
const src = path.join(repoRoot, ...parts);
|
|
34
46
|
if (!fs.existsSync(src)) {
|
|
35
47
|
console.error(`prepack: missing canonical source ${src}`);
|
|
36
48
|
process.exit(1);
|
|
37
49
|
}
|
|
38
|
-
const
|
|
39
|
-
fs.copyFileSync(src,
|
|
40
|
-
console.log(`prepack: staged ${
|
|
50
|
+
const name = Array.isArray(entry) ? path.basename(src) : entry.as;
|
|
51
|
+
fs.copyFileSync(src, path.join(assetsDir, name));
|
|
52
|
+
console.log(`prepack: staged ${name}`);
|
|
41
53
|
}
|