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.
- package/README.md +39 -32
- 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 +7 -4
- 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 +37 -6
- package/tools/inspect/functions.mjs +236 -12
- package/tools/inspect/helpers.mjs +217 -0
- package/tools/inspect/inspect.mjs +71 -5
- package/tools/inspect/measure-functions.mjs +12 -3
- package/tools/inspect/patterns.mjs +35 -18
- package/tools/inspect/processes.mjs +38 -5
- package/tools/inspect/report.mjs +33 -6
- 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,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
|
+
}
|