omakit 0.5.0 → 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 (96) hide show
  1. package/README.md +39 -32
  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 +7 -4
  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 +37 -6
  50. package/tools/inspect/functions.mjs +236 -12
  51. package/tools/inspect/helpers.mjs +217 -0
  52. package/tools/inspect/inspect.mjs +71 -5
  53. package/tools/inspect/measure-functions.mjs +12 -3
  54. package/tools/inspect/patterns.mjs +35 -18
  55. package/tools/inspect/processes.mjs +38 -5
  56. package/tools/inspect/report.mjs +33 -6
  57. package/tools/inspect/writes.mjs +22 -4
  58. package/tools/lab/guest.mjs +155 -0
  59. package/tools/lab/harness.sh +119 -0
  60. package/tools/lab/host.mjs +177 -0
  61. package/tools/lab/inspect.mjs +240 -0
  62. package/tools/lab/omarchy.gpg +13 -0
  63. package/tools/lab/patches/omarchy-iso-test.patch +351 -0
  64. package/tools/lab/paths.mjs +173 -0
  65. package/tools/lab/pin.json +42 -0
  66. package/tools/lab/pin.mjs +64 -0
  67. package/tools/lab/prune.mjs +68 -0
  68. package/tools/lab/qemu.mjs +153 -0
  69. package/tools/lab/qmp-cli.mjs +21 -0
  70. package/tools/lab/report.mjs +183 -0
  71. package/tools/lab/run.mjs +344 -0
  72. package/tools/lab/setup.mjs +430 -0
  73. package/tools/lab/suites/run.sh +35 -0
  74. package/tools/lab/suites/store.sh +41 -0
  75. package/tools/lab/suites/weigh.sh +196 -0
  76. package/tools/lab/suites.mjs +142 -0
  77. package/tools/lab/verify.mjs +134 -0
  78. package/tools/marketplace/README.md +38 -1
  79. package/tools/marketplace/banner.mjs +23 -2
  80. package/tools/marketplace/cli.mjs +449 -146
  81. package/tools/marketplace/completion-check.mjs +27 -1
  82. package/tools/marketplace/completion.mjs +32 -4
  83. package/tools/marketplace/doctor.mjs +47 -9
  84. package/tools/marketplace/github.mjs +52 -6
  85. package/tools/marketplace/local-transport.mjs +1 -1
  86. package/tools/marketplace/options.mjs +16 -5
  87. package/tools/marketplace/outcome.mjs +244 -0
  88. package/tools/marketplace/pin.mjs +178 -33
  89. package/tools/marketplace/setup.mjs +16 -15
  90. package/tools/marketplace/tree.mjs +1 -1
  91. package/tools/marketplace/upgrade.mjs +5 -5
  92. package/tools/marketplace/usage.mjs +116 -72
  93. package/tools/subject/resolve.mjs +19 -6
  94. package/tools/weigh/audit.mjs +47 -16
  95. package/tools/weigh/config.mjs +105 -24
  96. package/tools/weigh/list.mjs +10 -1
