omakit 0.6.8 → 0.6.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.
@@ -1,10 +1,12 @@
1
- // The two checks that make a file the pinned release: its SHA-256 against
2
- // the pin, and its detached signature against the packaged Omarchy key at
3
- // the pinned fingerprint.
1
+ // The two checks that make a file the release: its SHA-256 against the
2
+ // digest the release publishes, and its detached signature against the
3
+ // packaged Omarchy key at the pinned fingerprint.
4
4
  //
5
- // The digest is the identity the package reviewed; the signature is the
6
- // independent Omarchy authenticity check. Both, every time, and a mismatch
7
- // in either fails closed (packaging/LAB_PLAN.md, the acquisition boundary).
5
+ // The signature is the trust: a substituted ISO with a substituted
6
+ // checksum beside it fails here, because only Omarchy's key signs. The
7
+ // digest names the file and catches a download that went wrong. Both,
8
+ // every time, and a mismatch in either fails closed (docs/LAB.md, the
9
+ // trust anchor).
8
10
  // The key is imported into a throwaway GNUPGHOME under the lab, never into
9
11
  // the user's keyring: a lab that added keys to ~/.gnupg would be changing
10
12
  // the host, and the only host change the lab makes is the lab.
@@ -42,20 +44,139 @@ export function sha256File(file, { onProgress } = {}) {
42
44
  return { sha256: hash.digest("hex"), bytes: read }
43
45
  }
44
46
 
