omakit 0.5.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -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"
@@ -0,0 +1,173 @@
1
+ // Where the lab keeps its bytes, and the one guard every write goes
2
+ // through.
3
+ //
4
+ // Heavy files (the verified ISO, the base disk, a run's overlay) live
5
+ // under the user cache, $XDG_CACHE_HOME/omakit/lab or ~/.cache/omakit/lab;
6
+ // compact run records under the user state, $XDG_STATE_HOME/omakit/lab or
7
+ // ~/.local/state/omakit/lab. Nothing is written anywhere else: every path
8
+ // the lab writes to is built by `inLab`, which refuses a path outside the
9
+ // resolved root, and tests/unit/self-containment.test.mjs holds every
10
+ // write under tools/lab/ to that builder. Measured before this
11
+ // (docs/history/2026-09-18-lab-inventory.md P6): the base, the firmware
12
+ // variables and the SSH key lived inside the omarchy-iso checkout, named
13
+ // by the ISO's file name.
14
+
15
+ import { closeSync, createReadStream, createWriteStream, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"
16
+ import { pipeline } from "node:stream/promises"
17
+ import { join, resolve, sep } from "node:path"
18
+ import { omakitCacheDir, omakitStateDir } from "../marketplace/paths.mjs"
19
+
20
+ /** The cache root: downloads, base, staging, the lock. */
21
+ export function labCacheDir(env = process.env) {
22
+ return omakitCacheDir("lab", env)
23
+ }
24
+
25
+ /** The state root: run records, one directory per run id. */
26
+ export function labStateDir(env = process.env) {
27
+ return omakitStateDir("lab", env)
28
+ }
29
+
30
+ /**
31
+ * The layout under the cache root. One base, not one per release
32
+ * (packaging/LAB_PLAN.md, the one-base rule): a pin update replaces it.
33
+ */
34
+ export function labLayout(env = process.env) {
35
+ const cache = labCacheDir(env)
36
+ const state = labStateDir(env)
37
+ return Object.freeze({
38
+ cache,
39
+ state,
40
+ downloads: join(cache, "downloads"),
41
+ base: join(cache, "base"),
42
+ staging: join(cache, "staging"),
43
+ plugins: join(cache, "plugins"),
44
+ lock: join(cache, "lab.lock"),
45
+ toolchain: join(cache, "toolchain.json"),
46
+ runs: join(state, "runs"),
47
+ })
48
+ }
49
+
50
+ /**
51
+ * A path under one of the two lab roots, or a throw. Every write in
52
+ * tools/lab/ names its target through this, so a target outside the lab
53
+ * is a bug that fails before a byte moves, not a file somewhere else.
54
+ */
55
+ export function inLab(root, ...parts) {
56
+ const target = resolve(root, ...parts)
57
+ const roots = [resolve(root)]
58
+ if (!roots.some((allowed) => target === allowed || target.startsWith(`${allowed}${sep}`))) {
59
+ throw new Error(`lab: ${target} is outside ${root}`)
60
+ }
61
+ return target
62
+ }
63
+
64
+ /** A directory under the lab, created private (0700) with its parents. */
65
+ export function labDir(root, ...parts) {
66
+ const dir = inLab(root, ...parts)
67
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
68
+ return dir
69
+ }
70
+
71
+ /** JSON written whole and fsynced, so a manifest is either there or not. */
72
+ export function writeJson(root, relative, value) {
73
+ const file = inLab(root, relative)
74
+ const fd = openSync(file, "w", 0o600)
75
+ try {
76
+ writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`)
77
+ fsyncSync(fd)
78
+ } finally {
79
+ closeSync(fd)
80
+ }
81
+ return file
82
+ }
83
+
84
+ export function readJson(file) {
85
+ try {
86
+ return JSON.parse(readFileSync(file, "utf8"))
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Move a file or directory within the lab: a rename when the two are on
94
+ * one filesystem, and for a file on another one a streamed copy followed
95
+ * by the removal of the source. The destination must not exist. This is
96
+ * the one place the lab renames anything, and tests/unit/self-containment
97
+ * holds the tree to it.
98
+ */
99
+ export async function moveIntoLab(root, from, relative) {
100
+ const to = inLab(root, relative)
101
+ try {
102
+ statSync(to)
103
+ throw new Error(`lab: ${to} exists; nothing is moved over it`)
104
+ } catch (error) {
105
+ if (error.code !== "ENOENT") throw error
106
+ }
107
+ try {
108
+ renameSync(from, to)
109
+ return to
110
+ } catch (error) {
111
+ if (error.code !== "EXDEV") throw error
112
+ }
113
+ const source = statSync(from)
114
+ if (!source.isFile()) throw new Error(`lab: ${from} is on another filesystem and is not a file; move it by hand`)
115
+ await copyIntoLab(root, from, relative)
116
+ unlinkSync(from)
117
+ return to
118
+ }
119
+
120
+ /** A streamed copy of one file into the lab, mode 0600, fsynced. The destination must not exist. */
121
+ export async function copyIntoLab(root, from, relative) {
122
+ const to = inLab(root, relative)
123
+ try {
124
+ statSync(to)
125
+ throw new Error(`lab: ${to} exists; nothing is copied over it`)
126
+ } catch (error) {
127
+ if (error.code !== "ENOENT") throw error
128
+ }
129
+ const out = createWriteStream(to, { mode: 0o600, flags: "wx" })
130
+ await pipeline(createReadStream(from), out)
131
+ const fd = openSync(to, "r")
132
+ try {
133
+ fsyncSync(fd)
134
+ } finally {
135
+ closeSync(fd)
136
+ }
137
+ return to
138
+ }
139
+
140
+ /** Remove something under the lab, and nothing outside it. */
141
+ export function removeFromLab(root, relative) {
142
+ rmSync(inLab(root, relative), { recursive: true, force: true })
143
+ }
144
+
145
+ /** `20260918-150245`, the run id shape the toolchain used, kept so a run directory sorts by time. */
146
+ export function stampNow(date = new Date()) {
147
+ const pad = (n) => String(n).padStart(2, "0")
148
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
149
+ }
150
+
151
+ /** Allocated bytes of a path, like `du -B1`: blocks times 512, so a sparse qcow2 is measured by what it takes. */
152
+ export function allocatedBytes(path) {
153
+ const walk = (p) => {
154
+ let st
155
+ try {
156
+ st = statSync(p)
157
+ } catch {
158
+ return 0
159
+ }
160
+ let total = st.blocks * 512
161
+ if (st.isDirectory()) {
162
+ let entries = []
163
+ try {
164
+ entries = readdirSync(p)
165
+ } catch {
166
+ return total
167
+ }
168
+ for (const entry of entries) total += walk(join(p, entry))
169
+ }
170
+ return total
171
+ }
172
+ return walk(path)
173
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "schema": 1,
3
+ "release": {
4
+ "name": "4.0.3",
5
+ "embeddedBuild": "2026.09.08",
6
+ "volume": "OMARCHY_202609",
7
+ "isoUrl": "https://iso.omarchy.org/omarchy-4.0.3.iso",
8
+ "checksumUrl": "https://iso.omarchy.org/omarchy-4.0.3.iso.sha256",
9
+ "signatureUrl": "https://iso.omarchy.org/omarchy-4.0.3.iso.sig",
10
+ "fileName": "omarchy-4.0.3.iso",
11
+ "bytes": 6260654080,
12
+ "sha256": "03d60bc74306dca51f96e1a84b690871d8d606826b260edd0208962da8507d14",
13
+ "signingFingerprint": "40DFB630FF42BCFFB047046CF0134EE680CAC571",
14
+ "signingKey": "omarchy.gpg",
15
+ "signingKeySha256": "15d6aac44df688165b2ea35fe0b23af239bbc66a6909c10a5c219e8d94b707de",
16
+ "expectedGuestVersion": "4.0.3-1"
17
+ },
18
+ "toolchain": {
19
+ "repository": "https://github.com/omacom-io/omarchy-iso",
20
+ "commit": "268bac16d351a21d867e37565738f458b11cb06c",
21
+ "harness": "bin/omarchy-iso-test",
22
+ "upstreamHarnessSha256": "fbc236a674464743c6ff8e4cc53be932dc36a16c9fcb159932006e9d7452439c",
23
+ "patch": "patches/omarchy-iso-test.patch",
24
+ "patchedHarnessSha256": "8637e8cc82413fb421daa17eb758e3136cf71099b108b6412326172993974e97"
25
+ },
26
+ "guest": {
27
+ "memoryMiB": 5120,
28
+ "user": "omarchy",
29
+ "password": "omarchy",
30
+ "diskGiB": 40
31
+ },
32
+ "measured": {
33
+ "on": "2026-09-18",
34
+ "buildMilliseconds": 357800,
35
+ "baseAllocatedBytes": 6181490688,
36
+ "baseDirectoryBytes": 6182264832,
37
+ "preparedLabBytes": 12442931200,
38
+ "overlayAfterRunBytes": 610734080,
39
+ "pluginsBytes": 6836224,
40
+ "source": "docs/MEASUREMENTS.md M14, measured by omakit lab on the reference host; packaging/LAB_PLAN.md M4 to M7 are the 2026-09-10 to 2026-09-13 observations it re-measured"
41
+ }
42
+ }
@@ -0,0 +1,64 @@
1
+ // The release pin: the one Omarchy release the lab prepares, its exact
2
+ // digest, its signer, and the toolchain that builds a base from it.
3
+ //
4
+ // Read from tools/lab/pin.json, never from the network: the lab never
5
+ // resolves "latest" (docs/history/2026-09-18-lab-inventory.md P13: the
6
+ // old setup scraped omarchy.org for the first ISO link and verified it
7
+ // against a sidecar downloaded beside it, so a substituted pair passed).
8
+ // Updating the pin is a reviewed change, docs/LAB.md says how.
9
+
10
+ import { readFileSync } from "node:fs"
11
+ import { dirname, join } from "node:path"
12
+ import { fileURLToPath } from "node:url"
13
+
14
+ export const LAB_DIR = dirname(fileURLToPath(import.meta.url))
15
+
16
+ let cached = null
17
+
18
+ /** The pin, parsed once. `file` is injectable for tests that need a small release. */
19
+ export function labPin(file = join(LAB_DIR, "pin.json")) {
20
+ if (file === join(LAB_DIR, "pin.json") && cached) return cached
21
+ const pin = JSON.parse(readFileSync(file, "utf8"))
22
+ for (const key of ["release", "toolchain", "guest", "measured"]) {
23
+ if (!pin[key] || typeof pin[key] !== "object") throw new Error(`lab pin: no ${key} section in ${file}`)
24
+ }
25
+ if (!/^[0-9a-f]{64}$/.test(pin.release.sha256)) throw new Error("lab pin: the release sha256 is not 64 hex characters")
26
+ if (!/^[0-9A-F]{40}$/.test(pin.release.signingFingerprint)) throw new Error("lab pin: the signing fingerprint is not 40 hex characters")
27
+ if (!Number.isInteger(pin.release.bytes) || pin.release.bytes <= 0) throw new Error("lab pin: the release byte count is not a positive integer")
28
+ if (/latest/i.test(pin.release.isoUrl)) throw new Error("lab pin: the ISO URL resolves latest; the pin names one release")
29
+ if (file === join(LAB_DIR, "pin.json")) cached = pin
30
+ return pin
31
+ }
32
+
33
+ /**
34
+ * Bytes for a person, in both units, so a size cannot be made to look
35
+ * smaller by choosing one: `6,260,654,080 B (6.261 GB / 5.831 GiB)`.
36
+ * Decimal GB is what the download page and the disk vendor say, binary
37
+ * GiB is what `df` and `du -h` say; both are printed every time.
38
+ */
39
+ export function bytesBoth(bytes) {
40
+ const n = Number(bytes)
41
+ const gb = (n / 1e9).toFixed(3)
42
+ const gib = (n / 2 ** 30).toFixed(3)
43
+ return `${n.toLocaleString("en-US")} B (${gb} GB / ${gib} GiB)`
44
+ }
45
+
46
+ /** The two units without the byte count, for a breakdown whose total already printed it: `6.261 GB / 5.831 GiB`. */
47
+ export function gbBoth(bytes) {
48
+ const n = Number(bytes)
49
+ return `${(n / 1e9).toFixed(3)} GB / ${(n / 2 ** 30).toFixed(3)} GiB`
50
+ }
51
+
52
+ /** `4m 49.5s` from milliseconds, the way a person reads a build time. */
53
+ export function durationWords(ms) {
54
+ const seconds = Math.round(ms / 100) / 10
55
+ if (seconds < 60) return `${seconds.toFixed(1)}s`
56
+ const minutes = Math.floor(seconds / 60)
57
+ return `${minutes}m ${(seconds - minutes * 60).toFixed(1)}s`
58
+ }
59
+
60
+ /** The first eight and last eight characters of a digest or fingerprint, for a line that names it without being it. */
61
+ export function shortId(value) {
62
+ const s = String(value)
63
+ return s.length > 20 ? `${s.slice(0, 8)}...${s.slice(-8)}` : s
64
+ }