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.
Files changed (96) hide show
  1. package/README.md +39 -32
  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 +7 -4
  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 +37 -6
  50. package/tools/inspect/functions.mjs +236 -12
  51. package/tools/inspect/helpers.mjs +217 -0
  52. package/tools/inspect/inspect.mjs +71 -5
  53. package/tools/inspect/measure-functions.mjs +12 -3
  54. package/tools/inspect/patterns.mjs +35 -18
  55. package/tools/inspect/processes.mjs +38 -5
  56. package/tools/inspect/report.mjs +33 -6
  57. package/tools/inspect/writes.mjs +22 -4
  58. package/tools/lab/guest.mjs +155 -0
  59. package/tools/lab/harness.sh +119 -0
  60. package/tools/lab/host.mjs +177 -0
  61. package/tools/lab/inspect.mjs +240 -0
  62. package/tools/lab/omarchy.gpg +13 -0
  63. package/tools/lab/patches/omarchy-iso-test.patch +351 -0
  64. package/tools/lab/paths.mjs +173 -0
  65. package/tools/lab/pin.json +42 -0
  66. package/tools/lab/pin.mjs +64 -0
  67. package/tools/lab/prune.mjs +68 -0
  68. package/tools/lab/qemu.mjs +153 -0
  69. package/tools/lab/qmp-cli.mjs +21 -0
  70. package/tools/lab/report.mjs +183 -0
  71. package/tools/lab/run.mjs +344 -0
  72. package/tools/lab/setup.mjs +430 -0
  73. package/tools/lab/suites/run.sh +35 -0
  74. package/tools/lab/suites/store.sh +41 -0
  75. package/tools/lab/suites/weigh.sh +196 -0
  76. package/tools/lab/suites.mjs +142 -0
  77. package/tools/lab/verify.mjs +134 -0
  78. package/tools/marketplace/README.md +38 -1
  79. package/tools/marketplace/banner.mjs +23 -2
  80. package/tools/marketplace/cli.mjs +449 -146
  81. package/tools/marketplace/completion-check.mjs +27 -1
  82. package/tools/marketplace/completion.mjs +32 -4
  83. package/tools/marketplace/doctor.mjs +47 -9
  84. package/tools/marketplace/github.mjs +52 -6
  85. package/tools/marketplace/local-transport.mjs +1 -1
  86. package/tools/marketplace/options.mjs +16 -5
  87. package/tools/marketplace/outcome.mjs +244 -0
  88. package/tools/marketplace/pin.mjs +178 -33
  89. package/tools/marketplace/setup.mjs +16 -15
  90. package/tools/marketplace/tree.mjs +1 -1
  91. package/tools/marketplace/upgrade.mjs +5 -5
  92. package/tools/marketplace/usage.mjs +116 -72
  93. package/tools/subject/resolve.mjs +19 -6
  94. package/tools/weigh/audit.mjs +47 -16
  95. package/tools/weigh/config.mjs +105 -24
  96. package/tools/weigh/list.mjs +10 -1
