omakit 0.5.1 → 0.6.2
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.
- package/README.md +38 -45
- package/blocks/history.json +68 -0
- package/blocks/run/NOTICE +12 -0
- package/blocks/run/Run.qml +242 -0
- package/blocks/run/run-supervisor.py +522 -0
- package/blocks/store/NOTICE +12 -0
- package/blocks/store/Store.qml +157 -0
- package/blocks/store/store-helper.py +431 -0
- package/package.json +12 -5
- package/skills/omarchy-plugin-audit/SKILL.md +11 -5
- package/skills/omarchy-plugin-build/SKILL.md +164 -0
- package/skills/omarchy-plugin-check/SKILL.md +6 -3
- package/skills/omarchy-plugin-submit/SKILL.md +4 -1
- package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
- package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
- package/tests/fixtures/weigh/clean/Widget.qml +19 -0
- package/tests/fixtures/weigh/clean/manifest.json +9 -0
- package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
- package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
- package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
- package/tests/fixtures/weigh/poller/Service.qml +50 -0
- package/tests/fixtures/weigh/poller/manifest.json +9 -0
- package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
- package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
- package/tests/lab/run/harness/scenarios/controls.sh +6 -0
- package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
- package/tests/lab/run/harness/scenarios/forge.sh +11 -0
- package/tests/lab/run/harness/scenarios/holder.sh +5 -0
- package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
- package/tests/lab/run/harness/scenarios/stall.sh +5 -0
- package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
- package/tests/lab/run/harness/scenarios/tree.sh +7 -0
- package/tests/lab/run/harness/shell.qml +84 -0
- package/tests/lab/run/report.py +217 -0
- package/tests/lab/run/suite.sh +106 -0
- package/tests/lab/store/harness/shell.qml +73 -0
- package/tests/lab/store/report.py +133 -0
- package/tests/lab/store/suite.sh +109 -0
- package/tests/parity/corpus.mjs +8 -3
- package/tests/parity/run.mjs +4 -4
- package/tools/audit/audit.mjs +17 -6
- package/tools/audit/git.mjs +3 -3
- package/tools/audit/report.mjs +31 -5
- package/tools/blocks/add.mjs +138 -0
- package/tools/blocks/commit.json +5 -0
- package/tools/blocks/record-commit.mjs +77 -0
- package/tools/blocks/registry.mjs +191 -0
- package/tools/blocks/stamp.mjs +61 -0
- package/tools/inspect/contract.mjs +36 -5
- package/tools/inspect/helpers.mjs +217 -0
- package/tools/inspect/inspect.mjs +68 -3
- package/tools/inspect/patterns.mjs +18 -3
- package/tools/inspect/processes.mjs +38 -5
- package/tools/inspect/report.mjs +18 -2
- package/tools/inspect/writes.mjs +22 -4
- package/tools/lab/guest.mjs +155 -0
- package/tools/lab/harness.sh +119 -0
- package/tools/lab/host.mjs +177 -0
- package/tools/lab/inspect.mjs +240 -0
- package/tools/lab/omarchy.gpg +13 -0
- package/tools/lab/patches/omarchy-iso-test.patch +351 -0
- package/tools/lab/paths.mjs +173 -0
- package/tools/lab/pin.json +42 -0
- package/tools/lab/pin.mjs +64 -0
- package/tools/lab/prune.mjs +68 -0
- package/tools/lab/qemu.mjs +153 -0
- package/tools/lab/qmp-cli.mjs +21 -0
- package/tools/lab/report.mjs +183 -0
- package/tools/lab/run.mjs +344 -0
- package/tools/lab/setup.mjs +430 -0
- package/tools/lab/suites/run.sh +35 -0
- package/tools/lab/suites/store.sh +41 -0
- package/tools/lab/suites/weigh.sh +196 -0
- package/tools/lab/suites.mjs +142 -0
- package/tools/lab/verify.mjs +134 -0
- package/tools/marketplace/README.md +38 -1
- package/tools/marketplace/banner.mjs +23 -2
- package/tools/marketplace/cli.mjs +449 -146
- package/tools/marketplace/completion-check.mjs +27 -1
- package/tools/marketplace/completion.mjs +32 -4
- package/tools/marketplace/doctor.mjs +47 -9
- package/tools/marketplace/github.mjs +52 -6
- package/tools/marketplace/local-transport.mjs +1 -1
- package/tools/marketplace/options.mjs +16 -5
- package/tools/marketplace/outcome.mjs +244 -0
- package/tools/marketplace/pin.mjs +178 -33
- package/tools/marketplace/setup.mjs +16 -15
- package/tools/marketplace/tree.mjs +1 -1
- package/tools/marketplace/upgrade.mjs +5 -5
- package/tools/marketplace/usage.mjs +116 -72
- package/tools/subject/resolve.mjs +19 -6
- package/tools/weigh/audit.mjs +47 -16
- package/tools/weigh/config.mjs +105 -24
- 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-----
|