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
package/tools/inspect/report.mjs
CHANGED
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
import { colourEnabled, field, GUTTER, INSPECT_VERDICT, mark, outputColumns, styler, verdict, wrap } from "../marketplace/style.mjs"
|
|
21
21
|
import { withHomeAbbreviated } from "../marketplace/paths.mjs"
|
|
22
22
|
import { PATTERNS, SIZE } from "./patterns.mjs"
|
|
23
|
-
import { toolOf } from "./processes.mjs"
|
|
24
23
|
|
|
25
24
|
const NOTHING = "observed nothing of this kind"
|
|
26
25
|
|
|
@@ -35,11 +34,17 @@ function percent(share) {
|
|
|
35
34
|
return `${rounded}%`
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* The score sentence after the number: the share, then the position among
|
|
39
|
+
* the listed trees the document itself carries. The rank counts the listed
|
|
40
|
+
* trees with a strictly smaller share, so the tree is "no heavier than"
|
|
41
|
+
* the rest: a tie is level, not lighter.
|
|
42
|
+
*/
|
|
39
43
|
function scoreText(size) {
|
|
40
44
|
const shares = size.sample.heavyShares
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
if (!shares.length) return `${percent(size.heavyShare)} of its function lines sit in functions over the measured size; no listed tree to place it among (${size.measurement})`
|
|
46
|
+
const lighter = shares.filter((share) => share < size.heavyShare).length
|
|
47
|
+
return `${percent(size.heavyShare)} of its function lines sit in functions over the measured size, no heavier than ${shares.length - lighter} of ${shares.length} listed trees (${size.measurement})`
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
/** Under --allow-dirty: what the checkout holds that the tree at the commit does not. */
|
|
@@ -100,7 +105,7 @@ function hostRow(host, c) {
|
|
|
100
105
|
}
|
|
101
106
|
|
|
102
107
|
function writeRow(write, c) {
|
|
103
|
-
const what = write.via === "FileView" ? `FileView path: ${write.path}` : `${write.via} ${write.path}`
|
|
108
|
+
const what = write.via === "FileView" ? `FileView path: ${write.path}` : write.via === "block-store" ? `Store ${write.path} (through the store block)` : `${write.via} ${write.path}`
|
|
104
109
|
const where = write.controlledDirectory === "observed"
|
|
105
110
|
? `observed (${write.controlledBy})`
|
|
106
111
|
: write.controlledDirectory === "not-observed"
|
|
@@ -257,6 +262,7 @@ export function renderInspect(document, { colour = colourEnabled(), full = false
|
|
|
257
262
|
out.push(...field("subject", `${withHomeAbbreviated(document.subject.dir)} at ${document.subject.commit ? document.subject.commit.slice(0, 8) : "no commit"}`, c))
|
|
258
263
|
out.push(...uncommittedLine(document, c))
|
|
259
264
|
out.push(...field("baseline", baselineText(document.marketplaceBaseline), c))
|
|
265
|
+
if (document.blocks.length) out.push(...field("blocks", blocksText(document.blocks), c))
|
|
260
266
|
const score = document.size.score
|
|
261
267
|
out.push(...field("size score", score === null
|
|
262
268
|
? `none: no function to rank`
|
|
@@ -271,7 +277,12 @@ export function renderInspect(document, { colour = colourEnabled(), full = false
|
|
|
271
277
|
if (!ranked.length && !over.length) {
|
|
272
278
|
out.push(...field("attention", `nothing: no function over the size of ${SIZE.sample} (${SIZE.measurement}), and none of the ${PATTERNS.length} classes reviewers raise shows in this tree (${PATTERNS[0]?.measurement || "M11"})`, c))
|
|
273
279
|
} else {
|
|
274
|
-
|
|
280
|
+
const classes = shown.length
|
|
281
|
+
? `${plural(shown.length, "class", "classes")} reviewers raise, biggest first by share of review findings (${PATTERNS[0].measurement}); up to ${SHOWN_SITES} sites each`
|
|
282
|
+
: below.length
|
|
283
|
+
? `only classes under ${Math.round(MIN_SHARE * 100)}% of review findings show (${PATTERNS[0].measurement}), counted below`
|
|
284
|
+
: `none of the classes reviewers raise shows in this tree (${PATTERNS[0].measurement})`
|
|
285
|
+
out.push(...field("attention", `${over.length ? `long functions first, by length${shown.length ? ", then " : "; "}` : ""}${classes}`, c))
|
|
275
286
|
}
|
|
276
287
|
// Long functions first: what the person asked about, so the order is a
|
|
277
288
|
// preference and the heading says whose thresholds it uses.
|
|
@@ -325,6 +336,7 @@ function renderFull(document, { colour }) {
|
|
|
325
336
|
out.push(...field("subject", `${withHomeAbbreviated(document.subject.dir)} at ${document.subject.commit ? document.subject.commit.slice(0, 8) : "no commit"}, ${plural(total, "file")} read${kinds.length ? ` (${kinds.join(", ")})` : ""}`, c))
|
|
326
337
|
out.push(...uncommittedLine(document, c))
|
|
327
338
|
out.push(...field("method", document.method, c))
|
|
339
|
+
if (document.blocks.length) out.push(...field("blocks", blocksText(document.blocks), c))
|
|
328
340
|
out.push("")
|
|
329
341
|
|
|
330
342
|
const sections = [
|
|
@@ -359,6 +371,21 @@ function renderFull(document, { colour }) {
|
|
|
359
371
|
}
|
|
360
372
|
|
|
361
373
|
/** The process split in words: how many are QML Process sites and how many are shell lines. */
|
|
374
|
+
/**
|
|
375
|
+
* The omakit blocks in the tree, one clause each: an unmodified block is
|
|
376
|
+
* its name, version and file count, and its files raised no row; a
|
|
377
|
+
* modified one names the files whose body is not a shipped one, which
|
|
378
|
+
* were read like any other file.
|
|
379
|
+
*/
|
|
380
|
+
function blocksText(blocks) {
|
|
381
|
+
return blocks.map((block) => {
|
|
382
|
+
const version = block.shippedVersion && block.shippedVersion !== block.version ? `${block.version} (omakit ships ${block.shippedVersion})` : block.version
|
|
383
|
+
if (block.state === "unmodified") return `${block.name} ${version}, ${plural(block.files.length, "file")}, unmodified${block.complete ? "" : ", incomplete"}: no row of its own`
|
|
384
|
+
const changed = block.files.filter((file) => file.state === "modified").map((file) => file.path)
|
|
385
|
+
return `${block.name} ${version}, modified (${changed.join(", ")}): read like any other file`
|
|
386
|
+
}).join("; ")
|
|
387
|
+
}
|
|
388
|
+
|
|
362
389
|
function split({ qml, shell }) {
|
|
363
390
|
if (!shell) return qml === 1 ? "in qml" : "all in qml"
|
|
364
391
|
if (!qml) return shell === 1 ? "a shell line" : "all shell lines"
|
package/tools/inspect/writes.mjs
CHANGED
|
@@ -68,9 +68,26 @@ function row(file, line, rawPath, via, pluginId, mode = null) {
|
|
|
68
68
|
return { file: file.path, line, path: String(rawPath).trim(), canonicalPath: canonical, via, ...classified, mode }
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/**
|
|
72
|
+
* A `Store {` site of the omakit store block: one file under the plugin's
|
|
73
|
+
* private directory in the XDG state or cache base (docs/BLOCKS.md), so
|
|
74
|
+
* the path is `$XDG_STATE_HOME/<pluginId>/<name>` or the cache one, a
|
|
75
|
+
* directory the plugin controls, written at mode 0600 through a staging
|
|
76
|
+
* file. Read only where the tree carries the store block unmodified.
|
|
77
|
+
*/
|
|
78
|
+
function storeRow(file, text, block, pluginId) {
|
|
79
|
+
const kind = stringLiteral(propertyValue(block.body, "kind")?.text ?? "") ?? "state"
|
|
80
|
+
const base = kind === "cache" ? "$XDG_CACHE_HOME" : "$XDG_STATE_HOME"
|
|
81
|
+
const id = stringLiteral(propertyValue(block.body, "pluginId")?.text ?? "")
|
|
82
|
+
const name = stringLiteral(propertyValue(block.body, "name")?.text ?? "")
|
|
83
|
+
const path = `${base}/${id || "<pluginId>"}/${name || "<name>"}`
|
|
84
|
+
return { ...row(file, lineOf(text, block.start), path, "block-store", pluginId, "0600"), block: "store" }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function qmlWrites(file, pluginId, { storeBlock = false } = {}) {
|
|
72
88
|
const text = blankComments(file.text)
|
|
73
89
|
const rows = []
|
|
90
|
+
if (storeBlock) for (const block of blocks(text, "Store")) rows.push(storeRow(file, text, block, pluginId))
|
|
74
91
|
for (const block of blocks(text, "FileView")) {
|
|
75
92
|
const path = propertyValue(block.body, "path")
|
|
76
93
|
if (!path) continue
|
|
@@ -182,11 +199,12 @@ function shellWrites(file, pluginId) {
|
|
|
182
199
|
|
|
183
200
|
/**
|
|
184
201
|
* @param {{ path: string, kind: string, text: string }} file
|
|
185
|
-
* @param {{ pluginId?: string|null }} [
|
|
202
|
+
* @param {{ pluginId?: string|null, storeBlock?: boolean }} [options] storeBlock: the tree carries the store block unmodified, so `Store {` is a write site
|
|
186
203
|
* @returns {Array} write rows, in file order
|
|
187
204
|
*/
|
|
188
|
-
export function extractWrites(file,
|
|
189
|
-
|
|
205
|
+
export function extractWrites(file, options = {}) {
|
|
206
|
+
const { pluginId = null } = options
|
|
207
|
+
if (file.kind === "qml") return qmlWrites(file, pluginId, options)
|
|
190
208
|
if (file.kind === "js") return jsWrites(file, pluginId)
|
|
191
209
|
if (file.kind === "shell") return shellWrites(file, pluginId)
|
|
192
210
|
if (file.kind === "python") return pythonWrites(file, pluginId)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Talking to the guest: SSH with the lab's own key to 127.0.0.1 and
|
|
2
|
+
// nowhere else, the session established the way a person logs in (the
|
|
3
|
+
// password typed at SDDM through the virtual keyboard until a Hyprland
|
|
4
|
+
// owned by the user exists), and the identity read from inside.
|
|
5
|
+
//
|
|
6
|
+
// The SSH argument list is pure and tests/unit/lab.test.mjs reads it: the
|
|
7
|
+
// key is the base's, the host is the loopback address, BatchMode, no
|
|
8
|
+
// agent, no forwarding of any kind, no known-hosts entry written into the
|
|
9
|
+
// user's file (the guest's host key is new with every base and the
|
|
10
|
+
// connection is to a port QEMU forwards on the loopback interface).
|
|
11
|
+
|
|
12
|
+
import { spawnSync } from "node:child_process"
|
|
13
|
+
|
|
14
|
+
export const GUEST_HOST = "127.0.0.1"
|
|
15
|
+
|
|
16
|
+
/** @param {{ key: string, port: number, user: string }} guest */
|
|
17
|
+
export function sshArgs({ key, port, user }) {
|
|
18
|
+
return [
|
|
19
|
+
"-i", key,
|
|
20
|
+
"-p", String(port),
|
|
21
|
+
"-o", "BatchMode=yes",
|
|
22
|
+
"-o", "IdentitiesOnly=yes",
|
|
23
|
+
"-o", "IdentityAgent=none",
|
|
24
|
+
"-o", "StrictHostKeyChecking=no",
|
|
25
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
26
|
+
"-o", "ForwardAgent=no",
|
|
27
|
+
"-o", "ForwardX11=no",
|
|
28
|
+
"-o", "ConnectTimeout=5",
|
|
29
|
+
"-o", "LogLevel=ERROR",
|
|
30
|
+
`${user}@${GUEST_HOST}`,
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The environment a command needs to reach the running session from an
|
|
36
|
+
* SSH login, as the toolchain's `ssh_session` set it: the runtime
|
|
37
|
+
* directory, the session bus, the Hyprland signature, the Wayland display,
|
|
38
|
+
* and the Omarchy path from /etc/omarchy.conf when there is one (a
|
|
39
|
+
* dev-linked guest names its checkout there).
|
|
40
|
+
*/
|
|
41
|
+
export const SESSION_PREAMBLE = [
|
|
42
|
+
"export XDG_RUNTIME_DIR=/run/user/$(id -u)",
|
|
43
|
+
"export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus",
|
|
44
|
+
"export LANG=$(systemctl --user show-environment | sed -n 's/^LANG=//p' | head -1)",
|
|
45
|
+
"export LANG=${LANG:-C.UTF-8}",
|
|
46
|
+
"export HYPRLAND_INSTANCE_SIGNATURE=$(ls -t $XDG_RUNTIME_DIR/hypr | head -1)",
|
|
47
|
+
"export WAYLAND_DISPLAY=$(find $XDG_RUNTIME_DIR -maxdepth 1 -name 'wayland-*' ! -name '*.lock' -printf '%f\\n' | head -1)",
|
|
48
|
+
"[[ ! -f /etc/omarchy.conf ]] || source /etc/omarchy.conf",
|
|
49
|
+
"export OMARCHY_PATH=${OMARCHY_PATH:-/usr/share/omarchy}",
|
|
50
|
+
"export PATH=$OMARCHY_PATH/bin:$PATH",
|
|
51
|
+
].join("; ")
|
|
52
|
+
|
|
53
|
+
/** Run one command in the guest; stdout captured, status returned, never a throw. */
|
|
54
|
+
export function sshGuest(guest, command, { input = "", timeoutMs = 120000 } = {}) {
|
|
55
|
+
const result = spawnSync("ssh", [...sshArgs(guest), command], { encoding: "utf8", input, stdio: ["pipe", "pipe", "pipe"], timeout: timeoutMs })
|
|
56
|
+
return { status: result.status ?? 255, stdout: result.stdout || "", stderr: result.stderr || "", error: result.error || null }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The same, inside the session's environment. */
|
|
60
|
+
export function sshSession(guest, command, options) {
|
|
61
|
+
return sshGuest(guest, `${SESSION_PREAMBLE}; ${command}`, options)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One wait, shared by every lab module that polls. */
|
|
65
|
+
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
66
|
+
|
|
67
|
+
/** Wait until `ssh true` answers, polling every 5 s like the toolchain; `alive()` says whether the VM is still there. */
|
|
68
|
+
export async function waitForSsh(guest, { timeoutSeconds, alive, onPhase = () => {} }) {
|
|
69
|
+
const started = Date.now()
|
|
70
|
+
for (;;) {
|
|
71
|
+
if (sshGuest(guest, "true", { timeoutMs: 10000 }).status === 0) return Math.round((Date.now() - started) / 1000)
|
|
72
|
+
if (alive && !(await alive())) throw Object.assign(new Error("the guest exited while the lab waited for SSH"), { code: "guest-exited" })
|
|
73
|
+
const waited = (Date.now() - started) / 1000
|
|
74
|
+
if (waited >= timeoutSeconds) throw Object.assign(new Error(`no SSH answer from the guest after ${timeoutSeconds} s`), { code: "guest-timeout" })
|
|
75
|
+
onPhase(`waiting for the guest's SSH (${Math.round(waited)} s)`)
|
|
76
|
+
await sleep(5000)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Only a Hyprland owned by the guest user proves the session: SDDM runs a compositor of its own as sddm. */
|
|
81
|
+
export function sessionStarted(guest) {
|
|
82
|
+
return sshGuest(guest, "pgrep -u $(id -u) -x Hyprland >/dev/null", { timeoutMs: 10000 }).status === 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Log in at SDDM the way a person does: the password field is focused
|
|
87
|
+
* for the remembered user, so type the password and Enter, wait ten
|
|
88
|
+
* seconds, look for Hyprland, repeat. Measured by the toolchain: a login
|
|
89
|
+
* takes one or two rounds after first boot.
|
|
90
|
+
*/
|
|
91
|
+
export async function establishSession({ guest, socket, password, typeText, press, timeoutSeconds = 300, onPhase = () => {} }) {
|
|
92
|
+
const started = Date.now()
|
|
93
|
+
let rounds = 0
|
|
94
|
+
while (!sessionStarted(guest)) {
|
|
95
|
+
if ((Date.now() - started) / 1000 >= timeoutSeconds) throw Object.assign(new Error(`no Hyprland session owned by ${guest.user} after ${timeoutSeconds} s`), { code: "session-timeout" })
|
|
96
|
+
rounds += 1
|
|
97
|
+
onPhase(`logging in at the greeter (round ${rounds})`)
|
|
98
|
+
await typeText(socket, password)
|
|
99
|
+
await press(socket, ["ret"])
|
|
100
|
+
await sleep(10000)
|
|
101
|
+
}
|
|
102
|
+
return { rounds, seconds: Math.round((Date.now() - started) / 1000) }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `hyprctl -j layers` says whether a namespace is on screen; used to wait for the startup notifications to go. */
|
|
106
|
+
export function layerPresent(guest, namespace) {
|
|
107
|
+
return sshSession(guest, `hyprctl -j layers | jq -e --arg namespace '${namespace}' '[.. | objects | select(.namespace? == $namespace)] | length > 0'`).status === 0
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The startup notifications a fresh base shows, dismissed so a suite's
|
|
112
|
+
* screen is deterministic; a notification the suite raises later is its
|
|
113
|
+
* own. Ported from the toolchain's clear_startup_notifications.
|
|
114
|
+
*/
|
|
115
|
+
export async function clearStartupNotifications(guest, { onPhase = () => {} } = {}) {
|
|
116
|
+
onPhase("waiting for the notification service")
|
|
117
|
+
const deadline = Date.now() + 15000
|
|
118
|
+
while (sshSession(guest, "omarchy-shell notifications ping >/dev/null").status !== 0) {
|
|
119
|
+
if (Date.now() > deadline) return { cleared: false, reason: "the notification service did not answer within 15 s" }
|
|
120
|
+
await sleep(1000)
|
|
121
|
+
}
|
|
122
|
+
sshSession(guest, "omarchy-shell notifications dismissAll >/dev/null")
|
|
123
|
+
const gone = Date.now() + 15000
|
|
124
|
+
while (layerPresent(guest, "omarchy-notifications")) {
|
|
125
|
+
if (Date.now() > gone) return { cleared: false, reason: "the notification layer stayed on screen for 15 s after dismissAll" }
|
|
126
|
+
await sleep(1000)
|
|
127
|
+
}
|
|
128
|
+
return { cleared: true, reason: null }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Who the guest is, read from inside before any suite runs: the installed
|
|
133
|
+
* omarchy package, the kernel, and whether the session is running from
|
|
134
|
+
* the package or from a linked checkout (`OMARCHY_PATH` in
|
|
135
|
+
* /etc/omarchy.conf; a dev-linked guest names `.local/share/omarchy`).
|
|
136
|
+
* This is the line the old gates never wrote (docs/history/2026-09-18-lab-
|
|
137
|
+
* inventory.md P8), and packaging/LAB_PLAN.md's run identity requires.
|
|
138
|
+
*/
|
|
139
|
+
export function guestIdentity(guest) {
|
|
140
|
+
const read = (command) => sshGuest(guest, command).stdout.trim()
|
|
141
|
+
const omarchyPackage = read("pacman -Q omarchy 2>/dev/null")
|
|
142
|
+
const version = omarchyPackage.split(/\s+/)[1] || null
|
|
143
|
+
const conf = read("cat /etc/omarchy.conf 2>/dev/null")
|
|
144
|
+
const omarchyPath = conf.match(/^OMARCHY_PATH=(.+)$/m)?.[1]?.replace(/^["']|["']$/g, "") || null
|
|
145
|
+
const linked = Boolean(omarchyPath && !/^\/usr\/share\/omarchy\/?$/.test(omarchyPath))
|
|
146
|
+
return {
|
|
147
|
+
omarchyPackage: omarchyPackage || null,
|
|
148
|
+
version,
|
|
149
|
+
kernel: read("uname -r") || null,
|
|
150
|
+
hostname: read("cat /etc/hostname") || null,
|
|
151
|
+
omarchyPath,
|
|
152
|
+
linked,
|
|
153
|
+
testedSource: linked ? `linked checkout at ${omarchyPath}` : (omarchyPackage ? `installed package ${omarchyPackage}` : "unknown"),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/bash
|
|
2
|
+
# The one harness every suite runs through, on the host, against a guest
|
|
3
|
+
# that `omakit lab prove` has already booted, logged in and identified.
|
|
4
|
+
#
|
|
5
|
+
# bash tools/lab/harness.sh <suite.sh> <run-dir> <ssh-key> <ssh-port> <qmp-socket> <repo-root> <session-preamble> <guest-user> <guest-password> [suite arguments...]
|
|
6
|
+
#
|
|
7
|
+
# Every value comes in as an argument, none from the environment: omakit
|
|
8
|
+
# reads no variable of its own, and the harness inherits that. The suite
|
|
9
|
+
# file defines `omakit_lab_suite`, which gets the suite arguments and the
|
|
10
|
+
# helpers below, the same six the toolchain's host tests had (log,
|
|
11
|
+
# ssh_guest, ssh_session, wait_for_guest_state, capture_console, RUN_DIR)
|
|
12
|
+
# plus the staging and detached-job helpers the three gates each wrote for
|
|
13
|
+
# themselves (docs/history/2026-09-18-lab-inventory.md P1). Nothing here
|
|
14
|
+
# reaches the host's own session: every ssh goes to 127.0.0.1 on the
|
|
15
|
+
# forwarded port with the lab's key, and tests/unit/lab.test.mjs reads
|
|
16
|
+
# this file for the words it must not contain.
|
|
17
|
+
set -u
|
|
18
|
+
set -o pipefail
|
|
19
|
+
|
|
20
|
+
LAB_SUITE_FILE=$1; RUN_DIR=$2; LAB_SSH_KEY=$3; LAB_SSH_PORT=$4; LAB_QMP_SOCKET=$5; OMAKIT_DIR=$6; LAB_SESSION_PREAMBLE=$7; GUEST_USER=$8; GUEST_PASSWORD=$9
|
|
21
|
+
shift 9
|
|
22
|
+
|
|
23
|
+
log() { printf '==> %s\n' "$1"; }
|
|
24
|
+
|
|
25
|
+
ssh_guest() {
|
|
26
|
+
ssh -i "$LAB_SSH_KEY" -p "$LAB_SSH_PORT" \
|
|
27
|
+
-o BatchMode=yes -o IdentitiesOnly=yes -o IdentityAgent=none \
|
|
28
|
+
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
29
|
+
-o ForwardAgent=no -o ForwardX11=no -o ConnectTimeout=5 -o LogLevel=ERROR \
|
|
30
|
+
"$GUEST_USER@127.0.0.1" "$@"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# The same command inside the session: the preamble omakit built (the
|
|
34
|
+
# runtime directory, the session bus, the Hyprland signature, the Wayland
|
|
35
|
+
# display, the Omarchy path) comes in as one argument, so bash and Node
|
|
36
|
+
# agree on it by construction.
|
|
37
|
+
ssh_session() {
|
|
38
|
+
ssh_guest "$LAB_SESSION_PREAMBLE; $1" </dev/null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
wait_for_guest_state() {
|
|
42
|
+
local description="$1" timeout="$2"
|
|
43
|
+
shift 2
|
|
44
|
+
local deadline=$((SECONDS + timeout))
|
|
45
|
+
until "$@" >/dev/null 2>&1; do
|
|
46
|
+
if ((SECONDS >= deadline)); then
|
|
47
|
+
printf 'not ok - %s\n' "$description" >&2
|
|
48
|
+
return 1
|
|
49
|
+
fi
|
|
50
|
+
sleep 1
|
|
51
|
+
done
|
|
52
|
+
printf 'ok - %s\n' "$description"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# A screendump into the run directory, converted to PNG when magick is on
|
|
56
|
+
# PATH and left as PPM otherwise; QMP is spoken by node, never by socat.
|
|
57
|
+
capture_console() {
|
|
58
|
+
local name="$1" shot="$RUN_DIR/$1.ppm"
|
|
59
|
+
sleep 1
|
|
60
|
+
node "$OMAKIT_DIR/tools/lab/qmp-cli.mjs" "$LAB_QMP_SOCKET" screendump "$shot" >/dev/null 2>&1 || return 0
|
|
61
|
+
[[ -s $shot ]] || { rm -f "$shot"; return 0; }
|
|
62
|
+
if command -v magick >/dev/null 2>&1; then
|
|
63
|
+
magick "$shot" "$RUN_DIR/$name.png" 2>/dev/null && rm -f "$shot"
|
|
64
|
+
fi
|
|
65
|
+
return 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# A directory into the guest, whole, under a fresh destination.
|
|
69
|
+
stage_tree() {
|
|
70
|
+
local src="$1" dest="$2"
|
|
71
|
+
shift 2
|
|
72
|
+
tar -C "$src" "$@" -cf - . | ssh_guest "rm -rf $dest && mkdir -p $dest && tar -C $dest -xf -"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Named paths under a source directory into the guest, under a destination.
|
|
76
|
+
stage_paths() {
|
|
77
|
+
local src="$1" dest="$2"
|
|
78
|
+
shift 2
|
|
79
|
+
tar -C "$src" -cf - "$@" | ssh_guest "mkdir -p $dest && tar -C $dest -xf -"
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# A staged tree turned into a local git repository in the guest, which is
|
|
83
|
+
# what omarchy-plugin-add takes.
|
|
84
|
+
stage_plugin() {
|
|
85
|
+
stage_tree "$1" "$2" --exclude=.git --exclude=.cache --exclude=node_modules
|
|
86
|
+
ssh_guest "git -C $2 init -q && git -C $2 add . && git -C $2 -c user.name=Lab -c user.email=lab@invalid commit -qm staged"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# One detached job in the session: a suite that restarts the shell, or
|
|
90
|
+
# that must outlive the ssh call, runs under setsid and reports through a
|
|
91
|
+
# done file; its log is copied back beside host.log when it ends.
|
|
92
|
+
# guest_job <tag> <limit-seconds> <command>
|
|
93
|
+
guest_job() {
|
|
94
|
+
local tag="$1" limit="$2" command="$3"
|
|
95
|
+
ssh_session "rm -f /tmp/omakit-lab-$tag.done /tmp/omakit-lab-$tag.log; \
|
|
96
|
+
setsid bash -c '$command > /tmp/omakit-lab-$tag.log 2>&1; echo \$? > /tmp/omakit-lab-$tag.done' >/dev/null 2>&1 < /dev/null &" || return 1
|
|
97
|
+
wait_for_guest_state "the $tag job finished" "$limit" ssh_guest "test -f /tmp/omakit-lab-$tag.done" || {
|
|
98
|
+
ssh_guest "tail -n 40 /tmp/omakit-lab-$tag.log" || true
|
|
99
|
+
ssh_guest "cat /tmp/omakit-lab-$tag.log" > "$RUN_DIR/$tag.log" 2>/dev/null || true
|
|
100
|
+
return 1
|
|
101
|
+
}
|
|
102
|
+
ssh_guest "cat /tmp/omakit-lab-$tag.log" > "$RUN_DIR/$tag.log" 2>/dev/null || true
|
|
103
|
+
}
|
|
104
|
+
guest_job_status() { ssh_guest "cat /tmp/omakit-lab-$1.done"; }
|
|
105
|
+
|
|
106
|
+
# A file out of the guest into the run directory.
|
|
107
|
+
guest_file() { ssh_guest "cat $1" > "$RUN_DIR/$2" 2>/dev/null; }
|
|
108
|
+
|
|
109
|
+
# The two questions every gate asks after its suite: the shell answers,
|
|
110
|
+
# and Hyprland reports no configuration error.
|
|
111
|
+
guest_shell_healthy() {
|
|
112
|
+
ssh_session "omarchy-shell shell ping | grep -qx ok" || { echo "the guest's shell does not answer after the suite" >&2; return 1; }
|
|
113
|
+
ssh_session "test -z \"\$(hyprctl configerrors)\"" || { echo "hyprctl reports configuration errors after the suite" >&2; return 1; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# shellcheck source=/dev/null
|
|
117
|
+
source "$LAB_SUITE_FILE"
|
|
118
|
+
declare -F omakit_lab_suite >/dev/null || { echo "$LAB_SUITE_FILE must define omakit_lab_suite()" >&2; exit 2; }
|
|
119
|
+
omakit_lab_suite "$@"
|
|
@@ -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
|
+
}
|