omakit 0.5.1 → 0.6.1

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.
Files changed (94) hide show
  1. package/README.md +38 -45
  2. package/blocks/history.json +68 -0
  3. package/blocks/run/NOTICE +12 -0
  4. package/blocks/run/Run.qml +242 -0
  5. package/blocks/run/run-supervisor.py +522 -0
  6. package/blocks/store/NOTICE +12 -0
  7. package/blocks/store/Store.qml +157 -0
  8. package/blocks/store/store-helper.py +431 -0
  9. package/package.json +12 -5
  10. package/skills/omarchy-plugin-audit/SKILL.md +11 -5
  11. package/skills/omarchy-plugin-build/SKILL.md +164 -0
  12. package/skills/omarchy-plugin-check/SKILL.md +6 -3
  13. package/skills/omarchy-plugin-submit/SKILL.md +4 -1
  14. package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
  15. package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
  16. package/tests/fixtures/weigh/clean/Widget.qml +19 -0
  17. package/tests/fixtures/weigh/clean/manifest.json +9 -0
  18. package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
  19. package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
  20. package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
  21. package/tests/fixtures/weigh/poller/Service.qml +50 -0
  22. package/tests/fixtures/weigh/poller/manifest.json +9 -0
  23. package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
  24. package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
  25. package/tests/lab/run/harness/scenarios/controls.sh +6 -0
  26. package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
  27. package/tests/lab/run/harness/scenarios/forge.sh +11 -0
  28. package/tests/lab/run/harness/scenarios/holder.sh +5 -0
  29. package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
  30. package/tests/lab/run/harness/scenarios/stall.sh +5 -0
  31. package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
  32. package/tests/lab/run/harness/scenarios/tree.sh +7 -0
  33. package/tests/lab/run/harness/shell.qml +84 -0
  34. package/tests/lab/run/report.py +217 -0
  35. package/tests/lab/run/suite.sh +106 -0
  36. package/tests/lab/store/harness/shell.qml +73 -0
  37. package/tests/lab/store/report.py +133 -0
  38. package/tests/lab/store/suite.sh +109 -0
  39. package/tests/parity/corpus.mjs +8 -3
  40. package/tests/parity/run.mjs +4 -4
  41. package/tools/audit/audit.mjs +17 -6
  42. package/tools/audit/git.mjs +3 -3
  43. package/tools/audit/report.mjs +31 -5
  44. package/tools/blocks/add.mjs +138 -0
  45. package/tools/blocks/commit.json +5 -0
  46. package/tools/blocks/record-commit.mjs +77 -0
  47. package/tools/blocks/registry.mjs +191 -0
  48. package/tools/blocks/stamp.mjs +61 -0
  49. package/tools/inspect/contract.mjs +36 -5
  50. package/tools/inspect/helpers.mjs +217 -0
  51. package/tools/inspect/inspect.mjs +68 -3
  52. package/tools/inspect/patterns.mjs +18 -3
  53. package/tools/inspect/processes.mjs +38 -5
  54. package/tools/inspect/report.mjs +18 -2
  55. package/tools/inspect/writes.mjs +22 -4
  56. package/tools/lab/guest.mjs +155 -0
  57. package/tools/lab/harness.sh +119 -0
  58. package/tools/lab/host.mjs +177 -0
  59. package/tools/lab/inspect.mjs +240 -0
  60. package/tools/lab/omarchy.gpg +13 -0
  61. package/tools/lab/patches/omarchy-iso-test.patch +351 -0
  62. package/tools/lab/paths.mjs +173 -0
  63. package/tools/lab/pin.json +42 -0
  64. package/tools/lab/pin.mjs +64 -0
  65. package/tools/lab/prune.mjs +68 -0
  66. package/tools/lab/qemu.mjs +153 -0
  67. package/tools/lab/qmp-cli.mjs +21 -0
  68. package/tools/lab/report.mjs +183 -0
  69. package/tools/lab/run.mjs +344 -0
  70. package/tools/lab/setup.mjs +430 -0
  71. package/tools/lab/suites/run.sh +35 -0
  72. package/tools/lab/suites/store.sh +41 -0
  73. package/tools/lab/suites/weigh.sh +196 -0
  74. package/tools/lab/suites.mjs +142 -0
  75. package/tools/lab/verify.mjs +134 -0
  76. package/tools/marketplace/README.md +38 -1
  77. package/tools/marketplace/banner.mjs +23 -2
  78. package/tools/marketplace/cli.mjs +449 -146
  79. package/tools/marketplace/completion-check.mjs +27 -1
  80. package/tools/marketplace/completion.mjs +32 -4
  81. package/tools/marketplace/doctor.mjs +47 -9
  82. package/tools/marketplace/github.mjs +52 -6
  83. package/tools/marketplace/local-transport.mjs +1 -1
  84. package/tools/marketplace/options.mjs +16 -5
  85. package/tools/marketplace/outcome.mjs +244 -0
  86. package/tools/marketplace/pin.mjs +178 -33
  87. package/tools/marketplace/setup.mjs +16 -15
  88. package/tools/marketplace/tree.mjs +1 -1
  89. package/tools/marketplace/upgrade.mjs +5 -5
  90. package/tools/marketplace/usage.mjs +116 -72
  91. package/tools/subject/resolve.mjs +19 -6
  92. package/tools/weigh/audit.mjs +47 -16
  93. package/tools/weigh/config.mjs +105 -24
  94. package/tools/weigh/list.mjs +10 -1
