omakit 0.6.8 → 0.6.9

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 (packaging/LAB_PLAN.md,
9
+ // the acquisition boundary).
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
@@ -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,6 +742,7 @@ 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()
@@ -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 }))
@@ -68,6 +68,7 @@ import { pathHint } from "./path-hint.mjs"
68
68
  import { completionStatus } from "./completion-check.mjs"
69
69
  import { inspectLab } from "../lab/inspect.mjs"
70
70
  import { labDoctorChecks } from "../lab/report.mjs"
71
+ import { checkNewestRelease } from "../lab/release.mjs"
71
72
 
72
73
  export function tool(repoRoot) {
73
74
  try {
@@ -278,11 +279,12 @@ export function pinFreshness(identity, head, comparison = null, { upgrade = null
278
279
  * @param {{ repoRoot: string, offline?: boolean, env?: object, npmPrefix?: () => string|null,
279
280
  * resolveHead?: typeof defaultBranchHead, latest?: typeof registryLatest, compare?: typeof comparePin }} options
280
281
  * `env` and `npmPrefix` are injectable for tests of the PATH check;
281
- * `resolveHead`, `latest` and `compare` for tests of the two checks that
282
- * read the network, whose defaults are the tool's one HEAD resolver, its
283
- * one registry read and the pin comparison above.
282
+ * `resolveHead`, `latest`, `compare` and `newestRelease` for tests of the
283
+ * checks that read the network, whose defaults are the tool's one HEAD
284
+ * resolver, its one registry read, the pin comparison above and the
285
+ * lab's read of Omarchy's release list (tools/lab/release.mjs).
284
286
  */
285
- export async function doctor({ repoRoot, offline = false, onPhase, env = process.env, npmPrefix, resolveHead = defaultBranchHead, latest: latestVersion = registryLatest, compare = comparePin }) {
287
+ export async function doctor({ repoRoot, offline = false, onPhase, env = process.env, npmPrefix, resolveHead = defaultBranchHead, latest: latestVersion = registryLatest, compare = comparePin, newestRelease = checkNewestRelease }) {
286
288
  // Optional: told what is being read while the network answers. Never
287
289
  // affects the result.
288
290
  const phase = onPhase || (() => {})
@@ -431,9 +433,15 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
431
433
  // the base, each with its measured reason, every one advice and never a
432
434
  // problem, because the lab is optional and doctor installs nothing.
433
435
  // Read-only: inspectLab creates no directory, verifies nothing online
434
- // and starts nothing.
436
+ // and starts nothing. The one read is Omarchy's release list, so the
437
+ // lab says when a newer release is out; --offline skips it.
438
+ let newest = { checked: false, code: "offline", reason: "--offline" }
439
+ if (!offline) {
440
+ phase("looking for the newest Omarchy release")
441
+ newest = await newestRelease({ signal: AbortSignal.timeout(45_000) })
442
+ }
435
443
  phase("reading the lab")
436
- for (const check of labDoctorChecks(await inspectLab({ env }))) checks.push(check)
444
+ for (const check of labDoctorChecks(await inspectLab({ env, newest }))) checks.push(check)
437
445
 
438
446
  return { checks, problems: checks.filter((check) => check.state === "problem").length }
439
447
  }
@@ -119,7 +119,7 @@ export const CREDENTIAL_HOST = "api.github.com"
119
119
  */
120
120
  export const GET_DEADLINE_MS = 20_000
121
121
 
122
- async function get(url, { accept, signal, rangeFrom = 0 } = {}) {
122
+ async function get(url, { accept, signal, rangeFrom = 0, rangeEnd = null } = {}) {
123
123
  const { host } = new URL(url)
124
124
  // The credential is GitHub's and goes to GitHub's API and nowhere else.
125
125
  // Measured before this held: `omakit upgrade` and `doctor` sent the gh
@@ -136,7 +136,9 @@ async function get(url, { accept, signal, rangeFrom = 0 } = {}) {
136
136
  // A resumed download asks for the rest of the object; the lab's ISO
137
137
  // fetch is the one caller, and a server that ignores the range answers
138
138
  // 200 from the start, which the caller handles by starting over.
139
- if (rangeFrom > 0) headers.range = `bytes=${rangeFrom}-`
139
+ // The lab's release search asks for the first byte alone (`rangeEnd` 0)
140
+ // to read the ISO's size from Content-Range without fetching the image.
141
+ if (rangeFrom > 0 || rangeEnd !== null) headers.range = `bytes=${rangeFrom}-${rangeEnd ?? ""}`
140
142
  let response
141
143
  try {
142
144
  response = await fetch(url, { method: "GET", headers, redirect: "follow", signal: signal || AbortSignal.timeout(GET_DEADLINE_MS) })
@@ -144,8 +146,12 @@ async function get(url, { accept, signal, rangeFrom = 0 } = {}) {
144
146
  // Node reports every transport failure as "fetch failed" with the real
145
147
  // reason in `cause`. A person needs the reason, and the CLI keys its
146
148
  // remedy on the code, so both are carried out of here.
147
- const cause = error?.name === "TimeoutError" ? `no answer within ${GET_DEADLINE_MS / 1000} s` : error?.cause?.code || error?.cause?.message || error?.message || "fetch failed"
148
149
  const { host, pathname } = new URL(url)
150
+ // The caller stopped it (SIGINT, SIGTERM): that is an interrupt, not a
151
+ // network that did not answer, and the caller has to be able to tell.
152
+ if (signal?.aborted && signal.reason?.name !== "TimeoutError") throw new GitHubError("interrupted", `interrupted while reading ${pathname} from ${host}`)
153
+ // A caller's own deadline is the caller's figure; only the default is 20 s.
154
+ const cause = error?.name === "TimeoutError" ? (signal ? "no answer before the caller's deadline" : `no answer within ${GET_DEADLINE_MS / 1000} s`) : error?.cause?.code || error?.cause?.message || error?.message || "fetch failed"
149
155
  throw new GitHubError("network-unavailable", `${host} did not answer (${cause}) while reading ${pathname}`)
150
156
  }
151
157
  if (!response.ok) {
@@ -178,8 +184,8 @@ export async function getText(url, accept) {
178
184
  * same literal method, and the credential stays with api.github.com; the
179
185
  * ISO origin (iso.omarchy.org) never sees it.
180
186
  */
181
- export async function getStream(url, { rangeFrom = 0, signal } = {}) {
182
- return get(url, { accept: "application/octet-stream", signal, rangeFrom })
187
+ export async function getStream(url, { rangeFrom = 0, rangeEnd = null, signal } = {}) {
188
+ return get(url, { accept: "application/octet-stream", signal, rangeFrom, rangeEnd })
183
189
  }
184
190
 
185
191
  /** Parse a marketplace issue URL into its parts. */
@@ -31,7 +31,7 @@ export const ACCEPTED = Object.freeze({
31
31
  inspect: Object.freeze({ valued: ["--out"], flags: ["--full", "--json", "--offline", "--allow-dirty"], positionals: 1 }),
32
32
  add: Object.freeze({ valued: [], flags: ["--update", "--json"], positionals: 2 }),
33
33
  weigh: Object.freeze({ valued: ["--runs", "--window", "--settle", "--out"], flags: ["--all", "--list", "--json", "--yes"], positionals: 1 }),
34
- lab: Object.freeze({ valued: ["--runs", "--from", "--toolchain", "--out"], flags: ["--json", "--yes", "--verify", "--plugins", "--keep-iso", "--records"], positionals: 2 }),
34
+ lab: Object.freeze({ valued: ["--runs", "--from", "--toolchain", "--out"], flags: ["--json", "--yes", "--verify", "--plugins", "--keep-iso", "--records", "--offline"], positionals: 2 }),
35
35
  })
36
36
 
37
37
  /** The accepted options of a command in the words a refusal prints: `--runs N, --all, ...`. */
@@ -111,26 +111,29 @@ export const COMMANDS = Object.freeze([
111
111
  },
112
112
  {
113
113
  signature: [
114
- "omakit lab prove <suite> [--runs <n>] [--json] [--out <file>]",
115
- "omakit lab inspect [--verify] [--json] [--out <file>]",
114
+ "omakit lab prove <suite> [--runs <n>] [--offline] [--json] [--out <file>]",
115
+ "omakit lab inspect [--verify] [--offline] [--json] [--out <file>]",
116
116
  "omakit lab setup [--from <file>] [--toolchain <dir>] [--plugins] [--yes]",
117
117
  "omakit lab prune [--keep-iso] [--records] [--yes] [--json]",
118
118
  ],
119
119
  lines: [
120
- "Prove a suite in a disposable Omarchy guest, never on the desktop: the",
121
- "pinned 4.0.3 release booted from an immutable verified base, a fresh",
122
- "overlay per run, the guest's installed omarchy package read and printed",
123
- "before the suite, the document written with that identity. `prove` boots",
124
- "nothing until the base, the host and the suite's files are there, and",
125
- "names what is missing, what it takes, and the one command; it fetches",
126
- "nothing. `inspect` is read-only: the pinned release, its exact size,",
127
- "digest and signer, what is on disk and verified, what the host lacks.",
128
- "`setup` is the only path that fetches bytes: one consent naming the",
129
- "exact size and destination (--yes for an agent), a resumable GET of",
130
- "the pinned URL or a copy of --from, verified against the pinned SHA-256",
131
- "and the Omarchy signature before anything boots it, then one base built",
132
- "by the pinned omarchy-iso toolchain, whose checkout --toolchain records",
133
- "and which setup never fetches. `prune` frees the lab cache and says how",
120
+ "Prove a suite in a disposable Omarchy guest, never on the desktop: an",
121
+ "Omarchy release booted from an immutable verified base, a fresh overlay",
122
+ "per run, the guest's installed omarchy package read and printed before",
123
+ "the suite, the document written with that identity. Every action but",
124
+ "prune reads Omarchy's release list and says when a newer release is out",
125
+ "(--offline skips it). `prove` boots nothing until the base, the host",
126
+ "and the suite's files are there, and names what is missing, what it",
127
+ "takes, and the one command; it runs the base there, behind or not.",
128
+ "`inspect` writes nothing: the newest release, its size, digest and",
129
+ "signer, what is on disk and verified, what the host lacks. `setup` is",
130
+ "the only path that fetches bytes: it prepares the newest release, with",
131
+ "one consent naming the exact size and destination (--yes for an agent),",
132
+ "a resumable GET or a copy of --from, verified against the published",
133
+ "SHA-256 and the Omarchy signature omakit ships before anything boots it,",
134
+ "then one base built by the pinned omarchy-iso toolchain, whose checkout",
135
+ "--toolchain records and which setup never fetches; the old base stays",
136
+ "until the new one verifies. `prune` frees the lab cache and says how",
134
137
  "much. docs/LAB.md is the contract. Suites: run, store, weigh,",
135
138
  "weigh-evidence.",
136
139
  ],