omakit 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +38 -45
  2. package/blocks/history.json +68 -0
  3. package/blocks/run/NOTICE +12 -0
  4. package/blocks/run/Run.qml +242 -0
  5. package/blocks/run/run-supervisor.py +522 -0
  6. package/blocks/store/NOTICE +12 -0
  7. package/blocks/store/Store.qml +157 -0
  8. package/blocks/store/store-helper.py +431 -0
  9. package/package.json +12 -5
  10. package/skills/omarchy-plugin-audit/SKILL.md +11 -5
  11. package/skills/omarchy-plugin-build/SKILL.md +164 -0
  12. package/skills/omarchy-plugin-check/SKILL.md +6 -3
  13. package/skills/omarchy-plugin-submit/SKILL.md +4 -1
  14. package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
  15. package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
  16. package/tests/fixtures/weigh/clean/Widget.qml +19 -0
  17. package/tests/fixtures/weigh/clean/manifest.json +9 -0
  18. package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
  19. package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
  20. package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
  21. package/tests/fixtures/weigh/poller/Service.qml +50 -0
  22. package/tests/fixtures/weigh/poller/manifest.json +9 -0
  23. package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
  24. package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
  25. package/tests/lab/run/harness/scenarios/controls.sh +6 -0
  26. package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
  27. package/tests/lab/run/harness/scenarios/forge.sh +11 -0
  28. package/tests/lab/run/harness/scenarios/holder.sh +5 -0
  29. package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
  30. package/tests/lab/run/harness/scenarios/stall.sh +5 -0
  31. package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
  32. package/tests/lab/run/harness/scenarios/tree.sh +7 -0
  33. package/tests/lab/run/harness/shell.qml +84 -0
  34. package/tests/lab/run/report.py +217 -0
  35. package/tests/lab/run/suite.sh +106 -0
  36. package/tests/lab/store/harness/shell.qml +73 -0
  37. package/tests/lab/store/report.py +133 -0
  38. package/tests/lab/store/suite.sh +109 -0
  39. package/tests/parity/corpus.mjs +8 -3
  40. package/tests/parity/run.mjs +4 -4
  41. package/tools/audit/audit.mjs +17 -6
  42. package/tools/audit/git.mjs +3 -3
  43. package/tools/audit/report.mjs +31 -5
  44. package/tools/blocks/add.mjs +138 -0
  45. package/tools/blocks/commit.json +5 -0
  46. package/tools/blocks/record-commit.mjs +77 -0
  47. package/tools/blocks/registry.mjs +191 -0
  48. package/tools/blocks/stamp.mjs +61 -0
  49. package/tools/inspect/contract.mjs +36 -5
  50. package/tools/inspect/helpers.mjs +217 -0
  51. package/tools/inspect/inspect.mjs +68 -3
  52. package/tools/inspect/patterns.mjs +18 -3
  53. package/tools/inspect/processes.mjs +38 -5
  54. package/tools/inspect/report.mjs +18 -2
  55. package/tools/inspect/writes.mjs +22 -4
  56. package/tools/lab/guest.mjs +155 -0
  57. package/tools/lab/harness.sh +119 -0
  58. package/tools/lab/host.mjs +177 -0
  59. package/tools/lab/inspect.mjs +240 -0
  60. package/tools/lab/omarchy.gpg +13 -0
  61. package/tools/lab/patches/omarchy-iso-test.patch +351 -0
  62. package/tools/lab/paths.mjs +173 -0
  63. package/tools/lab/pin.json +42 -0
  64. package/tools/lab/pin.mjs +64 -0
  65. package/tools/lab/prune.mjs +68 -0
  66. package/tools/lab/qemu.mjs +153 -0
  67. package/tools/lab/qmp-cli.mjs +21 -0
  68. package/tools/lab/report.mjs +183 -0
  69. package/tools/lab/run.mjs +344 -0
  70. package/tools/lab/setup.mjs +430 -0
  71. package/tools/lab/suites/run.sh +35 -0
  72. package/tools/lab/suites/store.sh +41 -0
  73. package/tools/lab/suites/weigh.sh +196 -0
  74. package/tools/lab/suites.mjs +142 -0
  75. package/tools/lab/verify.mjs +134 -0
  76. package/tools/marketplace/README.md +38 -1
  77. package/tools/marketplace/banner.mjs +23 -2
  78. package/tools/marketplace/cli.mjs +449 -146
  79. package/tools/marketplace/completion-check.mjs +27 -1
  80. package/tools/marketplace/completion.mjs +32 -4
  81. package/tools/marketplace/doctor.mjs +47 -9
  82. package/tools/marketplace/github.mjs +52 -6
  83. package/tools/marketplace/local-transport.mjs +1 -1
  84. package/tools/marketplace/options.mjs +16 -5
  85. package/tools/marketplace/outcome.mjs +244 -0
  86. package/tools/marketplace/pin.mjs +178 -33
  87. package/tools/marketplace/setup.mjs +16 -15
  88. package/tools/marketplace/tree.mjs +1 -1
  89. package/tools/marketplace/upgrade.mjs +5 -5
  90. package/tools/marketplace/usage.mjs +116 -72
  91. package/tools/subject/resolve.mjs +19 -6
  92. package/tools/weigh/audit.mjs +47 -16
  93. package/tools/weigh/config.mjs +105 -24
  94. package/tools/weigh/list.mjs +10 -1