@@ -0,0 +1,430 @@
1
+ // `omakit lab setup`: the only path that may fetch bytes, and the only one
2
+ // that builds a base.
3
+ //
4
+ // One explicit consent, stating the exact size and the destination,
5
+ // before the first byte; `--yes` is that consent in the command itself
6
+ // for automation, and a pipe without it refuses. The plan is computed
7
+ // first and reads only; the disclosure prints it whole; then, in order:
8
+ //
9
+ // 1. The ISO, into $XDG_CACHE_HOME/omakit/lab/downloads/<sha256>/: a
10
+ // literal GET of the pinned URL to a .part file, resumed by byte
11
+ // range, or a copy of a local file named with --from. Never
12
+ // "latest": the URL, the byte count, the digest and the signer are
13
+ // the pin's.
14
+ // 2. Verification, of a downloaded file and of a file already there
15
+ // alike: the byte count, the SHA-256 against the pin, the published
16
+ // sidecar against the pin, the detached signature against the
17
+ // packaged key at the pinned fingerprint. A mismatch fails closed:
18
+ // the file is left as .part, nothing is recorded, nothing boots it.
19
+ // 3. The base, built by the pinned omarchy-iso toolchain's console
20
+ // driver from the verified ISO, in a staging directory under the lab
21
+ // (the driver derives its base directory from its own location, so
22
+ // a copy of the one script into staging keeps it out of the
23
+ // checkout: docs/history/2026-09-18-lab-inventory.md P6, P19), then
24
+ // booted once by the lab's own driver to read the installed
25
+ // omarchy package, hashed, made read-only, given its manifest, and
26
+ // promoted with one rename.
27
+ //
28
+ // Never fetched: the toolchain. It is a git checkout at a pinned commit
29
+ // with the packaged patch applied, and the one command that makes it is
30
+ // printed; `--toolchain <dir>` records where it is, after hashing its
31
+ // harness against the pin.
32
+
33
+ import { spawn, spawnSync } from "node:child_process"
34
+ import { chmodSync, closeSync, createWriteStream, existsSync, fsyncSync, openSync, readdirSync, statSync, writeFileSync } from "node:fs"
35
+ import { join, resolve } from "node:path"
36
+ import { getStream, GitHubError } from "../marketplace/github.mjs"
37
+ import { LAB_DIR, bytesBoth, durationWords, labPin } from "./pin.mjs"
38
+ import { allocatedBytes, copyIntoLab, inLab, labDir, labLayout, moveIntoLab, readJson, removeFromLab, stampNow, writeJson } from "./paths.mjs"
39
+ import { BUILD_COMMANDS, VERIFY_COMMANDS, freeBytesAt, probeCommands, probeRunHost } from "./host.mjs"
40
+ import { judgeRelease, packagedKey, sha256File } from "./verify.mjs"
41
+ import { BASE_FILES, downloadDir, inspectBase, inspectDownload, inspectToolchain, toolchainCommand } from "./inspect.mjs"
42
+ import { LabError, acquireLock, freePort, releaseLock, withGuest } from "./run.mjs"
43
+ import { WEIGH_LISTED, listedPlugin } from "./suites.mjs"
44
+ import { marketplacePinDir } from "../marketplace/pin.mjs"
45
+
46
+ /** Record the toolchain checkout after hashing its harness against the pin; the only write `--toolchain` makes. */
47
+ export function recordToolchain({ dir, env = process.env, pin = labPin() }) {
48
+ const layout = labLayout(env)
49
+ const checkout = resolve(dir)
50
+ const harness = join(checkout, pin.toolchain.harness)
51
+ if (!existsSync(harness)) throw new LabError("toolchain-missing", `${harness} is not there; --toolchain names a checkout of ${pin.toolchain.repository}`, { remedy: toolchainCommand(pin, checkout) })
52
+ const { sha256 } = sha256File(harness)
53
+ if (sha256 !== pin.toolchain.patchedHarnessSha256) {
54
+ const unpatched = sha256 === pin.toolchain.upstreamHarnessSha256
55
+ throw new LabError("toolchain-mismatch", unpatched
56
+ ? `${harness} is the upstream harness at ${pin.toolchain.commit.slice(0, 12)} without the lab patch`
57
+ : `${harness} hashes to ${sha256}; the pinned patched harness is ${pin.toolchain.patchedHarnessSha256} (omarchy-iso ${pin.toolchain.commit.slice(0, 12)} with ${pin.toolchain.patch})`,
58
+ { remedy: unpatched ? `git -C ${checkout} apply ${join(LAB_DIR, pin.toolchain.patch)} && omakit lab setup --toolchain ${checkout}` : toolchainCommand(pin, checkout) })
59
+ }
60
+ labDir(layout.cache)
61
+ writeJson(layout.cache, "toolchain.json", { dir: checkout, harnessSha256: sha256, commit: pin.toolchain.commit, recordedAt: new Date().toISOString() })
62
+ return { dir: checkout, sha256 }
63
+ }
64
+
65
+ /**
66
+ * The plan, reading only: what setup would do on this host, with every
67
+ * size and the destination. `steps` is what the consent covers.
68
+ */
69
+ export function planSetup({ env = process.env, pin = labPin(), from = null, plugins = false, repoRoot } = {}) {
70
+ const layout = labLayout(env)
71
+ const download = inspectDownload(layout, pin)
72
+ const base = inspectBase(layout, pin)
73
+ const toolchain = inspectToolchain(layout, pin)
74
+ const free = freeBytesAt(layout.cache)
75
+ const steps = []
76
+ const blockers = []
77
+ for (const line of probeCommands(VERIFY_COMMANDS, { env })) if (line.state !== "ok") blockers.push({ what: line.name, cost: line.reason, command: line.remedy })
78
+ if (!download.verified) {
79
+ if (from) {
80
+ const source = resolve(from)
81
+ if (!existsSync(source)) blockers.push({ what: `the file named by --from`, cost: `${source} is not there`, command: "omakit lab setup --from <path to omarchy-4.0.3.iso>" })
82
+ else steps.push({ kind: "import", from: source, bytes: statSync(source).size, to: join(download.dir, pin.release.fileName), sidecars: { checksum: existsSync(`${source}.sha256`) ? `${source}.sha256` : null, signature: existsSync(`${source}.sig`) ? `${source}.sig` : null } })
83
+ } else if (download.present) {
84
+ steps.push({ kind: "verify", file: download.iso, bytes: download.bytes })
85
+ } else {
86
+ steps.push({ kind: "download", url: pin.release.isoUrl, bytes: pin.release.bytes, resumeFrom: download.partial || 0, to: join(download.dir, pin.release.fileName) })
87
+ }
88
+ steps.push({ kind: "sidecars", bytes: 203, urls: [pin.release.checksumUrl, pin.release.signatureUrl], to: download.dir })
89
+ }
90
+ if (base.state !== "ready") {
91
+ for (const line of probeRunHost({ pin, env })) if (line.state !== "ok") blockers.push({ what: line.name, cost: line.reason, command: line.remedy })
92
+ // A base the toolchain built but a verification boot never promoted
93
+ // (an interrupted or failed setup) is verified and promoted, not
94
+ // rebuilt: six minutes and six gigabytes are not spent twice.
95
+ const staged = stagedBases(layout)
96
+ if (staged.length) {
97
+ steps.push({ kind: "promote", staged: staged.at(-1), to: layout.base, replacing: base.state === "missing" ? null : base })
98
+ } else {
99
+ for (const line of probeCommands(BUILD_COMMANDS, { env })) if (line.state !== "ok") blockers.push({ what: line.name, cost: line.reason, command: line.remedy })
100
+ if (toolchain.state !== "ready") blockers.push({ what: "the toolchain", cost: toolchain.reason, command: toolchain.state === "unpatched" ? `git -C ${toolchain.dir} apply ${join(LAB_DIR, pin.toolchain.patch)} && omakit lab setup --toolchain ${toolchain.dir}` : toolchainCommand(pin, join(layout.cache, "toolchain/omarchy-iso")) })
101
+ steps.push({ kind: "build", toolchain: toolchain.harness, to: layout.base, replacing: base.state === "missing" ? null : base, bytes: pin.measured.baseDirectoryBytes, milliseconds: pin.measured.buildMilliseconds })
102
+ }
103
+ }
104
+ if (plugins) {
105
+ const pinDir = marketplacePinDir(repoRoot)
106
+ const wanted = []
107
+ for (const id of WEIGH_LISTED) {
108
+ const listing = listedPlugin(pinDir, id)
109
+ if (!listing.ok) blockers.push({ what: `${id} in the pinned catalog`, cost: listing.reason, command: "omakit pin" })
110
+ else wanted.push({ id, repo: listing.repo, commit: listing.commit, to: join(layout.plugins, id) })
111
+ }
112
+ if (wanted.length) steps.push({ kind: "plugins", plugins: wanted, to: layout.plugins, bytes: pin.measured.pluginsBytes || null })
113
+ }
114
+ const needed = steps.reduce((sum, step) => sum + (step.kind === "download" ? step.bytes - (step.resumeFrom || 0) : step.kind === "import" ? step.bytes : step.kind === "build" ? step.bytes : 0), 0)
115
+ if (steps.length && free.bytes < needed) blockers.push({ what: "disk", cost: `${bytesBoth(free.bytes)} free at ${free.path}; this setup needs ${bytesBoth(needed)}`, command: "omakit lab prune" })
116
+ return { layout, pin, download, base, toolchain, free, steps, blockers, needed, afterBytes: pin.measured.preparedLabBytes }
117
+ }
118
+
119
+ /** Staged bases awaiting verification: `staging/base-*` with the four files and no manifest, oldest first. */
120
+ export function stagedBases(layout) {
121
+ if (!existsSync(layout.staging)) return []
122
+ return readdirSync(layout.staging).filter((name) => /^base-\d{8}-\d{6}$/.test(name)).sort()
123
+ .map((name) => join(layout.staging, name))
124
+ .filter((dir) => [BASE_FILES.disk, BASE_FILES.vars, BASE_FILES.key, BASE_FILES.publicKey].every((file) => existsSync(join(dir, file))) && !existsSync(join(dir, BASE_FILES.manifest)))
125
+ }
126
+
127
+ /** The lines of the disclosure, exactly as the contract in docs/LAB.md shows them; the consent question follows them. */
128
+ export function disclosureLines(plan) {
129
+ const { pin } = plan
130
+ const lines = []
131
+ const download = plan.steps.find((step) => step.kind === "download")
132
+ const imported = plan.steps.find((step) => step.kind === "import")
133
+ const build = plan.steps.find((step) => step.kind === "build")
134
+ const plugins = plan.steps.find((step) => step.kind === "plugins")
135
+ lines.push(["Omarchy", `release ${pin.release.name}; installed guest expected ${pin.release.expectedGuestVersion}`])
136
+ if (download) lines.push(["download", `${bytesBoth(download.bytes - download.resumeFrom)}${download.resumeFrom ? ` (resuming at ${download.resumeFrom.toLocaleString("en-US")} of ${download.bytes.toLocaleString("en-US")} B)` : ""}`], ["from", download.url])
137
+ else if (imported) lines.push(["download", "0 B: the file named by --from is copied and verified"], ["from", imported.from])
138
+ else lines.push(["download", "0 B: the ISO on disk is verified"])
139
+ lines.push(["verify", `pinned SHA-256 ${pin.release.sha256} and the Omarchy signature ${pin.release.signingFingerprint}`])
140
+ lines.push(["store", plan.layout.cache])
141
+ const promote = plan.steps.find((step) => step.kind === "promote")
142
+ if (build) {
143
+ lines.push(["build", `${durationWords(build.milliseconds)} on the reference host (M14); download excluded`])
144
+ if (build.replacing) lines.push(["replacing", `the ${build.replacing.state} base there (${bytesBoth(build.replacing.allocatedBytes)}), removed after the new one verifies`])
145
+ }
146
+ if (promote) {
147
+ lines.push(["build", `none: the base the toolchain built at ${promote.staged} is booted once, verified and promoted`])
148
+ if (promote.replacing) lines.push(["replacing", `the ${promote.replacing.state} base there (${bytesBoth(promote.replacing.allocatedBytes)}), removed after the staged one verifies`])
149
+ }
150
+ if (plugins) lines.push(["plugins", `${plugins.plugins.length} listed plugins at their validated commits, shallow, into ${plugins.to}`])
151
+ lines.push(["afterwards", "verified ISO, one immutable base, and manifests"])
152
+ lines.push(["on disk", `${bytesBoth(plan.afterBytes)} (M14), before evidence; ${bytesBoth(plan.free.bytes)} free now`])
153
+ return lines
154
+ }
155
+
156
+ /** The one question. */
157
+ export const CONSENT_QUESTION = "Acquire and build this verified base now?"
158
+
159
+ /**
160
+ * A resumable literal GET of the pinned object to `<to>.part`. The byte
161
+ * count is checked against the pin as it arrives and at the end; a
162
+ * server that answers 200 to a range request restarts the file.
163
+ */
164
+ export async function downloadRelease({ url, to, bytes, onProgress = () => {}, signal, fetchStream = getStream }) {
165
+ const part = `${to}.part`
166
+ let have = existsSync(part) ? statSync(part).size : 0
167
+ if (have > bytes) {
168
+ removeFromLab(resolve(to, ".."), `${to.split("/").at(-1)}.part`)
169
+ have = 0
170
+ }
171
+ let response
172
+ try {
173
+ response = await fetchStream(url, { rangeFrom: have, signal })
174
+ } catch (error) {
175
+ if (error instanceof GitHubError) throw new LabError(error.code, error.message, { remedy: "check the network, then run `omakit lab setup` again; the download resumes" })
176
+ throw error
177
+ }
178
+ const append = response.status === 206 && have > 0
179
+ const expected = append ? bytes - have : bytes
180
+ const length = Number(response.headers.get("content-length"))
181
+ if (Number.isFinite(length) && length > 0 && length !== expected) {
182
+ throw new LabError("size-mismatch", `${url} announces ${length.toLocaleString("en-US")} B where the pin expects ${expected.toLocaleString("en-US")}; the object at the versioned URL is not the pinned release`)
183
+ }
184
+ const out = createWriteStream(part, { flags: append ? "a" : "w", mode: 0o600 })
185
+ let written = append ? have : 0
186
+ const started = Date.now()
187
+ try {
188
+ for await (const chunk of response.body) {
189
+ if (signal?.aborted) throw new LabError("interrupted", "interrupted; the partial download stays and resumes next time")
190
+ written += chunk.length
191
+ if (written > bytes) throw new LabError("size-mismatch", `${url} sent more than the pinned ${bytes.toLocaleString("en-US")} B`)
192
+ if (!out.write(chunk)) await new Promise((resolvePromise) => out.once("drain", resolvePromise))
193
+ onProgress(written, bytes, Date.now() - started)
194
+ }
195
+ } finally {
196
+ await new Promise((resolvePromise) => out.end(resolvePromise))
197
+ }
198
+ if (written !== bytes) throw new LabError("size-mismatch", `${url} ended at ${written.toLocaleString("en-US")} of the pinned ${bytes.toLocaleString("en-US")} B; run setup again to resume`)
199
+ return { part, bytes: written, milliseconds: Date.now() - started }
200
+ }
201
+
202
+ /** The two sidecars, small, to the download directory; the signature is required, the checksum compared. */
203
+ async function fetchSidecars({ pin, dir, layout, fetchStream = getStream, signal }) {
204
+ for (const [url, name] of [[pin.release.checksumUrl, `${pin.release.fileName}.sha256`], [pin.release.signatureUrl, `${pin.release.fileName}.sig`]]) {
205
+ const response = await fetchStream(url, { signal })
206
+ const body = Buffer.from(await response.arrayBuffer())
207
+ if (body.length > 4096) throw new LabError("sidecar-mismatch", `${url} answered ${body.length} B; a sidecar is a few dozen`)
208
+ writeFileSync(inLab(layout.cache, `downloads/${pin.release.sha256}/${name}`), body, { mode: 0o600 })
209
+ }
210
+ }
211
+
212
+ /** Verify the file at `<to>.part` (or an import copy) and promote it to `to`, recording the verification. */
213
+ async function verifyAndPromote({ candidate, to, pin, layout, stagingRoot, onProgress }) {
214
+ const dir = resolve(to, "..")
215
+ const signature = `${to}.sig`
216
+ const checksum = `${to}.sha256`
217
+ const judged = judgeRelease({ file: candidate, signature: existsSync(signature) ? signature : null, checksum: existsSync(checksum) ? checksum : null, pin, stagingRoot, onProgress })
218
+ if (!judged.ok) {
219
+ throw new LabError("iso-mismatch", `the file at ${candidate} is not the pinned release: ${judged.reason}; it stays where it is and nothing will boot it`, { remedy: "remove it with `omakit lab prune`, then `omakit lab setup` again" })
220
+ }
221
+ if (judged.sidecarMatch === false) throw new LabError("sidecar-mismatch", `the published checksum sidecar names ${judged.sidecarSha256}, the pin ${pin.release.sha256}: the object at the versioned URL was replaced, and this file, which matches the pin, is not what the site publishes now`, { remedy: "a pin update is a reviewed change; docs/LAB.md says how" })
222
+ if (candidate !== to) await moveIntoLab(layout.cache, candidate, `downloads/${pin.release.sha256}/${pin.release.fileName}`)
223
+ chmodSync(to, 0o444)
224
+ const st = statSync(to)
225
+ writeJson(layout.cache, `downloads/${pin.release.sha256}/verified.json`, { schema: 1, sha256: judged.sha256, bytes: st.size, mtimeMs: st.mtimeMs, fingerprint: judged.signature.fingerprint, signer: judged.signature.detail, sidecarSha256: judged.sidecarSha256, verifiedAt: new Date().toISOString() })
226
+ return { file: to, judged, dir }
227
+ }
228
+
229
+ /**
230
+ * Build the base with the toolchain's console driver, verify it by
231
+ * booting it, and promote it. The driver runs from a copy under staging,
232
+ * so its `test-runs/` lands there and never in the checkout.
233
+ */
234
+ async function buildBase({ pin, layout, toolchainHarness, iso, onPhase, onLine, signal, repoRoot }) {
235
+ const buildId = `build-${stampNow()}`
236
+ const stagingDir = labDir(layout.cache, "staging", buildId)
237
+ labDir(layout.cache, "staging", buildId, "bin")
238
+ const harnessCopy = await copyIntoLab(layout.cache, toolchainHarness, `staging/${buildId}/bin/omarchy-iso-test`)
239
+ const { sha256 } = sha256File(harnessCopy)
240
+ if (sha256 !== pin.toolchain.patchedHarnessSha256) throw new LabError("toolchain-mismatch", `the harness changed between the check and the copy (${sha256})`)
241
+ chmodSync(harnessCopy, 0o700)
242
+ const port = await freePort(2322)
243
+ const started = Date.now()
244
+ onPhase(`building the base with the toolchain (about ${durationWords(pin.measured.buildMilliseconds)} on the reference host)`)
245
+ const log = openSync(inLab(layout.cache, `staging/${buildId}/build.log`), "a", 0o600)
246
+ const status = await new Promise((resolvePromise) => {
247
+ const child = spawn("bash", [harnessCopy, iso, "--install-only", "--memory", String(pin.guest.memoryMiB), "--port", String(port), "--no-preview"], { cwd: stagingDir, stdio: ["ignore", "pipe", "pipe"] })
248
+ let buffer = ""
249
+ const feed = (chunk) => {
250
+ writeFileSync(log, chunk)
251
+ buffer += chunk.toString("utf8")
252
+ let index
253
+ while ((index = buffer.indexOf("\n")) >= 0) {
254
+ // The toolchain paints its log lines; the escape is stripped so the
255
+ // lab's stdout stays its own.
256
+ const line = buffer.slice(0, index).replace(/\[[0-9;]*m/g, "")
257
+ buffer = buffer.slice(index + 1)
258
+ if (line.trim()) onLine({ state: /^==> /.test(line) ? "info" : "prose", text: line })
259
+ }
260
+ }
261
+ child.stdout.on("data", feed)
262
+ child.stderr.on("data", feed)
263
+ const abort = () => child.kill("SIGTERM")
264
+ signal?.addEventListener("abort", abort, { once: true })
265
+ child.on("close", (code, sig) => {
266
+ signal?.removeEventListener("abort", abort)
267
+ closeSync(log)
268
+ resolvePromise(sig ? null : code)
269
+ })
270
+ })
271
+ const buildMilliseconds = Date.now() - started
272
+ if (status !== 0) throw new LabError(signal?.aborted ? "interrupted" : "build-failed", `the toolchain's install driver exited ${status === null ? "on a signal" : status} after ${durationWords(buildMilliseconds)}; its log is ${join(stagingDir, "build.log")}, and nothing was promoted`, { remedy: "read the log, then `omakit lab prune` to remove the staging directory and `omakit lab setup` to build again" })
273
+ const built = join(stagingDir, "test-runs", pin.release.fileName.replace(/\.iso$/, ""))
274
+ for (const name of ["base.qcow2", "OVMF_VARS.4m.fd", "id_ed25519", "id_ed25519.pub"]) {
275
+ if (!existsSync(join(built, name))) throw new LabError("build-failed", `the driver exited 0 but ${join(built, name)} is not there`)
276
+ }
277
+ const baseId = `base-${stampNow()}`
278
+ const staged = labDir(layout.cache, "staging", baseId)
279
+ await moveIntoLab(layout.cache, join(built, "base.qcow2"), `staging/${baseId}/${BASE_FILES.disk}`)
280
+ await moveIntoLab(layout.cache, join(built, "OVMF_VARS.4m.fd"), `staging/${baseId}/${BASE_FILES.vars}`)
281
+ await moveIntoLab(layout.cache, join(built, "id_ed25519"), `staging/${baseId}/${BASE_FILES.key}`)
282
+ await moveIntoLab(layout.cache, join(built, "id_ed25519.pub"), `staging/${baseId}/${BASE_FILES.publicKey}`)
283
+ const runs = existsSync(join(built, "runs")) ? readdirSync(join(built, "runs")).sort() : []
284
+ if (runs.length) await moveIntoLab(layout.cache, join(built, "runs", runs.at(-1)), `staging/${baseId}/build`)
285
+ removeFromLab(layout.cache, `staging/${buildId}`)
286
+ // The measured duration travels with the staged base, so a promotion
287
+ // after an interrupted setup still records it.
288
+ writeJson(layout.cache, `staging/${baseId}/build/lab-build.json`, { buildMilliseconds, harnessSha256: sha256, iso, builtAt: new Date().toISOString() })
289
+ return { staged, baseId, buildMilliseconds, harnessSha256: sha256, origin: `built ${new Date().toISOString()} by the pinned toolchain harness ${sha256.slice(0, 12)} from the verified ISO` }
290
+ }
291
+
292
+ /** Boot the staged base once, read the installed package, hash, seal, manifest, promote. */
293
+ async function verifyAndPromoteBase({ staged, baseId, pin, layout, buildMilliseconds, origin, harnessSha256, iso, onPhase, signal }) {
294
+ onPhase("booting the staged base once to read the installed omarchy package")
295
+ const booted = await withGuest({ baseDir: staged, stagingName: `${baseId}-verify`, logFile: join(staged, "verify-boot.log"), layout, pin, onPhase, signal }, async () => null)
296
+ const version = booted.identity?.version
297
+ if (version !== pin.release.expectedGuestVersion) {
298
+ throw new LabError("guest-mismatch", `the staged base runs omarchy ${version || "unknown"} (${booted.identity?.omarchyPackage || "no package read"}); the pin expects ${pin.release.expectedGuestVersion}; the base stays in staging and is not promoted`, { remedy: "`omakit lab prune` removes it; a pin update is a reviewed change, docs/LAB.md says how" })
299
+ }
300
+ if (booted.identity.linked) throw new LabError("guest-mismatch", `the staged base is dev-linked to ${booted.identity.omarchyPath}; a base runs the installed package`)
301
+ onPhase("hashing the base disk and the firmware template")
302
+ const disk = join(staged, BASE_FILES.disk)
303
+ const vars = join(staged, BASE_FILES.vars)
304
+ const diskHash = sha256File(disk)
305
+ const varsHash = sha256File(vars)
306
+ const info = spawnSync("qemu-img", ["info", "--output=json", disk], { timeout: 60_000, encoding: "utf8" })
307
+ const qemuInfo = info.status === 0 ? JSON.parse(info.stdout) : null
308
+ chmodSync(disk, 0o444)
309
+ chmodSync(vars, 0o444)
310
+ const manifest = {
311
+ schema: 1,
312
+ state: "ready",
313
+ release: { name: pin.release.name, sha256: pin.release.sha256, bytes: pin.release.bytes, isoUrl: pin.release.isoUrl, embeddedBuild: pin.release.embeddedBuild, volume: pin.release.volume, iso },
314
+ signature: { fingerprint: pin.release.signingFingerprint },
315
+ guest: { version, omarchyPackage: booted.identity.omarchyPackage, kernel: booted.identity.kernel, hostname: booted.identity.hostname, readBy: "pacman -Q omarchy over SSH in the verification boot" },
316
+ disk: { file: BASE_FILES.disk, bytes: diskHash.bytes, sha256: diskHash.sha256, virtualBytes: qemuInfo?.["virtual-size"] ?? null, allocatedBytes: allocatedBytes(disk), format: qemuInfo?.format ?? null },
317
+ vars: { file: BASE_FILES.vars, bytes: varsHash.bytes, sha256: varsHash.sha256 },
318
+ key: { file: BASE_FILES.key },
319
+ build: { milliseconds: buildMilliseconds, harnessSha256, toolchainCommit: pin.toolchain.commit, origin, verificationBoot: booted.session },
320
+ createdAt: new Date().toISOString(),
321
+ createdBy: { omakit: readJson(join(LAB_DIR, "../../package.json"))?.version || null, kernel: spawnSync("uname", ["-r"], { timeout: 60_000, encoding: "utf8" }).stdout?.trim() || null, qemu: spawnSync("qemu-system-x86_64", ["--version"], { timeout: 60_000, encoding: "utf8" }).stdout?.split("\n")[0] || null },
322
+ }
323
+ writeJson(layout.cache, `staging/${baseId}/${BASE_FILES.manifest}`, manifest)
324
+ const fd = openSync(staged, "r")
325
+ try {
326
+ fsyncSync(fd)
327
+ } finally {
328
+ closeSync(fd)
329
+ }
330
+ onPhase("promoting the base")
331
+ const superseded = existsSync(layout.base) ? `staging/superseded-${baseId}` : null
332
+ if (superseded) await moveIntoLab(layout.cache, layout.base, superseded)
333
+ await moveIntoLab(layout.cache, staged, "base")
334
+ if (superseded) removeFromLab(layout.cache, superseded)
335
+ return { manifest, dir: layout.base, supersededRemoved: Boolean(superseded) }
336
+ }
337
+
338
+ /** The listed plugins at their validated commits, shallow, into the lab's plugin cache; idempotent per commit. */
339
+ function fetchPlugins({ plugins, layout, onPhase }) {
340
+ const fetched = []
341
+ for (const plugin of plugins) {
342
+ const dir = join(layout.plugins, plugin.id)
343
+ const head = existsSync(join(dir, ".git")) ? spawnSync("git", ["-C", dir, "rev-parse", "HEAD"], { timeout: 60_000, encoding: "utf8" }).stdout.trim() : null
344
+ const clean = head === plugin.commit && spawnSync("git", ["-C", dir, "status", "--porcelain"], { timeout: 60_000, encoding: "utf8" }).stdout.trim() === ""
345
+ if (clean) {
346
+ fetched.push({ id: plugin.id, commit: plugin.commit, state: "present" })
347
+ continue
348
+ }
349
+ onPhase(`fetching ${plugin.id} at ${plugin.commit.slice(0, 12)}`)
350
+ removeFromLab(layout.cache, `plugins/${plugin.id}`)
351
+ labDir(layout.cache, "plugins", plugin.id)
352
+ const steps = [["init", "-q", dir], ["-C", dir, "remote", "add", "origin", plugin.repo], ["-C", dir, "fetch", "-q", "--depth", "1", "origin", plugin.commit], ["-C", dir, "checkout", "-q", "--detach", plugin.commit]]
353
+ for (const args of steps) {
354
+ const result = spawnSync("git", args, { timeout: 300_000, encoding: "utf8" })
355
+ if (result.status !== 0) throw new LabError("plugin-fetch-failed", `could not fetch ${plugin.id} at ${plugin.commit} from ${plugin.repo}: ${(result.stderr || "").trim()}`)
356
+ }
357
+ fetched.push({ id: plugin.id, commit: plugin.commit, state: "fetched", bytes: allocatedBytes(dir) })
358
+ }
359
+ return fetched
360
+ }
361
+
362
+ /**
363
+ * Execute a plan after consent. `consented` is the caller's statement
364
+ * that the disclosure was shown and answered yes, or that --yes was
365
+ * passed; without it, a plan with steps refuses.
366
+ */
367
+ export async function setupLab({ plan, consented, onPhase = () => {}, onLine = () => {}, onProgress = () => {}, signal, fetchStream = getStream, repoRoot }) {
368
+ const { pin, layout } = plan
369
+ if (plan.blockers.length) throw new LabError("lab-not-ready", `\`omakit lab setup\` cannot start: ${plan.blockers.length} thing${plan.blockers.length === 1 ? " is" : "s are"} missing`, { missing: plan.blockers, remedy: plan.blockers[0].command })
370
+ if (!plan.steps.length) return { done: [], nothingToDo: true }
371
+ if (!consented) throw new LabError("not-confirmed", "not confirmed; nothing was fetched, nothing was built", { remedy: "omakit lab setup --yes" })
372
+ packagedKey(pin)
373
+ const done = []
374
+ await acquireLock(layout, { runId: `setup-${stampNow()}`, pid: process.pid, qmpSocket: null, startedAt: new Date().toISOString() })
375
+ const lockId = readJson(join(layout.lock, "holder.json"))?.runId
376
+ try {
377
+ labDir(layout.cache, "downloads", pin.release.sha256)
378
+ labDir(layout.cache, "staging")
379
+ const target = join(downloadDir(layout, pin), pin.release.fileName)
380
+ for (const step of plan.steps) {
381
+ if (signal?.aborted) throw new LabError("interrupted", "interrupted; what was verified stays verified, what was not stays unverified")
382
+ if (step.kind === "download") {
383
+ onPhase(`downloading ${bytesBoth(step.bytes - step.resumeFrom)} from ${step.url}`)
384
+ const got = await downloadRelease({ url: step.url, to: target, bytes: step.bytes, onProgress, signal, fetchStream })
385
+ done.push({ kind: "download", bytes: got.bytes, milliseconds: got.milliseconds, to: got.part })
386
+ } else if (step.kind === "import") {
387
+ onPhase(`copying ${bytesBoth(step.bytes)} from ${step.from}`)
388
+ const started = Date.now()
389
+ removeFromLab(layout.cache, `downloads/${pin.release.sha256}/${pin.release.fileName}.part`)
390
+ await copyIntoLab(layout.cache, step.from, `downloads/${pin.release.sha256}/${pin.release.fileName}.part`)
391
+ for (const [side, name] of [[step.sidecars.checksum, `${pin.release.fileName}.sha256`], [step.sidecars.signature, `${pin.release.fileName}.sig`]]) {
392
+ if (side && !existsSync(join(downloadDir(layout, pin), name))) await copyIntoLab(layout.cache, side, `downloads/${pin.release.sha256}/${name}`)
393
+ }
394
+ done.push({ kind: "import", bytes: step.bytes, milliseconds: Date.now() - started, from: step.from })
395
+ } else if (step.kind === "sidecars") {
396
+ const have = existsSync(`${target}.sig`) && existsSync(`${target}.sha256`)
397
+ if (!have) {
398
+ onPhase("fetching the checksum and signature sidecars")
399
+ await fetchSidecars({ pin, dir: step.to, layout, fetchStream, signal })
400
+ done.push({ kind: "sidecars", to: step.to })
401
+ }
402
+ // Verification runs here, once the sidecars are in place, for a
403
+ // download, an import and a file already on disk alike.
404
+ onPhase("verifying the ISO: byte count, SHA-256, sidecar, signature")
405
+ const candidate = existsSync(`${target}.part`) ? `${target}.part` : target
406
+ const verified = await verifyAndPromote({ candidate, to: target, pin, layout, stagingRoot: layout.staging, onProgress })
407
+ done.push({ kind: "verify", file: verified.file, reason: verified.judged.reason })
408
+ onLine({ state: "pass", text: `verified ${verified.file}: ${verified.judged.reason}` })
409
+ } else if (step.kind === "build") {
410
+ const built = await buildBase({ pin, layout, toolchainHarness: step.toolchain, iso: target, onPhase, onLine, signal, repoRoot })
411
+ onLine({ state: "pass", text: `the toolchain built a base in ${durationWords(built.buildMilliseconds)}` })
412
+ const promoted = await verifyAndPromoteBase({ ...built, pin, layout, iso: target, onPhase, signal })
413
+ done.push({ kind: "build", milliseconds: built.buildMilliseconds, dir: promoted.dir, guest: promoted.manifest.guest, diskSha256: promoted.manifest.disk.sha256, allocatedBytes: promoted.manifest.disk.allocatedBytes })
414
+ onLine({ state: "pass", text: `promoted ${promoted.dir}: guest omarchy ${promoted.manifest.guest.version}, disk sha256 ${promoted.manifest.disk.sha256}` })
415
+ } else if (step.kind === "promote") {
416
+ const baseId = step.staged.split("/").at(-1)
417
+ const buildRecord = readJson(join(step.staged, "build", "lab-build.json"))
418
+ const promoted = await verifyAndPromoteBase({ staged: step.staged, baseId, pin, layout, buildMilliseconds: buildRecord?.buildMilliseconds ?? null, origin: `built ${buildRecord?.builtAt || "earlier"} by the pinned toolchain harness ${(buildRecord?.harnessSha256 || pin.toolchain.patchedHarnessSha256).slice(0, 12)} from the verified ISO, promoted ${new Date().toISOString()} after a verification boot`, harnessSha256: buildRecord?.harnessSha256 || pin.toolchain.patchedHarnessSha256, iso: target, onPhase, signal })
419
+ done.push({ kind: "build", milliseconds: buildRecord?.buildMilliseconds ?? 0, dir: promoted.dir, guest: promoted.manifest.guest, diskSha256: promoted.manifest.disk.sha256, allocatedBytes: promoted.manifest.disk.allocatedBytes })
420
+ onLine({ state: "pass", text: `promoted ${promoted.dir}: guest omarchy ${promoted.manifest.guest.version}, disk sha256 ${promoted.manifest.disk.sha256}` })
421
+ } else if (step.kind === "plugins") {
422
+ const fetched = fetchPlugins({ plugins: step.plugins, layout, onPhase })
423
+ done.push({ kind: "plugins", fetched })
424
+ }
425
+ }
426
+ } finally {
427
+ if (lockId) releaseLock(layout, lockId)
428
+ }
429
+ return { done, nothingToDo: false }
430
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/bash
2
+ # The Run block's suite in the guest: tests/lab/run/suite.sh, the same file
3
+ # that runs on the desktop, staged with the block files and run inside the
4
+ # graphical session, its document copied back. What it proves that the
5
+ # desktop run cannot: the block runs on a stock install, where
6
+ # /usr/bin/python3 is a dependency of desktop packages and not an Omarchy
7
+ # choice (docs/BLOCKS_SPIKE.md), in the session of a shell nobody
8
+ # customised. The guest's shell is never restarted and never loads the
9
+ # harness: every scenario is its own quickshell instance.
10
+ #
11
+ # Runs through tools/lab/harness.sh, which defines the helpers used here.
12
+
13
+ omakit_lab_suite() {
14
+ local runs="${1:-1}" status
15
+
16
+ log "Checking the stock guest for the block's plumbing"
17
+ ssh_guest "test -x /usr/bin/python3 && test -x /usr/bin/quickshell && test -x /usr/bin/setsid && test -x /usr/bin/kill" || { echo "the guest lacks python3, quickshell, setsid or kill under /usr/bin" >&2; return 1; }
18
+ ssh_guest "/usr/bin/python3 --version; pacman -Q python quickshell util-linux; pacman -Qi python | grep '^Required By'" > "$RUN_DIR/guest-plumbing.txt" 2>&1 || true
19
+ log "$(head -n 4 "$RUN_DIR/guest-plumbing.txt" | tr '\n' ';')"
20
+
21
+ log "Staging the suite and the block files in the guest"
22
+ ssh_guest "rm -rf /tmp/omakit-runlab-src" || return 1
23
+ stage_paths "$OMAKIT_DIR" /tmp/omakit-runlab-src tests/lab/run blocks/run || return 1
24
+
25
+ log "Running the Run lab suite in the guest session ($runs run per scenario)"
26
+ guest_job runlab 900 "bash /tmp/omakit-runlab-src/tests/lab/run/suite.sh /tmp/omakit-runlab $runs" || return 1
27
+ guest_file /tmp/omakit-runlab/runlab.json runlab.json || true
28
+ ssh_guest "cd /tmp/omakit-runlab && tar -cf - runs" > "$RUN_DIR/runlab-runs.tar" 2>/dev/null || true
29
+ status=$(guest_job_status runlab)
30
+ [[ $status == 0 ]] || { echo "the Run lab suite exited $status in the guest" >&2; tail -n 30 "$RUN_DIR/runlab.log" >&2; return 1; }
31
+ guest_shell_healthy || return 1
32
+ capture_console "success-runlab-01-done"
33
+ grep '^ok - ' "$RUN_DIR/runlab.log"
34
+ printf 'ok - the Run block ran its lab scenarios on the stock guest, the shell untouched; document at %s\n' "$RUN_DIR/runlab.json"
35
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/bash
2
+ # The Store block's suite in the guest: tests/lab/store/suite.sh staged
3
+ # with both blocks' files and run inside the graphical session, its
4
+ # document copied back. Every hostile condition the contract refuses is
5
+ # planted in a throwaway HOME; the foreign-owner scenario needs one file
6
+ # owned by root, and the guest, being disposable, gets a sudoers drop-in
7
+ # for exactly `/usr/bin/chown`. That drop-in is written into the run's
8
+ # overlay, which dies with the run: the base is never touched, and the
9
+ # document records that the owner was simulated. The guest password is the
10
+ # harness's argument, never a literal here (docs/history/2026-09-18-lab-
11
+ # inventory.md P11).
12
+ #
13
+ # Runs through tools/lab/harness.sh, which defines the helpers used here.
14
+
15
+ omakit_lab_suite() {
16
+ local status
17
+
18
+ log "Checking the stock guest for the block's plumbing"
19
+ ssh_guest "test -x /usr/bin/python3 && test -x /usr/bin/quickshell && test -x /usr/bin/setsid && test -x /usr/bin/kill" || { echo "the guest lacks python3, quickshell, setsid or kill under /usr/bin" >&2; return 1; }
20
+ ssh_guest "/usr/bin/python3 --version; pacman -Q python quickshell util-linux; pacman -Qi python | grep '^Required By'" > "$RUN_DIR/guest-plumbing.txt" 2>&1 || true
21
+ log "$(head -n 4 "$RUN_DIR/guest-plumbing.txt" | tr '\n' ';')"
22
+
23
+ log "Staging the suite and the block files in the guest"
24
+ ssh_guest "rm -rf /tmp/omakit-storelab-src" || return 1
25
+ stage_paths "$OMAKIT_DIR" /tmp/omakit-storelab-src tests/lab/store blocks/run blocks/store || return 1
26
+
27
+ log "Allowing chown without a password in the guest's overlay, for the foreign-owner scenario"
28
+ printf '%s\n' "$GUEST_PASSWORD" | ssh_guest "sudo -S sh -c \"printf 'omarchy ALL=(ALL) NOPASSWD: /usr/bin/chown\\n' > /etc/sudoers.d/storelab && chmod 0440 /etc/sudoers.d/storelab\"" || return 1
29
+ ssh_guest "sudo -n /usr/bin/chown --version >/dev/null" || { echo "chown is not passwordless in the guest" >&2; return 1; }
30
+
31
+ log "Running the Store lab suite in the guest session"
32
+ guest_job storelab 900 "bash /tmp/omakit-storelab-src/tests/lab/store/suite.sh /tmp/omakit-storelab" || return 1
33
+ guest_file /tmp/omakit-storelab/storelab.json storelab.json || true
34
+ ssh_guest "cd /tmp/omakit-storelab && tar -cf - runs" > "$RUN_DIR/storelab-runs.tar" 2>/dev/null || true
35
+ status=$(guest_job_status storelab)
36
+ [[ $status == 0 ]] || { echo "the Store lab suite exited $status in the guest" >&2; tail -n 30 "$RUN_DIR/storelab.log" >&2; return 1; }
37
+ guest_shell_healthy || return 1
38
+ capture_console "success-storelab-01-done"
39
+ grep '^ok - ' "$RUN_DIR/storelab.log"
40
+ printf 'ok - the Store block ran its lab scenarios on the stock guest, the foreign owner simulated with chown, the shell untouched; document at %s\n' "$RUN_DIR/storelab.json"
41
+ }