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,173 @@
1
+ // Where the lab keeps its bytes, and the one guard every write goes
2
+ // through.
3
+ //
4
+ // Heavy files (the verified ISO, the base disk, a run's overlay) live
5
+ // under the user cache, $XDG_CACHE_HOME/omakit/lab or ~/.cache/omakit/lab;
6
+ // compact run records under the user state, $XDG_STATE_HOME/omakit/lab or
7
+ // ~/.local/state/omakit/lab. Nothing is written anywhere else: every path
8
+ // the lab writes to is built by `inLab`, which refuses a path outside the
9
+ // resolved root, and tests/unit/self-containment.test.mjs holds every
10
+ // write under tools/lab/ to that builder. Measured before this
11
+ // (docs/history/2026-09-18-lab-inventory.md P6): the base, the firmware
12
+ // variables and the SSH key lived inside the omarchy-iso checkout, named
13
+ // by the ISO's file name.
14
+
15
+ import { closeSync, createReadStream, createWriteStream, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"
16
+ import { pipeline } from "node:stream/promises"
17
+ import { join, resolve, sep } from "node:path"
18
+ import { omakitCacheDir, omakitStateDir } from "../marketplace/paths.mjs"
19
+
20
+ /** The cache root: downloads, base, staging, the lock. */
21
+ export function labCacheDir(env = process.env) {
22
+ return omakitCacheDir("lab", env)
23
+ }
24
+
25
+ /** The state root: run records, one directory per run id. */
26
+ export function labStateDir(env = process.env) {
27
+ return omakitStateDir("lab", env)
28
+ }
29
+
30
+ /**
31
+ * The layout under the cache root. One base, not one per release
32
+ * (packaging/LAB_PLAN.md, the one-base rule): a pin update replaces it.
33
+ */
34
+ export function labLayout(env = process.env) {
35
+ const cache = labCacheDir(env)
36
+ const state = labStateDir(env)
37
+ return Object.freeze({
38
+ cache,
39
+ state,
40
+ downloads: join(cache, "downloads"),
41
+ base: join(cache, "base"),
42
+ staging: join(cache, "staging"),
43
+ plugins: join(cache, "plugins"),
44
+ lock: join(cache, "lab.lock"),
45
+ toolchain: join(cache, "toolchain.json"),
46
+ runs: join(state, "runs"),
47
+ })
48
+ }
49
+
50
+ /**
51
+ * A path under one of the two lab roots, or a throw. Every write in
52
+ * tools/lab/ names its target through this, so a target outside the lab
53
+ * is a bug that fails before a byte moves, not a file somewhere else.
54
+ */
55
+ export function inLab(root, ...parts) {
56
+ const target = resolve(root, ...parts)
57
+ const roots = [resolve(root)]
58
+ if (!roots.some((allowed) => target === allowed || target.startsWith(`${allowed}${sep}`))) {
59
+ throw new Error(`lab: ${target} is outside ${root}`)
60
+ }
61
+ return target
62
+ }
63
+
64
+ /** A directory under the lab, created private (0700) with its parents. */
65
+ export function labDir(root, ...parts) {
66
+ const dir = inLab(root, ...parts)
67
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
68
+ return dir
69
+ }
70
+
71
+ /** JSON written whole and fsynced, so a manifest is either there or not. */
72
+ export function writeJson(root, relative, value) {
73
+ const file = inLab(root, relative)
74
+ const fd = openSync(file, "w", 0o600)
75
+ try {
76
+ writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`)
77
+ fsyncSync(fd)
78
+ } finally {
79
+ closeSync(fd)
80
+ }
81
+ return file
82
+ }
83
+
84
+ export function readJson(file) {
85
+ try {
86
+ return JSON.parse(readFileSync(file, "utf8"))
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Move a file or directory within the lab: a rename when the two are on
94
+ * one filesystem, and for a file on another one a streamed copy followed
95
+ * by the removal of the source. The destination must not exist. This is
96
+ * the one place the lab renames anything, and tests/unit/self-containment
97
+ * holds the tree to it.
98
+ */
99
+ export async function moveIntoLab(root, from, relative) {
100
+ const to = inLab(root, relative)
101
+ try {
102
+ statSync(to)
103
+ throw new Error(`lab: ${to} exists; nothing is moved over it`)
104
+ } catch (error) {
105
+ if (error.code !== "ENOENT") throw error
106
+ }
107
+ try {
108
+ renameSync(from, to)
109
+ return to
110
+ } catch (error) {
111
+ if (error.code !== "EXDEV") throw error
112
+ }
113
+ const source = statSync(from)
114
+ if (!source.isFile()) throw new Error(`lab: ${from} is on another filesystem and is not a file; move it by hand`)
115
+ await copyIntoLab(root, from, relative)
116
+ unlinkSync(from)
117
+ return to
118
+ }
119
+
120
+ /** A streamed copy of one file into the lab, mode 0600, fsynced. The destination must not exist. */
121
+ export async function copyIntoLab(root, from, relative) {
122
+ const to = inLab(root, relative)
123
+ try {
124
+ statSync(to)
125
+ throw new Error(`lab: ${to} exists; nothing is copied over it`)
126
+ } catch (error) {
127
+ if (error.code !== "ENOENT") throw error
128
+ }
129
+ const out = createWriteStream(to, { mode: 0o600, flags: "wx" })
130
+ await pipeline(createReadStream(from), out)
131
+ const fd = openSync(to, "r")
132
+ try {
133
+ fsyncSync(fd)
134
+ } finally {
135
+ closeSync(fd)
136
+ }
137
+ return to
138
+ }
139
+
140
+ /** Remove something under the lab, and nothing outside it. */
141
+ export function removeFromLab(root, relative) {
142
+ rmSync(inLab(root, relative), { recursive: true, force: true })
143
+ }
144
+
145
+ /** `20260918-150245`, the run id shape the toolchain used, kept so a run directory sorts by time. */
146
+ export function stampNow(date = new Date()) {
147
+ const pad = (n) => String(n).padStart(2, "0")
148
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
149
+ }
150
+
151
+ /** Allocated bytes of a path, like `du -B1`: blocks times 512, so a sparse qcow2 is measured by what it takes. */
152
+ export function allocatedBytes(path) {
153
+ const walk = (p) => {
154
+ let st
155
+ try {
156
+ st = statSync(p)
157
+ } catch {
158
+ return 0
159
+ }
160
+ let total = st.blocks * 512
161
+ if (st.isDirectory()) {
162
+ let entries = []
163
+ try {
164
+ entries = readdirSync(p)
165
+ } catch {
166
+ return total
167
+ }
168
+ for (const entry of entries) total += walk(join(p, entry))
169
+ }
170
+ return total
171
+ }
172
+ return walk(path)
173
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "schema": 1,
3
+ "release": {
4
+ "name": "4.0.3",
5
+ "embeddedBuild": "2026.09.08",
6
+ "volume": "OMARCHY_202609",
7
+ "isoUrl": "https://iso.omarchy.org/omarchy-4.0.3.iso",
8
+ "checksumUrl": "https://iso.omarchy.org/omarchy-4.0.3.iso.sha256",
9
+ "signatureUrl": "https://iso.omarchy.org/omarchy-4.0.3.iso.sig",
10
+ "fileName": "omarchy-4.0.3.iso",
11
+ "bytes": 6260654080,
12
+ "sha256": "03d60bc74306dca51f96e1a84b690871d8d606826b260edd0208962da8507d14",
13
+ "signingFingerprint": "40DFB630FF42BCFFB047046CF0134EE680CAC571",
14
+ "signingKey": "omarchy.gpg",
15
+ "signingKeySha256": "15d6aac44df688165b2ea35fe0b23af239bbc66a6909c10a5c219e8d94b707de",
16
+ "expectedGuestVersion": "4.0.3-1"
17
+ },
18
+ "toolchain": {
19
+ "repository": "https://github.com/omacom-io/omarchy-iso",
20
+ "commit": "268bac16d351a21d867e37565738f458b11cb06c",
21
+ "harness": "bin/omarchy-iso-test",
22
+ "upstreamHarnessSha256": "fbc236a674464743c6ff8e4cc53be932dc36a16c9fcb159932006e9d7452439c",
23
+ "patch": "patches/omarchy-iso-test.patch",
24
+ "patchedHarnessSha256": "8637e8cc82413fb421daa17eb758e3136cf71099b108b6412326172993974e97"
25
+ },
26
+ "guest": {
27
+ "memoryMiB": 5120,
28
+ "user": "omarchy",
29
+ "password": "omarchy",
30
+ "diskGiB": 40
31
+ },
32
+ "measured": {
33
+ "on": "2026-09-18",
34
+ "buildMilliseconds": 357800,
35
+ "baseAllocatedBytes": 6181490688,
36
+ "baseDirectoryBytes": 6182264832,
37
+ "preparedLabBytes": 12442931200,
38
+ "overlayAfterRunBytes": 610734080,
39
+ "pluginsBytes": 6836224,
40
+ "source": "docs/MEASUREMENTS.md M14, measured by omakit lab on the reference host; packaging/LAB_PLAN.md M4 to M7 are the 2026-09-10 to 2026-09-13 observations it re-measured"
41
+ }
42
+ }
@@ -0,0 +1,64 @@
1
+ // The release pin: the one Omarchy release the lab prepares, its exact
2
+ // digest, its signer, and the toolchain that builds a base from it.
3
+ //
4
+ // Read from tools/lab/pin.json, never from the network: the lab never
5
+ // resolves "latest" (docs/history/2026-09-18-lab-inventory.md P13: the
6
+ // old setup scraped omarchy.org for the first ISO link and verified it
7
+ // against a sidecar downloaded beside it, so a substituted pair passed).
8
+ // Updating the pin is a reviewed change, docs/LAB.md says how.
9
+
10
+ import { readFileSync } from "node:fs"
11
+ import { dirname, join } from "node:path"
12
+ import { fileURLToPath } from "node:url"
13
+
14
+ export const LAB_DIR = dirname(fileURLToPath(import.meta.url))
15
+
16
+ let cached = null
17
+
18
+ /** The pin, parsed once. `file` is injectable for tests that need a small release. */
19
+ export function labPin(file = join(LAB_DIR, "pin.json")) {
20
+ if (file === join(LAB_DIR, "pin.json") && cached) return cached
21
+ const pin = JSON.parse(readFileSync(file, "utf8"))
22
+ for (const key of ["release", "toolchain", "guest", "measured"]) {
23
+ if (!pin[key] || typeof pin[key] !== "object") throw new Error(`lab pin: no ${key} section in ${file}`)
24
+ }
25
+ if (!/^[0-9a-f]{64}$/.test(pin.release.sha256)) throw new Error("lab pin: the release sha256 is not 64 hex characters")
26
+ if (!/^[0-9A-F]{40}$/.test(pin.release.signingFingerprint)) throw new Error("lab pin: the signing fingerprint is not 40 hex characters")
27
+ if (!Number.isInteger(pin.release.bytes) || pin.release.bytes <= 0) throw new Error("lab pin: the release byte count is not a positive integer")
28
+ if (/latest/i.test(pin.release.isoUrl)) throw new Error("lab pin: the ISO URL resolves latest; the pin names one release")
29
+ if (file === join(LAB_DIR, "pin.json")) cached = pin
30
+ return pin
31
+ }
32
+
33
+ /**
34
+ * Bytes for a person, in both units, so a size cannot be made to look
35
+ * smaller by choosing one: `6,260,654,080 B (6.261 GB / 5.831 GiB)`.
36
+ * Decimal GB is what the download page and the disk vendor say, binary
37
+ * GiB is what `df` and `du -h` say; both are printed every time.
38
+ */
39
+ export function bytesBoth(bytes) {
40
+ const n = Number(bytes)
41
+ const gb = (n / 1e9).toFixed(3)
42
+ const gib = (n / 2 ** 30).toFixed(3)
43
+ return `${n.toLocaleString("en-US")} B (${gb} GB / ${gib} GiB)`
44
+ }
45
+
46
+ /** The two units without the byte count, for a breakdown whose total already printed it: `6.261 GB / 5.831 GiB`. */
47
+ export function gbBoth(bytes) {
48
+ const n = Number(bytes)
49
+ return `${(n / 1e9).toFixed(3)} GB / ${(n / 2 ** 30).toFixed(3)} GiB`
50
+ }
51
+
52
+ /** `4m 49.5s` from milliseconds, the way a person reads a build time. */
53
+ export function durationWords(ms) {
54
+ const seconds = Math.round(ms / 100) / 10
55
+ if (seconds < 60) return `${seconds.toFixed(1)}s`
56
+ const minutes = Math.floor(seconds / 60)
57
+ return `${minutes}m ${(seconds - minutes * 60).toFixed(1)}s`
58
+ }
59
+
60
+ /** The first eight and last eight characters of a digest or fingerprint, for a line that names it without being it. */
61
+ export function shortId(value) {
62
+ const s = String(value)
63
+ return s.length > 20 ? `${s.slice(0, 8)}...${s.slice(-8)}` : s
64
+ }
@@ -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
+ }