@@ -34,11 +34,20 @@ function hasFlag(flags, letter, long = null) {
34
34
  return flags.some((flag) => flag === `-${letter}` || (long && (flag === long || flag.startsWith(`${long}=`))) || (/^-[A-Za-z]+$/.test(flag) && flag.includes(letter)))
35
35
  }
36
36
 
37
- /** The tool a process resolves through PATH: its first non-wrapper word when that word has no slash. */
37
+ /**
38
+ * The tool a process resolves through PATH: its first non-wrapper word
39
+ * when that word is a literal with no slash. A word that is an expression
40
+ * (`[script, name]`, `[root.helperPath("x.sh")]`) is not a name looked up
41
+ * in PATH; it is a value the text does not show, and the argument-grammar
42
+ * class already names the site. Measured on the Theme Manager port
43
+ * (2026-09-17): 12 of its 24 Run sites read as "resolved from PATH" with
44
+ * the tool `script`, an absolute path at run time.
45
+ */
38
46
  function pathResolved(process) {
39
47
  if (!Array.isArray(process.argv) || !process.argv.length) return null
40
- const { tool } = toolOf(process.argv)
48
+ const { tool, index } = toolOf(process.argv)
41
49
  if (!tool || tool.includes("/")) return null
50
+ if ((process.expressions || []).some((entry) => entry.index === index)) return null
42
51
  return tool
43
52
  }
44
53
 
