getculpa 1.0.7 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/preflight.mjs CHANGED
@@ -1,274 +1,322 @@
1
- // CF20-T3 — preflight checks for the shared install core. Detection only:
2
- // nothing here starts Docker, pulls an image, or touches the app dir.
3
- //
4
- // Docker checks take an injectable spawnSync (default: the real one), the
5
- // same pattern install-culpa.ps1 uses for Test-DockerInstalled's -Probe —
6
- // so a test can stub docker's presence/engine state without a real Docker
7
- // install or a fragile PATH shim.
8
-
9
- import { spawnSync as realSpawnSync } from "node:child_process";
10
- import { existsSync, readFileSync, statfsSync } from "node:fs";
11
- import path from "node:path";
12
- import { PROBE_TIMEOUT_MS } from "./docker.mjs";
13
-
14
- const SUPPORTED_TRIPLES = new Set(["win32-x64", "linux-x64", "darwin-x64", "darwin-arm64"]);
15
-
16
- // Mirrors the launcher triples already load-bearing in scripts/install.js's
17
- // targetTriple() (win32/x64, linux/x64, darwin/x64, darwin/arm64).
18
- export function checkPlatformSupport(platform = process.platform, arch = process.arch) {
19
- if (SUPPORTED_TRIPLES.has(`${platform}-${arch}`)) return { supported: true };
20
- const message =
21
- "Culpa currently supports Windows x64, Linux x64, and macOS (Intel or Apple Silicon). " +
22
- `Detected: ${platform}/${arch}.`;
23
- return { supported: false, message };
24
- }
25
-
26
- // Best-effort: `statfs` is unavailable or throws on some hosts/paths (e.g. a
27
- // directory that does not exist yet). Unknown is a valid, honestly-reported
28
- // outcome — this never blocks or fails the install.
29
- export function checkDiskSpaceBestEffort(targetDir) {
30
- try {
31
- const probeDir = nearestExistingAncestor(targetDir);
32
- const stats = statfsSync(probeDir);
33
- return { known: true, availableBytes: stats.bavail * stats.bsize };
34
- } catch {
35
- return { known: false, availableBytes: null };
36
- }
37
- }
38
-
39
- function nearestExistingAncestor(startDir) {
40
- let dir = startDir;
41
- for (let i = 0; i < 64; i++) {
42
- if (existsSync(dir)) return dir;
43
- const parent = path.dirname(dir);
44
- if (parent === dir) return dir; // reached the filesystem root
45
- dir = parent;
46
- }
47
- return dir;
48
- }
49
-
50
- export function checkDockerPresent(spawnSync = realSpawnSync) {
51
- // CF20-R: PROBE_TIMEOUT_MS (docker.mjs) — a hung docker CLI must not stall
52
- // npm install forever.
53
- const result = spawnSync("docker", ["--version"], { timeout: PROBE_TIMEOUT_MS });
54
- return result.status === 0;
55
- }
56
-
57
- // Reachability only — never starts the engine (that is launch-time's job).
58
- export function checkDockerEngineReachable(spawnSync = realSpawnSync) {
59
- const result = spawnSync("docker", ["info", "--format", "ok"], { timeout: PROBE_TIMEOUT_MS });
60
- return result.status === 0;
61
- }
62
-
63
- // Same parsing target as launch-culpa.ps1's Get-PinnedServerVersion.
64
- // Full-line YAML comments, removed before any pin is parsed. Review finding
65
- // (2026-08-23): the unanchored parser below matched
66
- // # ghcr.io/myaigidev/culpa-server:v1.0.3
67
- // inside installers/culpa-compose.yml's cosign-verify EXAMPLE at line 29 —
68
- // which precedes the real `image:` pin at line 58, so the comment won the
69
- // first-match race.
70
- // Both read v1.0.3 today so nothing was visibly wrong, but publish.sh rewrites
71
- // the example and the pin with SEPARATE seds: a server release whose example
72
- // sed failed to match would leave QW-3 comparing a stale version against
73
- // last-boot-version.txt and REFUSING to launch a perfectly good pin.
74
- function stripComposeComments(composeText) {
75
- return String(composeText ?? "")
76
- .split(/\r?\n/)
77
- .filter((line) => !/^\s*#/.test(line))
78
- .join("\n");
79
- }
80
-
81
- // Anchored on `image:` as well as comment-stripped: a pin is something docker
82
- // would actually pull, never prose that happens to name the image.
83
- export function parsePinnedServerVersion(composeText) {
84
- // Comment-stripping is what actually cures the defect: prose can never be a
85
- // pin. The `image:` anchor is preferred but NOT required — callers legitimately
86
- // pass a bare "culpa-server:vX.Y.Z" fragment (isIncomingComposeOlder's unit
87
- // facts do), and requiring the anchor turned 5 of those 7 tests vacuous while
88
- // only 2 went red. Anchored first so a real compose is read from its image
89
- // line; unanchored fallback over the SAME comment-stripped text, where the
90
- // only thing left to match is real content.
91
- const text = stripComposeComments(composeText);
92
- const anchored = /^[ \t]*image:\s*\S*?culpa-server:([^"'\s]+)/m.exec(text);
93
- if (anchored) return anchored[1];
94
- const bare = /culpa-server:([^"'\s]+)/.exec(text);
95
- return bare ? bare[1] : null;
96
- }
97
-
98
- // Same fail-open-to-"equal" semantics as launch-culpa.ps1's
99
- // Compare-CulpaVersions: an unparsable tag never brick the comparison, it
100
- // just reads as 0 (equal). -1 = a older, 1 = a newer.
101
- export function compareVersions(a, b) {
102
- const rx = /^v?(\d+)\.(\d+)\.(\d+)/;
103
- const ma = rx.exec(a ?? "");
104
- const mb = rx.exec(b ?? "");
105
- if (!ma || !mb) return 0;
106
- for (let i = 1; i <= 3; i++) {
107
- const na = Number(ma[i]);
108
- const nb = Number(mb[i]);
109
- if (na < nb) return -1;
110
- if (na > nb) return 1;
111
- }
112
- return 0;
113
- }
114
-
115
- // ISS-V103-12. Every pinned CULPA image in a compose text, as
116
- // { "culpa-server": "v1.0.3", "culpa-dashboard": "v1.0.5" }.
117
- //
118
- // Deliberately scoped to `culpa-*`: the compose also pins postgres, and a
119
- // postgres bump must never be read as a Culpa release. Non-Culpa images are
120
- // simply absent from the map.
121
- export function parsePinnedImageVersions(composeText) {
122
- const out = {};
123
- // `^[ \t]*image:` with /m, not a bare `image:` anywhere. Comment-stripping
124
- // alone was not enough: `command: echo "image: .../culpa-dashboard:v9.9.9"`
125
- // is not a comment, yet an unanchored match read v9.9.9 as the pin (verified
126
- // 2026-08-23, from a CodeRabbit finding). A pin is a YAML key at line start.
127
- const rx = /^[ \t]*image:\s*\S*?(culpa-[A-Za-z0-9_-]+):([^"'\s]+)/gm;
128
- let m;
129
- // comment-stripped for the same reason parsePinnedServerVersion is: a
130
- // commented-out or example `image:` line is documentation, not a pin.
131
- const text = stripComposeComments(composeText);
132
- while ((m = rx.exec(text)) !== null) out[m[1]] = m[2];
133
- return out;
134
- }
135
-
136
- // ISS-V103-12. Is the SHIPPED compose a release the live one has not had?
137
- //
138
- // This used to be `compareVersions(shippedServerPin, livePin) === 1` — the
139
- // culpa-server tag and nothing else. A release is PER-COMPONENT (D-087), so
140
- // any release that did not bump culpa-server left shipped == live and the
141
- // re-pin never fired: v1.0.4 and v1.0.5 both reached existing installs as new
142
- // npm FILES over old CONTAINERS, which is worse than not shipping because the
143
- // two then disagree.
144
- //
145
- // The rule now: at least one pinned Culpa image is NEWER, and none is OLDER.
146
- // - "at least one newer" generalises the original intent (the shipped file
147
- // represents a release that moved something forward) without caring WHICH
148
- // component moved;
149
- // - "none older" preserves the one-direction rule this function has always
150
- // had — an OLDER shipped file never wins. A file moving one image forward
151
- // and another back is ambiguous, cannot be produced by publish.sh, and
152
- // must not silently win;
153
- // - an image in shipped but not live counts as newer: a release that ADDS a
154
- // service must reach the live file or that service never starts;
155
- // - an image in live but not shipped is ignored, not treated as older;
156
- // - unparsable tags still compare as 0, so exotic dev tags contribute
157
- // neither direction and can never brick a launch.
158
- //
159
- // NB QW-3 (backward-pin refusal) and QW-4 (pre-migration backup) deliberately
160
- // still key off the culpa-server pin: those are about database migration
161
- // history, not release delivery, and widening them would change when backups
162
- // and refusals fire.
163
- export function isComposeUpgrade(shippedComposeText, liveComposeText, onRefusal = () => {}) {
164
- const shipped = parsePinnedImageVersions(shippedComposeText);
165
- const live = parsePinnedImageVersions(liveComposeText);
166
- if (Object.keys(shipped).length === 0 || Object.keys(live).length === 0) return false;
167
-
168
- let anyNewer = false;
169
- for (const [name, shippedTag] of Object.entries(shipped)) {
170
- if (!(name in live)) {
171
- anyNewer = true;
172
- continue;
173
- }
174
- const cmp = compareVersions(shippedTag, live[name]);
175
- // Refusing an ambiguous file must not look like "nothing to do". QW-3
176
- // explains itself loudly when it refuses; this branch used to be silent,
177
- // leaving an operator asking why an upgrade never landed. The claim that
178
- // publish.sh cannot produce such a file is also weaker than it sounds —
179
- // this very release had step 5 applied BY HAND.
180
- if (cmp === -1) {
181
- onRefusal(
182
- `getculpa: the shipped compose moves ${name} BACKWARD (${live[name]} -> ${shippedTag}) while moving others forward; refusing to re-pin from an ambiguous file.`,
183
- );
184
- return false;
185
- }
186
- if (cmp === 1) anyNewer = true;
187
- }
188
- return anyNewer;
189
- }
190
-
191
- // Shared by classifyInstall and classifyUpgrade so the two can never read
192
- // "what version is already here" two different ways.
193
- function resolveInstalledVersionInfo(appDir) {
194
- const liveComposePath = path.join(appDir, "docker-compose.yml");
195
- if (!existsSync(appDir) || !existsSync(liveComposePath)) {
196
- return { exists: false, installedVersion: null, state: null };
197
- }
198
- const statePath = path.join(appDir, "install-state.json");
199
- const state = readJsonSafe(statePath);
200
- const pinned = parsePinnedServerVersion(readTextSafe(liveComposePath));
201
- const installedVersion = state?.packageVersion ?? pinned ?? null;
202
- return { exists: true, installedVersion, state };
203
- }
204
-
205
- // fresh: nothing installed here yet.
206
- // repair: something is here (a live compose exists) but there is no state
207
- // file we trust to say what version it is (e.g. a Windows-GUI-only
208
- // install, or a corrupt state file) — never guess, ask for a repair.
209
- // upgrade / same-version: read from install-state.json's packageVersion
210
- // (falling back to the live compose's pinned tag) compared against the
211
- // version of the package currently running.
212
- export function classifyInstall({ appDir, currentVersion }) {
213
- const info = resolveInstalledVersionInfo(appDir);
214
- if (!info.exists) return "fresh";
215
- if (!info.installedVersion || !info.state) return "repair";
216
-
217
- const cmp = compareVersions(info.installedVersion, currentVersion);
218
- if (cmp === 0) return "same-version";
219
- if (cmp === -1) return "upgrade";
220
- return "repair"; // installed reads NEWER than this package — never silently downgrade
221
- }
222
-
223
- // CF20-T6 — wires classifyInstall (T3 review Minor #4: implemented, tested,
224
- // ORPHANED — nothing called it) into the postinstall flow, and splits
225
- // classifyInstall's "repair" bucket into the two shapes postinstall must
226
- // message and record differently:
227
- // - a genuinely unknown install (no trusted version info at all), vs.
228
- // - "downgrade-package": npm delivered an OLDER getculpa than the
229
- // version already staged here (a stale mirror, a pinned older version,
230
- // an explicit rollback) — never silently downgrade.
231
- // previousVersion is best-effort (state's packageVersion first, the live
232
- // compose's pinned tag as fallback) and is null only for a genuinely fresh
233
- // install.
234
- export function classifyUpgrade({ appDir, currentVersion }) {
235
- const classification = classifyInstall({ appDir, currentVersion });
236
- const { installedVersion: previousVersion } = resolveInstalledVersionInfo(appDir);
237
- if (classification === "repair" && previousVersion && compareVersions(previousVersion, currentVersion) === 1) {
238
- return { classification: "downgrade-package", previousVersion };
239
- }
240
- return { classification, previousVersion };
241
- }
242
-
243
- // CF20-T6 — mirrors launch-culpa.ps1's Test-BackwardPin one-direction rule
244
- // (QW-1/QW-3 semantics) so the SHIPPED culpa-compose.yml can get the same
245
- // protection the live compose already has via provision.mjs's
246
- // stageLiveComposeIfAbsent: an older shipped file must never silently win
247
- // over a newer one already staged — the downgrade-package case. A malformed
248
- // incoming pin against a well-formed staged baseline fails CLOSED (treated
249
- // as older, the same asymmetry Test-BackwardPin uses); an absent incoming
250
- // or staged pin is permissive — there is no provable baseline to violate.
251
- export function isIncomingComposeOlder(incomingComposeText, stagedComposeText) {
252
- const incoming = parsePinnedServerVersion(incomingComposeText);
253
- const staged = parsePinnedServerVersion(stagedComposeText);
254
- if (!incoming || !staged) return false;
255
- const rx = /^v?(\d+)\.(\d+)\.(\d+)/;
256
- if (!rx.test(incoming) && rx.test(staged)) return true;
257
- return compareVersions(incoming, staged) === -1;
258
- }
259
-
260
- function readJsonSafe(filePath) {
261
- try {
262
- return JSON.parse(readFileSync(filePath, "utf8"));
263
- } catch {
264
- return null;
265
- }
266
- }
267
-
268
- function readTextSafe(filePath) {
269
- try {
270
- return readFileSync(filePath, "utf8");
271
- } catch {
272
- return "";
273
- }
274
- }
1
+ // CF20-T3 — preflight checks for the shared install core. Detection only:
2
+ // nothing here starts Docker, pulls an image, or touches the app dir.
3
+ //
4
+ // Docker checks take an injectable spawnSync (default: the real one), the
5
+ // same pattern install-culpa.ps1 uses for Test-DockerInstalled's -Probe —
6
+ // so a test can stub docker's presence/engine state without a real Docker
7
+ // install or a fragile PATH shim.
8
+
9
+ import { spawnSync as realSpawnSync } from "node:child_process";
10
+ import { existsSync, readFileSync, statfsSync } from "node:fs";
11
+ import path from "node:path";
12
+ import { PROBE_TIMEOUT_MS } from "./docker.mjs";
13
+
14
+ const SUPPORTED_TRIPLES = new Set(["win32-x64", "linux-x64", "darwin-x64", "darwin-arm64"]);
15
+
16
+ // Mirrors the launcher triples already load-bearing in scripts/install.js's
17
+ // targetTriple() (win32/x64, linux/x64, darwin/x64, darwin/arm64).
18
+ // T-179 (ISS-MAC-8): the ONE diagnosis for an install whose postinstall never
19
+ // ran. Verified against npm's own reference (npm-install-scripts, v12): from
20
+ // npm 12.0.0 (2026-07-08) dependency install scripts are blocked by default;
21
+ // a global install has no project package.json for `npm install-scripts
22
+ // approve` (EGLOBAL), so the documented forms are the --allow-scripts install
23
+ // flag or a one-time user config. An older npm with ignore-scripts=true leaves
24
+ // the same silent state. `getculpa` and `getculpa doctor` print exactly this.
25
+ export function unprovisionedDiagnosis() {
26
+ return {
27
+ message:
28
+ "Culpa is installed but was never provisioned: npm did not run this package's install script (npm 12 blocks install scripts by default; an older npm with ignore-scripts=true does the same), so nothing was staged.",
29
+ recovery:
30
+ "Run `npm config get ignore-scripts` (it must be false), then reinstall with scripts allowed: `npm i -g --allow-scripts=getculpa getculpa` (npm 12 or newer; or run `npm config set allow-scripts=getculpa --location=user` once and reinstall). On npm 11 a plain `npm i -g getculpa` provisions.",
31
+ };
32
+ }
33
+
34
+ export function checkPlatformSupport(platform = process.platform, arch = process.arch) {
35
+ if (SUPPORTED_TRIPLES.has(`${platform}-${arch}`)) return { supported: true };
36
+ const message =
37
+ "Culpa currently supports Windows x64, Linux x64, and macOS (Intel or Apple Silicon). " +
38
+ `Detected: ${platform}/${arch}.`;
39
+ return { supported: false, message };
40
+ }
41
+
42
+ // Best-effort: `statfs` is unavailable or throws on some hosts/paths (e.g. a
43
+ // directory that does not exist yet). Unknown is a valid, honestly-reported
44
+ // outcome — this never blocks or fails the install.
45
+ export function checkDiskSpaceBestEffort(targetDir) {
46
+ try {
47
+ const probeDir = nearestExistingAncestor(targetDir);
48
+ const stats = statfsSync(probeDir);
49
+ return { known: true, availableBytes: stats.bavail * stats.bsize };
50
+ } catch {
51
+ return { known: false, availableBytes: null };
52
+ }
53
+ }
54
+
55
+ function nearestExistingAncestor(startDir) {
56
+ let dir = startDir;
57
+ for (let i = 0; i < 64; i++) {
58
+ if (existsSync(dir)) return dir;
59
+ const parent = path.dirname(dir);
60
+ if (parent === dir) return dir; // reached the filesystem root
61
+ dir = parent;
62
+ }
63
+ return dir;
64
+ }
65
+
66
+ export function checkDockerPresent(spawnSync = realSpawnSync) {
67
+ // CF20-R: PROBE_TIMEOUT_MS (docker.mjs) — a hung docker CLI must not stall
68
+ // npm install forever.
69
+ const result = spawnSync("docker", ["--version"], { timeout: PROBE_TIMEOUT_MS });
70
+ return result.status === 0;
71
+ }
72
+
73
+ // Reachability only — never starts the engine (that is launch-time's job).
74
+ export function checkDockerEngineReachable(spawnSync = realSpawnSync) {
75
+ const result = spawnSync("docker", ["info", "--format", "ok"], { timeout: PROBE_TIMEOUT_MS });
76
+ return result.status === 0;
77
+ }
78
+
79
+ // Same parsing target as launch-culpa.ps1's Get-PinnedServerVersion.
80
+ // Full-line YAML comments, removed before any pin is parsed. Review finding
81
+ // (2026-08-23): the unanchored parser below matched
82
+ // # ghcr.io/myaigidev/culpa-server:v1.0.3
83
+ // inside installers/culpa-compose.yml's cosign-verify EXAMPLE at line 29 —
84
+ // which precedes the real `image:` pin at line 58, so the comment won the
85
+ // first-match race.
86
+ // Both read v1.0.3 today so nothing was visibly wrong, but publish.sh rewrites
87
+ // the example and the pin with SEPARATE seds: a server release whose example
88
+ // sed failed to match would leave QW-3 comparing a stale version against
89
+ // last-boot-version.txt and REFUSING to launch a perfectly good pin.
90
+ function stripComposeComments(composeText) {
91
+ return String(composeText ?? "")
92
+ .split(/\r?\n/)
93
+ .filter((line) => !/^\s*#/.test(line))
94
+ .join("\n");
95
+ }
96
+
97
+ // Anchored on `image:` as well as comment-stripped: a pin is something docker
98
+ // would actually pull, never prose that happens to name the image.
99
+ export function parsePinnedServerVersion(composeText) {
100
+ // Comment-stripping is what actually cures the defect: prose can never be a
101
+ // pin. The `image:` anchor is preferred but NOT required — callers legitimately
102
+ // pass a bare "culpa-server:vX.Y.Z" fragment (isIncomingComposeOlder's unit
103
+ // facts do), and requiring the anchor turned 5 of those 7 tests vacuous while
104
+ // only 2 went red. Anchored first so a real compose is read from its image
105
+ // line; unanchored fallback over the SAME comment-stripped text, where the
106
+ // only thing left to match is real content.
107
+ const text = stripComposeComments(composeText);
108
+ const anchored = /^[ \t]*image:\s*\S*?culpa-server:([^"'\s]+)/m.exec(text);
109
+ if (anchored) return anchored[1];
110
+ const bare = /culpa-server:([^"'\s]+)/.exec(text);
111
+ return bare ? bare[1] : null;
112
+ }
113
+
114
+ // Same fail-open-to-"equal" semantics as launch-culpa.ps1's
115
+ // Compare-CulpaVersions: an unparsable tag never brick the comparison, it
116
+ // just reads as 0 (equal). -1 = a older, 1 = a newer.
117
+ export function compareVersions(a, b) {
118
+ const rx = /^v?(\d+)\.(\d+)\.(\d+)/;
119
+ const ma = rx.exec(a ?? "");
120
+ const mb = rx.exec(b ?? "");
121
+ if (!ma || !mb) return 0;
122
+ for (let i = 1; i <= 3; i++) {
123
+ const na = Number(ma[i]);
124
+ const nb = Number(mb[i]);
125
+ if (na < nb) return -1;
126
+ if (na > nb) return 1;
127
+ }
128
+ return 0;
129
+ }
130
+
131
+ // ISS-V103-12. Every pinned CULPA image in a compose text, as
132
+ // { "culpa-server": "v1.0.3", "culpa-dashboard": "v1.0.5" }.
133
+ //
134
+ // Deliberately scoped to `culpa-*`: the compose also pins postgres, and a
135
+ // postgres bump must never be read as a Culpa release. Non-Culpa images are
136
+ // simply absent from the map.
137
+ export function parsePinnedImageVersions(composeText) {
138
+ const out = {};
139
+ // `^[ \t]*image:` with /m, not a bare `image:` anywhere. Comment-stripping
140
+ // alone was not enough: `command: echo "image: .../culpa-dashboard:v9.9.9"`
141
+ // is not a comment, yet an unanchored match read v9.9.9 as the pin (verified
142
+ // 2026-08-23, from a CodeRabbit finding). A pin is a YAML key at line start.
143
+ const rx = /^[ \t]*image:\s*\S*?(culpa-[A-Za-z0-9_-]+):([^"'\s]+)/gm;
144
+ let m;
145
+ // comment-stripped for the same reason parsePinnedServerVersion is: a
146
+ // commented-out or example `image:` line is documentation, not a pin.
147
+ const text = stripComposeComments(composeText);
148
+ while ((m = rx.exec(text)) !== null) out[m[1]] = m[2];
149
+ return out;
150
+ }
151
+
152
+ // ISS-V103-12. Is the SHIPPED compose a release the live one has not had?
153
+ //
154
+ // This used to be `compareVersions(shippedServerPin, livePin) === 1` — the
155
+ // culpa-server tag and nothing else. A release is PER-COMPONENT (D-087), so
156
+ // any release that did not bump culpa-server left shipped == live and the
157
+ // re-pin never fired: v1.0.4 and v1.0.5 both reached existing installs as new
158
+ // npm FILES over old CONTAINERS, which is worse than not shipping because the
159
+ // two then disagree.
160
+ //
161
+ // The rule now: at least one pinned Culpa image is NEWER, and none is OLDER.
162
+ // - "at least one newer" generalises the original intent (the shipped file
163
+ // represents a release that moved something forward) without caring WHICH
164
+ // component moved;
165
+ // - "none older" preserves the one-direction rule this function has always
166
+ // had — an OLDER shipped file never wins. A file moving one image forward
167
+ // and another back is ambiguous, cannot be produced by publish.sh, and
168
+ // must not silently win;
169
+ // - an image in shipped but not live counts as newer: a release that ADDS a
170
+ // service must reach the live file or that service never starts;
171
+ // - an image in live but not shipped is ignored, not treated as older;
172
+ // - unparsable tags still compare as 0, so exotic dev tags contribute
173
+ // neither direction and can never brick a launch.
174
+ //
175
+ // NB QW-3 (backward-pin refusal) and QW-4 (pre-migration backup) deliberately
176
+ // still key off the culpa-server pin: those are about database migration
177
+ // history, not release delivery, and widening them would change when backups
178
+ // and refusals fire.
179
+ // T-178 (ISS-MAC-7 + ISS-V103-7): ONE skew rule for launch, postinstall and
180
+ // doctor. A skew is either a RUNNING Culpa container whose image tag is not
181
+ // the live compose's pin for it (checked first — the stack's truth), or a
182
+ // shipped compose that is a release the live one has not had (isComposeUpgrade,
183
+ // the one-direction rule unchanged). `running` is the map runningImageVersions
184
+ // returns ({} when unknown — unknown never reports a skew); unparsable tags
185
+ // compare 0 and never report a skew either. `component` prefers culpa-server.
186
+ export function versionSkew({ shippedText, liveText, running }) {
187
+ const live = parsePinnedImageVersions(liveText ?? "");
188
+ const runningMap = running ?? {};
189
+ const ordered = (names) => [...names].sort((a, b) => (a === "culpa-server" ? -1 : b === "culpa-server" ? 1 : a.localeCompare(b)));
190
+ for (const name of ordered(Object.keys(live))) {
191
+ const runningTag = runningMap[name];
192
+ if (runningTag === undefined) continue;
193
+ // Direction-agnostic on purpose (review N1): the stack's truth is "not on
194
+ // the pin". Nothing today moves a container PAST its compose pin, so a
195
+ // running tag ahead of the pin has no path; if one ever appears the label
196
+ // is still "running-behind" but the message carries from/to verbatim.
197
+ if (compareVersions(runningTag, live[name]) !== 0) {
198
+ return { kind: "running-behind", component: name, from: runningTag, to: live[name] };
199
+ }
200
+ }
201
+ if (!isComposeUpgrade(shippedText ?? "", liveText ?? "")) return null;
202
+ const shipped = parsePinnedImageVersions(shippedText ?? "");
203
+ for (const name of ordered(Object.keys(shipped))) {
204
+ if (!(name in live) || compareVersions(shipped[name], live[name]) === 1) {
205
+ return { kind: "staged-upgrade", component: name, from: live[name] ?? "absent", to: shipped[name] };
206
+ }
207
+ }
208
+ return null;
209
+ }
210
+
211
+ export function isComposeUpgrade(shippedComposeText, liveComposeText, onRefusal = () => {}) {
212
+ const shipped = parsePinnedImageVersions(shippedComposeText);
213
+ const live = parsePinnedImageVersions(liveComposeText);
214
+ if (Object.keys(shipped).length === 0 || Object.keys(live).length === 0) return false;
215
+
216
+ let anyNewer = false;
217
+ for (const [name, shippedTag] of Object.entries(shipped)) {
218
+ if (!(name in live)) {
219
+ anyNewer = true;
220
+ continue;
221
+ }
222
+ const cmp = compareVersions(shippedTag, live[name]);
223
+ // Refusing an ambiguous file must not look like "nothing to do". QW-3
224
+ // explains itself loudly when it refuses; this branch used to be silent,
225
+ // leaving an operator asking why an upgrade never landed. The claim that
226
+ // publish.sh cannot produce such a file is also weaker than it sounds —
227
+ // this very release had step 5 applied BY HAND.
228
+ if (cmp === -1) {
229
+ onRefusal(
230
+ `getculpa: the shipped compose moves ${name} BACKWARD (${live[name]} -> ${shippedTag}) while moving others forward; refusing to re-pin from an ambiguous file.`,
231
+ );
232
+ return false;
233
+ }
234
+ if (cmp === 1) anyNewer = true;
235
+ }
236
+ return anyNewer;
237
+ }
238
+
239
+ // Shared by classifyInstall and classifyUpgrade so the two can never read
240
+ // "what version is already here" two different ways.
241
+ function resolveInstalledVersionInfo(appDir) {
242
+ const liveComposePath = path.join(appDir, "docker-compose.yml");
243
+ if (!existsSync(appDir) || !existsSync(liveComposePath)) {
244
+ return { exists: false, installedVersion: null, state: null };
245
+ }
246
+ const statePath = path.join(appDir, "install-state.json");
247
+ const state = readJsonSafe(statePath);
248
+ const pinned = parsePinnedServerVersion(readTextSafe(liveComposePath));
249
+ const installedVersion = state?.packageVersion ?? pinned ?? null;
250
+ return { exists: true, installedVersion, state };
251
+ }
252
+
253
+ // fresh: nothing installed here yet.
254
+ // repair: something is here (a live compose exists) but there is no state
255
+ // file we trust to say what version it is (e.g. a Windows-GUI-only
256
+ // install, or a corrupt state file) — never guess, ask for a repair.
257
+ // upgrade / same-version: read from install-state.json's packageVersion
258
+ // (falling back to the live compose's pinned tag) compared against the
259
+ // version of the package currently running.
260
+ export function classifyInstall({ appDir, currentVersion }) {
261
+ const info = resolveInstalledVersionInfo(appDir);
262
+ if (!info.exists) return "fresh";
263
+ if (!info.installedVersion || !info.state) return "repair";
264
+
265
+ const cmp = compareVersions(info.installedVersion, currentVersion);
266
+ if (cmp === 0) return "same-version";
267
+ if (cmp === -1) return "upgrade";
268
+ return "repair"; // installed reads NEWER than this package — never silently downgrade
269
+ }
270
+
271
+ // CF20-T6 — wires classifyInstall (T3 review Minor #4: implemented, tested,
272
+ // ORPHANED — nothing called it) into the postinstall flow, and splits
273
+ // classifyInstall's "repair" bucket into the two shapes postinstall must
274
+ // message and record differently:
275
+ // - a genuinely unknown install (no trusted version info at all), vs.
276
+ // - "downgrade-package": npm delivered an OLDER getculpa than the
277
+ // version already staged here (a stale mirror, a pinned older version,
278
+ // an explicit rollback) — never silently downgrade.
279
+ // previousVersion is best-effort (state's packageVersion first, the live
280
+ // compose's pinned tag as fallback) and is null only for a genuinely fresh
281
+ // install.
282
+ export function classifyUpgrade({ appDir, currentVersion }) {
283
+ const classification = classifyInstall({ appDir, currentVersion });
284
+ const { installedVersion: previousVersion } = resolveInstalledVersionInfo(appDir);
285
+ if (classification === "repair" && previousVersion && compareVersions(previousVersion, currentVersion) === 1) {
286
+ return { classification: "downgrade-package", previousVersion };
287
+ }
288
+ return { classification, previousVersion };
289
+ }
290
+
291
+ // CF20-T6 — mirrors launch-culpa.ps1's Test-BackwardPin one-direction rule
292
+ // (QW-1/QW-3 semantics) so the SHIPPED culpa-compose.yml can get the same
293
+ // protection the live compose already has via provision.mjs's
294
+ // stageLiveComposeIfAbsent: an older shipped file must never silently win
295
+ // over a newer one already staged — the downgrade-package case. A malformed
296
+ // incoming pin against a well-formed staged baseline fails CLOSED (treated
297
+ // as older, the same asymmetry Test-BackwardPin uses); an absent incoming
298
+ // or staged pin is permissive — there is no provable baseline to violate.
299
+ export function isIncomingComposeOlder(incomingComposeText, stagedComposeText) {
300
+ const incoming = parsePinnedServerVersion(incomingComposeText);
301
+ const staged = parsePinnedServerVersion(stagedComposeText);
302
+ if (!incoming || !staged) return false;
303
+ const rx = /^v?(\d+)\.(\d+)\.(\d+)/;
304
+ if (!rx.test(incoming) && rx.test(staged)) return true;
305
+ return compareVersions(incoming, staged) === -1;
306
+ }
307
+
308
+ function readJsonSafe(filePath) {
309
+ try {
310
+ return JSON.parse(readFileSync(filePath, "utf8"));
311
+ } catch {
312
+ return null;
313
+ }
314
+ }
315
+
316
+ function readTextSafe(filePath) {
317
+ try {
318
+ return readFileSync(filePath, "utf8");
319
+ } catch {
320
+ return "";
321
+ }
322
+ }