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/README.md +6 -2
- package/assets/culpa-compose.yml +3 -3
- package/assets/uninstall-culpa.ps1 +251 -227
- package/bin/getculpa.js +9 -5
- package/lib/dispatch.mjs +7 -0
- package/lib/docker.mjs +290 -270
- package/lib/doctor.mjs +454 -420
- package/lib/preflight.mjs +322 -274
- package/lib/provision.mjs +47 -5
- package/lib/start.mjs +524 -487
- package/lib/uninstall.d.mts +20 -17
- package/lib/uninstall.mjs +158 -138
- package/package.json +26 -25
- package/scripts/preuninstall.js +82 -0
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
function
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
function
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
+
}
|