@@ -222,13 +231,19 @@ export const PATTERNS = Object.freeze([
222
231
  share: 0.07,
223
232
  sample: SAMPLE,
224
233
  precondition: ({ processes, hosts }) => {
225
- const resolved = processes.map((row) => ({ row, tool: pathResolved(row) })).filter((entry) => entry.tool)
234
+ // A line of a helper started through Run resolves its tools in the
235
+ // block's closed PATH (docs/BLOCKS.md); it is counted apart, never as
236
+ // an ambient lookup.
237
+ const ambient = processes.filter((row) => !row.closedEnvironment)
238
+ const resolved = ambient.map((row) => ({ row, tool: pathResolved(row) })).filter((entry) => entry.tool)
239
+ const closed = processes.filter((row) => row.closedEnvironment && pathResolved(row))
226
240
  const curls = hosts.filter((row) => row.tool === "curl" && !hasFlag(row.flags, "q", "--disable"))
227
241
  const parts = []
228
242
  const names = [...new Set(resolved.map((entry) => entry.tool))]
229
243
  const named = names.length > 6 ? `${names.slice(0, 6).join(", ")} and ${names.length - 6} more` : names.join(", ")
230
244
  if (resolved.length) parts.push(`${plural(resolved.length, "tool")} resolved from PATH (${named}; ${sites(resolved.map((entry) => entry.row))})`)
231
245
  if (curls.length) parts.push(`curl without -q (${sites(curls)})`)
246
+ if (closed.length) parts.push(`${plural(closed.length, "tool name")} in ${plural(new Set(closed.map((row) => row.file)).size, "helper")} started through Run, resolved in the block's closed PATH and not counted`)
232
247
  return { sites: [...resolved.map((entry) => entry.row), ...curls].map(site), observation: `observed ${parts.join("; ")}` }
233
248
  },
234
249
  },
@@ -1,5 +1,7 @@
1
1
  // Process sites: every `Process {` block in QML (its `command:` inside the
2
- // block or assigned to its id elsewhere in the file), every
2
+ // block or assigned to its id elsewhere in the file), every `Run {` block
3
+ // where the tree carries the omakit run block unmodified (its deadline and
4
+ // caps are the block's, docs/BLOCKS.md), every
3
5
  // `Quickshell.execDetached([...])`, and every command line of a shell
4
6
  // script. Each row carries the argv the text shows, whether a deadline is
5
7
  // observed for it, what collects its output and whether a producer-side
@@ -167,6 +169,7 @@ function qmlRow(file, text, block, command, commandOffset) {
167
169
  deadline: deadlineFor(text, id, resolved.argv),
168
170
  output: outputOf(block.body, resolved.argv),
169
171
  shellWrapper: isShellWrapper(resolved.argv),
172
+ closedEnvironment: false,
170
173
  pipedFrom: null,
171
174
  }
172
175
  }
@@ -195,16 +198,42 @@ function detachedRows(file, text) {
195
198
  deadline: { observed: false, via: null, ms: null },
196
199
  output: { collector: "none", capObserved: false, via: null },
197
200
  shellWrapper: isShellWrapper(resolved.argv),
201
+ closedEnvironment: false,
198
202
  pipedFrom: null,
199
203
  })
200
204
  }
201
205
  return rows
202
206
  }
203
207
 
