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,68 @@
1
+ // `omakit lab prune`: the explicit owner of lab-cache deletion. It
2
+ // inventories what the lab owns under its own roots, prints each target
3
+ // with its allocated bytes and the exact total, asks once (or takes
4
+ // --yes), refuses while a run holds the lab or a QEMU still answers on a
5
+ // staged socket, and afterwards reports what was recovered and what
6
+ // remains. It never follows a symlink out of the lab, never touches the
7
+ // marketplace pin or a checkout, and leaves run records alone unless
8
+ // --runs names them.
9
+
10
+ import { existsSync, lstatSync, readdirSync } from "node:fs"
11
+ import { join } from "node:path"
12
+ import { bytesBoth, labPin } from "./pin.mjs"
13
+ import { allocatedBytes, labLayout, removeFromLab } from "./paths.mjs"
14
+ import { inspectBase, inspectLock, inspectStaging } from "./inspect.mjs"
15
+ import { LabError } from "./run.mjs"
16
+
17
+ /**
18
+ * What prune would remove. `keepIso` leaves the verified download (the
19
+ * expensive fetch); `runs` adds the run records under the state root.
20
+ */
21
+ export async function planPrune({ env = process.env, pin = labPin(), keepIso = false, runs = false } = {}) {
22
+ const layout = labLayout(env)
23
+ const targets = []
24
+ const add = (root, relative, what) => {
25
+ const path = join(root, relative)
26
+ if (!existsSync(path)) return
27
+ if (lstatSync(path).isSymbolicLink()) throw new LabError("prune-refused", `${path} is a symbolic link; the lab wrote none, so nothing under it is removed`)
28
+ targets.push({ root, relative, path, what, bytes: allocatedBytes(path) })
29
+ }
30
+ const lock = await inspectLock(layout)
31
+ const staging = await inspectStaging(layout)
32
+ const active = staging.filter((entry) => entry.alive)
33
+ const blockers = []
34
+ if (lock.held && lock.alive) blockers.push(`run ${lock.record?.runId || "unknown"} holds the lab (pid ${lock.record?.pid || "?"}${lock.qemuAnswers ? ", its QEMU answers on QMP" : ""})`)
35
+ for (const entry of active) blockers.push(`a QEMU still answers on ${entry.socket}`)
36
+ if (existsSync(layout.downloads)) {
37
+ for (const digest of readdirSync(layout.downloads)) {
38
+ const dir = join(layout.downloads, digest)
39
+ const verified = digest === pin.release.sha256 && existsSync(join(dir, "verified.json")) && existsSync(join(dir, pin.release.fileName))
40
+ if (verified && keepIso) {
41
+ add(layout.cache, `downloads/${digest}/${pin.release.fileName}.part`, "a partial download beside the verified ISO")
42
+ continue
43
+ }
44
+ add(layout.cache, `downloads/${digest}`, verified ? `the verified ISO of ${pin.release.name} and its record` : digest === pin.release.sha256 ? "the unverified or partial download of the pinned release" : `a download directory of another digest (${digest.slice(0, 12)})`)
45
+ }
46
+ }
47
+ const base = inspectBase(layout, pin)
48
+ if (base.state !== "missing") add(layout.cache, "base", `the ${base.state} base${base.manifest ? ` (Omarchy ${base.manifest.release.name}, guest ${base.manifest.guest.version})` : ""}`)
49
+ for (const entry of staging) if (!entry.alive) add(layout.cache, `staging/${entry.name}`, `staging left by ${entry.name}`)
50
+ if (existsSync(layout.plugins)) add(layout.cache, "plugins", "the listed plugins cached for the weigh evidence gate")
51
+ if (lock.held && !lock.alive) add(layout.cache, "lab.lock", `a stale lock from ${lock.record?.runId || "an unknown run"}`)
52
+ if (runs && existsSync(layout.runs)) add(layout.state, "runs", "every run record and document under the state root")
53
+ const total = targets.reduce((sum, target) => sum + target.bytes, 0)
54
+ return { layout, targets, total, blockers, remaining: { cache: existsSync(layout.cache) ? allocatedBytes(layout.cache) : 0, runs: existsSync(layout.runs) ? allocatedBytes(layout.runs) : 0 } }
55
+ }
56
+
57
+ /** Remove exactly the plan's targets, after the caller's consent; report recovered and remaining bytes. */
58
+ export function prune(plan) {
59
+ if (plan.blockers.length) throw new LabError("lab-busy", `prune refuses while ${plan.blockers.join("; ")}`, { remedy: "wait for the run, or `omakit lab inspect` to see it" })
60
+ const removed = []
61
+ for (const target of plan.targets) {
62
+ removeFromLab(target.root, target.relative)
63
+ removed.push(target)
64
+ }
65
+ const recovered = removed.reduce((sum, target) => sum + target.bytes, 0)
66
+ const remaining = { cache: existsSync(plan.layout.cache) ? allocatedBytes(plan.layout.cache) : 0, runs: existsSync(plan.layout.runs) ? allocatedBytes(plan.layout.runs) : 0 }
67
+ return { removed, recovered, remaining, words: `recovered ${bytesBoth(recovered)}; ${bytesBoth(remaining.cache)} remain in the lab cache and ${bytesBoth(remaining.runs)} in run records` }
68
+ }
@@ -0,0 +1,153 @@
1
+ // The guest's machine: QEMU's argument list, built pure so a test can read
2
+ // it, and the QMP socket, spoken to from Node so a run needs no socat.
3
+ //
4
+ // What the argument list guarantees, and tests/unit/lab.test.mjs holds it
5
+ // to: the base disk is the overlay's backing file, opened by QEMU read-only
6
+ // because the overlay is what is written; the firmware variables are the
7
+ // run's own copy, never the base's template (docs/history/2026-09-18-lab-
8
+ // inventory.md P15: the template's mtime moved with every run); no display,
9
+ // no host directory, no host socket and no host device reaches the guest;
10
+ // the one network device is user-mode with SSH forwarded on 127.0.0.1 and
11
+ // nothing else forwarded.
12
+
13
+ import { spawn } from "node:child_process"
14
+ import { connect } from "node:net"
15
+ import { OVMF_CODE } from "./host.mjs"
16
+
17
+ /**
18
+ * @param {{ overlay: string, vars: string, memoryMiB: number, cpus: number, sshPort: number, qmpSocket: string, serialLog: string, pidFile: string, ovmfCode?: string }} options
19
+ */
20
+ export function qemuArgs({ overlay, vars, memoryMiB, cpus, sshPort, qmpSocket, serialLog, pidFile, ovmfCode = OVMF_CODE }) {
21
+ if (!Number.isInteger(sshPort) || sshPort < 1024 || sshPort > 65535) throw new Error(`lab: the SSH port must be an unprivileged port, not ${sshPort}`)
22
+ return [
23
+ "-cpu", "host", "-enable-kvm", "-machine", "q35,accel=kvm",
24
+ "-smp", String(cpus),
25
+ "-m", String(memoryMiB),
26
+ "-drive", `if=pflash,format=raw,readonly=on,file=${ovmfCode}`,
27
+ "-drive", `if=pflash,format=raw,file=${vars}`,
28
+ "-drive", `file=${overlay},format=qcow2,if=none,id=drive0`,
29
+ "-device", "virtio-blk-pci,drive=drive0,bootindex=1",
30
+ "-device", "virtio-vga",
31
+ "-display", "none",
32
+ "-usb", "-device", "usb-tablet",
33
+ "-netdev", `user,id=net0,hostfwd=tcp:127.0.0.1:${sshPort}-:22`,
34
+ "-device", "virtio-net-pci,netdev=net0",
35
+ "-qmp", `unix:${qmpSocket},server,nowait`,
36
+ "-serial", `file:${serialLog}`,
37
+ "-pidfile", pidFile,
38
+ ]
39
+ }
40
+
41
+ /**
42
+ * Start QEMU as a child of this process, not daemonised: the lifetime is
43
+ * the run's, and the exit handlers in run.mjs end it on every path. The
44
+ * child's stdio goes to the run's log so a QEMU that refuses to start
45
+ * says why.
46
+ */
47
+ export function startQemu(args, { log, binary = "qemu-system-x86_64" } = {}) {
48
+ const child = spawn(binary, args, { stdio: ["ignore", log, log] })
49
+ return child
50
+ }
51
+
52
+ /**
53
+ * One QMP session: the capabilities handshake, then `execute` per
54
+ * command. A command's answer is the first `return` or `error` object
55
+ * after it, events in between are dropped. The socket is opened per
56
+ * command, as the toolchain's socat did; QMP allows one client and a
57
+ * held-open socket would block a diagnostic connection.
58
+ */
59
+ export function qmpExecute(socket, command, args = undefined, { timeoutMs = 5000 } = {}) {
60
+ return new Promise((resolve, reject) => {
61
+ const client = connect(socket)
62
+ let buffer = ""
63
+ let stage = 0
64
+ let settled = false
65
+ const timer = setTimeout(() => finish(new Error(`QMP ${command} did not answer within ${timeoutMs} ms`)), timeoutMs)
66
+ const finish = (error, value) => {
67
+ if (settled) return
68
+ settled = true
69
+ clearTimeout(timer)
70
+ client.destroy()
71
+ if (error) reject(error)
72
+ else resolve(value)
73
+ }
74
+ client.on("error", (error) => finish(Object.assign(new Error(`QMP socket ${socket}: ${error.code || error.message}`), { code: "qmp-unavailable" })))
75
+ client.on("data", (chunk) => {
76
+ buffer += chunk.toString("utf8")
77
+ let index
78
+ while ((index = buffer.indexOf("\n")) >= 0) {
79
+ const line = buffer.slice(0, index)
80
+ buffer = buffer.slice(index + 1)
81
+ let message
82
+ try {
83
+ message = JSON.parse(line)
84
+ } catch {
85
+ continue
86
+ }
87
+ if (stage === 0 && message.QMP) {
88
+ stage = 1
89
+ client.write(`${JSON.stringify({ execute: "qmp_capabilities" })}\n`)
90
+ } else if (stage === 1 && "return" in message) {
91
+ stage = 2
92
+ client.write(`${JSON.stringify({ execute: command, ...(args ? { arguments: args } : {}) })}\n`)
93
+ } else if (stage === 2 && "return" in message) {
94
+ finish(null, message.return)
95
+ } else if (stage === 2 && message.error) {
96
+ finish(Object.assign(new Error(`QMP ${command}: ${message.error.desc || message.error.class}`), { code: "qmp-error" }))
97
+ }
98
+ }
99
+ })
100
+ })
101
+ }
102
+
103
+ /** Whether a QEMU answers on this socket: the cross-namespace liveness question prune and the lock ask. */
104
+ export async function qmpAlive(socket) {
105
+ try {
106
+ const status = await qmpExecute(socket, "query-status", undefined, { timeoutMs: 2000 })
107
+ return { alive: true, status: status?.status || null }
108
+ } catch {
109
+ return { alive: false, status: null }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * The qcode for one character, as the toolchain's `type_text` mapped it.
115
+ * Only what a login needs is typed by the lab (the guest password), but
116
+ * the table is the toolchain's whole one so a suite that asks for more
117
+ * gets the same keys.
118
+ */
119
+ const SHIFTED = {
120
+ "_": "minus", ":": "semicolon", "@": "2", "|": "backslash", '"': "apostrophe", "+": "equal", "%": "5", "&": "7", "*": "8",
121
+ "(": "9", ")": "0", "<": "comma", ">": "dot", "!": "1", "#": "3", "$": "4", "?": "slash", "~": "grave_accent", "{": "bracket_left", "}": "bracket_right",
122
+ }
123
+ const PLAIN = { " ": "spc", ".": "dot", ",": "comma", "-": "minus", "/": "slash", ";": "semicolon", "'": "apostrophe", "=": "equal", "\\": "backslash", "[": "bracket_left", "]": "bracket_right" }
124
+
125
+ export function qcodesFor(text) {
126
+ const chords = []
127
+ for (const ch of String(text)) {
128
+ if (/^[a-z0-9]$/.test(ch)) chords.push([ch])
129
+ else if (/^[A-Z]$/.test(ch)) chords.push(["shift", ch.toLowerCase()])
130
+ else if (ch in PLAIN) chords.push([PLAIN[ch]])
131
+ else if (ch in SHIFTED) chords.push(["shift", SHIFTED[ch]])
132
+ else throw new Error(`lab: no qcode for ${JSON.stringify(ch)}`)
133
+ }
134
+ return chords
135
+ }
136
+
137
+ /** Press one chord: `["ctrl","alt","f3"]`, or `["ret"]`. */
138
+ export async function press(socket, chord) {
139
+ await qmpExecute(socket, "send-key", { keys: chord.map((key) => ({ type: "qcode", data: key })) })
140
+ }
141
+
142
+ /** Type text one chord at a time, 50 ms apart as the toolchain did. */
143
+ export async function typeText(socket, text) {
144
+ for (const chord of qcodesFor(text)) {
145
+ await press(socket, chord)
146
+ await new Promise((resolve) => setTimeout(resolve, 50))
147
+ }
148
+ }
149
+
150
+ /** A screendump to a PPM file; the caller converts it if it has `magick`. */
151
+ export async function screendump(socket, file) {
152
+ await qmpExecute(socket, "screendump", { filename: file })
153
+ }
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ // QMP from a shell: `node tools/lab/qmp-cli.mjs <socket> screendump <file>`,
3
+ // `... status`, `... press <chord>`. The harness's capture_console is the
4
+ // one caller; it exists so a suite needs no socat.
5
+
6
+ import { press, qmpExecute, screendump } from "./qemu.mjs"
7
+
8
+ const [socket, command, argument] = process.argv.slice(2)
9
+ if (!socket || !command) {
10
+ process.stderr.write("usage: qmp-cli.mjs <socket> screendump <file> | status | press <chord>\n")
11
+ process.exit(2)
12
+ }
13
+ try {
14
+ if (command === "screendump") await screendump(socket, argument)
15
+ else if (command === "status") process.stdout.write(`${JSON.stringify(await qmpExecute(socket, "query-status"))}\n`)
16
+ else if (command === "press") await press(socket, String(argument).split("-"))
17
+ else throw new Error(`unknown command ${command}`)
18
+ } catch (error) {
19
+ process.stderr.write(`${error.message}\n`)
20
+ process.exit(1)
21
+ }
@@ -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
+ }