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.
- package/package.json +1 -1
- package/tools/blocks/commit.json +2 -2
- package/tools/lab/guest.mjs +1 -1
- package/tools/lab/host.mjs +2 -2
- package/tools/lab/inspect.mjs +170 -31
- package/tools/lab/paths.mjs +1 -1
- 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 +11 -10
- package/tools/marketplace/cli.mjs +36 -8
- package/tools/marketplace/doctor.mjs +128 -23
- package/tools/marketplace/github.mjs +11 -5
- package/tools/marketplace/options.mjs +1 -1
- package/tools/marketplace/pin.mjs +79 -14
- package/tools/marketplace/preflight.mjs +2 -2
- package/tools/marketplace/registry.mjs +31 -49
- package/tools/marketplace/review-cost.mjs +2 -3
- package/tools/marketplace/submit.mjs +2 -2
- package/tools/marketplace/usage.mjs +19 -16
- package/tools/marketplace/watch.mjs +2 -3
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 (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
|
-
/**
|
|
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
|
|
@@ -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 `
|
|
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
|
|
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,
|
|
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` |
|
|
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,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
|
-
//
|
|
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
|
-
|
|
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 }))
|