@@ -0,0 +1,183 @@
1
+ // What the lab commands print for a person: palette roles only, the
2
+ // shared marks, fields and actions from style.mjs, every path under the
3
+ // home directory abbreviated the way a shell takes it. The documents
4
+ // themselves (`--json`) keep every path absolute.
5
+
6
+ import { action, colourEnabled, field, GUTTER, labelled, mark, section, styler, verdict, wrap } from "../marketplace/style.mjs"
7
+ import { withHomeAbbreviated } from "../marketplace/paths.mjs"
8
+ import { bytesBoth, durationWords, gbBoth, shortId } from "./pin.mjs"
9
+ import { disclosureLines } from "./setup.mjs"
10
+
11
+ const state = (ok) => (ok ? "pass" : "advisory")
12
+
13
+ /** The lab's lines for `omakit doctor`, in doctor's check shape; every one advice, never a problem, because the lab is optional. */
14
+ export function labDoctorChecks(lab) {
15
+ const checks = []
16
+ const add = (id, ok, detail, remedy = null, evidence = null) => checks.push({ id, state: ok ? "ok" : "advice", detail, action: ok ? null : remedy, ...(evidence ? { evidence } : {}) })
17
+ for (const line of lab.host.run) {
18
+ if (line.name === "cpus" || line.name === "memory") continue
19
+ add(`lab.${line.name}`, line.state === "ok", line.reason, line.remedy)
20
+ }
21
+ // MemTotal alone: MemAvailable moves between two runs of doctor, and
22
+ // doctor's bytes are held to being the same twice (tests/unit/cli.test.mjs).
23
+ const memory = lab.host.run.find((line) => line.name === "memory")
24
+ if (memory) add("lab.memory", memory.state === "ok", memory.reason.replace(/, \d+ MiB available/, ""), memory.remedy)
25
+ for (const line of lab.host.verify) add(`lab.${line.name}`, line.state === "ok", line.reason, line.remedy)
26
+ const enough = lab.free.bytes >= lab.pin.measured.preparedLabBytes
27
+ // The free figure alone, no path: the lab cache's path can be wider than
28
+ // a terminal, and doctor's one wide line is the pin's; `omakit lab
29
+ // inspect` prints the path and the cache's own size.
30
+ // To the GiB: the exact figure moves with every write on the filesystem,
31
+ // and doctor's two consecutive runs are held to the same bytes (a tenth
32
+ // of a GiB flipped once while another test packed a tarball beside it).
33
+ add("lab.disk", enough, `${Math.floor(lab.free.bytes / 2 ** 30).toLocaleString("en-US")} GiB free on the lab cache's filesystem; a prepared lab measured ${bytesBoth(lab.pin.measured.preparedLabBytes)} (M14)`, enough ? null : "omakit lab prune, or free the difference")
34
+ add("lab.iso", lab.download.verified, `${lab.pin.release.name}, ${lab.download.reason}`, lab.download.verified ? null : "omakit lab setup", { sha256: lab.pin.release.sha256, present: lab.download.present, verified: lab.download.verified })
35
+ add("lab.base", lab.base.state === "ready", `${lab.base.state}: ${lab.base.reason}`, lab.base.state === "ready" ? null : "omakit lab setup", { state: lab.base.state, release: lab.base.manifest?.release?.name ?? null, guestVersion: lab.base.manifest?.guest?.version ?? null, diskSha256: lab.base.manifest?.disk?.sha256 ?? null, allocatedBytes: lab.base.allocatedBytes, lockHeld: lab.lock.held, lockAlive: lab.lock.alive })
36
+ if (lab.lock.held) add("lab.lock", !lab.lock.alive, lab.lock.alive ? `held by run ${lab.lock.record?.runId || "unknown"} (pid ${lab.lock.record?.pid || "?"})` : `a stale lock from ${lab.lock.record?.runId || "an unknown run"}`, lab.lock.alive ? "wait for the run" : "omakit lab prune")
37
+ return checks
38
+ }
39
+
40
+ /** `omakit lab inspect`. */
41
+ export function renderLab(lab, { colour = colourEnabled(), env = process.env } = {}) {
42
+ const c = styler(colour)
43
+ const home = (text) => withHomeAbbreviated(text, env)
44
+ const out = []
45
+ const { release } = lab.pin
46
+ out.push(...section("the release the lab is pinned to", c))
47
+ out.push(...field("release", `Omarchy ${release.name}; guest package expected ${release.expectedGuestVersion}; ISO build ${release.embeddedBuild}, volume ${release.volume}`, c))
48
+ out.push(...field("iso", release.isoUrl, c))
49
+ out.push(...field("size", bytesBoth(release.bytes), c))
50
+ out.push(...field("sha256", release.sha256, c, { wrapValue: false }))
51
+ out.push(...field("signed by", `${release.signingFingerprint} (Omarchy <pkgs@omarchy.org>, the packaged key)`, c))
52
+ out.push(...field("toolchain", `omarchy-iso ${lab.pin.toolchain.commit.slice(0, 12)} with ${lab.pin.toolchain.patch}; harness sha256 ${shortId(lab.pin.toolchain.patchedHarnessSha256)}`, c))
53
+ out.push("")
54
+ out.push(...section("on disk", c))
55
+ out.push(`${mark(state(lab.download.verified), c)}${c("name", "iso")}`)
56
+ out.push(...wrap(home(`${lab.download.present ? `${bytesBoth(lab.download.bytes)} at ${lab.download.iso}: ` : ""}${lab.download.reason}`), { indent: GUTTER }, c))
57
+ out.push(`${mark(state(lab.base.state === "ready"), c)}${c("name", `base (${lab.base.state})`)}`)
58
+ out.push(...wrap(home(lab.base.reason), { indent: GUTTER }, c))
59
+ if (lab.base.manifest) out.push(...labelled("disk sha256", lab.base.manifest.disk.sha256, c), ...labelled("origin", home(lab.base.manifest.build?.origin || "unknown"), c))
60
+ out.push(`${mark(state(lab.toolchain.state === "ready"), c)}${c("name", `toolchain (${lab.toolchain.state})`)}`)
61
+ out.push(...wrap(home(lab.toolchain.reason), { indent: GUTTER }, c))
62
+ if (lab.staging.length) {
63
+ out.push(`${mark(lab.staging.some((entry) => entry.alive) ? "info" : "advisory", c)}${c("name", "staging")}`)
64
+ for (const entry of lab.staging) out.push(...wrap(home(`${entry.name}: ${bytesBoth(entry.allocatedBytes)}${entry.alive ? ", a QEMU answers on its socket" : ", inactive"}`), { indent: GUTTER }, c))
65
+ }
66
+ if (lab.lock.held) {
67
+ out.push(`${mark(lab.lock.alive ? "info" : "advisory", c)}${c("name", "lock")}`)
68
+ out.push(...wrap(lab.lock.alive ? `held by run ${lab.lock.record?.runId || "unknown"} (pid ${lab.lock.record?.pid || "?"})` : `stale, from ${lab.lock.record?.runId || "an unknown run"}`, { indent: GUTTER }, c))
69
+ }
70
+ out.push(...field("lab cache", `${bytesBoth(lab.totals.cacheBytes)} at ${home(lab.layout.cache)}: downloads ${gbBoth(lab.totals.downloadsBytes)}, base ${gbBoth(lab.totals.baseBytes)}, staging ${gbBoth(lab.totals.stagingBytes)}, plugins ${gbBoth(lab.totals.pluginsBytes)}`, c))
71
+ out.push(...field("runs", `${lab.runs.length} record${lab.runs.length === 1 ? "" : "s"}, ${bytesBoth(lab.totals.runsBytes)} at ${home(lab.layout.runs)}${lab.runs.length ? `; newest ${lab.runs.at(-1)}` : ""}`, c))
72
+ out.push(...field("free", `${bytesBoth(lab.free.bytes)} at ${home(lab.free.path)}`, c))
73
+ out.push("")
74
+ out.push(...section("the host", c))
75
+ for (const line of [...lab.host.run, ...lab.host.verify, ...lab.host.build]) {
76
+ out.push(`${mark(state(line.state === "ok"), c)}${c("name", line.name)}`)
77
+ out.push(...wrap(line.reason, { indent: GUTTER }, c))
78
+ }
79
+ out.push("")
80
+ if (lab.missing.length) {
81
+ out.push(...section(`missing for \`omakit lab prove\`: ${lab.missing.length}`, c))
82
+ for (const item of lab.missing) {
83
+ out.push(`${mark("advisory", c)}${c("name", item.what)}`)
84
+ out.push(...labelled("costs", home(item.cost), c))
85
+ if (item.command) out.push(...action(home(item.command), c))
86
+ }
87
+ out.push("")
88
+ out.push(...verdict("advisory", "NOT PREPARED", `${lab.missing.length} thing${lab.missing.length === 1 ? "" : "s"} before a run; nothing was fetched or written.`, c))
89
+ } else {
90
+ out.push(...verdict("pass", "PREPARED", `verified ${release.name}, a ready base, the host can run it; nothing was fetched or written.`, c))
91
+ }
92
+ return out.join("\n")
93
+ }
94
+
95
+ /** The setup disclosure, and what happened. */
96
+ export function renderSetupPlan(plan, { colour = colourEnabled(), env = process.env } = {}) {
97
+ const c = styler(colour)
98
+ const out = []
99
+ if (plan.blockers.length) {
100
+ out.push(...section(`\`omakit lab setup\` cannot start: ${plan.blockers.length} missing`, c))
101
+ for (const item of plan.blockers) {
102
+ out.push(`${mark("fail", c)}${c("name", item.what)}`)
103
+ out.push(...labelled("costs", withHomeAbbreviated(item.cost, env), c))
104
+ if (item.command) out.push(...action(withHomeAbbreviated(item.command, env), c))
105
+ }
106
+ return out.join("\n")
107
+ }
108
+ if (!plan.steps.length) {
109
+ out.push(...verdict("pass", "PREPARED", "the verified ISO and a ready base are there; nothing to acquire.", c))
110
+ return out.join("\n")
111
+ }
112
+ out.push(...section("what setup will do, once you say yes", c))
113
+ for (const [key, value] of disclosureLines(plan)) out.push(...field(key, withHomeAbbreviated(value, env), c))
114
+ return out.join("\n")
115
+ }
116
+
117
+ export function renderSetupResult(result, { colour = colourEnabled(), env = process.env } = {}) {
118
+ const c = styler(colour)
119
+ const out = []
120
+ if (result.nothingToDo) return verdict("pass", "PREPARED", "nothing to acquire; the lab was already prepared.", c).join("\n")
121
+ for (const step of result.done) {
122
+ if (step.kind === "download") out.push(`${mark("pass", c)}downloaded ${bytesBoth(step.bytes)} in ${durationWords(step.milliseconds)}`)
123
+ else if (step.kind === "import") out.push(`${mark("pass", c)}copied ${bytesBoth(step.bytes)} from ${withHomeAbbreviated(step.from, env)} in ${durationWords(step.milliseconds)}`)
124
+ else if (step.kind === "sidecars") out.push(`${mark("pass", c)}fetched the checksum and signature sidecars into ${withHomeAbbreviated(step.to, env)}`)
125
+ else if (step.kind === "verify") out.push(`${mark("pass", c)}verified ${withHomeAbbreviated(step.file, env)}`, ...wrap(step.reason, { indent: GUTTER }, c))
126
+ else if (step.kind === "build") out.push(`${mark("pass", c)}built and promoted the base in ${durationWords(step.milliseconds)}: guest omarchy ${step.guest.version}, ${bytesBoth(step.allocatedBytes)} allocated, disk sha256 ${step.diskSha256}`)
127
+ else if (step.kind === "plugins") out.push(`${mark("pass", c)}${step.fetched.length} listed plugins in the lab cache: ${step.fetched.map((row) => `${row.id} ${row.state}`).join(", ")}`)
128
+ }
129
+ out.push("")
130
+ out.push(...verdict("pass", "PREPARED", "the lab is ready; `omakit lab prove <suite>` boots it.", c))
131
+ return out.join("\n")
132
+ }
133
+
134
+ /** The identity block before a suite, and the verdict after. */
135
+ export function renderRunIdentity(record, { colour = colourEnabled() } = {}) {
136
+ const c = styler(colour)
137
+ const out = []
138
+ out.push(...field("run", record.runId, c))
139
+ out.push(...field("release", record.pin.release, c))
140
+ out.push(...field("guest", record.guest ? `omarchy ${record.guest.version || "unknown"} on ${record.guest.kernel || "?"}` : "not read", c))
141
+ out.push(...field("iso sha256", record.pin.isoSha256, c, { wrapValue: false }))
142
+ out.push(...field("base", `created ${record.base.createdAt}; ${bytesBoth(record.base.allocatedBytes)}; disk sha256 ${shortId(record.base.diskSha256)}`, c))
143
+ out.push(...field("tested", record.testedSource || "unknown", c))
144
+ out.push(...field("skew", record.skew === null ? "unknown" : record.skew ? `true: the guest is ${record.guest?.linked ? "linked to a checkout" : `omarchy ${record.guest?.version}`}, the pin expects ${record.pin.expectedGuestVersion}` : `false: the installed package is the pinned ${record.pin.expectedGuestVersion}`, c))
145
+ return out.join("\n")
146
+ }
147
+
148
+ export function renderRunResult(record, { colour = colourEnabled(), env = process.env } = {}) {
149
+ const c = styler(colour)
150
+ const out = []
151
+ out.push(...field("duration", durationWords(record.durationMs || 0), c))
152
+ if (record.overlayBytes !== null) out.push(...field("overlay", `${bytesBoth(record.overlayBytes)} allocated by the run, removed`, c))
153
+ out.push(...field("base", record.baseUnchanged ? "unchanged (size, mtime and inode of the disk and the template)" : "CHANGED: the base or its template differs from before the run", c))
154
+ if (record.document) out.push(...field("document", withHomeAbbreviated(record.document, env), c))
155
+ out.push(...field("record", withHomeAbbreviated(`${record.runDir}/run.json`, env), c))
156
+ out.push("")
157
+ if (record.ok) out.push(...verdict("pass", "PROVED", `${record.suite}: ${record.assertion?.reason || "ok"}; guest omarchy ${record.guest?.version}, skew ${record.skew}.`, c))
158
+ else out.push(...verdict("fail", "NOT PROVED", `${record.suite}: ${record.assertion?.reason || record.error || `the suite exited ${record.suiteStatus}`}.`, c))
159
+ return out.join("\n")
160
+ }
161
+
162
+ /** `omakit lab prune`: the targets, the total, the question's subject. */
163
+ export function renderPrunePlan(plan, { colour = colourEnabled(), env = process.env } = {}) {
164
+ const c = styler(colour)
165
+ const out = []
166
+ if (plan.blockers.length) {
167
+ for (const line of plan.blockers) out.push(`${mark("fail", c)}${line}`)
168
+ return out.join("\n")
169
+ }
170
+ if (!plan.targets.length) return verdict("pass", "NOTHING TO PRUNE", `the lab cache holds ${bytesBoth(plan.remaining.cache)} and every byte of it is in use or kept; run records ${bytesBoth(plan.remaining.runs)} (--records removes them).`, c).join("\n")
171
+ out.push(...section("what prune removes", c))
172
+ for (const target of plan.targets) {
173
+ out.push(`${mark("advisory", c)}${c("name", withHomeAbbreviated(target.path, env))}`)
174
+ out.push(...wrap(`${bytesBoth(target.bytes)}: ${target.what}`, { indent: GUTTER }, c))
175
+ }
176
+ out.push(...field("total", bytesBoth(plan.total), c))
177
+ return out.join("\n")
178
+ }
179
+
180
+ export function renderPruneResult(result, { colour = colourEnabled() } = {}) {
181
+ const c = styler(colour)
182
+ return verdict("pass", "PRUNED", `${result.words}.`, c).join("\n")
183
+ }
@@ -0,0 +1,344 @@
1
+ // `omakit lab prove <suite>`: one suite in a disposable guest, its document
2
+ // written with the identity of the guest it ran on.
3
+ //
4
+ // The lifecycle, in order, and what each step guarantees:
5
+ //
6
+ // 1. Preflight, reading only: the base is ready and is the pin's, the
7
+ // host can run a guest, the suite's files are there, the disk has
8
+ // room for one overlay. A missing thing is named with its cost and
9
+ // the one command; nothing is fetched, nothing is created.
10
+ // 2. The lock: one directory, made atomically, holding the run id, this
11
+ // pid and the QMP socket. A lock whose QEMU still answers on its
12
+ // socket is held, whatever PID namespace it is in; one whose QEMU is
13
+ // gone and whose holder is gone is stale and is taken over.
14
+ // 3. Staging, private to the run: a qcow2 overlay backed by the base
15
+ // (the base opened read-only through the backing chain, and made
16
+ // 0444 at promotion), a copy of the base's firmware variables (the
17
+ // template is never opened writable: docs/history/2026-09-18-lab-
18
+ // inventory.md P15), the QMP socket, the serial log.
19
+ // 4. QEMU as a child of this process: no daemon, no host directory, no
20
+ // host socket, SSH forwarded on 127.0.0.1 and nothing else.
21
+ // 5. The session the way a person gets one: SSH answers, the password
22
+ // is typed at the greeter until a Hyprland owned by the guest user
23
+ // exists, the startup notifications are dismissed.
24
+ // 6. The identity, read from inside before anything else: the installed
25
+ // omarchy package, the kernel, whether the session runs from the
26
+ // package or a linked checkout. Skew is printed, never collapsed (P8).
27
+ // 7. The suite, through tools/lab/harness.sh, its output streamed to the
28
+ // run's host.log; its document read back and held to the suite's
29
+ // assertion; provenance added to the document (P18).
30
+ // 8. The guest powered off, QEMU ended (QMP, then the signal), the
31
+ // overlay measured and removed, the base and the template checked
32
+ // unchanged, the lock released, on the normal path, on failure, and
33
+ // on SIGINT and SIGTERM.
34
+ //
35
+ // Nothing here touches the host's own session: no hyprctl, no
36
+ // omarchy-shell, no shell.json on the host, and tests/unit/lab.test.mjs
37
+ // proves it over this file, the harness and the suites.
38
+
39
+ import { spawn, spawnSync } from "node:child_process"
40
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs"
41
+ import { createServer } from "node:net"
42
+ import { join, resolve } from "node:path"
43
+ import { bytesBoth, labPin } from "./pin.mjs"
44
+ import { allocatedBytes, inLab, labDir, labLayout, readJson, removeFromLab, stampNow, writeJson } from "./paths.mjs"
45
+ import { freeBytesAt, guestCpus, probeRunHost } from "./host.mjs"
46
+ import { BASE_FILES, inspectBase, inspectLock } from "./inspect.mjs"
47
+ import { press, qemuArgs, qmpExecute, startQemu, typeText } from "./qemu.mjs"
48
+ import { clearStartupNotifications, establishSession, guestIdentity, SESSION_PREAMBLE, sleep, sshGuest, waitForSsh } from "./guest.mjs"
49
+ import { SUITES, suitePreflight } from "./suites.mjs"
50
+
51
+ export class LabError extends Error {
52
+ constructor(code, message, { remedy = null, missing = [] } = {}) {
53
+ super(message)
54
+ this.name = "LabError"
55
+ this.code = code
56
+ // An interrupt's one action is the lab's, not weigh's (whose
57
+ // `interrupted` remedy in cli.mjs speaks of shell.json).
58
+ this.remedy = remedy || (code === "interrupted" ? "the guest was ended and its overlay removed; run it again when you are ready" : null)
59
+ this.missing = missing
60
+ }
61
+ }
62
+
63
+ /** The first free port on the loopback interface from 2222 up; the toolchain's fixed port is where two runs collided (P7). */
64
+ export async function freePort(start = 2222, { attempts = 50 } = {}) {
65
+ for (let port = start; port < start + attempts; port += 1) {
66
+ const free = await new Promise((resolve) => {
67
+ const server = createServer()
68
+ server.once("error", () => resolve(false))
69
+ server.listen({ host: "127.0.0.1", port }, () => server.close(() => resolve(true)))
70
+ })
71
+ if (free) return port
72
+ }
73
+ throw new LabError("no-port", `no free port between ${start} and ${start + attempts - 1} on 127.0.0.1`)
74
+ }
75
+
76
+ /**
77
+ * Take the lab lock or refuse. `holder.json` inside it names the run; a
78
+ * QEMU answering on the recorded socket, or a live holder pid, means
79
+ * held. A stale lock is reported and taken.
80
+ */
81
+ export async function acquireLock(layout, record) {
82
+ let stale = null
83
+ for (let attempt = 0; attempt < 2; attempt += 1) {
84
+ try {
85
+ mkdirSync(inLab(layout.cache, "lab.lock"), { recursive: false, mode: 0o700 })
86
+ writeJson(layout.cache, "lab.lock/holder.json", record)
87
+ return { taken: true, stale }
88
+ } catch (error) {
89
+ if (error.code === "ENOENT") {
90
+ labDir(layout.cache)
91
+ continue
92
+ }
93
+ if (error.code !== "EEXIST") throw error
94
+ const held = await inspectLock(layout)
95
+ if (held.alive) {
96
+ throw new LabError("lab-busy", `the lab is in use by run ${held.record?.runId || "unknown"} (pid ${held.record?.pid || "?"}${held.qemuAnswers ? ", its QEMU answers on QMP" : ""}); one run at a time`, { remedy: "wait for it, or `omakit lab inspect` to see it" })
97
+ }
98
+ stale = held.record
99
+ removeFromLab(layout.cache, "lab.lock")
100
+ }
101
+ }
102
+ throw new LabError("lab-busy", "the lock could not be taken")
103
+ }
104
+
105
+ export function releaseLock(layout, runId) {
106
+ const holder = readJson(join(layout.lock, "holder.json"))
107
+ if (holder && holder.runId !== runId) return false
108
+ removeFromLab(layout.cache, "lab.lock")
109
+ return true
110
+ }
111
+
112
+ function identityOf(file) {
113
+ const st = statSync(file)
114
+ return { bytes: st.size, mtimeMs: st.mtimeMs, ino: st.ino }
115
+ }
116
+
117
+ function sameIdentity(a, b) {
118
+ return a.bytes === b.bytes && a.mtimeMs === b.mtimeMs && a.ino === b.ino
119
+ }
120
+
121
+ /**
122
+ * Boot a guest from a base directory, log in, read its identity, hand it
123
+ * to `body`, and end it on every path. The staging directory is the
124
+ * caller's (under the lab cache) and is removed here; the overlay's
125
+ * allocated bytes are measured before removal, which is the per-run disk
126
+ * cost M14 records.
127
+ *
128
+ * @param {{ baseDir: string, stagingName: string, logFile: string, layout: object, pin: object, onPhase?: Function, signal?: AbortSignal }} options
129
+ * @param {(context: { guest: object, paths: object, identity: object, session: object }) => Promise<any>} body
130
+ */
131
+ export async function withGuest({ baseDir, stagingName, logFile, layout, pin, onPhase = () => {}, signal }, body) {
132
+ const staging = labDir(layout.cache, "staging", stagingName)
133
+ const paths = { overlay: join(staging, "run.qcow2"), vars: join(staging, "vars.fd"), qmp: join(staging, "qmp.sock"), serial: join(staging, "serial.log"), pid: join(staging, "qemu.pid") }
134
+ const baseDisk = join(baseDir, BASE_FILES.disk)
135
+ const baseVars = join(baseDir, BASE_FILES.vars)
136
+ const before = { disk: identityOf(baseDisk), vars: identityOf(baseVars) }
137
+ const guest = { key: join(baseDir, BASE_FILES.key), port: null, user: pin.guest.user }
138
+ const out = { identity: null, session: null, overlayBytes: null, baseUnchanged: null, result: undefined, sshPort: null, cpus: guestCpus() }
139
+ let qemu = null
140
+ let stopped = false
141
+ const stopGuest = async () => {
142
+ if (stopped) return
143
+ stopped = true
144
+ if (!qemu || qemu.exitCode !== null) return
145
+ onPhase("powering the guest off")
146
+ let off = { status: 1 }
147
+ if (guest.port) off = sshGuest(guest, "sudo -S systemctl poweroff", { input: `${pin.guest.password}\n`, timeoutMs: 15000 })
148
+ if (off.status !== 0) await qmpExecute(paths.qmp, "system_powerdown").catch(() => {})
149
+ for (let waited = 0; waited < 20 && qemu.exitCode === null; waited += 1) await sleep(1000)
150
+ if (qemu.exitCode === null) {
151
+ await qmpExecute(paths.qmp, "quit").catch(() => {})
152
+ await sleep(1000)
153
+ }
154
+ if (qemu.exitCode === null) {
155
+ qemu.kill("SIGTERM")
156
+ await sleep(1000)
157
+ }
158
+ if (qemu.exitCode === null) qemu.kill("SIGKILL")
159
+ }
160
+ const end = async () => {
161
+ await stopGuest()
162
+ if (existsSync(paths.overlay)) out.overlayBytes = allocatedBytes(paths.overlay)
163
+ removeFromLab(layout.cache, `staging/${stagingName}`)
164
+ const after = { disk: identityOf(baseDisk), vars: identityOf(baseVars) }
165
+ out.baseUnchanged = sameIdentity(before.disk, after.disk) && sameIdentity(before.vars, after.vars)
166
+ }
167
+ try {
168
+ onPhase("creating the overlay and the run's firmware variables")
169
+ const created = spawnSync("qemu-img", ["create", "-f", "qcow2", "-b", baseDisk, "-F", "qcow2", paths.overlay], { timeout: 60_000, encoding: "utf8" })
170
+ if (created.status !== 0) throw new LabError("overlay-failed", `qemu-img could not create the overlay: ${(created.stderr || "").trim()}`)
171
+ chmodSync(paths.overlay, 0o600)
172
+ // The base's variables hold the boot entries the install wrote, so a
173
+ // guest boots from them; the run gets its own copy and the template
174
+ // stays as it was (P15).
175
+ writeFileSync(inLab(layout.cache, `staging/${stagingName}/vars.fd`), readFileSync(baseVars), { mode: 0o600 })
176
+ guest.port = await freePort()
177
+ out.sshPort = guest.port
178
+ const args = qemuArgs({ overlay: paths.overlay, vars: paths.vars, memoryMiB: pin.guest.memoryMiB, cpus: out.cpus, sshPort: guest.port, qmpSocket: paths.qmp, serialLog: paths.serial, pidFile: paths.pid })
179
+ const logFd = openSync(logFile, "a", 0o600)
180
+ onPhase(`booting the guest (${pin.guest.memoryMiB} MiB, ${out.cpus} vCPUs, SSH on 127.0.0.1:${guest.port})`)
181
+ qemu = startQemu(args, { log: logFd })
182
+ closeSync(logFd)
183
+ await new Promise((resolvePromise, reject) => {
184
+ qemu.once("spawn", resolvePromise)
185
+ qemu.once("error", (error) => reject(new LabError("qemu-failed", `QEMU did not start: ${error.message}`)))
186
+ })
187
+ // Alive means our child has not exited: it is ours, so its exit code is
188
+ // the whole answer. Measured on the first verification boot: a QMP
189
+ // probe in the first second, before QEMU had created its socket, read
190
+ // as "the guest exited" and the base was never promoted.
191
+ const alive = async () => qemu.exitCode === null
192
+ if (signal?.aborted) throw new LabError("interrupted", "interrupted before the guest booted")
193
+ const sshSeconds = await waitForSsh(guest, { timeoutSeconds: 600, alive, onPhase })
194
+ // Five seconds for the greeter to draw after sshd answers, as the toolchain waited; a keystroke typed before it is lost.
195
+ await sleep(5000)
196
+ if (signal?.aborted) throw new LabError("interrupted", "interrupted while the guest booted")
197
+ const session = await establishSession({ guest, socket: paths.qmp, password: pin.guest.password, typeText, press, onPhase })
198
+ const notifications = await clearStartupNotifications(guest, { onPhase })
199
+ out.session = { sshSeconds, loginRounds: session.rounds, loginSeconds: session.seconds, notificationsCleared: notifications.cleared, notificationsReason: notifications.reason }
200
+ onPhase("reading the guest's identity")
201
+ out.identity = guestIdentity(guest)
202
+ if (signal?.aborted) throw new LabError("interrupted", "interrupted before the suite started")
203
+ out.result = await body({ guest, paths, identity: out.identity, session: out.session })
204
+ } finally {
205
+ await end()
206
+ }
207
+ return out
208
+ }
209
+
210
+ /**
211
+ * Everything a run needs before a guest boots, or a LabError naming what
212
+ * is missing. Reads only.
213
+ */
214
+ export function preflightRun({ suiteName, env = process.env, pin = labPin(), repoRoot, options = {} }) {
215
+ const suite = SUITES[suiteName]
216
+ if (!suite) throw new LabError("usage", `no suite named ${JSON.stringify(suiteName)}; the suites are ${Object.keys(SUITES).join(", ")}`, { remedy: `omakit lab prove <${Object.keys(SUITES).join("|")}>` })
217
+ const layout = labLayout(env)
218
+ const missing = []
219
+ const base = inspectBase(layout, pin)
220
+ if (base.state !== "ready") missing.push({ what: `a ready base (${base.state})`, cost: base.reason, command: "omakit lab setup" })
221
+ for (const line of probeRunHost({ pin, env })) if (line.state !== "ok") missing.push({ what: line.name, cost: line.reason, command: line.remedy })
222
+ missing.push(...suitePreflight(suite, { repoRoot, layout }))
223
+ const free = freeBytesAt(layout.cache)
224
+ if (free.bytes < pin.measured.overlayAfterRunBytes) missing.push({ what: "disk for one overlay", cost: `${bytesBoth(free.bytes)} free at ${free.path}; one run's overlay measured ${bytesBoth(pin.measured.overlayAfterRunBytes)} (M14)`, command: "omakit lab prune" })
225
+ if (missing.length) throw new LabError("lab-not-ready", `\`omakit lab prove ${suiteName}\` cannot start: ${missing.length} thing${missing.length === 1 ? " is" : "s are"} missing`, { missing, remedy: missing[0].command })
226
+ return { suite, layout, base, free }
227
+ }
228
+
229
+ /**
230
+ * Run one suite. `onPhase` gets the progress line, `onLine` every line
231
+ * the harness prints, `signal` aborts (SIGINT and SIGTERM in cli.mjs).
232
+ * Returns the run record; the suite's document is in the run directory,
233
+ * with its provenance.
234
+ */
235
+ export async function runSuite({ suiteName, env = process.env, pin = labPin(), repoRoot, options = {}, onPhase = () => {}, onLine = () => {}, signal }) {
236
+ const { suite, layout, base } = preflightRun({ suiteName, env, pin, repoRoot, options })
237
+ const runId = `${stampNow()}-${suite.name}`
238
+ const runDir = labDir(layout.state, "runs", runId)
239
+ const startedAt = new Date()
240
+ const record = {
241
+ schema: 1,
242
+ runId,
243
+ suite: suite.name,
244
+ startedAt: startedAt.toISOString(),
245
+ pin: { release: pin.release.name, isoSha256: pin.release.sha256, expectedGuestVersion: pin.release.expectedGuestVersion },
246
+ base: { dir: base.dir, createdAt: base.manifest.createdAt, allocatedBytes: base.allocatedBytes, diskSha256: base.manifest.disk.sha256, varsSha256: base.manifest.vars.sha256, origin: base.manifest.build?.origin || null },
247
+ guest: null,
248
+ skew: null,
249
+ testedSource: null,
250
+ host: { 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, cpus: guestCpus(), memoryMiB: pin.guest.memoryMiB, sshPort: null },
251
+ session: null,
252
+ suiteStatus: null,
253
+ document: null,
254
+ assertion: null,
255
+ overlayBytes: null,
256
+ baseUnchanged: null,
257
+ durationMs: null,
258
+ ok: false,
259
+ runDir,
260
+ }
261
+ const save = () => writeJson(layout.state, `runs/${runId}/run.json`, record)
262
+ await acquireLock(layout, { runId, pid: process.pid, qmpSocket: join(layout.staging, `run-${runId}`, "qmp.sock"), startedAt: record.startedAt })
263
+ save()
264
+ let booted
265
+ try {
266
+ booted = await withGuest({ baseDir: base.dir, stagingName: `run-${runId}`, logFile: join(runDir, "qemu.log"), layout, pin, onPhase, signal }, async ({ guest, paths, identity }) => {
267
+ record.guest = identity
268
+ record.host.sshPort = guest.port
269
+ record.skew = identity.linked || identity.version !== pin.release.expectedGuestVersion
270
+ record.testedSource = identity.testedSource
271
+ save()
272
+ onLine({ state: "info", text: `guest omarchy ${identity.version || "unknown"} on ${identity.kernel || "?"}; tested source ${identity.testedSource}; skew ${record.skew}`, record })
273
+ onPhase(`running ${suite.title}`)
274
+ const status = await runHarness({ suite, runId, runDir, layout, guest, paths, repoRoot, pin, suiteArgs: suite.args(options, layout), timeoutSeconds: suite.timeoutSeconds, onLine, signal })
275
+ record.suiteStatus = status
276
+ // An interrupt during the suite is an interrupt, exit 130, the same as
277
+ // one before it: the guest is ended and the staging removed on the way out.
278
+ if (status === "interrupted") throw new LabError("interrupted", "interrupted during the suite")
279
+ const documentPath = join(runDir, suite.document)
280
+ const document = readJson(documentPath)
281
+ record.document = existsSync(documentPath) ? documentPath : null
282
+ record.assertion = document ? suite.assert(document) : { ok: false, reason: `the suite wrote no ${suite.document}` }
283
+ record.ok = status === 0 && record.assertion.ok
284
+ if (document) {
285
+ // Provenance into the document itself, in the shape the evidence
286
+ // files already carried by hand (`where`), plus the identity a
287
+ // reader can check: run id, guest version, pin, skew.
288
+ document.where = `the ${record.skew ? "linked" : "stock"} Omarchy ${identity.version || "?"} guest of omakit lab, run ${runId}`
289
+ document.lab = { runId, suite: suite.name, guest: identity, skew: record.skew, testedSource: identity.testedSource, pin: record.pin, base: { createdAt: record.base.createdAt, diskSha256: record.base.diskSha256, origin: record.base.origin }, host: record.host, startedAt: record.startedAt }
290
+ writeJson(layout.state, `runs/${runId}/${suite.document}`, document)
291
+ }
292
+ })
293
+ } catch (error) {
294
+ record.error = error?.message || String(error)
295
+ record.errorCode = error?.code || null
296
+ if (signal?.aborted) record.suiteStatus = "interrupted"
297
+ throw error
298
+ } finally {
299
+ if (booted) {
300
+ record.session = booted.session
301
+ record.host.sshPort = booted.sshPort
302
+ record.overlayBytes = booted.overlayBytes
303
+ record.baseUnchanged = booted.baseUnchanged
304
+ }
305
+ releaseLock(layout, runId)
306
+ record.durationMs = Date.now() - startedAt.getTime()
307
+ save()
308
+ }
309
+ return record
310
+ }
311
+
312
+ /** The harness as a child, its lines streamed, its exit status the suite's. `null` for a signal, `"interrupted"` for an abort. */
313
+ function runHarness({ suite, runId, runDir, layout, guest, paths, repoRoot, pin, suiteArgs, timeoutSeconds, onLine, signal }) {
314
+ return new Promise((resolvePromise) => {
315
+ const hostLog = openSync(inLab(layout.state, `runs/${runId}/host.log`), "a", 0o600)
316
+ const child = spawn("bash", [join(resolve(repoRoot), "tools/lab/harness.sh"), suite.host, runDir, guest.key, String(guest.port), paths.qmp, resolve(repoRoot), SESSION_PREAMBLE, pin.guest.user, pin.guest.password, ...suiteArgs], { stdio: ["ignore", "pipe", "pipe"] })
317
+ let buffer = ""
318
+ const feed = (chunk, stream) => {
319
+ writeFileSync(hostLog, chunk)
320
+ buffer += chunk.toString("utf8")
321
+ let index
322
+ while ((index = buffer.indexOf("\n")) >= 0) {
323
+ const line = buffer.slice(0, index)
324
+ buffer = buffer.slice(index + 1)
325
+ onLine({ state: /^not ok/.test(line) ? "fail" : /^ok - /.test(line) ? "pass" : /^==> /.test(line) ? "info" : "prose", text: line, stream })
326
+ }
327
+ }
328
+ child.stdout.on("data", (chunk) => feed(chunk, "stdout"))
329
+ child.stderr.on("data", (chunk) => feed(chunk, "stderr"))
330
+ const timer = setTimeout(() => {
331
+ onLine({ state: "fail", text: `the suite ran past ${timeoutSeconds} s and was stopped` })
332
+ child.kill("SIGKILL")
333
+ }, timeoutSeconds * 1000)
334
+ const abort = () => child.kill("SIGTERM")
335
+ signal?.addEventListener("abort", abort, { once: true })
336
+ child.on("close", (code, sig) => {
337
+ clearTimeout(timer)
338
+ signal?.removeEventListener("abort", abort)
339
+ if (buffer) feed("\n", "stdout")
340
+ closeSync(hostLog)
341
+ resolvePromise(signal?.aborted ? "interrupted" : sig ? null : code)
342
+ })
343
+ })
344
+ }