45
- /** The packaged key, checked against its pinned digest before it is trusted with anything. */
47
+ /**
48
+ * The packaged key, checked against its pinned digest before it is trusted
49
+ * with anything. The release names it (releaseOf copies it from the pin,
50
+ * never from the network); with no release, the pin's own.
51
+ */
46
52
  export function packagedKey(pin = labPin(), dir = LAB_DIR) {
47
- const file = join(dir, pin.release.signingKey)
53
+ const name = pin.release?.signingKey || pin.releases.signingKey
54
+ const digest = pin.release?.signingKeySha256 || pin.releases.signingKeySha256
55
+ const file = join(dir, name)
48
56
  const { sha256 } = sha256File(file)
49
- if (sha256 !== pin.release.signingKeySha256) {
50
- throw Object.assign(new Error(`the packaged signing key at ${file} has digest ${sha256}, not the pinned ${pin.release.signingKeySha256}`), { code: "key-mismatch" })
57
+ if (sha256 !== digest) {
58
+ throw Object.assign(new Error(`the packaged signing key at ${file} has digest ${sha256}, not the pinned ${digest}`), { code: "key-mismatch" })
51
59
  }
52
60
  return file
53
61
  }
54
62
 
63
+ /**
64
+ * Who a detached OpenPGP signature says signed it, read from the packet
65
+ * without gpg, so the release search can refuse a changed signer before
66
+ * a six-gigabyte download: `{ fingerprint, keyId }`, each null when the
67
+ * packet does not carry it. A version 4 signature packet in either header
68
+ * format, binary or armoured; the issuer fingerprint subpacket (33) and
69
+ * the issuer key id (16). Anything it cannot read is `{ null, null }`,
70
+ * never a throw: the verdict is gpg's, over the whole file, in
71
+ * judgeRelease; this only lets a wrong key stop early. Measured on the
72
+ * 4.0.3 and 4.0.4 signatures (119 B each): fingerprint
73
+ * 40DFB630FF42BCFFB047046CF0134EE680CAC571 in a hashed subpacket.
74
+ */
75
+ export function signatureIssuer(input) {
76
+ return signatureIssuers(input)[0] || { fingerprint: null, keyId: null }
77
+ }
78
+
79
+ /**
80
+ * Every signer a detached signature names, one per signature packet: a
81
+ * `.sig` made during a key rotation can carry the new key's signature and
82
+ * the old one's, and gpg verifies it when any of them is the trusted key,
83
+ * so the early check must see them all. An empty list when nothing reads.
84
+ */
85
+ export function signatureIssuers(input) {
86
+ try {
87
+ let bytes = Buffer.isBuffer(input) ? input : Buffer.from(input || [])
88
+ const text = bytes.toString("latin1")
89
+ if (text.startsWith("-----BEGIN PGP SIGNATURE-----")) {
90
+ const body = text.split(/\r?\n\r?\n/).slice(1).join("\n").split(/\r?\n/).filter((line) => line && !line.startsWith("=") && !line.startsWith("-----")).join("")
91
+ bytes = Buffer.from(body, "base64")
92
+ }
93
+ const found = []
94
+ let at = 0
95
+ while (at < bytes.length && found.length < 16) {
96
+ const packet = readPacket(bytes, at)
97
+ if (!packet) break
98
+ const issuer = packet.tag === 2 ? readIssuer(packet.body) : null
99
+ if (issuer && (issuer.fingerprint || issuer.keyId)) found.push(issuer)
100
+ at = packet.next
101
+ }
102
+ return found
103
+ } catch {
104
+ return []
105
+ }
106
+ }
107
+
108
+ /** One OpenPGP packet at `at`, either header format: its tag, body and where the next begins; null when it does not read. */
109
+ function readPacket(bytes, at) {
110
+ const header = bytes[at]
111
+ if (header === undefined || !(header & 0x80)) return null
112
+ let tag
113
+ let offset
114
+ let length
115
+ if (header & 0x40) {
116
+ tag = header & 0x3f
117
+ const first = bytes[at + 1]
118
+ if (first < 192) [offset, length] = [at + 2, first]
119
+ else if (first < 224 && bytes.length > at + 2) [offset, length] = [at + 3, ((first - 192) << 8) + bytes[at + 2] + 192]
120
+ else if (first === 255 && bytes.length > at + 5) [offset, length] = [at + 6, bytes.readUInt32BE(at + 2)]
121
+ else return null
122
+ } else {
123
+ tag = (header >> 2) & 0x0f
124
+ const type = header & 3
125
+ if (type === 0 && bytes.length > at + 1) [offset, length] = [at + 2, bytes[at + 1]]
126
+ else if (type === 1 && bytes.length > at + 2) [offset, length] = [at + 3, bytes.readUInt16BE(at + 1)]
127
+ else if (type === 2 && bytes.length > at + 4) [offset, length] = [at + 5, bytes.readUInt32BE(at + 1)]
128
+ else return null
129
+ }
130
+ const body = bytes.subarray(offset, offset + length)
131
+ if (body.length !== length) return null
132
+ return { tag, body, next: offset + length }
133
+ }
134
+
135
+ /** The issuer of one version 4 signature packet's body, from its subpackets; null for any other version. */
136
+ function readIssuer(packet) {
137
+ if (packet.length < 6 || packet[0] !== 4) return null
138
+ const out = { fingerprint: null, keyId: null }
139
+ const readSubpackets = (start, end) => {
140
+ let at = start
141
+ while (at < end) {
142
+ let size
143
+ const first = packet[at]
144
+ if (first < 192) [size, at] = [first, at + 1]
145
+ else if (first < 255) [size, at] = [((first - 192) << 8) + packet[at + 1] + 192, at + 2]
146
+ else [size, at] = [packet.readUInt32BE(at + 1), at + 5]
147
+ if (!size || at + size > end) return
148
+ const type = packet[at] & 0x7f
149
+ const data = packet.subarray(at + 1, at + size)
150
+ if (type === 33 && data[0] === 4 && data.length === 21) out.fingerprint = data.subarray(1).toString("hex").toUpperCase()
151
+ if (type === 16 && data.length === 8) out.keyId = data.toString("hex").toUpperCase()
152
+ at += size
153
+ }
154
+ }
155
+ const hashedLength = packet.readUInt16BE(4)
156
+ if (6 + hashedLength + 2 > packet.length) return null
157
+ readSubpackets(6, 6 + hashedLength)
158
+ const unhashedStart = 6 + hashedLength + 2
159
+ const unhashedLength = packet.readUInt16BE(6 + hashedLength)
160
+ if (unhashedStart + unhashedLength <= packet.length) readSubpackets(unhashedStart, unhashedStart + unhashedLength)
161
+ return out
162
+ }
163
+
164
+ /**
165
+ * Whether the signers a signature names include the pinned one: `true`
166
+ * when one does, `false` when every signer it names is another key,
167
+ * `null` when it names none that can be read (the verdict is then gpg's
168
+ * alone, over the whole file).
169
+ */
170
+ export function signedByPinned(issuers, fingerprint) {
171
+ const named = issuers.filter((issuer) => issuer.fingerprint || issuer.keyId)
172
+ if (!named.length) return null
173
+ return named.some((issuer) => issuer.fingerprint ? issuer.fingerprint === fingerprint : fingerprint.endsWith(issuer.keyId))
174
+ }
175
+
55
176
  /**
56
177
  * Verify a detached signature with gpg in a throwaway keyring under
57
178
  * `stagingRoot`. The answer is the fingerprint gpg reports as VALIDSIG, or
58
- * null; the caller compares it with the pin. `run` is injectable.
179
+ * null; the caller compares it with the pinned signer. `run` is injectable.
59
180
  */
60
181
  export function verifySignature({ file, signature, keyFile, stagingRoot, run = spawnSync }) {
61
182
  const home = labDir(stagingRoot, `gnupg-${process.pid}`)
@@ -68,21 +189,29 @@ export function verifySignature({ file, signature, keyFile, stagingRoot, run = s
68
189
  const verified = run("gpg", ["--batch", "--status-fd", "1", "--verify", signature, file], { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 600_000 })
69
190
  const status = String(verified.stdout || "")
70
191
  const valid = status.match(/^\[GNUPG:\] VALIDSIG ([0-9A-F]{40}) /m)
71
- if (valid && verified.status === 0) return { state: "valid", fingerprint: valid[1], detail: status.match(/^\[GNUPG:\] GOODSIG \S+ (.+)$/m)?.[1] || null }
72
- const bad = status.match(/^\[GNUPG:\] (BADSIG|NO_PUBKEY|ERRSIG|NODATA)\b.*$/m)
73
- return { state: bad ? bad[1].toLowerCase() : "invalid", fingerprint: null, detail: (bad?.[0] || String(verified.stderr || "").trim().split("\n")[0] || "gpg did not report a valid signature").trim() }
192
+ // A .sig made during a key rotation carries a second signature by a key
193
+ // this keyring does not hold: gpg reports VALIDSIG for the packaged key,
194
+ // NO_PUBKEY for the other, and exits 2 (gpg 2.4.9). The keyring holds
195
+ // the packaged key alone, so a VALIDSIG can only be its own; the file is
196
+ // valid when there is one and no signature failed (BADSIG), whatever
197
+ // else gpg could not check.
198
+ const bad = /^\[GNUPG:\] BADSIG\b/m.test(status)
199
+ const unknownOnly = verified.status === 2 && /^\[GNUPG:\] (?:NO_PUBKEY|ERRSIG)\b/m.test(status)
200
+ if (valid && !bad && (verified.status === 0 || unknownOnly)) return { state: "valid", fingerprint: valid[1], detail: status.match(/^\[GNUPG:\] GOODSIG \S+ (.+)$/m)?.[1] || null }
201
+ const failed = status.match(/^\[GNUPG:\] (BADSIG|NO_PUBKEY|ERRSIG|NODATA)\b.*$/m)
202
+ return { state: failed ? failed[1].toLowerCase() : "invalid", fingerprint: null, detail: (failed?.[0] || String(verified.stderr || "").trim().split("\n")[0] || "gpg did not report a valid signature").trim() }
74
203
  } finally {
75
204
  removeFromLab(stagingRoot, `gnupg-${process.pid}`)
76
205
  }
77
206
  }
78
207
 
79
208
  /**
80
- * The whole judgement over a file on disk against the pin: byte count,
209
+ * The whole judgement over a file on disk against the release: byte count,
81
210
  * digest, signature. Every field is reported even after the first failure,
82
211
  * so a report can say "the size matches, the digest does not" rather than
83
212
  * only the first thing wrong. The sidecar's digest is compared too, so a
84
- * published checksum that disagrees with the pin is named (it would mean
85
- * the object at the versioned URL was replaced).
213
+ * checksum on disk that disagrees with the release's digest is named (the
214
+ * object at the versioned URL was replaced after the release was read).
86
215
  */
87
216
  export function judgeRelease({ file, signature, checksum, pin = labPin(), stagingRoot, keyFile = packagedKey(pin), onProgress, run }) {
88
217
  const verdict = { file, bytes: null, bytesMatch: false, sha256: null, sha256Match: false, sidecarSha256: null, sidecarMatch: null, signature: null, ok: false }
@@ -96,7 +225,7 @@ export function judgeRelease({ file, signature, checksum, pin = labPin(), stagin
96
225
  verdict.bytes = st.size
97
226
  verdict.bytesMatch = st.size === pin.release.bytes
98
227
  if (!verdict.bytesMatch) {
99
- verdict.reason = `${file} is ${st.size.toLocaleString("en-US")} B, the pin says ${pin.release.bytes.toLocaleString("en-US")} B`
228
+ verdict.reason = `${file} is ${st.size.toLocaleString("en-US")} B; Omarchy ${pin.release.name} is ${pin.release.bytes.toLocaleString("en-US")} B as published`
100
229
  return verdict
101
230
  }
102
231
  verdict.sha256 = sha256File(file, { onProgress }).sha256
@@ -111,7 +240,7 @@ export function judgeRelease({ file, signature, checksum, pin = labPin(), stagin
111
240
  }
112
241
  }
113
242
  if (!verdict.sha256Match) {
114
- verdict.reason = `${file} has digest ${verdict.sha256}, the pin says ${pin.release.sha256}`
243
+ verdict.reason = `${file} has digest ${verdict.sha256}; Omarchy ${pin.release.name} publishes ${pin.release.sha256}`
115
244
  return verdict
116
245
  }
117
246
  if (!signature) {
@@ -125,7 +254,7 @@ export function judgeRelease({ file, signature, checksum, pin = labPin(), stagin
125
254
  return verdict
126
255
  }
127
256
  if (signed.fingerprint !== pin.release.signingFingerprint) {
128
- verdict.reason = `the signature is by ${signed.fingerprint}, the pin says ${pin.release.signingFingerprint}`
257
+ verdict.reason = `the signature is by ${signed.fingerprint}, not the key omakit ships (${pin.release.signingFingerprint})`
129
258
  return verdict
130
259
  }
131
260
  verdict.ok = true
@@ -7,14 +7,14 @@ local commit through the transport seam the marketplace tests itself
7
7
 
8
8
  | File | Purpose |
9
9
  | --- | --- |
10
- | `pin.mjs` | The pin identity (one home) and the reproducible setup: `omakit pin` fetches exactly that commit into `$XDG_CACHE_HOME/omakit/marketplace` and refuses a modified checkout. `PIN_PATHS` is what is fetched; `PIN_READS` is what omakit opens under `scripts/` and how (imported or read as text), and `pinnedReadSet(pinDir)` adds what the imported files import, by regex over the pinned text and never by loading a module: 16 of 34 files at `b7b29654`, pinned by `tests/unit/pin.test.mjs`. `policyConstants(text)` reads the two policy constants out of the module's text, for the pin's identity and for doctor's comparison at HEAD. |
10
+ | `pin.mjs` | The pin identity (one home) and the reproducible setup: `omakit pin` fetches exactly that commit into `$XDG_CACHE_HOME/omakit/marketplace` and refuses a modified checkout. `PIN_PATHS` is what is fetched; `PIN_READS` is what omakit opens under `scripts/` and how (imported or read as text), and `pinnedReadSet(pinDir)` adds what the imported files import, by regex over the pinned text and never by loading a module: 16 of 34 files at `b441b4f0`, pinned by `tests/unit/pin.test.mjs`. `policyConstants(text)` reads the two policy constants out of the module's text, for the pin's identity and for doctor's comparison at HEAD; each file read as text carries its `readers` in `PIN_READS`, the values taken out of it as functions from text to value (`reservedNamespace`, `catalogPresentation`, `validatedLabel`, `updateLabel`), which the checks call on the pin's text and doctor on the pin's and HEAD's. |
11
11
  | `local-transport.mjs` | Answers the four request shapes the official resolver makes, from a local clone at the exact commit. No network, no credentials, no writes. With `subdir`, serves a directory below the root as the whole tree, which is how `inspect` keeps the baseline to the plugin's own tree. |
12
12
  | `run-baseline.mjs` | Runs the pinned official baseline over either transport and reports the pin identity beside the result. |
13
13
  | `verify.mjs` | Builds the `marketplaceBaseline` section: pin, transport, assumptions, the official result verbatim, the statement. `omakit verify` renders it for a person (`renderVerify` in `report.mjs`) and prints the document itself behind `--json` and `--out`. |
14
14
  | `preflight.mjs` | Translates that result into what it will cause on submission, using the pinned policy, and renders the marketplace's own report text with its attestation marker stripped and asserted absent. |
15
15
  | `yaml.mjs` | A deliberately small YAML reader for the pinned issue form. Accepts that subset and throws on anything else. |
16
16
  | `form.mjs` | The submission contract, read from the form and cross-checked against the marketplace's own constants. Also the route for a plugin that is already listed: the marketplace's verification form and its "newer commit" choice, read from `verify-plugin.yml` at the pin and cross-checked against `plugin-verification-request.mjs`. |
17
- | `registry.mjs` | The plugin-id and repository universe: the reserved namespace from the pinned catalog builder, the listed and retired ids and listed repositories from `registry.json` and `site/catalog.json` at the marketplace's current HEAD when the network is there (cached under `$XDG_CACHE_HOME/omakit/registry/<commit>/`, never in the pin) and at the pin with `--offline`; `liveFileUrl()` is the way to the raw file host for those two files, and `headTextUrl()` for the one text `doctor` compares and drops (the policy module at HEAD, `HEAD_TEXT_PATHS`), both at a 40-character commit. `sameRepository()` is the one rule for "the subject's own repository" (owner and name, case-insensitively, a trailing `.git` ignored), and `listingOf()` is what the catalog records about a listing: since when, which commit, verified or not, checked when. |
17
+ | `registry.mjs` | The plugin-id and repository universe: the reserved namespace from the pinned catalog builder, the listed and retired ids and listed repositories from `registry.json` and `site/catalog.json` at the marketplace's current HEAD when the network is there (cached under `$XDG_CACHE_HOME/omakit/registry/<commit>/`, never in the pin) and at the pin with `--offline`; `liveFileUrl()` is the way to the raw file host for those two files, and `headTextUrl()` for the text `doctor` compares and drops (the policy module and the three files read as text at HEAD, `HEAD_TEXT_PATHS`), both at a 40-character commit; `catalogBuilderText()` is the pin's catalog builder, and `reservedNamespace` and `catalogPresentation` are re-exported from `pin.mjs`. `sameRepository()` is the one rule for "the subject's own repository" (owner and name, case-insensitively, a trailing `.git` ignored), and `listingOf()` is what the catalog records about a listing: since when, which commit, verified or not, checked when. |
18
18
  | `tree.mjs` | The installable tree of a subject at one exact commit, from the Git object database. |
19
19
  | `plugin.mjs` | The root files the submission contract needs, and the declared plugin identity. |
20
20
  | `agent-control.mjs` | The recursive agent-control warning, and its remedy. |
@@ -23,7 +23,7 @@ local commit through the transport seam the marketplace tests itself
23
23
  | `issue.mjs` | Renders the issue the way the form would, then has the marketplace's own parser judge it. |
24
24
  | `submit.mjs` | Assembles every check with its measured reason, and withholds the body when a blocking check fails. Three outcomes: `ready` (the body), `refused` (a blocking check failed) and `listed` (the plugin is already listed by its own repository: `identity.available` passes with the listing's record, the five body checks are omitted rather than drawn as waiting, no body exists on purpose, and `listing` carries the listed commit against the local one and the form to use for a newer commit). Decides the category and tags after the registry: a listed plugin, own or taken, is asked for neither; an unlisted one without them is asked through `ask.mjs` at a terminal, and is a usage error otherwise. Ends with `reproduce`, the command line that repeats the run without asking. Under `--offline` the validation-commit check is `skipped`, not passed: verdict `skipped`, listed under `skipped` and not `unknown`, never blocking, and the READY line says "1 check skipped (--offline)". |
25
25
  | `ask.mjs` | The two questions `submit` asks a person at a terminal, and only there: category and tags, numbered from the pinned form, with the marketplace's own presentation for the manifest's kinds (read from the pinned catalog builder) as the default where it is on the list. Prompts on stderr, nothing persisted. |
26
- | `doctor.mjs` | What is installed, what is pinned, and what has moved. `omakit.version` is one check for one question, is this the current omakit: `ok` with "0.1.8, the newest published version", a `note` with "0.1.8; 0.1.9 is published" and the upgrade command, `unknown` with the registry's failure code, `info` offline; its evidence carries `{ installed, latest, source }`. `pin.freshness` compares what omakit reads between the pin and the marketplace's HEAD (`comparePin`): the 16 files in `pinnedReadSet()` by blob id, the two data files by blob id, the form directory by tree id, and the two policy constants by the module's text at HEAD when its blob moved; three tree reads, a fourth for `scripts/` only when its id moved, a fifth GET for the policy text only when that blob moved. Graded: `ok` when nothing in the read set moved (the data files are read live; `scripts/` moving elsewhere is said as "in none of the 16 files omakit reads"), `info` when a read file moved and both constants read the same (the files named, verdicts unchanged, nothing to do), `advice` when a constant differs, a read file is gone, or the form moved, with `omakit upgrade` as the action when a newer omakit is published and otherwise that the maintainer is notified; it never asks for an issue, the weekly workflow opens the one there is. Measured (M7): 5e401552, which changed only `repository-identity.mjs`, graded `advice` by tree id and grades `ok` here. The maintainer's pin procedure stays in `docs/UPSTREAM_CONTRACT.md` and is never printed. HEAD unreadable is `unknown`. `--json` carries `changedPaths`, `readLive` and `pinned` under the check's evidence as before, plus `reads`, `moved`, `missing` and `policy.{pin,head}`. |
26
+ | `doctor.mjs` | What is installed, what is pinned, and what has moved. `omakit.version` is one check for one question, is this the current omakit: `ok` with "0.1.8, the newest published version", a `note` with "0.1.8; 0.1.9 is published" and the upgrade command, `unknown` with the registry's failure code, `info` offline; its evidence carries `{ installed, latest, source }`. `pin.freshness` compares what omakit reads between the pin and the marketplace's HEAD (`comparePin`): the 16 files in `pinnedReadSet()` by blob id, the two data files by blob id, the form directory by tree id, the two policy constants by the module's text at HEAD when its blob moved, and each file read as text, when its blob moved, by the values its `readers` take out of it at both ends (`textReadsBetween`, `textReads`); three tree reads, a fourth for `scripts/` only when its id moved, one more GET per moved text. Graded on `effective`, the moved files less the text-read ones whose every value is unchanged: `ok` when it is empty (the data files are read live; `scripts/` moving elsewhere is said as "in none of the 16 files omakit reads"; an unchanged text-read file is named with how many values), `info` when it holds only `WORDING_READS`, `advice` when a verdict-bearing file moved, a text-read value differs (both printed), a read file is gone, or the form moved, with `omakit upgrade` as the action when a newer omakit is published and otherwise that the maintainer is notified; it never asks for an issue, the weekly workflow opens the one there is. Measured (M7): of the five commits since `38060f89` that touched the read set, tree and blob comparison grade five advice, this two. The maintainer's pin procedure stays in `docs/UPSTREAM_CONTRACT.md` and is never printed. HEAD unreadable is `unknown`. `--json` carries `changedPaths`, `readLive` and `pinned` under the check's evidence as before, plus `reads`, `moved`, `missing` and `policy.{pin,head}`. |
27
27
  | `watch.mjs` | The validation watch: validated commit versus current default-branch HEAD, and the one action that refreshes it. |
28
28
  | `github.mjs` | Read-only GitHub access. GET only. The credential is your `gh` login, read through one frozen `gh auth token` call, and is never written anywhere. |
29
29
  | `style.mjs` | The visual system, defined once: the palette, the status vocabulary, the block ramp, the columns, the motion budgets, and the composition helpers every command draws with. Six states: `▁ ok`, `█ FAIL`, `▓ note`, `░ info`, `▒ ?` for a check that could not be made, and `▔ skip` for a check a flag said not to make, the floor's ink at the ceiling so it is never read as a pass. `docs/TUI.md` explains it. |
@@ -81,22 +81,23 @@ in the document:
81
81
 
82
82
  | File | Purpose |
83
83
  | --- | --- |
84
- | `lab/pin.json`, `lab/pin.mjs` | The release pin: Omarchy 4.0.3, its URL, exact bytes, SHA-256, signer fingerprint, expected guest package; the toolchain commit and the digests of its harness before and after the patch; the measured costs. Refuses a URL that resolves latest. `bytesBoth` prints every size in GB and GiB. |
84
+ | `lab/pin.json`, `lab/pin.mjs` | What the lab holds fixed, never a release: where Omarchy's releases are listed (GitHub) and published (the ISO URL template), the floor (4.0.3), how many newer tags are read, the signer fingerprint and the key's digest; the toolchain commit and the digests of its harness before and after the patch; the costs measured with Omarchy 4.0.3. Refuses an ISO URL that names latest. `releaseOf` gives one release in the shape every module reads, `compareVersions` orders by the three numbers, `guestIsRelease` holds a guest package to its release. `bytesBoth` prints every size in GB and GiB. |
85
85
  | `lab/omarchy.gpg` | The Omarchy public signing key, 632 bytes of armoured text, pinned by digest. |
86
- | `lab/patches/omarchy-iso-test.patch` | The working harness of the reference host against the pinned omarchy-iso commit: the 4.0.3 greeter, no host package install, the host-test extensions. Applied by a person, never by omakit. |
86
+ | `lab/release.mjs` | The newest release, looked up each time through `github.mjs`'s GETs: the release list, newest first by version, drafts and pre-releases aside, at or above the floor, each candidate's `.sha256` and `.sig` and the ISO's announced size (its body cancelled unread). A 404 passes a newer tag over and names it; any other failure stops the search; a signer other than the pinned one stops it before the ISO is asked for (`signer-changed`). `checkNewestRelease` is the same as a value for inspect, doctor and a run. |
87
+ | `lab/patches/omarchy-iso-test.patch` | The working harness of the reference host against the pinned omarchy-iso commit: the 4.0.3 greeter (unchanged in 4.0.4), no host package install, the host-test extensions. Applied by a person, never by omakit. |
87
88
  | `lab/paths.mjs` | The two lab roots under the user cache and state, the layout, and `inLab`, the guard every write goes through; the one rename and the one stream copy in the tree. |
88
89
  | `lab/host.mjs` | Read-only probes: KVM, the commands a run, a build and a verification need (`--version`, never `ssh-keygen` bare), the OVMF firmware, free disk, memory, the CPU count. Installs nothing. |
89
- | `lab/verify.mjs` | SHA-256 streamed in Node, the signature check in a throwaway keyring, and `judgeRelease`: byte count, digest, sidecar, signature, fingerprint, in that order, every field reported. |
90
- | `lab/inspect.mjs` | The read-only view: the download and its verification record, the base's state from its manifest (`ready`, `mismatch`, `invalid`, `missing`), the toolchain by its harness's hash, staging with QMP liveness, the lock, the totals, what is missing with its cost and command. |
90
+ | `lab/verify.mjs` | SHA-256 streamed in Node, the signature check in a throwaway keyring, `judgeRelease`: byte count, digest, sidecar, signature, fingerprint, in that order, every field reported, against the release as published; and `signatureIssuer`, the signer read from the `.sig` packet without gpg, so a changed key stops the search before a download. |
91
+ | `lab/inspect.mjs` | The read-only view, against the newest release its caller looked up (or the base's own): the download and its verification record, each download directory by what it holds, the base's state from its manifest (`ready`, `outdated`, `mismatch`, `invalid`, `missing`), whether the lab is behind, the toolchain by its harness's hash, staging with QMP liveness and the build QEMUs a pidfile and `/proc` show still running, the lock, the totals, what is missing with its cost and command. |
91
92
  | `lab/qemu.mjs` | QEMU's argument list, pure; QMP over the Unix socket from Node; the qcode table and `typeText` for the greeter. |
92
93
  | `lab/guest.mjs` | SSH to 127.0.0.1 with the base's key and no forwarding; the session preamble; the login loop; the startup-notification dismissal; the guest's identity (`pacman -Q omarchy`, the kernel, whether the session is linked). |
93
94
  | `lab/run.mjs` | The lifecycle: the lock, `withGuest` (overlay, the run's firmware copy, QEMU as a child, SSH, login, identity, the body, power-off, the overlay measured and removed, the base checked unchanged), `preflightRun`, `runSuite` with the harness as a child and the document's provenance. |
94
- | `lab/setup.mjs` | The plan and the disclosure, the resumable literal GET through `github.mjs`'s one call site, the import of a local file, verification and promotion of the ISO, the toolchain record, the build through a copy of the toolchain's harness under staging, the verification boot, the manifest, the promotion; the listed plugins for the evidence suite. |
95
- | `lab/prune.mjs` | The inventory of what the lab owns with allocated bytes, the refusal while a QEMU answers, the removal of exactly the targets, recovered and remaining bytes. |
95
+ | `lab/setup.mjs` | The plan (first what no release changes, so a host that cannot build is refused before the network; then the newest release, or `--from` named by its file offline) and the disclosure, the resumable literal GET through `github.mjs`'s one call site, the import of a local file, verification and promotion of the ISO, the toolchain record, the build through a copy of the toolchain's harness under staging with every QEMU it left stopped, the verification boot, the manifest, the promotion, and after it the removal of older downloads; the listed plugins for the evidence suite. |
96
+ | `lab/prune.mjs` | The inventory of what the lab owns with allocated bytes, each download named by its own record, the refusal while a QEMU answers or a build's QEMU still runs, the removal of exactly the targets, recovered and remaining bytes. |
96
97
  | `lab/suites.mjs` | The four suites as data: host body, files needed, document, assertion, timeout, arguments; the listed plugin ids for `weigh-evidence`, their commits the pinned catalog's. |
97
98
  | `lab/harness.sh`, `lab/suites/*.sh` | The one harness every suite runs through, every value an argument; the Run, Store and weigh bodies. |
98
99
  | `lab/qmp-cli.mjs` | QMP from a shell: a screendump or a chord, for the harness. |
99
- | `lab/report.mjs` | The lab's lines for `doctor`, and `inspect`, `setup`, `run` and `prune` for a person, drawn with `style.mjs`. |
100
+ | `lab/report.mjs` | The lab's lines for `doctor` (`lab.release`: on the newest release, behind, or not looked up), and `inspect`, `setup`, `run` and `prune` for a person, drawn with `style.mjs`. |
100
101
 
101
102
  ```text
102
103
  omakit pin
@@ -54,6 +54,7 @@ import { addBlock } from "../blocks/add.mjs"
54
54
  import { inspectLab } from "../lab/inspect.mjs"
55
55
  import { runSuite } from "../lab/run.mjs"
56
56
  import { CONSENT_QUESTION, planSetup, recordToolchain, setupLab } from "../lab/setup.mjs"
57
+ import { checkNewestRelease } from "../lab/release.mjs"
57
58
  import { planPrune, prune } from "../lab/prune.mjs"
58
59
  import { renderLab, renderPrunePlan, renderPruneResult, renderRunIdentity, renderRunResult, renderSetupPlan, renderSetupResult } from "../lab/report.mjs"
59
60
 
@@ -101,12 +102,14 @@ const REMEDY = Object.freeze({
101
102
  "lab-not-ready": "omakit lab inspect",
102
103
  "lab-busy": "omakit lab inspect",
103
104
  "iso-mismatch": "omakit lab prune, then omakit lab setup",
104
- "size-mismatch": "The object at the pinned URL is not the pinned release; a pin update is a reviewed change, and docs/LAB.md says how.",
105
- "sidecar-mismatch": "The published checksum is not the pin's; a pin update is a reviewed change, and docs/LAB.md says how.",
105
+ "size-mismatch": "omakit lab setup: Omarchy changed the object at the versioned URL since the release was read, and setup reads it again.",
106
+ "sidecar-mismatch": "omakit lab setup: Omarchy republished the release while setup ran, and setup reads it again.",
106
107
  "key-mismatch": "The packaged signing key is not the one the pin names: reinstall omakit from the registry (`omakit upgrade`).",
108
+ "release-unavailable": "omakit lab inspect names the newest release and why the newer ones were passed over; run it again once Omarchy has published the ISO, its .sha256 and its .sig.",
109
+ "signer-changed": "omakit upgrade: a newer omakit carries Omarchy's new key once it is verified. Until then the lab keeps the base it has.",
107
110
  "toolchain-missing": "omakit lab inspect prints the one command that prepares the toolchain.",
108
111
  "toolchain-mismatch": "omakit lab inspect prints the one command that prepares the toolchain.",
109
- "guest-mismatch": "omakit lab prune removes the staged base; a pin update is a reviewed change, and docs/LAB.md says how.",
112
+ "guest-mismatch": "omakit lab prune removes the staged base; an ISO that installs another release's omarchy package is not used as that release.",
110
113
  "build-failed": "Read build.log under the lab's staging directory, then omakit lab prune and omakit lab setup again.",
111
114
  "qemu-failed": "Read qemu.log in the run directory; omakit lab inspect names what the host lacks.",
112
115
  "overlay-failed": "omakit lab inspect: the base must be ready and the disk must have room for one overlay.",
@@ -674,7 +677,7 @@ async function cmdWeigh(args) {
674
677
  */
675
678
  async function cmdLab(args) {
676
679
  const parsed = checkArgs(args, ACCEPTED.lab)
677
- const signature = "omakit lab prove <suite> | inspect [--verify] | setup [--from <file>] [--toolchain <dir>] [--plugins] [--yes] | prune [--keep-iso] [--records] [--yes]"
680
+ const signature = "omakit lab prove <suite> [--offline] | inspect [--verify] [--offline] | setup [--from <file>] [--toolchain <dir>] [--plugins] [--offline] [--yes] | prune [--keep-iso] [--records] [--yes]"
678
681
  if (parsed.offending !== null) fail("usage", `${parsed.reason}. Accepted: ${acceptedWords("lab")}.`, 2, signature)
679
682
  const [what, suite] = parsed.positionals
680
683
  if (!["prove", "inspect", "setup", "prune"].includes(what || "")) fail("usage", `lab needs one of prove, inspect, setup or prune${what ? `, not ${JSON.stringify(what)}` : ""}.`, 2, signature)
@@ -693,6 +696,13 @@ async function cmdLab(args) {
693
696
  }
694
697
  const listen = () => { for (const signal of Object.keys(SIGNAL_EXIT)) process.on(signal, interrupt) }
695
698
  const unlisten = () => { for (const signal of Object.keys(SIGNAL_EXIT)) process.off(signal, interrupt) }
699
+ // The newest Omarchy release, read from the release list once per
700
+ // command, with its own deadline so a network that drops packets costs
701
+ // at most that; --offline skips it and says so.
702
+ const offline = parsed.options.has("--offline")
703
+ const newestRelease = async (seconds = 45) => offline
704
+ ? { checked: false, code: "offline", reason: "--offline" }
705
+ : checkNewestRelease({ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(seconds * 1000)]) })
696
706
  /** A lab error that stopped for a signal carries the signal, so the exit status follows it. */
697
707
  const stopped = (error) => {
698
708
  if (error?.code === "interrupted" && !error.signal) error.signal = stoppedBy
@@ -703,8 +713,10 @@ async function cmdLab(args) {
703
713
  if (suite) fail("usage", `inspect takes no suite, so ${JSON.stringify(suite)} is one argument more than it takes.`, 2, signature)
704
714
  let lab
705
715
  try {
716
+ if (!offline) spinner.phase("looking for the newest Omarchy release")
717
+ const newest = await newestRelease()
706
718
  spinner.phase(parsed.options.has("--verify") ? "hashing the ISO and checking its signature" : "reading the lab")
707
- lab = await inspectLab({ verify: parsed.options.has("--verify") })
719
+ lab = await inspectLab({ newest, verify: parsed.options.has("--verify") })
708
720
  } catch (error) {
709
721
  spinner.done()
710
722
  failFrom(error)
@@ -730,11 +742,12 @@ async function cmdLab(args) {
730
742
  repoRoot: ROOT,
731
743
  options: { runs },
732
744
  signal: controller.signal,
745
+ checkNewest: () => newestRelease(30),
733
746
  onPhase: spinner.phase,
734
747
  onLine: (line) => {
735
748
  spinner.done()
736
749
  // The identity block, once the guest has been read: the line
737
- // packaging/LAB_PLAN.md says every run prints before its suite.
750
+ // docs/LAB.md says every run prints before its suite.
738
751
  if (line.record) {
739
752
  narrate.write(`${withOutputStream(narrate, () => renderRunIdentity(line.record, { colour: colourEnabled(narrate) }))}\n\n`)
740
753
  return
@@ -767,15 +780,30 @@ async function cmdLab(args) {
767
780
  failFrom(error)
768
781
  }
769
782
  let plan
783
+ const from = parsed.options.get("--from") || null
770
784
  try {
771
- plan = planSetup({ from: parsed.options.get("--from") || null, plugins: parsed.options.has("--plugins"), repoRoot: ROOT })
785
+ // First what no release changes: a host that cannot build is told so
786
+ // before the network is read. Then the newest release, and the plan
787
+ // that prepares it.
788
+ plan = planSetup({ from, plugins: parsed.options.has("--plugins"), repoRoot: ROOT })
789
+ if (!plan.blockers.length) {
790
+ if (!offline) spinner.phase("looking for the newest Omarchy release")
791
+ const newest = await newestRelease()
792
+ spinner.done()
793
+ // A newer release signed by a key omakit does not ship is a finding,
794
+ // not a lookup that failed: the lab cannot be brought current, and
795
+ // the person has to know, whatever base is there.
796
+ if (!newest.checked && newest.code === "signer-changed") failFrom(Object.assign(new Error(newest.reason), { code: "signer-changed", remedy: "omakit upgrade: a newer omakit carries the new key once it is verified" }))
797
+ plan = planSetup({ newest, from, plugins: parsed.options.has("--plugins"), repoRoot: ROOT })
798
+ }
772
799
  } catch (error) {
800
+ spinner.done()
773
801
  failFrom(error)
774
802
  }
775
803
  // The plan is narrated before the question, so the record says what
776
804
  // was agreed to; a plan that cannot run is the failure, on stderr.
777
805
  if (plan.blockers.length) {
778
- refuse(args, plan, (colour) => renderSetupPlan(plan, { colour }), { code: "lab-blocked", message: `setup cannot start: ${plan.blockers.join("; ")}` })
806
+ refuse(args, plan, (colour) => renderSetupPlan(plan, { colour }), { code: "lab-blocked", message: `setup cannot start: ${plan.blockers.map((item) => item.what).join("; ")} missing` })
779
807
  return
780
808
  }
781
809
  if (!plan.steps.length) return succeed(args, plan, (colour) => renderSetupPlan(plan, { colour }))