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,177 @@
1
+ // What the host has and what it lacks, each with its measured reason.
2
+ //
3
+ // Read-only: every probe is a stat, a read of /proc, or a `--version`.
4
+ // Nothing here installs anything (packaging/LAB_PLAN.md: lab-only host
5
+ // capabilities are preflighted and reported, never installed). Measured
6
+ // before this (docs/history/2026-09-18-lab-inventory.md P10, P20): the
7
+ // upstream harness ran `omarchy-pkg-add` for six packages at the top of
8
+ // every invocation, and the old doctor refused on the first missing thing
9
+ // without saying what it cost.
10
+
11
+ import { accessSync, constants, existsSync, readFileSync, statSync, statfsSync } from "node:fs"
12
+ import { spawnSync } from "node:child_process"
13
+ import { cpus } from "node:os"
14
+ import { dirname } from "node:path"
15
+ import { labPin } from "./pin.mjs"
16
+
17
+ export const OVMF_CODE = "/usr/share/edk2/x64/OVMF_CODE.4m.fd"
18
+ export const OVMF_VARS_TEMPLATE = "/usr/share/edk2/x64/OVMF_VARS.4m.fd"
19
+
20
+ /** The commands a run needs, and what each is for. `--version` is the whole question asked of any of them here. */
21
+ export const RUN_COMMANDS = Object.freeze([
22
+ Object.freeze({ command: "qemu-system-x86_64", package: "qemu-full", why: "boots the guest" }),
23
+ Object.freeze({ command: "qemu-img", package: "qemu-full", why: "creates the per-run overlay and inspects disks" }),
24
+ Object.freeze({ command: "ssh", versionArgs: ["-V"], package: "openssh", why: "runs the suite in the guest" }),
25
+ ])
26
+
27
+ /** What base preparation needs beyond a run: the toolchain's console driver reads the installer's screen. */
28
+ export const BUILD_COMMANDS = Object.freeze([
29
+ Object.freeze({ command: "socat", versionArgs: ["-V"], package: "socat", why: "the toolchain's QMP transport" }),
30
+ Object.freeze({ command: "magick", package: "imagemagick", why: "the toolchain's screendump conversion" }),
31
+ Object.freeze({ command: "tesseract", package: "tesseract, tesseract-data-eng", why: "the toolchain reads the installer's screens" }),
32
+ // `-l -f /dev/null` is a read that fails; a bare `ssh-keygen` starts
33
+ // generating a key at ~/.ssh/id_ed25519 (measured: it printed
34
+ // "Generating public/private ed25519 key pair." under this probe). The
35
+ // probe's output is an error sentence, not a version, so the line says
36
+ // the command is there and nothing it printed (measured on 2026-09-19:
37
+ // doctor showed "ok ssh-keygen /dev/null is not a public key file.").
38
+ Object.freeze({ command: "ssh-keygen", versionArgs: ["-l", "-f", "/dev/null"], presenceOnly: true, package: "openssh", why: "the guest's lab key" }),
39
+ Object.freeze({ command: "python3", package: "python", why: "the toolchain's one-file bootstrap server" }),
40
+ ])
41
+
42
+ /** What acquisition needs: the signature check. */
43
+ export const VERIFY_COMMANDS = Object.freeze([
44
+ Object.freeze({ command: "gpg", package: "gnupg", why: "verifies the release signature against the packaged key" }),
45
+ ])
46
+
47
+ /**
48
+ * Whether a command is on PATH, and the first line it prints for its
49
+ * version. Presence is what matters and is read from the spawn itself: a
50
+ * command that is there answers, whatever its exit status (`ssh -V` prints
51
+ * on stderr, `ssh-keygen` has no version flag and prints usage with exit
52
+ * 1), and one that is not is ENOENT.
53
+ */
54
+ function versionOf(entry, run = spawnSync, env = process.env) {
55
+ const result = run(entry.command, [...(entry.versionArgs || ["--version"])], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 4000, env })
56
+ if (result.error?.code === "ENOENT") return null
57
+ if (entry.presenceOnly) return `${entry.command} is on PATH (${entry.package})`
58
+ const line = `${result.stdout || ""}\n${result.stderr || ""}`.split("\n").map((l) => l.trim()).find(Boolean)
59
+ return line || `${entry.command} is on PATH`
60
+ }
61
+
62
+ /** One line per command: present with its first version line, or missing with the package that provides it. */
63
+ export function probeCommands(list, { run, env = process.env } = {}) {
64
+ return list.map((entry) => {
65
+ const version = versionOf(entry, run, env)
66
+ return {
67
+ name: entry.command,
68
+ state: version ? "ok" : "missing",
69
+ reason: version ? version : `${entry.command} is not on PATH (${entry.why})`,
70
+ remedy: version ? null : `sudo pacman -S --needed ${entry.package}`,
71
+ }
72
+ })
73
+ }
74
+
75
+ /**
76
+ * Where this process runs, when /dev/kvm is not there: a container leaves
77
+ * marks (`/.dockerenv`, `/run/.containerenv`, a container runtime in
78
+ * /proc/1/cgroup), and a loaded kvm module without its device node is a
79
+ * missing node, not a missing kernel feature. Measured on 2026-09-19 by a
80
+ * first user whose `doctor` said /dev/kvm was absent on the machine whose
81
+ * lab suites had run hours earlier: the product was run from inside a
82
+ * sandbox without the device, and the message blamed the kernel and the
83
+ * firmware (docs/evidence/ux/2026-09-19-first-user-test.json, finding 11).
84
+ */
85
+ export function kvmContext({ files = { dockerenv: "/.dockerenv", containerenv: "/run/.containerenv", cgroup: "/proc/1/cgroup", module: "/sys/module/kvm" } } = {}) {
86
+ const marks = []
87
+ if (existsSync(files.dockerenv)) marks.push(files.dockerenv)
88
+ if (existsSync(files.containerenv)) marks.push(files.containerenv)
89
+ try {
90
+ const cgroup = readFileSync(files.cgroup, "utf8")
91
+ const runtime = cgroup.match(/docker|lxc|podman|containerd|libpod|machine\.slice|nspawn/)?.[0]
92
+ if (runtime) marks.push(`${files.cgroup} names ${runtime}`)
93
+ } catch {
94
+ // no cgroup file to read: not Linux, or no /proc; nothing to conclude
95
+ }
96
+ return { container: marks.length > 0, marks, moduleLoaded: existsSync(files.module) }
97
+ }
98
+
99
+ /** /dev/kvm: a character device this user can open for reading and writing, which is what QEMU needs from it. */
100
+ export function probeKvm(path = "/dev/kvm", context = kvmContext) {
101
+ try {
102
+ const st = statSync(path)
103
+ if (!st.isCharacterDevice()) return { name: "kvm", state: "missing", reason: `${path} is not a character device`, remedy: "load the kvm module for this CPU (kvm_amd or kvm_intel)" }
104
+ accessSync(path, constants.R_OK | constants.W_OK)
105
+ return { name: "kvm", state: "ok", reason: `${path} is a character device this user can open read-write` }
106
+ } catch (error) {
107
+ if (error.code === "ENOENT") {
108
+ const where = context()
109
+ if (where.container) return { name: "kvm", state: "missing", reason: `${path} is not there in this environment, which is a container (${where.marks.join("; ")}); the host's device is not mapped in, and a lab that ran on the host says nothing about this process`, remedy: "run omakit on the host itself, or start the container with --device /dev/kvm" }
110
+ if (where.moduleLoaded) return { name: "kvm", state: "missing", reason: `${path} is not there although the kvm module is loaded (/sys/module/kvm exists): the device node is missing, or this process runs in a mount namespace without the host's /dev`, remedy: "check udev for /dev/kvm on the host, or run omakit outside the sandbox" }
111
+ return { name: "kvm", state: "missing", reason: `${path} is not there and no kvm module is loaded: virtualisation may be off in firmware, or this is a virtual machine without nested virtualisation`, remedy: "enable virtualisation in firmware (kvm_amd or kvm_intel loads on its own); a guest without KVM is not something the lab runs" }
112
+ }
113
+ return { name: "kvm", state: "missing", reason: `${path} exists but this user cannot open it (${error.code}): a permission of the device node or a group this login does not carry yet`, remedy: "sudo usermod -aG kvm $USER, then log in again (a new login picks the group up; a new shell does not)" }
114
+ }
115
+ }
116
+
117
+ /** The two firmware files QEMU boots the guest with, readable. */
118
+ export function probeOvmf(code = OVMF_CODE, vars = OVMF_VARS_TEMPLATE) {
119
+ const missing = [code, vars].filter((file) => {
120
+ try {
121
+ accessSync(file, constants.R_OK)
122
+ return false
123
+ } catch {
124
+ return true
125
+ }
126
+ })
127
+ if (missing.length) return { name: "ovmf", state: "missing", reason: `${missing.join(" and ")} not readable`, remedy: "sudo pacman -S --needed edk2-ovmf" }
128
+ return { name: "ovmf", state: "ok", reason: `${code} (${statSync(code).size.toLocaleString("en-US")} B) and the ${statSync(vars).size.toLocaleString("en-US")} B variables template readable` }
129
+ }
130
+
131
+ /** Free bytes on the filesystem that holds (or would hold) the lab cache, from the nearest existing ancestor. */
132
+ export function freeBytesAt(path) {
133
+ let probe = path
134
+ while (!existsSync(probe)) {
135
+ const parent = dirname(probe)
136
+ if (parent === probe) break
137
+ probe = parent
138
+ }
139
+ const fs = statfsSync(probe)
140
+ return { path: probe, bytes: Number(fs.bavail) * Number(fs.bsize) }
141
+ }
142
+
143
+ /** MemTotal and MemAvailable from /proc/meminfo, in bytes. */
144
+ export function memoryBytes(file = "/proc/meminfo") {
145
+ const text = readFileSync(file, "utf8")
146
+ const read = (key) => Number(text.match(new RegExp(`^${key}:\\s+(\\d+) kB`, "m"))?.[1] || 0) * 1024
147
+ return { total: read("MemTotal"), available: read("MemAvailable") }
148
+ }
149
+
150
+ /**
151
+ * The host for a run: KVM, the three commands, the firmware, memory against
152
+ * the guest's, and the CPU count the guest gets. `--json` carries the same
153
+ * fields.
154
+ */
155
+ export function probeRunHost({ pin = labPin(), run, env = process.env } = {}) {
156
+ const memory = memoryBytes()
157
+ const guestBytes = pin.guest.memoryMiB * 1024 * 1024
158
+ const lines = [probeKvm(), ...probeCommands(RUN_COMMANDS, { run, env }), probeOvmf()]
159
+ lines.push({
160
+ name: "memory",
161
+ state: memory.total >= guestBytes * 1.5 ? "ok" : "missing",
162
+ reason: `${(memory.total / 2 ** 20).toFixed(0)} MiB total, ${(memory.available / 2 ** 20).toFixed(0)} MiB available; the guest takes ${pin.guest.memoryMiB} MiB`,
163
+ remedy: memory.total >= guestBytes * 1.5 ? null : `the guest needs ${pin.guest.memoryMiB} MiB and the host its own; this host has less than one and a half times that`,
164
+ })
165
+ lines.push({ name: "cpus", state: "ok", reason: `${cpus().length} logical CPUs, and the guest gets every one, as the toolchain's -smp $(nproc) gave the reference build (32, M14)` })
166
+ return lines
167
+ }
168
+
169
+ /**
170
+ * The vCPU count a run gives the guest: every logical CPU, which is what
171
+ * the toolchain's `-smp $(nproc)` gave the reference build (32, packaging/LAB_PLAN.md M4, M14). A
172
+ * smaller number would be a guess about what a suite needs, and no run
173
+ * has measured one.
174
+ */
175
+ export function guestCpus() {
176
+ return Math.max(1, cpus().length)
177
+ }
@@ -0,0 +1,240 @@
1
+ // `omakit lab inspect`, and the lab lines of `omakit doctor`: what the pin
2
+ // advertises, what is on disk, whether it was verified, what the host is
3
+ // missing, and what each missing thing costs. Read-only: no directory is
4
+ // created, no file is written, nothing is fetched. `--verify` re-hashes
5
+ // the ISO and re-checks the signature now instead of reporting the record.
6
+
7
+ import { existsSync, readdirSync, statSync } from "node:fs"
8
+ import { join } from "node:path"
9
+ import { LAB_DIR, bytesBoth, durationWords, labPin } from "./pin.mjs"
10
+ import { allocatedBytes, labLayout, readJson } from "./paths.mjs"
11
+ import { BUILD_COMMANDS, VERIFY_COMMANDS, freeBytesAt, probeCommands, probeRunHost } from "./host.mjs"
12
+ import { judgeRelease, sha256File } from "./verify.mjs"
13
+ import { qmpAlive } from "./qemu.mjs"
14
+
15
+ /** The names the base directory holds; a manifest names the same. */
16
+ export const BASE_FILES = Object.freeze({
17
+ disk: "base.qcow2",
18
+ vars: "firmware-vars.template",
19
+ key: "id_ed25519",
20
+ publicKey: "id_ed25519.pub",
21
+ manifest: "manifest.json",
22
+ })
23
+
24
+ /** The verified ISO's place under downloads: by digest, so a second release never shares a directory with the first. */
25
+ export function downloadDir(layout, pin = labPin()) {
26
+ return join(layout.downloads, pin.release.sha256)
27
+ }
28
+
29
+ /**
30
+ * The ISO on disk against its verification record. The record is what
31
+ * `setup` wrote after the digest and the signature passed; the file is
32
+ * held to the record by size and mtime, which is what can be read in a
33
+ * millisecond, and `--verify` is the five-second re-hash.
34
+ */
35
+ export function inspectDownload(layout, pin = labPin(), { verify = false, stagingRoot = layout.staging, onProgress } = {}) {
36
+ const dir = downloadDir(layout, pin)
37
+ const iso = join(dir, pin.release.fileName)
38
+ const out = { dir, iso, present: false, bytes: null, partial: null, record: readJson(join(dir, "verified.json")), verified: false, reason: null, sidecars: { checksum: existsSync(`${iso}.sha256`), signature: existsSync(`${iso}.sig`) } }
39
+ const part = `${iso}.part`
40
+ if (existsSync(part)) out.partial = statSync(part).size
41
+ if (!existsSync(iso)) {
42
+ out.reason = out.partial !== null ? `not there; a partial download of ${out.partial.toLocaleString("en-US")} of ${pin.release.bytes.toLocaleString("en-US")} B is waiting to resume` : "not there"
43
+ return out
44
+ }
45
+ const st = statSync(iso)
46
+ out.present = true
47
+ out.bytes = st.size
48
+ if (verify) {
49
+ const judged = judgeRelease({ file: iso, signature: out.sidecars.signature ? `${iso}.sig` : null, checksum: out.sidecars.checksum ? `${iso}.sha256` : null, pin, stagingRoot, onProgress })
50
+ out.verified = judged.ok
51
+ out.reason = judged.ok ? `verified now: ${judged.reason}` : `not verified: ${judged.reason}`
52
+ out.judged = judged
53
+ return out
54
+ }
55
+ if (!out.record) {
56
+ out.reason = `${st.size.toLocaleString("en-US")} B on disk with no verification record; \`omakit lab setup\` verifies it, or \`omakit lab inspect --verify\` checks it now`
57
+ return out
58
+ }
59
+ const same = out.record.bytes === st.size && out.record.sha256 === pin.release.sha256 && Math.abs(Number(out.record.mtimeMs) - st.mtimeMs) < 1
60
+ out.verified = same
61
+ out.reason = same
62
+ ? `verified ${out.record.verifiedAt}: sha256 ${out.record.sha256}, signed by ${out.record.fingerprint}; the file's size and mtime still match that record`
63
+ : `the file changed since the record of ${out.record.verifiedAt} (${out.record.bytes === st.size ? "same size, different mtime" : `${st.size.toLocaleString("en-US")} B now, ${Number(out.record.bytes).toLocaleString("en-US")} B then`}); \`omakit lab setup\` verifies it again`
64
+ return out
65
+ }
66
+
67
+ /**
68
+ * The base: missing, ready, mismatch (a base of another release than the
69
+ * pin), or invalid (files without a complete manifest, or a disk whose
70
+ * size is not the recorded one). Never booted here. `allocatedBytes` is
71
+ * `du -B1` over the directory, the figure prune will recover.
72
+ */
73
+ export function inspectBase(layout, pin = labPin()) {
74
+ const dir = layout.base
75
+ const out = { dir, state: "missing", reason: "no base; run `omakit lab setup`", manifest: null, allocatedBytes: 0, disk: join(dir, BASE_FILES.disk) }
76
+ if (!existsSync(dir)) return out
77
+ out.allocatedBytes = allocatedBytes(dir)
78
+ const manifest = readJson(join(dir, BASE_FILES.manifest))
79
+ const diskThere = existsSync(out.disk)
80
+ if (!manifest || manifest.state !== "ready" || !manifest.release || !manifest.guest || !manifest.disk) {
81
+ out.state = "invalid"
82
+ out.reason = diskThere ? `${BASE_FILES.disk} is there (${bytesBoth(out.allocatedBytes)}) but ${BASE_FILES.manifest} is ${manifest ? "incomplete" : "missing"}; it will not be booted` : `a base directory with no disk and ${manifest ? "an incomplete" : "no"} manifest; \`omakit lab prune\` removes it`
83
+ return out
84
+ }
85
+ out.manifest = manifest
86
+ if (!diskThere) {
87
+ out.state = "invalid"
88
+ out.reason = `the manifest names ${BASE_FILES.disk} and it is not there; it will not be booted`
89
+ return out
90
+ }
91
+ const size = statSync(out.disk).size
92
+ if (size !== manifest.disk.bytes) {
93
+ out.state = "invalid"
94
+ out.reason = `${BASE_FILES.disk} is ${size.toLocaleString("en-US")} B and the manifest recorded ${Number(manifest.disk.bytes).toLocaleString("en-US")} B; it will not be booted`
95
+ return out
96
+ }
97
+ for (const name of [BASE_FILES.vars, BASE_FILES.key]) {
98
+ if (!existsSync(join(dir, name))) {
99
+ out.state = "invalid"
100
+ out.reason = `${name} is missing from the base directory; it will not be booted`
101
+ return out
102
+ }
103
+ }
104
+ if (manifest.release.sha256 !== pin.release.sha256 || manifest.guest.version !== pin.release.expectedGuestVersion) {
105
+ out.state = "mismatch"
106
+ out.reason = `cached Omarchy ${manifest.release.name} (guest ${manifest.guest.version}, iso ${manifest.release.sha256}); required ${pin.release.name} (guest ${pin.release.expectedGuestVersion}, iso ${pin.release.sha256}); \`omakit lab setup\` replaces it`
107
+ return out
108
+ }
109
+ out.state = "ready"
110
+ out.reason = `Omarchy ${manifest.release.name}, guest omarchy ${manifest.guest.version}; ${bytesBoth(out.allocatedBytes)}; created ${manifest.createdAt}`
111
+ return out
112
+ }
113
+
114
+ /**
115
+ * The toolchain: the omarchy-iso checkout `setup --toolchain` recorded,
116
+ * whose harness must hash to the pinned patched digest before setup runs
117
+ * it. What is checked is the file, not git state: a harness that hashes
118
+ * to the pin is the pinned commit with the patch applied, whatever else
119
+ * the checkout holds.
120
+ */
121
+ export function inspectToolchain(layout, pin = labPin()) {
122
+ const record = readJson(layout.toolchain)
123
+ const out = { record, dir: record?.dir || null, harness: null, sha256: null, state: "missing", reason: null }
124
+ if (!record?.dir) {
125
+ out.reason = "no toolchain recorded; `omakit lab setup --toolchain <omarchy-iso checkout>` records one"
126
+ return out
127
+ }
128
+ out.harness = join(record.dir, pin.toolchain.harness)
129
+ if (!existsSync(out.harness)) {
130
+ out.reason = `${out.harness} is not there (the recorded checkout moved or was removed)`
131
+ return out
132
+ }
133
+ out.sha256 = sha256File(out.harness).sha256
134
+ if (out.sha256 === pin.toolchain.patchedHarnessSha256) {
135
+ out.state = "ready"
136
+ out.reason = `${out.harness} hashes to the pinned patched harness (omarchy-iso ${pin.toolchain.commit.slice(0, 12)} with ${pin.toolchain.patch})`
137
+ } else if (out.sha256 === pin.toolchain.upstreamHarnessSha256) {
138
+ out.state = "unpatched"
139
+ out.reason = `${out.harness} is the upstream harness at ${pin.toolchain.commit.slice(0, 12)} without the patch; \`git -C ${record.dir} apply ${join(LAB_DIR, pin.toolchain.patch)}\``
140
+ } else {
141
+ out.state = "unknown"
142
+ out.reason = `${out.harness} hashes to ${out.sha256}, neither the pinned patched harness nor the upstream one at ${pin.toolchain.commit.slice(0, 12)}`
143
+ }
144
+ return out
145
+ }
146
+
147
+ /**
148
+ * The one command that prepares a toolchain checkout, printed whole so it
149
+ * can be run as it stands. `dir` is under the resolved lab cache: with
150
+ * XDG_CACHE_HOME set, the remedy names that root, not ~/.cache (measured
151
+ * on 2026-09-19 by a first user under XDG_CACHE_HOME=/tmp/empty-cache,
152
+ * whose `lab inspect` printed clone and setup commands into ~/.cache;
153
+ * docs/evidence/ux/2026-09-19-first-user-test.json, finding 6).
154
+ */
155
+ export function toolchainCommand(pin = labPin(), dir = join(labLayout().cache, "toolchain/omarchy-iso")) {
156
+ return `git clone ${pin.toolchain.repository} ${dir} && git -C ${dir} checkout --detach ${pin.toolchain.commit} && git -C ${dir} apply ${join(LAB_DIR, pin.toolchain.patch)} && omakit lab setup --toolchain ${dir}`
157
+ }
158
+
159
+ /** Staging directories, each with whether a QEMU still answers on its socket (the cross-namespace liveness question). */
160
+ export async function inspectStaging(layout) {
161
+ if (!existsSync(layout.staging)) return []
162
+ const entries = []
163
+ for (const name of readdirSync(layout.staging).sort()) {
164
+ const dir = join(layout.staging, name)
165
+ let st
166
+ try {
167
+ st = statSync(dir)
168
+ } catch {
169
+ continue
170
+ }
171
+ if (!st.isDirectory()) continue
172
+ const socket = join(dir, "qmp.sock")
173
+ const alive = existsSync(socket) ? (await qmpAlive(socket)).alive : false
174
+ entries.push({ name, dir, allocatedBytes: allocatedBytes(dir), alive, socket: existsSync(socket) ? socket : null })
175
+ }
176
+ return entries
177
+ }
178
+
179
+ /** The lock: a directory with a record of the run holding it, and whether that run's QEMU still answers. */
180
+ export async function inspectLock(layout) {
181
+ const record = readJson(join(layout.lock, "holder.json"))
182
+ if (!existsSync(layout.lock)) return { held: false, record: null, alive: false }
183
+ const alive = record?.qmpSocket ? (await qmpAlive(record.qmpSocket)).alive : false
184
+ let pidAlive = false
185
+ try {
186
+ if (record?.pid) {
187
+ process.kill(record.pid, 0)
188
+ pidAlive = true
189
+ }
190
+ } catch {
191
+ pidAlive = false
192
+ }
193
+ return { held: true, record, alive: alive || pidAlive, qemuAnswers: alive, holderPidAlive: pidAlive }
194
+ }
195
+
196
+ /**
197
+ * Everything `inspect` and `doctor` print, as one document. `host` is the
198
+ * run probes plus what acquisition and a build need; `missing` is the list
199
+ * of what stands between this host and a run, each with its cost and the
200
+ * one command.
201
+ */
202
+ export async function inspectLab({ env = process.env, pin = labPin(), verify = false, onProgress, run } = {}) {
203
+ const layout = labLayout(env)
204
+ const download = inspectDownload(layout, pin, { verify, onProgress })
205
+ const base = inspectBase(layout, pin)
206
+ const toolchain = inspectToolchain(layout, pin)
207
+ const staging = await inspectStaging(layout)
208
+ const lock = await inspectLock(layout)
209
+ const host = { run: probeRunHost({ pin, run, env }), verify: probeCommands(VERIFY_COMMANDS, { run, env }), build: probeCommands(BUILD_COMMANDS, { run, env }) }
210
+ const free = freeBytesAt(layout.cache)
211
+ const runs = existsSync(layout.runs) ? readdirSync(layout.runs).filter((name) => /^\d{8}-\d{6}/.test(name)).sort() : []
212
+ const totals = {
213
+ cacheBytes: existsSync(layout.cache) ? allocatedBytes(layout.cache) : 0,
214
+ downloadsBytes: existsSync(layout.downloads) ? allocatedBytes(layout.downloads) : 0,
215
+ baseBytes: base.allocatedBytes,
216
+ stagingBytes: staging.reduce((sum, entry) => sum + entry.allocatedBytes, 0),
217
+ pluginsBytes: existsSync(layout.plugins) ? allocatedBytes(layout.plugins) : 0,
218
+ runsBytes: existsSync(layout.runs) ? allocatedBytes(layout.runs) : 0,
219
+ }
220
+ const missing = []
221
+ for (const line of host.run) if (line.state !== "ok") missing.push({ what: line.name, cost: line.reason, command: line.remedy })
222
+ if (!download.verified) {
223
+ missing.push({
224
+ what: "the verified ISO",
225
+ cost: download.present ? `verification of the ${bytesBoth(download.bytes)} on disk (about ${durationWords(5215 + 9430)} on the reference host: the hash and the signature check)` : `a download of ${bytesBoth(pin.release.bytes)} from ${pin.release.isoUrl}, verified against the pinned sha256 and the Omarchy signature, into ${download.dir}`,
226
+ command: "omakit lab setup",
227
+ })
228
+ }
229
+ if (toolchain.state !== "ready" && base.state !== "ready") {
230
+ missing.push({ what: "the toolchain", cost: `a checkout of omarchy-iso at ${pin.toolchain.commit.slice(0, 12)} with ${pin.toolchain.patch} applied; it drives the installer once, to build the base`, command: toolchainCommand(pin, join(layout.cache, "toolchain/omarchy-iso")) })
231
+ }
232
+ if (base.state !== "ready") {
233
+ missing.push({
234
+ what: base.state === "missing" ? "a prepared base" : `a usable base (the one there is ${base.state})`,
235
+ cost: `${bytesBoth(pin.measured.baseDirectoryBytes)} on disk, ${durationWords(pin.measured.buildMilliseconds)} to build on the reference host (M14), plus one verification boot`,
236
+ command: "omakit lab setup",
237
+ })
238
+ }
239
+ return { pin: { release: pin.release, toolchain: pin.toolchain, guest: pin.guest, measured: pin.measured }, layout, download, base, toolchain, staging, lock, host, free, runs, totals, missing }
240
+ }
@@ -0,0 +1,13 @@
1
+ -----BEGIN PGP PUBLIC KEY BLOCK-----
2
+
3
+ mDMEaLAsIhYJKwYBBAHaRw8BAQdAYmKmrWMlfpdIPh3QzvEMzzxdNd/kqDl/Bj8H
4
+ mCavKji0Gk9tYXJjaHkgPHBrZ3NAb21hcmNoeS5vcmc+iJAEExYKADgWIQRA37Yw
5
+ /0K8/7BHBGzwE07mgMrFcQUCaLAsIgIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIX
6
+ gAAKCRDwE07mgMrFcRF2AQCjjvjgju/kowkN69nenqqRnE+v2MrHmWh2wU0ugIt9
7
+ 3gD/bXGugWc5J1vqoT+5aCnJAtfRRmHEfsrAiFgjduB+VA24OARosCwiEgorBgEE
8
+ AZdVAQUBAQdAJoU73WdgxE/pEYK5ofyRvQDs1IFRdfJHV9j4Jm+tuDUDAQgHiHgE
9
+ GBYKACAWIQRA37Yw/0K8/7BHBGzwE07mgMrFcQUCaLAsIgIbDAAKCRDwE07mgMrF
10
+ cYXsAQC5eBEGK0xKsGwtnDDVeNAapx32dpvLgJ94W4DtIpdLlwEA85cSsTiBMuGy
11
+ 6kwYmHzqw7oFx3VYGXdMNl0SMKzvJA4=
12
+ =G7wU
13
+ -----END PGP PUBLIC KEY BLOCK-----