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.
- package/package.json +1 -1
- package/tools/blocks/commit.json +2 -2
- package/tools/lab/inspect.mjs +170 -31
- package/tools/lab/pin.json +10 -14
- package/tools/lab/pin.mjs +78 -13
- package/tools/lab/prune.mjs +10 -7
- package/tools/lab/release.mjs +178 -0
- package/tools/lab/report.mjs +78 -18
- package/tools/lab/run.mjs +27 -9
- package/tools/lab/setup.mjs +301 -68
- package/tools/lab/verify.mjs +149 -20
- package/tools/marketplace/README.md +8 -7
- package/tools/marketplace/cli.mjs +35 -7
- package/tools/marketplace/doctor.mjs +14 -6
- package/tools/marketplace/github.mjs +11 -5
- package/tools/marketplace/options.mjs +1 -1
- package/tools/marketplace/usage.mjs +19 -16
package/tools/lab/verify.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
// The two checks that make a file the
|
|
2
|
-
// the
|
|
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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
/**
|
|
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
|
|
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 !==
|
|
50
|
-
throw Object.assign(new Error(`the packaged signing key at ${file} has digest ${sha256}, not the pinned ${
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
*
|
|
85
|
-
*
|
|
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
|
|
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}
|
|
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
|
|
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` |
|
|
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/
|
|
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,
|
|
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
|
|
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": "
|
|
105
|
-
"sidecar-mismatch": "
|
|
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;
|
|
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
|
-
|
|
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 `
|
|
282
|
-
* read the network, whose defaults are the tool's one HEAD
|
|
283
|
-
* one registry read
|
|
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
|
-
|
|
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:
|
|
121
|
-
"
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
"`
|
|
129
|
-
"
|
|
130
|
-
"the
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
"and
|
|
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
|
],
|