getculpa 1.0.4 → 1.0.6
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/LICENSE +3 -1
- package/assets/culpa-compose.yml +10 -8
- package/assets/launch-culpa.ps1 +70 -5
- package/lib/preflight.d.mts +6 -0
- package/lib/preflight.mjs +107 -2
- package/lib/start.mjs +7 -4
- package/package.json +1 -1
package/LICENSE
CHANGED
|
@@ -17,7 +17,9 @@ the Culpa End User Licence Agreement ("EULA") between you and Myaigi AI Labs
|
|
|
17
17
|
Product: Culpa
|
|
18
18
|
Governing law: Republic of South Africa
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
The full End User Licence Agreement ships WITH the product: it is shown at
|
|
21
|
+
first launch and is readable any time at /docs/terms-of-use in the app, and
|
|
22
|
+
as client_docs/terms-of-use.md in the source. You do not need to ask for it.
|
|
21
23
|
|
|
22
24
|
Except as the EULA expressly permits, you may not copy, modify, merge,
|
|
23
25
|
publish, distribute, sublicense, sell, rent, lease, reverse engineer, or
|
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.6 PUBLISHED AND SIGNED (2026-08-26) ─────────
|
|
9
9
|
# The line above is REWRITTEN BY installers/publish.sh at real-publish time —
|
|
10
10
|
# this file has shipped a hand-edited, wrong publication claim twice, so the
|
|
11
11
|
# claim is now mechanical, never prose. While it reads NOT PUBLISHED, the pins
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
# cosign verify \
|
|
27
27
|
# --certificate-identity 'info@myaigi.ai' \
|
|
28
28
|
# --certificate-oidc-issuer 'https://github.com/login/oauth' \
|
|
29
|
-
# ghcr.io/myaigidev/culpa-server:v1.0.
|
|
29
|
+
# ghcr.io/myaigidev/culpa-server:v1.0.6
|
|
30
30
|
# Repeat for culpa-dashboard. Both must report VERIFIED.
|
|
31
31
|
#
|
|
32
32
|
# If a pull fails with an authentication or "denied" error rather than a
|
|
@@ -55,7 +55,7 @@ services:
|
|
|
55
55
|
restart: unless-stopped
|
|
56
56
|
|
|
57
57
|
server:
|
|
58
|
-
image: ghcr.io/myaigidev/culpa-server:v1.0.
|
|
58
|
+
image: ghcr.io/myaigidev/culpa-server:v1.0.6
|
|
59
59
|
container_name: culpa-server
|
|
60
60
|
environment:
|
|
61
61
|
DATABASE_URL: postgres://culpa:culpa@db:5432/culpa
|
|
@@ -123,11 +123,13 @@ services:
|
|
|
123
123
|
restart: unless-stopped
|
|
124
124
|
|
|
125
125
|
dashboard:
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
|
|
126
|
+
# Per-component release (D-087). v1.0.5 is a dashboard + relay release: no
|
|
127
|
+
# server, migration or API change, so culpa-server above deliberately stays
|
|
128
|
+
# at v1.0.3 rather than being re-tagged for a release it has no diff in
|
|
129
|
+
# (D-072: never rebuild a tag that already exists). The example this note
|
|
130
|
+
# used to give was CF14's v0.11.0/v0.11.1 pair, which no longer matches
|
|
131
|
+
# either pin in this file.
|
|
132
|
+
image: ghcr.io/myaigidev/culpa-dashboard:v1.0.6
|
|
131
133
|
container_name: culpa-dashboard
|
|
132
134
|
environment:
|
|
133
135
|
CULPA_API_BASE: http://server:4545
|
package/assets/launch-culpa.ps1
CHANGED
|
@@ -42,8 +42,28 @@ function Test-DockerUp {
|
|
|
42
42
|
|
|
43
43
|
# The server tag pinned in a compose text; $null when absent (QW-1/2/3 all
|
|
44
44
|
# read the pin through this ONE function so they can never disagree).
|
|
45
|
+
# Full-line YAML comments, removed before ANY pin is parsed. Review finding
|
|
46
|
+
# (2026-08-23): the unanchored parser below matched the cosign-verify EXAMPLE
|
|
47
|
+
# at installers/culpa-compose.yml:29 - a COMMENT - which precedes the real
|
|
48
|
+
# image: pin at line 58 and so won the first-match race. Both read v1.0.3
|
|
49
|
+
# today, but publish.sh rewrites the example and the pin with SEPARATE seds,
|
|
50
|
+
# so a server release whose example sed missed would leave QW-3 comparing a
|
|
51
|
+
# stale version and REFUSING to launch a perfectly good pin.
|
|
52
|
+
function Remove-ComposeComments([string]$ComposeText) {
|
|
53
|
+
if ([string]::IsNullOrEmpty($ComposeText)) { return "" }
|
|
54
|
+
return ((($ComposeText -split "`r?`n") | Where-Object { $_ -notmatch '^\s*#' }) -join "`n")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Anchored on image: as well as comment-stripped - a pin is something docker
|
|
58
|
+
# would actually pull, never prose that happens to name the image.
|
|
45
59
|
function Get-PinnedServerVersion([string]$ComposeText) {
|
|
46
|
-
|
|
60
|
+
# Comment-stripping is what cures the defect; the image: anchor is preferred
|
|
61
|
+
# but NOT required, because callers legitimately pass a bare
|
|
62
|
+
# "culpa-server:vX.Y.Z" fragment. Anchored first, then an unanchored fallback
|
|
63
|
+
# over the SAME comment-stripped text.
|
|
64
|
+
$text = Remove-ComposeComments $ComposeText
|
|
65
|
+
if ($text -match '(?m)^[ ]*image:\s*\S*?culpa-server:([^"''\s]+)') { return $Matches[1] }
|
|
66
|
+
if ($text -match 'culpa-server:([^"''\s]+)') { return $Matches[1] }
|
|
47
67
|
return $null
|
|
48
68
|
}
|
|
49
69
|
|
|
@@ -64,6 +84,54 @@ function Compare-CulpaVersions([string]$A, [string]$B) {
|
|
|
64
84
|
return 0
|
|
65
85
|
}
|
|
66
86
|
|
|
87
|
+
# ISS-V103-12 - every pinned CULPA image in a compose text, as a hashtable
|
|
88
|
+
# @{ "culpa-server" = "v1.0.3"; "culpa-dashboard" = "v1.0.5" }. Scoped to
|
|
89
|
+
# culpa-* deliberately: the compose also pins postgres, and a postgres bump
|
|
90
|
+
# must never read as a Culpa release.
|
|
91
|
+
function Get-PinnedImageVersions([string]$ComposeText) {
|
|
92
|
+
$out = @{}
|
|
93
|
+
if ($null -eq $ComposeText) { return $out }
|
|
94
|
+
foreach ($m in [regex]::Matches((Remove-ComposeComments $ComposeText), '(?m)^[ ]*image:\s*\S*?(culpa-[A-Za-z0-9_-]+):([^"''\s]+)')) {
|
|
95
|
+
$out[$m.Groups[1].Value] = $m.Groups[2].Value
|
|
96
|
+
}
|
|
97
|
+
return $out
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# ISS-V103-12 - is the SHIPPED compose a release the LIVE one has not had?
|
|
101
|
+
#
|
|
102
|
+
# This used to be Compare-CulpaVersions on the culpa-server pin and nothing
|
|
103
|
+
# else, so any per-component release (D-087) that did not bump culpa-server
|
|
104
|
+
# never re-pinned: new npm files over old containers. Observed on v1.0.4.
|
|
105
|
+
#
|
|
106
|
+
# Rule: at least one pinned Culpa image is NEWER, and none is OLDER.
|
|
107
|
+
# - an image in shipped but not live counts as NEWER (a release that ADDS a
|
|
108
|
+
# service must reach the live file, or that service never starts);
|
|
109
|
+
# - an image in live but not shipped is ignored, not "older";
|
|
110
|
+
# - unparsable tags still compare 0, so exotic dev tags contribute neither
|
|
111
|
+
# direction and can never brick a launch.
|
|
112
|
+
#
|
|
113
|
+
# MUST stay behaviourally identical to isComposeUpgrade in
|
|
114
|
+
# packaging/npm-getculpa/lib/preflight.mjs - tests/npm-ps1-repin-parity.test.ts
|
|
115
|
+
# executes both against one shared table of cases.
|
|
116
|
+
function Test-ComposeUpgrade([string]$ShippedText, [string]$LiveText) {
|
|
117
|
+
$shipped = Get-PinnedImageVersions $ShippedText
|
|
118
|
+
$live = Get-PinnedImageVersions $LiveText
|
|
119
|
+
if ($shipped.Count -eq 0 -or $live.Count -eq 0) { return $false }
|
|
120
|
+
|
|
121
|
+
$anyNewer = $false
|
|
122
|
+
foreach ($name in $shipped.Keys) {
|
|
123
|
+
if (-not $live.ContainsKey($name)) { $anyNewer = $true; continue }
|
|
124
|
+
$cmp = Compare-CulpaVersions $shipped[$name] $live[$name]
|
|
125
|
+
if ($cmp -eq -1) {
|
|
126
|
+
# not silent: an ambiguous file must not look like "nothing to do"
|
|
127
|
+
Write-Warning "The shipped compose moves $name BACKWARD ($($live[$name]) -> $($shipped[$name])) while moving others forward; refusing to re-pin from an ambiguous file."
|
|
128
|
+
return $false
|
|
129
|
+
}
|
|
130
|
+
if ($cmp -eq 1) { $anyNewer = $true }
|
|
131
|
+
}
|
|
132
|
+
return $anyNewer
|
|
133
|
+
}
|
|
134
|
+
|
|
67
135
|
# QW-1 - ONE canonical compose. The live file ($LivePath) owns the pin; the
|
|
68
136
|
# shipped culpa-compose.yml beside the install ($ShippedPath) is only a
|
|
69
137
|
# SOURCE: when an upgrade drops a NEWER shipped file, the live one is
|
|
@@ -73,10 +141,7 @@ function Compare-CulpaVersions([string]$A, [string]$B) {
|
|
|
73
141
|
function Sync-CanonicalCompose([string]$ShippedPath, [string]$LivePath) {
|
|
74
142
|
if (-not (Test-Path $LivePath)) { return "no-live" }
|
|
75
143
|
if (-not (Test-Path $ShippedPath)) { return "no-shipped" }
|
|
76
|
-
|
|
77
|
-
$live = Get-PinnedServerVersion (Get-Content $LivePath -Raw)
|
|
78
|
-
if ($null -eq $shipped -or $null -eq $live) { return "kept" }
|
|
79
|
-
if ((Compare-CulpaVersions $shipped $live) -eq 1) {
|
|
144
|
+
if (Test-ComposeUpgrade (Get-Content $ShippedPath -Raw) (Get-Content $LivePath -Raw)) {
|
|
80
145
|
Copy-Item $ShippedPath $LivePath -Force
|
|
81
146
|
return "synced"
|
|
82
147
|
}
|
package/lib/preflight.d.mts
CHANGED
|
@@ -19,6 +19,12 @@ export function checkDockerEngineReachable(spawnSync?: SpawnSyncLike): boolean;
|
|
|
19
19
|
|
|
20
20
|
export function parsePinnedServerVersion(composeText: string | null | undefined): string | null;
|
|
21
21
|
export function compareVersions(a: string | null | undefined, b: string | null | undefined): -1 | 0 | 1;
|
|
22
|
+
export function parsePinnedImageVersions(composeText: string | null | undefined): Record<string, string>;
|
|
23
|
+
export function isComposeUpgrade(
|
|
24
|
+
shippedComposeText: string | null | undefined,
|
|
25
|
+
liveComposeText: string | null | undefined,
|
|
26
|
+
onRefusal?: (message: string) => void,
|
|
27
|
+
): boolean;
|
|
22
28
|
|
|
23
29
|
export type InstallKind = "fresh" | "upgrade" | "same-version" | "repair";
|
|
24
30
|
|
package/lib/preflight.mjs
CHANGED
|
@@ -61,9 +61,38 @@ export function checkDockerEngineReachable(spawnSync = realSpawnSync) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
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.
|
|
64
83
|
export function parsePinnedServerVersion(composeText) {
|
|
65
|
-
|
|
66
|
-
|
|
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;
|
|
67
96
|
}
|
|
68
97
|
|
|
69
98
|
// Same fail-open-to-"equal" semantics as launch-culpa.ps1's
|
|
@@ -83,6 +112,82 @@ export function compareVersions(a, b) {
|
|
|
83
112
|
return 0;
|
|
84
113
|
}
|
|
85
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
|
+
|
|
86
191
|
// Shared by classifyInstall and classifyUpgrade so the two can never read
|
|
87
192
|
// "what version is already here" two different ways.
|
|
88
193
|
function resolveInstalledVersionInfo(appDir) {
|
package/lib/start.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
waitForHealth,
|
|
41
41
|
waitForPgReady,
|
|
42
42
|
} from "./docker.mjs";
|
|
43
|
-
import { checkDockerPresent, compareVersions, parsePinnedServerVersion } from "./preflight.mjs";
|
|
43
|
+
import { checkDockerPresent, compareVersions, isComposeUpgrade, parsePinnedServerVersion } from "./preflight.mjs";
|
|
44
44
|
import { askYesNo as realAskYesNo } from "./tty.mjs";
|
|
45
45
|
|
|
46
46
|
export const STAGES = Object.freeze({
|
|
@@ -123,9 +123,12 @@ function isBackwardPin(pinnedVersion, lastBootVersion) {
|
|
|
123
123
|
async function runVersionGuards({ appDir, liveCompose, spawnSync, env, log, warn, sleepFn }) {
|
|
124
124
|
const shippedPath = path.join(appDir, "culpa-compose.yml");
|
|
125
125
|
if (existsSync(shippedPath)) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
// ISS-V103-12: this compared the culpa-server pin and NOTHING else, so a
|
|
127
|
+
// per-component release (D-087) that did not bump culpa-server never
|
|
128
|
+
// re-pinned the live compose — the customer got new npm files over old
|
|
129
|
+
// containers. isComposeUpgrade weighs EVERY pinned Culpa image, and keeps
|
|
130
|
+
// the one-direction rule (an older shipped file still never wins).
|
|
131
|
+
if (isComposeUpgrade(readFileSync(shippedPath, "utf8"), readFileSync(liveCompose, "utf8"), warn)) {
|
|
129
132
|
copyFileSync(shippedPath, liveCompose);
|
|
130
133
|
log("==> Upgrade detected: live compose re-pinned from the shipped culpa-compose.yml");
|
|
131
134
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "getculpa",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
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": {
|