@@ -0,0 +1,240 @@
1
+ // `omakit lab inspect`, and the lab lines of `omakit doctor`: what the pin
2
+ // advertises, what is on disk, whether it was verified, what the host is
3
+ // missing, and what each missing thing costs. Read-only: no directory is
4
+ // created, no file is written, nothing is fetched. `--verify` re-hashes
5
+ // the ISO and re-checks the signature now instead of reporting the record.
6
+
7
+ import { existsSync, readdirSync, statSync } from "node:fs"
8
+ import { join } from "node:path"
9
+ import { LAB_DIR, bytesBoth, durationWords, labPin } from "./pin.mjs"
10
+ import { allocatedBytes, labLayout, readJson } from "./paths.mjs"
11
+ import { BUILD_COMMANDS, VERIFY_COMMANDS, freeBytesAt, probeCommands, probeRunHost } from "./host.mjs"
12
+ import { judgeRelease, sha256File } from "./verify.mjs"
13
+ import { qmpAlive } from "./qemu.mjs"
14
+
15
+ /** The names the base directory holds; a manifest names the same. */
16
+ export const BASE_FILES = Object.freeze({
17
+ disk: "base.qcow2",
18
+ vars: "firmware-vars.template",
19
+ key: "id_ed25519",
20
+ publicKey: "id_ed25519.pub",
21
+ manifest: "manifest.json",
22
+ })
23
+
24
+ /** The verified ISO's place under downloads: by digest, so a second release never shares a directory with the first. */
25
+ export function downloadDir(layout, pin = labPin()) {
26
+ return join(layout.downloads, pin.release.sha256)
27
+ }
28
+
29
+ /**
30
+ * The ISO on disk against its verification record. The record is what
31
+ * `setup` wrote after the digest and the signature passed; the file is
32
+ * held to the record by size and mtime, which is what can be read in a
33
+ * millisecond, and `--verify` is the five-second re-hash.
34
+ */
35
+ export function inspectDownload(layout, pin = labPin(), { verify = false, stagingRoot = layout.staging, onProgress } = {}) {
36
+ const dir = downloadDir(layout, pin)
37
+ const iso = join(dir, pin.release.fileName)
38
+ const out = { dir, iso, present: false, bytes: null, partial: null, record: readJson(join(dir, "verified.json")), verified: false, reason: null, sidecars: { checksum: existsSync(`${iso}.sha256`), signature: existsSync(`${iso}.sig`) } }
39
+ const part = `${iso}.part`
40
+ if (existsSync(part)) out.partial = statSync(part).size
41
+ if (!existsSync(iso)) {
42
+ out.reason = out.partial !== null ? `not there; a partial download of ${out.partial.toLocaleString("en-US")} of ${pin.release.bytes.toLocaleString("en-US")} B is waiting to resume` : "not there"
43
+ return out
44
+ }
45
+ const st = statSync(iso)
46
+ out.present = true
47
+ out.bytes = st.size
48
+ if (verify) {
49
+ const judged = judgeRelease({ file: iso, signature: out.sidecars.signature ? `${iso}.sig` : null, checksum: out.sidecars.checksum ? `${iso}.sha256` : null, pin, stagingRoot, onProgress })
50
+ out.verified = judged.ok
51
+ out.reason = judged.ok ? `verified now: ${judged.reason}` : `not verified: ${judged.reason}`
52
+ out.judged = judged
53
+ return out
54
+ }
55
+ if (!out.record) {
56
+ out.reason = `${st.size.toLocaleString("en-US")} B on disk with no verification record; \`omakit lab setup\` verifies it, or \`omakit lab inspect --verify\` checks it now`
57
+ return out
58
+ }
59
+ const same = out.record.bytes === st.size && out.record.sha256 === pin.release.sha256 && Math.abs(Number(out.record.mtimeMs) - st.mtimeMs) < 1
60
+ out.verified = same
61
+ out.reason = same
62
+ ? `verified ${out.record.verifiedAt}: sha256 ${out.record.sha256}, signed by ${out.record.fingerprint}; the file's size and mtime still match that record`
63
+ : `the file changed since the record of ${out.record.verifiedAt} (${out.record.bytes === st.size ? "same size, different mtime" : `${st.size.toLocaleString("en-US")} B now, ${Number(out.record.bytes).toLocaleString("en-US")} B then`}); \`omakit lab setup\` verifies it again`
64
+ return out
65
+ }
66
+
67
+ /**
68
+ * The base: missing, ready, mismatch (a base of another release than the
69
+ * pin), or invalid (files without a complete manifest, or a disk whose
70
+ * size is not the recorded one). Never booted here. `allocatedBytes` is
71
+ * `du -B1` over the directory, the figure prune will recover.
72
+ */
73
+ export function inspectBase(layout, pin = labPin()) {
74
+ const dir = layout.base
75
+ const out = { dir, state: "missing", reason: "no base; run `omakit lab setup`", manifest: null, allocatedBytes: 0, disk: join(dir, BASE_FILES.disk) }
76
+ if (!existsSync(dir)) return out
77
+ out.allocatedBytes = allocatedBytes(dir)
78
+ const manifest = readJson(join(dir, BASE_FILES.manifest))
79
+ const diskThere = existsSync(out.disk)
80
+ if (!manifest || manifest.state !== "ready" || !manifest.release || !manifest.guest || !manifest.disk) {
81
+ out.state = "invalid"
82
+ out.reason = diskThere ? `${BASE_FILES.disk} is there (${bytesBoth(out.allocatedBytes)}) but ${BASE_FILES.manifest} is ${manifest ? "incomplete" : "missing"}; it will not be booted` : `a base directory with no disk and ${manifest ? "an incomplete" : "no"} manifest; \`omakit lab prune\` removes it`
83
+ return out
84
+ }
85
+ out.manifest = manifest
86
+ if (!diskThere) {
87
+ out.state = "invalid"
88
+ out.reason = `the manifest names ${BASE_FILES.disk} and it is not there; it will not be booted`
89
+ return out
90
+ }
91
+ const size = statSync(out.disk).size
92
+ if (size !== manifest.disk.bytes) {
93
+ out.state = "invalid"
94
+ out.reason = `${BASE_FILES.disk} is ${size.toLocaleString("en-US")} B and the manifest recorded ${Number(manifest.disk.bytes).toLocaleString("en-US")} B; it will not be booted`
95
+ return out
96
+ }
97
+ for (const name of [BASE_FILES.vars, BASE_FILES.key]) {
98
+ if (!existsSync(join(dir, name))) {
99
+ out.state = "invalid"
100
+ out.reason = `${name} is missing from the base directory; it will not be booted`
101
+ return out
102
+ }
103
+ }
104
+ if (manifest.release.sha256 !== pin.release.sha256 || manifest.guest.version !== pin.release.expectedGuestVersion) {
105
+ out.state = "mismatch"
106
+ out.reason = `cached Omarchy ${manifest.release.name} (guest ${manifest.guest.version}, iso ${manifest.release.sha256}); required ${pin.release.name} (guest ${pin.release.expectedGuestVersion}, iso ${pin.release.sha256}); \`omakit lab setup\` replaces it`
107
+ return out
108
+ }
109
+ out.state = "ready"
110
+ out.reason = `Omarchy ${manifest.release.name}, guest omarchy ${manifest.guest.version}; ${bytesBoth(out.allocatedBytes)}; created ${manifest.createdAt}`
111
+ return out
112
+ }
113
+
114
+ /**
115
+ * The toolchain: the omarchy-iso checkout `setup --toolchain` recorded,
116
+ * whose harness must hash to the pinned patched digest before setup runs
117
+ * it. What is checked is the file, not git state: a harness that hashes
118
+ * to the pin is the pinned commit with the patch applied, whatever else
119
+ * the checkout holds.
120
+ */
121
+ export function inspectToolchain(layout, pin = labPin()) {
122
+ const record = readJson(layout.toolchain)
123
+ const out = { record, dir: record?.dir || null, harness: null, sha256: null, state: "missing", reason: null }
124
+ if (!record?.dir) {
125
+ out.reason = "no toolchain recorded; `omakit lab setup --toolchain <omarchy-iso checkout>` records one"
126
+ return out
127
+ }
128
+ out.harness = join(record.dir, pin.toolchain.harness)
129
+ if (!existsSync(out.harness)) {
130
+ out.reason = `${out.harness} is not there (the recorded checkout moved or was removed)`
131
+ return out
132
+ }
133
+ out.sha256 = sha256File(out.harness).sha256
134
+ if (out.sha256 === pin.toolchain.patchedHarnessSha256) {
135
+ out.state = "ready"
136
+ out.reason = `${out.harness} hashes to the pinned patched harness (omarchy-iso ${pin.toolchain.commit.slice(0, 12)} with ${pin.toolchain.patch})`
137
+ } else if (out.sha256 === pin.toolchain.upstreamHarnessSha256) {
138
+ out.state = "unpatched"
139
+ out.reason = `${out.harness} is the upstream harness at ${pin.toolchain.commit.slice(0, 12)} without the patch; \`git -C ${record.dir} apply ${join(LAB_DIR, pin.toolchain.patch)}\``
140
+ } else {
141
+ out.state = "unknown"
142
+ out.reason = `${out.harness} hashes to ${out.sha256}, neither the pinned patched harness nor the upstream one at ${pin.toolchain.commit.slice(0, 12)}`
143
+ }
144
+ return out
145
+ }
146
+
147
+ /**
148
+ * The one command that prepares a toolchain checkout, printed whole so it
149
+ * can be run as it stands. `dir` is under the resolved lab cache: with
150
+ * XDG_CACHE_HOME set, the remedy names that root, not ~/.cache (measured
151
+ * on 2026-09-19 by a first user under XDG_CACHE_HOME=/tmp/empty-cache,
152
+ * whose `lab inspect` printed clone and setup commands into ~/.cache;
153
+ * docs/evidence/ux/2026-09-19-first-user-test.json, finding 6).
154
+ */
155
+ export function toolchainCommand(pin = labPin(), dir = join(labLayout().cache, "toolchain/omarchy-iso")) {
156
+ return `git clone ${pin.toolchain.repository} ${dir} && git -C ${dir} checkout --detach ${pin.toolchain.commit} && git -C ${dir} apply ${join(LAB_DIR, pin.toolchain.patch)} && omakit lab setup --toolchain ${dir}`
157
+ }
158
+
159
+ /** Staging directories, each with whether a QEMU still answers on its socket (the cross-namespace liveness question). */
160
+ export async function inspectStaging(layout) {
161
+ if (!existsSync(layout.staging)) return []
162
+ const entries = []
163
+ for (const name of readdirSync(layout.staging).sort()) {
164
+ const dir = join(layout.staging, name)
165
+ let st
166
+ try {
167
+ st = statSync(dir)
168
+ } catch {
169
+ continue
170
+ }
171
+ if (!st.isDirectory()) continue
172
+ const socket = join(dir, "qmp.sock")
173
+ const alive = existsSync(socket) ? (await qmpAlive(socket)).alive : false
174
+ entries.push({ name, dir, allocatedBytes: allocatedBytes(dir), alive, socket: existsSync(socket) ? socket : null })
175
+ }
176
+ return entries
177
+ }
178
+
179
+ /** The lock: a directory with a record of the run holding it, and whether that run's QEMU still answers. */
180
+ export async function inspectLock(layout) {
181
+ const record = readJson(join(layout.lock, "holder.json"))
182
+ if (!existsSync(layout.lock)) return { held: false, record: null, alive: false }
183
+ const alive = record?.qmpSocket ? (await qmpAlive(record.qmpSocket)).alive : false
184
+ let pidAlive = false
185
+ try {
186
+ if (record?.pid) {
187
+ process.kill(record.pid, 0)
188
+ pidAlive = true
189
+ }
190
+ } catch {
191
+ pidAlive = false
192
+ }
193
+ return { held: true, record, alive: alive || pidAlive, qemuAnswers: alive, holderPidAlive: pidAlive }
194
+ }
195
+
196
+ /**
197
+ * Everything `inspect` and `doctor` print, as one document. `host` is the
198
+ * run probes plus what acquisition and a build need; `missing` is the list
199
+ * of what stands between this host and a run, each with its cost and the
200
+ * one command.
201
+ */
202
+ export async function inspectLab({ env = process.env, pin = labPin(), verify = false, onProgress, run } = {}) {
203
+ const layout = labLayout(env)
204
+ const download = inspectDownload(layout, pin, { verify, onProgress })
205
+ const base = inspectBase(layout, pin)
206
+ const toolchain = inspectToolchain(layout, pin)
207
+ const staging = await inspectStaging(layout)
208
+ const lock = await inspectLock(layout)
209
+ const host = { run: probeRunHost({ pin, run, env }), verify: probeCommands(VERIFY_COMMANDS, { run, env }), build: probeCommands(BUILD_COMMANDS, { run, env }) }
210
+ const free = freeBytesAt(layout.cache)
211
+ const runs = existsSync(layout.runs) ? readdirSync(layout.runs).filter((name) => /^\d{8}-\d{6}/.test(name)).sort() : []
212
+ const totals = {
213
+ cacheBytes: existsSync(layout.cache) ? allocatedBytes(layout.cache) : 0,
214
+ downloadsBytes: existsSync(layout.downloads) ? allocatedBytes(layout.downloads) : 0,
215
+ baseBytes: base.allocatedBytes,
216
+ stagingBytes: staging.reduce((sum, entry) => sum + entry.allocatedBytes, 0),
217
+ pluginsBytes: existsSync(layout.plugins) ? allocatedBytes(layout.plugins) : 0,
218
+ runsBytes: existsSync(layout.runs) ? allocatedBytes(layout.runs) : 0,
219
+ }
220
+ const missing = []
221
+ for (const line of host.run) if (line.state !== "ok") missing.push({ what: line.name, cost: line.reason, command: line.remedy })
222
+ if (!download.verified) {
223
+ missing.push({
224
+ what: "the verified ISO",
225
+ cost: download.present ? `verification of the ${bytesBoth(download.bytes)} on disk (about ${durationWords(5215 + 9430)} on the reference host: the hash and the signature check)` : `a download of ${bytesBoth(pin.release.bytes)} from ${pin.release.isoUrl}, verified against the pinned sha256 and the Omarchy signature, into ${download.dir}`,
226
+ command: "omakit lab setup",
227
+ })
228
+ }
229
+ if (toolchain.state !== "ready" && base.state !== "ready") {
230
+ missing.push({ what: "the toolchain", cost: `a checkout of omarchy-iso at ${pin.toolchain.commit.slice(0, 12)} with ${pin.toolchain.patch} applied; it drives the installer once, to build the base`, command: toolchainCommand(pin, join(layout.cache, "toolchain/omarchy-iso")) })
231
+ }
232
+ if (base.state !== "ready") {
233
+ missing.push({
234
+ what: base.state === "missing" ? "a prepared base" : `a usable base (the one there is ${base.state})`,
235
+ cost: `${bytesBoth(pin.measured.baseDirectoryBytes)} on disk, ${durationWords(pin.measured.buildMilliseconds)} to build on the reference host (M14), plus one verification boot`,
236
+ command: "omakit lab setup",
237
+ })
238
+ }
239
+ return { pin: { release: pin.release, toolchain: pin.toolchain, guest: pin.guest, measured: pin.measured }, layout, download, base, toolchain, staging, lock, host, free, runs, totals, missing }
240
+ }
@@ -0,0 +1,13 @@
1
+ -----BEGIN PGP PUBLIC KEY BLOCK-----
2
+
3
+ mDMEaLAsIhYJKwYBBAHaRw8BAQdAYmKmrWMlfpdIPh3QzvEMzzxdNd/kqDl/Bj8H
4
+ mCavKji0Gk9tYXJjaHkgPHBrZ3NAb21hcmNoeS5vcmc+iJAEExYKADgWIQRA37Yw
5
+ /0K8/7BHBGzwE07mgMrFcQUCaLAsIgIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIX
6
+ gAAKCRDwE07mgMrFcRF2AQCjjvjgju/kowkN69nenqqRnE+v2MrHmWh2wU0ugIt9
7
+ 3gD/bXGugWc5J1vqoT+5aCnJAtfRRmHEfsrAiFgjduB+VA24OARosCwiEgorBgEE
8
+ AZdVAQUBAQdAJoU73WdgxE/pEYK5ofyRvQDs1IFRdfJHV9j4Jm+tuDUDAQgHiHgE
9
+ GBYKACAWIQRA37Yw/0K8/7BHBGzwE07mgMrFcQUCaLAsIgIbDAAKCRDwE07mgMrF
10
+ cYXsAQC5eBEGK0xKsGwtnDDVeNAapx32dpvLgJ94W4DtIpdLlwEA85cSsTiBMuGy
11
+ 6kwYmHzqw7oFx3VYGXdMNl0SMKzvJA4=
12
+ =G7wU
13
+ -----END PGP PUBLIC KEY BLOCK-----
@@ -0,0 +1,351 @@
1
+ diff --git c/bin/omarchy-iso-test w/bin/omarchy-iso-test
2
+ index 281cf56..be30127 100755
3
+ --- c/bin/omarchy-iso-test
4
+ +++ w/bin/omarchy-iso-test
5
+ @@ -21,10 +21,18 @@
6
+ # --sync-omarchy DIR Push DIR's test/acceptance into the guest before running
7
+ # (defaults to OMARCHY_PATH when the suite is present)
8
+ # --sync-all DIR Push all of DIR (bin/config/shell/test) into the guest
9
+ +# --dev-link With --sync-all, start a fresh graphical session linked
10
+ +# to the checkout so the whole desktop uses local source
11
+ +# --host-test FILE Source FILE after boot; it must define omarchy_host_test
12
+ +# and may use the harness QMP/SSH/artifact helpers
13
+ +# --host-test-only Run only --host-test after source activation, skipping
14
+ +# the broad desktop and in-guest acceptance suites
15
+ # --port PORT Host port forwarded to guest SSH (default 2222)
16
+ # --memory MB VM memory (default 8192)
17
+ # --timeout SECS Install timeout (default 2400)
18
+ # --keep-running Leave the acceptance VM running after the suite finishes
19
+ +# --discard-overlay Delete the disposable run disk after the VM stops
20
+ +# --keep-overlay Retain the run disk (overrides --discard-overlay)
21
+ # --no-preview Do not open the collected screenshots in imv
22
+
23
+ set -euo pipefail
24
+ @@ -42,12 +50,17 @@ PROVISION=false
25
+ REUSE_BASE=false
26
+ INSTALL_ONLY=false
27
+ KEEP_RUNNING=false
28
+ +DISCARD_OVERLAY=false
29
+ NO_PREVIEW=false
30
+ SYNC_DIR=""
31
+ SYNC_ALL=false
32
+ +DEV_LINK=false
33
+ +HOST_TEST=""
34
+ +HOST_TEST_ONLY=false
35
+
36
+ GUEST_USER="omarchy"
37
+ GUEST_PASSWORD="omarchy"
38
+ +GREETER_PATTERN='Opinionated\|Beautiful, Fun & Agentic Linux by DHH'
39
+ GUEST_HOSTNAME="omarchy-test"
40
+
41
+ while (($#)); do
42
+ @@ -57,9 +70,14 @@ while (($#)); do
43
+ --reuse-base) REUSE_BASE=true ;;
44
+ --install-only) INSTALL_ONLY=true ;;
45
+ --keep-running) KEEP_RUNNING=true ;;
46
+ + --discard-overlay) DISCARD_OVERLAY=true ;;
47
+ + --keep-overlay) DISCARD_OVERLAY=false ;;
48
+ --no-preview) NO_PREVIEW=true ;;
49
+ --sync-omarchy) SYNC_DIR="$2"; shift ;;
50
+ --sync-all) SYNC_DIR="$2"; SYNC_ALL=true; shift ;;
51
+ + --dev-link) DEV_LINK=true ;;
52
+ + --host-test) HOST_TEST="$2"; shift ;;
53
+ + --host-test-only) HOST_TEST_ONLY=true ;;
54
+ --port) SSH_PORT="$2"; shift ;;
55
+ --memory) MEMORY="$2"; shift ;;
56
+ --timeout) INSTALL_TIMEOUT="$2"; shift ;;
57
+ @@ -69,6 +87,23 @@ while (($#)); do
58
+ shift
59
+ done
60
+
61
+ +if $DEV_LINK && ! $SYNC_ALL; then
62
+ + echo "--dev-link requires --sync-all DIR" >&2
63
+ + exit 1
64
+ +fi
65
+ +
66
+ +if [[ -n $HOST_TEST ]]; then
67
+ + HOST_TEST=$(realpath -e "$HOST_TEST") || {
68
+ + echo "Host test not found: $HOST_TEST" >&2
69
+ + exit 1
70
+ + }
71
+ +fi
72
+ +
73
+ +if $HOST_TEST_ONLY && [[ -z $HOST_TEST ]]; then
74
+ + echo "--host-test-only requires --host-test FILE" >&2
75
+ + exit 1
76
+ +fi
77
+ +
78
+ if [[ -z $SYNC_DIR && -f ${OMARCHY_PATH:-}/test/acceptance ]]; then
79
+ SYNC_DIR="$OMARCHY_PATH"
80
+ fi
81
+ @@ -83,10 +118,21 @@ if [[ ! -f ${ISO:-} ]]; then
82
+ exit 1
83
+ fi
84
+
85
+ -omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng
86
+ +for required_command in qemu-system-x86_64 qemu-img socat magick tesseract; do
87
+ + if ! command -v "$required_command" >/dev/null; then
88
+ + echo "Missing host prerequisite: $required_command" >&2
89
+ + exit 1
90
+ + fi
91
+ +done
92
+
93
+ OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd"
94
+ OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
95
+ +for firmware in "$OVMF_CODE" "$OVMF_VARS_TEMPLATE"; do
96
+ + if [[ ! -r $firmware ]]; then
97
+ + echo "Missing readable UEFI firmware: $firmware" >&2
98
+ + exit 1
99
+ + fi
100
+ +done
101
+
102
+ BASE_NAME="$(basename "$ISO" .iso)"
103
+ $ENCRYPT && BASE_NAME+="-encrypted"
104
+ @@ -121,6 +167,52 @@ qmp() {
105
+ timeout 5 socat -t 2 - "UNIX-CONNECT:$QMP_SOCK" 2>/dev/null || true
106
+ }
107
+
108
+ +qmp_pointer_move() {
109
+ + local width="$1" height="$2" x="$3" y="$4" qx qy response
110
+ +
111
+ + if ((width < 2 || height < 2 || x < 0 || y < 0 || x >= width || y >= height)); then
112
+ + printf 'invalid QMP pointer geometry: viewport=%sx%s point=%s,%s\n' \
113
+ + "$width" "$height" "$x" "$y" >&2
114
+ + return 2
115
+ + fi
116
+ + qx=$((x * 32767 / (width - 1)))
117
+ + qy=$((y * 32767 / (height - 1)))
118
+ + response=$(qmp "\"input-send-event\", \"arguments\": {\"events\": [
119
+ + {\"type\":\"abs\",\"data\":{\"axis\":\"x\",\"value\":$qx}},
120
+ + {\"type\":\"abs\",\"data\":{\"axis\":\"y\",\"value\":$qy}}
121
+ + ]}")
122
+ + if grep -q '"error"' <<<"$response"; then
123
+ + printf 'QMP pointer move failed: %s\n' "$response" >&2
124
+ + return 1
125
+ + fi
126
+ +}
127
+ +
128
+ +qmp_pointer_tap() {
129
+ + local width="$1" height="$2" x="$3" y="$4" button="${5:-left}" response
130
+ +
131
+ + case $button in
132
+ + left | right | middle) ;;
133
+ + *)
134
+ + printf 'invalid QMP pointer button: %s\n' "$button" >&2
135
+ + return 2
136
+ + ;;
137
+ + esac
138
+ + qmp_pointer_move "$width" "$height" 1 1
139
+ + sleep 0.1
140
+ + qmp_pointer_move "$width" "$height" "$x" "$y"
141
+ + sleep 0.1
142
+ + response=$(qmp "\"input-send-event\", \"arguments\": {\"events\": [
143
+ + {\"type\":\"btn\",\"data\":{\"down\":true,\"button\":\"$button\"}}
144
+ + ]}")
145
+ + grep -q '"error"' <<<"$response" && return 1
146
+ + sleep 0.12
147
+ + response=$(qmp "\"input-send-event\", \"arguments\": {\"events\": [
148
+ + {\"type\":\"btn\",\"data\":{\"down\":false,\"button\":\"$button\"}}
149
+ + ]}")
150
+ + grep -q '"error"' <<<"$response" && return 1
151
+ + sleep 0.4
152
+ +}
153
+ +
154
+ screendump() {
155
+ qmp "\"screendump\", \"arguments\": {\"filename\": \"$1\"}" >/dev/null
156
+ }
157
+ @@ -188,6 +280,15 @@ cleanup() {
158
+ if ! $KEEP_RUNNING; then
159
+ vm_running && log "Stopping test VM"
160
+ stop_vm
161
+ +
162
+ + if $DISCARD_OVERLAY && [[ -f $RUN_DIR/run.qcow2 ]]; then
163
+ + if vm_running; then
164
+ + echo "WARN: VM did not stop; preserving its run overlay: $RUN_DIR/run.qcow2" >&2
165
+ + else
166
+ + rm -f -- "$RUN_DIR/run.qcow2"
167
+ + log "Discarded the stopped VM overlay; run evidence remains in $RUN_DIR"
168
+ + fi
169
+ + fi
170
+ fi
171
+
172
+ rm -f "$RUN_DIR/.screen.ppm" "$RUN_DIR/.screen.png"
173
+ @@ -355,7 +456,10 @@ wait_for_ssh() {
174
+ }
175
+
176
+ session_started() {
177
+ - ssh_guest "ls /run/user/1000/hypr 2>/dev/null | grep -q ." 2>/dev/null
178
+ + # Hyprland leaves its runtime directory behind briefly after logout, while
179
+ + # SDDM starts a different compositor as the sddm user. Only the compositor
180
+ + # owned by the test user proves that the Omarchy session is actually ready.
181
+ + ssh_guest "pgrep -u \$(id -u) -x Hyprland >/dev/null" 2>/dev/null
182
+ }
183
+
184
+ ssh_session() {
185
+ @@ -367,7 +471,8 @@ ssh_session() {
186
+ export LANG=\${LANG:-C.UTF-8}; \
187
+ export HYPRLAND_INSTANCE_SIGNATURE=\$(ls -t \$XDG_RUNTIME_DIR/hypr | head -1); \
188
+ export WAYLAND_DISPLAY=\$(find \$XDG_RUNTIME_DIR -maxdepth 1 -name 'wayland-*' ! -name '*.lock' -printf '%f\\n' | head -1); \
189
+ - export OMARCHY_PATH=/usr/share/omarchy; \
190
+ + [[ ! -f /etc/omarchy.conf ]] || source /etc/omarchy.conf; \
191
+ + export OMARCHY_PATH=\${OMARCHY_PATH:-/usr/share/omarchy}; \
192
+ export PATH=\$OMARCHY_PATH/bin:\$PATH; \
193
+ $command" </dev/null
194
+ }
195
+ @@ -399,6 +504,20 @@ guest_layer_absent() {
196
+ ! guest_layer_present "$1"
197
+ }
198
+
199
+ +session_stopped() {
200
+ + ! session_started
201
+ +}
202
+ +
203
+ +clear_startup_notifications() {
204
+ + [[ -n $HOST_TEST ]] || return 0
205
+ +
206
+ + wait_for_guest_state "notification service is ready" 15 \
207
+ + ssh_session "omarchy-shell notifications ping >/dev/null" || return 1
208
+ + ssh_session "omarchy-shell notifications dismissAll >/dev/null" || return 1
209
+ + wait_for_guest_state "startup notifications are dismissed" 15 \
210
+ + guest_layer_absent "omarchy-notifications"
211
+ +}
212
+ +
213
+ cleanup_shortcut_state() {
214
+ ssh_session 'for plugin in omarchy.launcher omarchy.menu omarchy.emojis omarchy.clipboard omarchy.audio; do
215
+ omarchy-shell shell hide "$plugin" >/dev/null 2>&1 || true
216
+ @@ -607,7 +726,10 @@ drive_configurator() {
217
+ log "Driving the installer wizard"
218
+
219
+ # A greeter (animated logo + tagline) opens the installer; Return begins.
220
+ - wait_for_screen "Opinionated" 300
221
+ + # Released installers before v4.0.3 use the original slogan; v4.0.3 uses
222
+ + # the exact replacement below. Keep this allowlist explicit so unrelated
223
+ + # OCR text cannot be mistaken for an installer-ready signal.
224
+ + wait_for_screen "$GREETER_PATTERN" 300
225
+ capture_console "success-installer-00-greeter"
226
+ press ret
227
+
228
+ @@ -631,7 +753,10 @@ drive_configurator() {
229
+ capture_console "success-installer-04-prepare-disk-warning-encrypted"
230
+ if ! $ENCRYPT; then
231
+ press ctrl-c
232
+ - wait_for_screen "without encryption" 30
233
+ + # The selected confirmation label is light text on a light background,
234
+ + # which Tesseract intentionally loses. The prompt itself stays put, so
235
+ + # give the Ctrl+C state change time to render and capture it as proof.
236
+ + sleep 2
237
+ capture_console "success-installer-05-prepare-disk-warning-unencrypted"
238
+ fi
239
+ press ret
240
+ @@ -695,7 +820,10 @@ drive_configurator() {
241
+ capture_console "success-installer-12-disk-warning-encrypted"
242
+ if ! $ENCRYPT; then
243
+ press ctrl-c # toggles the confirm to the unencrypted flow
244
+ - wait_for_screen "without encryption" 30
245
+ + # The selected confirmation label is light text on a light background,
246
+ + # which Tesseract intentionally loses. The prompt itself stays put, so
247
+ + # give the Ctrl+C state change time to render and capture it as proof.
248
+ + sleep 2
249
+ capture_console "success-installer-13-disk-warning-unencrypted"
250
+ fi
251
+ press ret
252
+ @@ -716,7 +844,7 @@ drive_provision_owner() {
253
+ # layout — UK leaves every letter identical to US, so the typed password (sent
254
+ # as raw keycodes) still matches the literal one the harness pipes to sudo.
255
+ # A greeter (animated logo + tagline) opens first boot; Return begins.
256
+ - wait_for_screen "Opinionated" 600
257
+ + wait_for_screen "$GREETER_PATTERN" 600
258
+ capture_console "success-provision-00-greeter"
259
+ press ret
260
+
261
+ @@ -791,7 +919,7 @@ wait_for_install() {
262
+ # Deferred-provisioning installs skip the celebration and reboot on their own
263
+ # (no Reboot Now prompt); the first-boot greeter tagline appearing means the
264
+ # install finished and rebooted into setup.
265
+ - if $PROVISION && grep -qi "Opinionated" <<<"$text"; then
266
+ + if $PROVISION && grep -qi "$GREETER_PATTERN" <<<"$text"; then
267
+ log "Install finished and auto-rebooted into first-boot setup."
268
+ capture_console "success-installer-15-autoreboot"
269
+ return 0
270
+ @@ -1063,6 +1191,49 @@ sync_omarchy() {
271
+ fi
272
+ }
273
+
274
+ +activate_dev_checkout() {
275
+ + $DEV_LINK || return 0
276
+ +
277
+ + local target=".local/share/omarchy"
278
+ + log "Linking the disposable guest session to the synced Omarchy checkout"
279
+ +
280
+ + ssh_guest "echo '$GUEST_PASSWORD' | sudo -S -v && $target/bin/omarchy-dev-link $target --no-reboot"
281
+ +
282
+ + # omarchy-dev-link deliberately leaves the running UWSM environment alone.
283
+ + # A fresh graphical login is enough to rebuild that environment from
284
+ + # /etc/omarchy.conf. Avoid rebooting here: the acceptance disk is already a
285
+ + # throwaway overlay, and some released unencrypted ISOs have a separate
286
+ + # post-install Limine/resume defect that would obscure source-level tests.
287
+ + ssh_guest "uwsm stop" >/dev/null 2>&1 || true
288
+ +
289
+ + if ! wait_for_guest_state "linked graphical session stops" 60 session_stopped; then
290
+ + return 1
291
+ + fi
292
+ +
293
+ + establish_session
294
+ + ssh_guest "grep -Fq '$target' /etc/omarchy.conf"
295
+ + log "Disposable guest is running entirely from the synced checkout"
296
+ +}
297
+ +
298
+ +run_host_test() {
299
+ + [[ -n $HOST_TEST ]] || return 0
300
+ +
301
+ + log "Running host-driven acceptance: $HOST_TEST"
302
+ + # The host test is trusted local test code sourced deliberately so it can use
303
+ + # press, ssh_guest, ssh_session, capture_console, wait_for_guest_state, and
304
+ + # the current RUN_DIR without duplicating the QMP/SSH harness.
305
+ + source "$HOST_TEST"
306
+ + declare -F omarchy_host_test >/dev/null || {
307
+ + echo "$HOST_TEST must define omarchy_host_test()" >&2
308
+ + return 1
309
+ + }
310
+ + set +e
311
+ + omarchy_host_test 2>&1 | tee "$RUN_DIR/host-test.log"
312
+ + local status=${PIPESTATUS[0]}
313
+ + set -e
314
+ + return "$status"
315
+ +}
316
+ +
317
+ acceptance_phase() {
318
+ log "Booting acceptance VM from base image overlay"
319
+
320
+ @@ -1074,15 +1245,26 @@ acceptance_phase() {
321
+
322
+ establish_session
323
+ sync_omarchy
324
+ + activate_dev_checkout
325
+ + if ! clear_startup_notifications; then
326
+ + capture_console "failure-startup-notifications"
327
+ + return 1
328
+ + fi
329
+
330
+ local status=0
331
+ - shortcut_smoke_phase || status=1
332
+
333
+ - log "Running acceptance suite"
334
+ - ssh_guest "OMARCHY_PATH=$guest_omarchy_path OMARCHY_ACCEPTANCE_DIR=/tmp/omarchy-acceptance bash .local/share/omarchy/test/acceptance" || status=$?
335
+ + if $HOST_TEST_ONLY; then
336
+ + run_host_test || status=1
337
+ + else
338
+ + shortcut_smoke_phase || status=1
339
+ + run_host_test || status=1
340
+
341
+ - log "Collecting artifacts into $RUN_DIR"
342
+ - ssh_guest "tar -C /tmp -cf - omarchy-acceptance 2>/dev/null" | tar -C "$RUN_DIR" -xf - || true
343
+ + log "Running acceptance suite"
344
+ + ssh_guest "OMARCHY_PATH=$guest_omarchy_path OMARCHY_ACCEPTANCE_DIR=/tmp/omarchy-acceptance bash .local/share/omarchy/test/acceptance" || status=$?
345
+ +
346
+ + log "Collecting artifacts into $RUN_DIR"
347
+ + ssh_guest "tar -C /tmp -cf - omarchy-acceptance 2>/dev/null" | tar -C "$RUN_DIR" -xf - || true
348
+ + fi
349
+ ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/omarchy-install.log 2>/dev/null" >"$RUN_DIR/omarchy-install.log" 2>/dev/null || true
350
+ if ((status == 0)); then
351
+ capture_console "success-acceptance-final"