204
- /** Every `Process {` block and detached exec in a QML file. */
205
- export function qmlProcesses(file) {
208
+ /**
209
+ * A `Run {` site of the omakit run block: the deadline is the block's
210
+ * (`deadlineMs`, default 10000), the caps are the block's (`maxBytes`),
211
+ * and a shell string is refused unless `allowShellString: true` is on the
212
+ * block's own line, in which case the wrapper is observed as it would be
213
+ * on a Process.
214
+ */
215
+ function runRow(file, text, block) {
216
+ const inside = propertyValue(block.body, "command")
217
+ const command = inside && !/^\[\s*\]$/.test(inside.text) ? inside : assignedCommand(text, block.id)
218
+ const offset = inside && !/^\[\s*\]$/.test(inside.text) ? block.open + 1 + inside.offset : command ? command.offset : null
219
+ const row = qmlRow(file, text, block, command, offset)
220
+ const deadline = numeric(propertyValue(block.body, "deadlineMs")?.text)
221
+ const allowShellString = propertyValue(block.body, "allowShellString")?.text === "true"
222
+ return {
223
+ ...row,
224
+ running: row.running || (block.id ? new RegExp(`(?<![\\w.])${block.id}\\.start\\s*\\(`).test(text) : false),
225
+ block: "run",
226
+ deadline: { observed: true, via: "block-run", ms: deadline ?? 10000 },
227
+ output: { collector: "Run", capObserved: true, via: "maxBytes" },
228
+ shellWrapper: allowShellString ? row.shellWrapper : false,
229
+ }
230
+ }
231
+
232
+ /** Every `Process {` block and detached exec in a QML file; `Run {` blocks too when the tree carries the run block. */
233
+ export function qmlProcesses(file, { runBlock = false } = {}) {
206
234
  const text = blankComments(file.text)
207
235
  const rows = []
236
+ if (runBlock) for (const block of blocks(text, "Run")) rows.push(runRow(file, text, block))
208
237
  for (const block of blocks(text, "Process")) {
209
238
  const inside = propertyValue(block.body, "command")
210
239
  // `command: []` is a placeholder for a value set later: the assignment to
@@ -235,6 +264,9 @@ function commandWords(segment) {
235
264
  if (words[0] === "for" || words[0] === "case" || words[0] === "select" || words[0] === "function") return null
236
265
  words = words.slice(1)
237
266
  }
267
+ // `exec cmd` replaces the shell with cmd: a process site, the wrapper
268
+ // dropped; `exec` alone or with only redirections runs nothing.
269
+ while (words.length > 1 && words[0] === "exec" && !/^[<>&\d]/.test(words[1])) words = words.slice(1)
238
270
  if (!words.length) return null
239
271
  if (/^[A-Za-z_]\w*\(\)$/.test(words[0])) return null
240
272
  if (BUILTINS.has(basename(words[0])) && basename(words[0]) !== "eval") return null
@@ -278,6 +310,7 @@ export function shellProcesses(file) {
278
310
  deadline: stripped.some((word) => basename(word) === "timeout") ? { observed: true, via: "timeout-argv", ms: deadlineMs } : { observed: false, via: null, ms: null },
279
311
  output: { collector: "none", capObserved: lineCap.observed, via: lineCap.via },
280
312
  shellWrapper: isShellWrapper(stripped),
313
+ closedEnvironment: false,
281
314
  pipedFrom: segment.operator === "|" || segment.operator === "|&" ? previous : null,
282
315
  }
283
316
  rows.push(row)
@@ -291,8 +324,8 @@ export function shellProcesses(file) {
291
324
  * @param {{ path: string, kind: string, text: string }} file
292
325
  * @returns {Array} process rows, in file order
293
326
  */
294
- export function extractProcesses(file) {
295
- if (file.kind === "qml") return qmlProcesses(file)
327
+ export function extractProcesses(file, options = {}) {
328
+ if (file.kind === "qml") return qmlProcesses(file, options)
296
329
  if (file.kind === "shell") return shellProcesses(file)
297
330
  return []
298
331
  }
@@ -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
 
@@ -106,7 +105,7 @@ function hostRow(host, c) {
106
105
  }
107
106
 
108
107
  function writeRow(write, c) {
109
- 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}`
110
109
  const where = write.controlledDirectory === "observed"
111
110
  ? `observed (${write.controlledBy})`
112
111
  : write.controlledDirectory === "not-observed"
@@ -263,6 +262,7 @@ export function renderInspect(document, { colour = colourEnabled(), full = false
263
262
  out.push(...field("subject", `${withHomeAbbreviated(document.subject.dir)} at ${document.subject.commit ? document.subject.commit.slice(0, 8) : "no commit"}`, c))
264
263
  out.push(...uncommittedLine(document, c))
265
264
  out.push(...field("baseline", baselineText(document.marketplaceBaseline), c))
265
+ if (document.blocks.length) out.push(...field("blocks", blocksText(document.blocks), c))
266
266
  const score = document.size.score
267
267
  out.push(...field("size score", score === null
268
268
  ? `none: no function to rank`
@@ -336,6 +336,7 @@ function renderFull(document, { colour }) {
336
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))
337
337
  out.push(...uncommittedLine(document, c))
338
338
  out.push(...field("method", document.method, c))
339
+ if (document.blocks.length) out.push(...field("blocks", blocksText(document.blocks), c))
339
340
  out.push("")
340
341
 
341
342
  const sections = [
@@ -370,6 +371,21 @@ function renderFull(document, { colour }) {
370
371
  }
371
372
 
372
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
+
373
389
  function split({ qml, shell }) {
374
390
  if (!shell) return qml === 1 ? "in qml" : "all in qml"
375
391
  if (!qml) return shell === 1 ? "a shell line" : "all shell lines"
@@ -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
- function qmlWrites(file, pluginId) {
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 }} [context]
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, { pluginId = null } = {}) {
189
- if (file.kind === "qml") return qmlWrites(file, pluginId)
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 "$@"