getculpa 1.0.5 → 1.0.7

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 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
- To obtain the current EULA, contact the licensor at the address below.
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
@@ -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.5 PUBLISHED AND SIGNED (2026-08-23) ─────────
8
+ # ── RELEASE STATE: v1.0.7 PUBLISHED AND SIGNED (2026-08-29) ─────────
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.3
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.3
58
+ image: ghcr.io/myaigidev/culpa-server:v1.0.7
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
- # CF14 (2026-08-12): dashboard-only bump. v0.11.1 is v0.11.0 plus the dark
127
- # black-surface palette — no server, migration or API change, so the server
128
- # above deliberately stays at v0.11.0 rather than being re-tagged for a
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.5
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.7
131
133
  container_name: culpa-dashboard
132
134
  environment:
133
135
  CULPA_API_BASE: http://server:4545
@@ -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
- if ($ComposeText -match 'culpa-server:([^"\s]+)') { return $Matches[1] }
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
- $shipped = Get-PinnedServerVersion (Get-Content $ShippedPath -Raw)
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
  }
@@ -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
- const m = /culpa-server:([^"\s]+)/.exec(composeText ?? "");
66
- return m ? m[1] : null;
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
- const shippedVer = parsePinnedServerVersion(readFileSync(shippedPath, "utf8"));
127
- const liveVer = parsePinnedServerVersion(readFileSync(liveCompose, "utf8"));
128
- if (shippedVer && liveVer && compareVersions(shippedVer, liveVer) === 1) {
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.5",
3
+ "version": "1.0.7",
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": {