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,196 @@
1
+ #!/usr/bin/bash
2
+ # `omakit weigh` against a real shell, in the guest, never on a desktop:
3
+ # the command restarts a shell, and the lab is the only shell that is
4
+ # anyone's to restart unasked.
5
+ #
6
+ # omakit_lab_suite <mode> <runs> <plugins-dir> [listed-id...]
7
+ #
8
+ # `smoke` proves what no unit test can, in about a minute after the guest
9
+ # is up: a real shell restarts on a written configuration, the real /proc
10
+ # is read (a Pss, ticks that move), the document follows the contract,
11
+ # shell.json comes back byte for byte, and an interrupt mid-measurement
12
+ # restores it and exits 130. Two fixtures at one run, a 2 s settle and a
13
+ # 3 s window, three restarts, then a second measurement interrupted.
14
+ # `evidence` is the five-run gate over the four fixtures and the three
15
+ # listed plugins, forty restarts at the default 30 s settle and 15 s
16
+ # window, about forty minutes, and the only mode whose document belongs
17
+ # under docs/evidence/weigh/. The listed plugins come staged from the
18
+ # lab's plugin cache, fetched once by `omakit lab setup --plugins` at the
19
+ # exact commit the pinned catalog records as validated; this suite fetches
20
+ # nothing.
21
+ #
22
+ # Runs through tools/lab/harness.sh, which defines the helpers used here.
23
+
24
+ omakit_lab_suite() {
25
+ local mode="$1" runs="$2" plugins_dir="$3"
26
+ shift 3
27
+ local -a reals=("$@") fixtures timing ids
28
+ local fixtures_dir="$OMAKIT_DIR/tests/fixtures/weigh"
29
+ local name real real_dir md5_before md5_after count restarts status ids_json
30
+ # Node in the guest comes through mise, the way a stock Omarchy has it.
31
+ local guest_path='PATH=$HOME/.local/share/mise/shims:$PATH'
32
+
33
+ if [[ $mode == evidence ]]; then
34
+ fixtures=(clean timer-180ms poller idle-panel)
35
+ timing=()
36
+ else
37
+ runs=1
38
+ reals=()
39
+ fixtures=(clean timer-180ms)
40
+ timing=(--settle 2 --window 3)
41
+ fi
42
+ ids=("${fixtures[@]/#/fixture.}" "${reals[@]}")
43
+ count=${#ids[@]}
44
+ restarts=$(( (1 + count) * runs ))
45
+ ids_json=$(jq -c -n '$ARGS.positional' --args "${ids[@]}")
46
+
47
+ # One detached measurement in the guest: the shell it restarts is the
48
+ # session's, so it must not hang off the ssh call.
49
+ guest_weigh() {
50
+ local tag="$1" limit="$2"; shift 2
51
+ guest_job "weigh-$tag" "$limit" "$guest_path node /tmp/omakit/bin/omakit weigh $* --yes --out /tmp/omakit-weigh-$tag.json" || return 1
52
+ guest_file "/tmp/omakit-weigh-$tag.json" "omakit-weigh-$tag.json" || true
53
+ }
54
+ # shell.json is byte for byte what it was, no backup is left, the shell answers.
55
+ assert_restored() {
56
+ md5_after=$(ssh_session "md5sum < \"\$HOME/.config/omarchy/shell.json\"")
57
+ log "shell.json after $1: $md5_after"
58
+ [[ $md5_after == "$md5_before" ]] || { echo "shell.json changed ($1)" >&2; return 1; }
59
+ ssh_session "test -z \"\$(ls \"\$HOME/.config/omarchy/\" | grep omakit-backup)\"" || { echo "a backup was left behind ($1)" >&2; return 1; }
60
+ ssh_session "omarchy-shell shell ping | grep -qx ok" || { echo "the shell does not answer ($1)" >&2; return 1; }
61
+ }
62
+
63
+ log "Checking that the guest has node"
64
+ ssh_session "$guest_path node --version" || { echo "no node in the guest (mise shims)" >&2; return 1; }
65
+
66
+ log "Staging omakit, the ${#fixtures[@]} fixtures and ${#reals[@]} listed plugins in the guest"
67
+ ssh_guest "rm -rf /tmp/omakit" || return 1
68
+ stage_paths "$OMAKIT_DIR" /tmp/omakit bin tools package.json || return 1
69
+ for name in "${fixtures[@]}"; do stage_plugin "$fixtures_dir/$name" "/tmp/omakit-fixtures/$name" || return 1; done
70
+ for real in "${reals[@]}"; do
71
+ real_dir="$plugins_dir/$real"
72
+ log "$real at $(git -C "$real_dir" rev-parse HEAD), the commit the pinned catalog lists as validated"
73
+ stage_plugin "$real_dir" "/tmp/omakit-real/$real" || return 1
74
+ done
75
+ for name in "${fixtures[@]}"; do ssh_session "omarchy-plugin-add /tmp/omakit-fixtures/$name --enable --yes" || return 1; done
76
+ for real in "${reals[@]}"; do ssh_session "omarchy-plugin-add /tmp/omakit-real/$real --enable --yes" || return 1; done
77
+ wait_for_guest_state "the $count plugins are installed and enabled" 60 ssh_session \
78
+ "omarchy-plugin-list --json | jq -e --argjson want '$ids_json' '
79
+ . as \$list | all(\$want[]; . as \$id | any(\$list[]; .id == \$id and .enabled == true))'" || return 1
80
+ capture_console "success-weigh-01-installed"
81
+
82
+ md5_before=$(ssh_session "md5sum < \"\$HOME/.config/omarchy/shell.json\"")
83
+ [[ -n $md5_before ]] || return 1
84
+ log "shell.json before: $md5_before"
85
+
86
+ # The plan, refused: without --yes in a pipe the command prints the count
87
+ # and touches nothing. Exit 2 is the expected outcome.
88
+ ssh_session "$guest_path node /tmp/omakit/bin/omakit weigh --all --runs $runs ${timing[*]} </dev/null; test \$? -eq 2" || { echo "the unconfirmed run did not refuse with exit 2" >&2; return 1; }
89
+ [[ $(ssh_session "md5sum < \"\$HOME/.config/omarchy/shell.json\"") == "$md5_before" ]] || { echo "the refused run touched shell.json" >&2; return 1; }
90
+
91
+ if [[ $mode == evidence ]]; then
92
+ log "Running omakit weigh --all --runs $runs in the guest ($restarts restarts, the default 30 s settle and 15 s window)"
93
+ guest_weigh evidence 3600 --all --runs "$runs" || return 1
94
+ status=$(guest_job_status weigh-evidence)
95
+ # The shell's own journal over the run, for C2: a rescan or a bar rebuild between two levels.
96
+ ssh_session "journalctl --user -t omarchy-shell --no-pager -o short-iso --since '-2 hours'" > "$RUN_DIR/omarchy-shell.journal" 2>/dev/null || true
97
+ ssh_session "ls \$XDG_RUNTIME_DIR/omarchy/plugin-runtime/" > "$RUN_DIR/plugin-runtime-generations.txt" 2>/dev/null || true
98
+ cat "$RUN_DIR/weigh-evidence.log" > "$RUN_DIR/omakit-weigh.log" && cat "$RUN_DIR/omakit-weigh-evidence.json" > "$RUN_DIR/omakit-weigh.json" || return 1
99
+ else
100
+ log "Running omakit weigh --all --runs 1 --settle 2 --window 3 in the guest ($restarts restarts)"
101
+ guest_weigh smoke 300 --all --runs 1 "${timing[@]}" || return 1
102
+ status=$(guest_job_status weigh-smoke)
103
+ cat "$RUN_DIR/weigh-smoke.log" > "$RUN_DIR/omakit-weigh.log" && cat "$RUN_DIR/omakit-weigh-smoke.json" > "$RUN_DIR/omakit-weigh.json" || return 1
104
+ fi
105
+ [[ $status == 0 ]] || { echo "omakit weigh exited $status" >&2; tail -n 40 "$RUN_DIR/omakit-weigh.log" >&2; return 1; }
106
+ assert_restored "the measurement" || return 1
107
+ capture_console "success-weigh-02-restored"
108
+
109
+ # The document follows the contract, on the host, with the tool's own validator.
110
+ node "$OMAKIT_DIR/tools/weigh/contract.mjs" "$RUN_DIR/omakit-weigh.json" || return 1
111
+
112
+ if [[ $mode == smoke ]]; then
113
+ # Every configuration produced a sample from the real /proc: a Pss and a
114
+ # VmRSS in kB, ticks that did not go backwards, and a trace; nothing
115
+ # failed and nothing was read as zero in a gone shell's place.
116
+ jq -e --arg before "${md5_before%% *}" --argjson count "$count" '
117
+ (.failedRuns | length) == 0
118
+ and (.plugins | length) == $count
119
+ and all(.plugins[]; .runsCompleted == 1)
120
+ and ([.baseline.runs[], .plugins[].runs[]] | length) == ($count + 1)
121
+ and all([.baseline.runs[], .plugins[].runs[]][];
122
+ (.shell.pssKb | type) == "number" and .shell.pssKb > 0
123
+ and (.shell.rssKb | type) == "number" and .shell.rssKb > 0
124
+ and .shell.cpuTicksEnd >= .shell.cpuTicksStart
125
+ and (.shell.trace | length) >= 2
126
+ and .readyAfterSeconds >= 0)
127
+ and .config.restored == true
128
+ and .config.md5Before == $before and .config.md5After == $before' "$RUN_DIR/omakit-weigh.json" || {
129
+ jq -c '{failedRuns, config, plugins: [.plugins[] | {id, runsCompleted}]}' "$RUN_DIR/omakit-weigh.json" >&2
130
+ return 1
131
+ }
132
+ grep -q "^▒ WEIGHED" "$RUN_DIR/omakit-weigh.log" || { echo "a one-run measurement does not close with the question mark" >&2; return 1; }
133
+
134
+ # The recovery path, against the real shell: a measurement interrupted
135
+ # inside its settle restores shell.json, restarts the shell once more,
136
+ # removes the backup and exits 130 (docs/WEIGH.md, the shell.json
137
+ # mutation). The signal goes to the recorded pid, the way Ctrl-C would
138
+ # reach the command at a terminal. `set -m` because a background job
139
+ # under a non-interactive bash inherits SIGINT ignored (measured: a
140
+ # `kill -INT` to such a job did nothing).
141
+ log "Interrupting a second measurement inside its settle"
142
+ ssh_session "rm -f /tmp/omakit-weigh-interrupted.done /tmp/omakit-weigh-interrupted.log /tmp/omakit-weigh-interrupted.json /tmp/omakit-weigh-interrupted.pid; \
143
+ setsid bash -c 'set -m; $guest_path node /tmp/omakit/bin/omakit weigh fixture.clean --runs 2 --settle 8 --window 5 --yes --out /tmp/omakit-weigh-interrupted.json \
144
+ > /tmp/omakit-weigh-interrupted.log 2>&1 & echo \$! > /tmp/omakit-weigh-interrupted.pid; wait \$!; echo \$? > /tmp/omakit-weigh-interrupted.done' >/dev/null 2>&1 < /dev/null &" || return 1
145
+ wait_for_guest_state "the interrupted measurement has backed shell.json up" 60 ssh_guest "grep -q 'backed up to' /tmp/omakit-weigh-interrupted.log" || return 1
146
+ # The first restart takes about a second in the guest and the settle is
147
+ # 8 s, so five seconds after the backup line the run is inside its
148
+ # settle, or still waiting for the shell; both paths restore.
149
+ sleep 5
150
+ ssh_guest "kill -INT \$(cat /tmp/omakit-weigh-interrupted.pid)" || { echo "no measurement to interrupt" >&2; return 1; }
151
+ wait_for_guest_state "the interrupted measurement has exited" 120 ssh_guest "test -f /tmp/omakit-weigh-interrupted.done" || {
152
+ ssh_guest "tail -n 40 /tmp/omakit-weigh-interrupted.log" || true
153
+ return 1
154
+ }
155
+ guest_file /tmp/omakit-weigh-interrupted.log omakit-weigh-interrupted.log || true
156
+ status=$(ssh_guest "cat /tmp/omakit-weigh-interrupted.done")
157
+ [[ $status == 130 ]] || { echo "the interrupted measurement exited $status, not 130" >&2; tail -n 40 "$RUN_DIR/omakit-weigh-interrupted.log" >&2; return 1; }
158
+ grep -q "interrupted: restoring shell.json before exiting" "$RUN_DIR/omakit-weigh-interrupted.log" || { echo "the interrupt was not announced" >&2; return 1; }
159
+ grep -q "restored and verified" "$RUN_DIR/omakit-weigh-interrupted.log" || { echo "the restore after the interrupt did not verify" >&2; return 1; }
160
+ grep -q "NOT WEIGHED interrupted before the measurement completed" "$RUN_DIR/omakit-weigh-interrupted.log" || { echo "the interrupt did not close as NOT WEIGHED" >&2; return 1; }
161
+ ssh_guest "test ! -f /tmp/omakit-weigh-interrupted.json" || { echo "an interrupted measurement wrote a document" >&2; return 1; }
162
+ assert_restored "the interrupt" || return 1
163
+ capture_console "success-weigh-03-interrupt-restored"
164
+ guest_shell_healthy || return 1
165
+ printf 'ok - omakit weigh smoke: %d measured restarts and a restore weighed two fixtures from the real /proc and put shell.json back byte for byte, the document follows docs/WEIGH.md, and an interrupted measurement restored it and exited 130\n' "$restarts"
166
+ return 0
167
+ fi
168
+
169
+ # The acceptance line: the busy fixture is above noise on CPU; the clean
170
+ # fixture is within noise on both and the report says so in words; every
171
+ # plugin completed every run; the md5s in the document match the guest's.
172
+ jq -e --arg before "${md5_before%% *}" --argjson runs "$runs" --argjson count "$count" '
173
+ (.plugins[] | select(.id == "fixture.clean")) as $clean
174
+ | (.plugins[] | select(.id == "fixture.timer-180ms")) as $busy
175
+ | all(.plugins[]; .runsCompleted == $runs)
176
+ and $busy.verdict.cpu == "above-noise"
177
+ and $clean.verdict.cpu == "within-noise"
178
+ and $clean.verdict.memory == "within-noise"
179
+ and $clean.verdict.summary == "no measurable CPU"
180
+ and .config.restored == true
181
+ and .config.md5Before == $before and .config.md5After == $before
182
+ and (.plugins | length) == $count' "$RUN_DIR/omakit-weigh.json" || {
183
+ jq -c '.noiseFloor' "$RUN_DIR/omakit-weigh.json" >&2
184
+ jq -c '.plugins[] | {id, runsCompleted, shellPssMb, shellCpuPercent, verdict}' "$RUN_DIR/omakit-weigh.json" >&2
185
+ return 1
186
+ }
187
+ grep -Eq "^▁ ok +fixture\.clean " "$RUN_DIR/omakit-weigh.log" || { echo "the report does not mark fixture.clean ok" >&2; return 1; }
188
+ grep -A1 -E "ok +fixture\.clean " "$RUN_DIR/omakit-weigh.log" | grep -q "no measurable CPU" || { echo "the report does not say no measurable CPU for fixture.clean" >&2; return 1; }
189
+ grep -q "^memory within the shell's own startup variance" "$RUN_DIR/omakit-weigh.log" || { echo "the header does not label memory as the shell's" >&2; return 1; }
190
+ grep -q "^for the README" "$RUN_DIR/omakit-weigh.log" || { echo "no README sentence" >&2; return 1; }
191
+
192
+ jq -r '.noiseFloor | "noise floor: Pss \(.pssMb) MB and VmRSS \(.rssMb) MB at the end of the window, Pss \(.pssMbSettled) MB at the settle, CPU \(.cpuPercent)%"' "$RUN_DIR/omakit-weigh.json"
193
+ jq -r '.plugins[] | [.id, (.shellPssMb.median * 100 | round / 100), (.shellPssMb.spread * 100 | round / 100), (.shellCpuPercent.median * 100 | round / 100), (.shellCpuPercent.spread * 100 | round / 100), (.childRssMb.median * 100 | round / 100), (.childCpuPercent.median * 100 | round / 100), .childSpawns.median, .verdict.summary] | @tsv' "$RUN_DIR/omakit-weigh.json"
194
+ guest_shell_healthy || return 1
195
+ printf 'ok - omakit weigh measured %d plugins over %d runs each, told the 180 ms timer from the clean fixture, said within noise in words, restored shell.json byte for byte, and the document follows docs/WEIGH.md\n' "$count" "$runs"
196
+ }
@@ -0,0 +1,142 @@
1
+ // The suites `omakit lab prove` knows, as data: what each stages, what it
2
+ // runs, which document it writes, and what that document must say for the
3
+ // gate to pass. The host-side body of each is a bash file under
4
+ // tools/lab/suites/, run through tools/lab/harness.sh; the in-guest
5
+ // content (a suite that also runs on the desktop, its reader, its harness
6
+ // QML, the weigh fixtures) lives with the tests, under tests/lab/ and
7
+ // tests/fixtures/weigh/, and ships in the package too (package.json
8
+ // `files`), so an installed omakit proves a suite without a checkout.
9
+ // Measured on 2026-09-19 by a first user: the packaged `lab prove` refused
10
+ // for files that were repository-only, with a remedy that cloned the
11
+ // repository and then ran the global package again (docs/evidence/ux/
12
+ // 2026-09-19-first-user-test.json, finding 5). A suite whose content is
13
+ // missing is still named, not guessed at, and the remedy names the tree's
14
+ // own entry point.
15
+
16
+ import { existsSync, readFileSync } from "node:fs"
17
+ import { join } from "node:path"
18
+ import { LAB_DIR } from "./pin.mjs"
19
+ import { marketplacePinDir } from "../marketplace/pin.mjs"
20
+
21
+ /** The listed plugins the weigh evidence gate weighs beside the fixtures, by id; their repositories and commits are the pinned catalog's to say. */
22
+ export const WEIGH_LISTED = Object.freeze(["io.github.calebhat.weather", "omaplug", "io.github.pablo-merino.altswitch"])
23
+ export const WEIGH_FIXTURES = Object.freeze(["clean", "timer-180ms", "poller", "idle-panel"])
24
+
25
+ const BLOCK_PLUMBING = ["tests/lab/run/suite.sh", "tests/lab/run/report.py", "tests/lab/run/harness/shell.qml", "blocks/run/Run.qml", "blocks/run/run-supervisor.py"]
26
+
27
+ export const SUITES = Object.freeze({
28
+ run: Object.freeze({
29
+ name: "run",
30
+ title: "the Run block's lab scenarios on the stock guest",
31
+ host: join(LAB_DIR, "suites/run.sh"),
32
+ needs: Object.freeze(BLOCK_PLUMBING),
33
+ document: "runlab.json",
34
+ evidence: "run-lab-guest",
35
+ timeoutSeconds: 1200,
36
+ args: () => ["1"],
37
+ /** `ok` over 19 summary entries (one per scenario, keyed by name), the count tests/lab/run/suite.sh names in its scenario list. */
38
+ assert: (document) => {
39
+ const summary = document?.summary
40
+ const count = Array.isArray(summary) ? summary.length : summary && typeof summary === "object" ? Object.keys(summary).length : 0
41
+ if (document?.ok !== true || count !== 19) return { ok: false, reason: `the document says ok=${document?.ok} over ${count} scenarios; the gate is ok over 19` }
42
+ return { ok: true, reason: `ok over ${count} scenarios` }
43
+ },
44
+ }),
45
+ store: Object.freeze({
46
+ name: "store",
47
+ title: "the Store block's lab scenarios on the stock guest, the foreign owner simulated",
48
+ host: join(LAB_DIR, "suites/store.sh"),
49
+ needs: Object.freeze([...BLOCK_PLUMBING, "tests/lab/store/suite.sh", "tests/lab/store/report.py", "tests/lab/store/harness/shell.qml", "blocks/store/Store.qml", "blocks/store/store-helper.py"]),
50
+ document: "storelab.json",
51
+ evidence: "store-lab-guest",
52
+ timeoutSeconds: 1200,
53
+ args: () => [],
54
+ /** `ok` over 15 scenarios with `foreign-owner` not skipped: the chown happened. */
55
+ assert: (document) => {
56
+ const scenarios = Array.isArray(document?.scenarios) ? document.scenarios : []
57
+ const foreign = scenarios.find((row) => row?.scenario === "foreign-owner")
58
+ if (document?.ok !== true || scenarios.length !== 15) return { ok: false, reason: `the document says ok=${document?.ok} over ${scenarios.length} scenarios; the gate is ok over 15` }
59
+ if (!foreign || foreign.skipped != null) return { ok: false, reason: "the foreign-owner scenario was skipped; the gate needs it simulated" }
60
+ return { ok: true, reason: `ok over ${scenarios.length} scenarios, the foreign owner simulated` }
61
+ },
62
+ }),
63
+ weigh: Object.freeze({
64
+ name: "weigh",
65
+ title: "omakit weigh against the stock shell: the smoke check, about a minute after the guest is up",
66
+ host: join(LAB_DIR, "suites/weigh.sh"),
67
+ needs: Object.freeze(["bin/omakit", "tools/weigh/contract.mjs", "tests/fixtures/weigh/clean/manifest.json", "tests/fixtures/weigh/timer-180ms/manifest.json"]),
68
+ document: "omakit-weigh.json",
69
+ evidence: null,
70
+ timeoutSeconds: 1500,
71
+ args: (options, layout) => ["smoke", "1", layout.plugins],
72
+ assert: (document) => (document?.config?.restored === true ? { ok: true, reason: "the document says the shell configuration was restored" } : { ok: false, reason: "the document does not say the shell configuration was restored" }),
73
+ }),
74
+ "weigh-evidence": Object.freeze({
75
+ name: "weigh-evidence",
76
+ title: "omakit weigh against the stock shell: five runs over four fixtures and three listed plugins, about forty minutes",
77
+ host: join(LAB_DIR, "suites/weigh.sh"),
78
+ needs: Object.freeze(["bin/omakit", "tools/weigh/contract.mjs", ...WEIGH_FIXTURES.map((name) => `tests/fixtures/weigh/${name}/manifest.json`)]),
79
+ document: "omakit-weigh.json",
80
+ evidence: "weigh-lab",
81
+ timeoutSeconds: 4200,
82
+ plugins: WEIGH_LISTED,
83
+ args: (options, layout) => ["evidence", String(options.runs || 5), layout.plugins, ...WEIGH_LISTED],
84
+ assert: (document) => (document?.config?.restored === true ? { ok: true, reason: "the document says the shell configuration was restored" } : { ok: false, reason: "the document does not say the shell configuration was restored" }),
85
+ }),
86
+ })
87
+
88
+ export function suiteNames() {
89
+ return Object.keys(SUITES)
90
+ }
91
+
92
+ /**
93
+ * What a suite is missing on this host, before any guest boots: the
94
+ * repository files it stages, and for the evidence gate the listed
95
+ * plugins in the lab's plugin cache at their validated commits.
96
+ */
97
+ export function suitePreflight(suite, { repoRoot, layout, pinDir = marketplacePinDir(repoRoot) }) {
98
+ const missing = []
99
+ for (const relative of suite.needs) {
100
+ if (!existsSync(join(repoRoot, relative))) missing.push({ what: relative, cost: `a file this omakit ships (${repoRoot}) and does not have; the tree is incomplete`, command: existsSync(join(repoRoot, ".git")) ? `git -C ${repoRoot} checkout -- ${relative} && ${join(repoRoot, "bin/omakit")} lab prove ${suite.name}` : "npm i -g omakit, then run it again" })
101
+ }
102
+ if (suite.plugins) {
103
+ for (const id of suite.plugins) {
104
+ const listing = listedPlugin(pinDir, id)
105
+ if (!listing.ok) {
106
+ missing.push({ what: `${id} in the pinned catalog`, cost: listing.reason, command: "omakit pin" })
107
+ continue
108
+ }
109
+ const dir = join(layout.plugins, id)
110
+ const head = existsSync(join(dir, ".git")) ? readHead(dir) : null
111
+ if (head !== listing.commit) missing.push({ what: `${id} at ${listing.commit.slice(0, 12)} in ${dir}`, cost: `one shallow fetch of ${listing.repo} at that commit`, command: "omakit lab setup --plugins" })
112
+ }
113
+ }
114
+ return missing
115
+ }
116
+
117
+ /** `<repo> <commit>` for a listed id, from the pinned catalog; never written here. */
118
+ export function listedPlugin(pinDir, id) {
119
+ let catalog
120
+ try {
121
+ catalog = JSON.parse(readFileSync(join(pinDir, "site/catalog.json"), "utf8"))
122
+ } catch {
123
+ return { ok: false, reason: "the pinned catalog is not readable; the marketplace pin is not in place" }
124
+ }
125
+ const plugin = (catalog.plugins || []).find((entry) => entry && entry.id === id)
126
+ const commit = plugin?.listingValidatedCommit
127
+ if (!plugin || !/^[0-9a-f]{40}$/.test(String(commit || "")) || !/^https:\/\/github\.com\/[^/]+\/[^/]+$/.test(String(plugin.repo || ""))) {
128
+ return { ok: false, reason: `${id} is not listed with a validated commit and a github.com repository in the pinned catalog` }
129
+ }
130
+ return { ok: true, repo: plugin.repo, commit }
131
+ }
132
+
133
+ function readHead(dir) {
134
+ try {
135
+ const head = readFileSync(join(dir, ".git/HEAD"), "utf8").trim()
136
+ if (/^[0-9a-f]{40}$/.test(head)) return head
137
+ const ref = head.match(/^ref: (.+)$/)?.[1]
138
+ return ref ? readFileSync(join(dir, ".git", ref), "utf8").trim() : null
139
+ } catch {
140
+ return null
141
+ }
142
+ }
@@ -0,0 +1,134 @@
1
+ // The two checks that make a file the pinned release: its SHA-256 against
2
+ // the pin, and its detached signature against the packaged Omarchy key at
3
+ // the pinned fingerprint.
4
+ //
5
+ // The digest is the identity the package reviewed; the signature is the
6
+ // independent Omarchy authenticity check. Both, every time, and a mismatch
7
+ // in either fails closed (packaging/LAB_PLAN.md, the acquisition boundary).
8
+ // The key is imported into a throwaway GNUPGHOME under the lab, never into
9
+ // the user's keyring: a lab that added keys to ~/.gnupg would be changing
10
+ // the host, and the only host change the lab makes is the lab.
11
+
12
+ import { createHash } from "node:crypto"
13
+ import { spawnSync } from "node:child_process"
14
+ import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs"
15
+ import { join } from "node:path"
16
+ import { LAB_DIR, labPin } from "./pin.mjs"
17
+ import { labDir, removeFromLab } from "./paths.mjs"
18
+
19
+ /**
20
+ * SHA-256 of a file, streamed in 4 MiB reads. Measured on the 6.26 GB
21
+ * 4.0.3 ISO on the reference host, page-cached: 5.2 s in Node, 5.1 s in
22
+ * `sha256sum` (docs/MEASUREMENTS.md M14), so there is no reason to shell
23
+ * out. `onProgress(read, total)` at most once per read.
24
+ */
25
+ export function sha256File(file, { onProgress } = {}) {
26
+ const total = statSync(file).size
27
+ const hash = createHash("sha256")
28
+ const buffer = Buffer.alloc(4 * 1024 * 1024)
29
+ const fd = openSync(file, "r")
30
+ let read = 0
31
+ try {
32
+ for (;;) {
33
+ const n = readSync(fd, buffer, 0, buffer.length, null)
34
+ if (n <= 0) break
35
+ hash.update(buffer.subarray(0, n))
36
+ read += n
37
+ if (onProgress) onProgress(read, total)
38
+ }
39
+ } finally {
40
+ closeSync(fd)
41
+ }
42
+ return { sha256: hash.digest("hex"), bytes: read }
43
+ }
44
+
45
+ /** The packaged key, checked against its pinned digest before it is trusted with anything. */
46
+ export function packagedKey(pin = labPin(), dir = LAB_DIR) {
47
+ const file = join(dir, pin.release.signingKey)
48
+ const { sha256 } = sha256File(file)
49
+ if (sha256 !== pin.release.signingKeySha256) {
50
+ throw Object.assign(new Error(`the packaged signing key at ${file} has digest ${sha256}, not the pinned ${pin.release.signingKeySha256}`), { code: "key-mismatch" })
51
+ }
52
+ return file
53
+ }
54
+
55
+ /**
56
+ * Verify a detached signature with gpg in a throwaway keyring under
57
+ * `stagingRoot`. The answer is the fingerprint gpg reports as VALIDSIG, or
58
+ * null; the caller compares it with the pin. `run` is injectable.
59
+ */
60
+ export function verifySignature({ file, signature, keyFile, stagingRoot, run = spawnSync }) {
61
+ const home = labDir(stagingRoot, `gnupg-${process.pid}`)
62
+ const env = { ...process.env, GNUPGHOME: home }
63
+ try {
64
+ const imported = run("gpg", ["--batch", "--quiet", "--import", keyFile], { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60_000 })
65
+ if (imported.error || imported.status !== 0) {
66
+ return { state: "gpg-failed", fingerprint: null, detail: String(imported.stderr || imported.error?.message || "").trim().split("\n")[0] || "gpg could not import the packaged key" }
67
+ }
68
+ const verified = run("gpg", ["--batch", "--status-fd", "1", "--verify", signature, file], { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 600_000 })
69
+ const status = String(verified.stdout || "")
70
+ const valid = status.match(/^\[GNUPG:\] VALIDSIG ([0-9A-F]{40}) /m)
71
+ if (valid && verified.status === 0) return { state: "valid", fingerprint: valid[1], detail: status.match(/^\[GNUPG:\] GOODSIG \S+ (.+)$/m)?.[1] || null }
72
+ const bad = status.match(/^\[GNUPG:\] (BADSIG|NO_PUBKEY|ERRSIG|NODATA)\b.*$/m)
73
+ return { state: bad ? bad[1].toLowerCase() : "invalid", fingerprint: null, detail: (bad?.[0] || String(verified.stderr || "").trim().split("\n")[0] || "gpg did not report a valid signature").trim() }
74
+ } finally {
75
+ removeFromLab(stagingRoot, `gnupg-${process.pid}`)
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The whole judgement over a file on disk against the pin: byte count,
81
+ * digest, signature. Every field is reported even after the first failure,
82
+ * so a report can say "the size matches, the digest does not" rather than
83
+ * only the first thing wrong. The sidecar's digest is compared too, so a
84
+ * published checksum that disagrees with the pin is named (it would mean
85
+ * the object at the versioned URL was replaced).
86
+ */
87
+ export function judgeRelease({ file, signature, checksum, pin = labPin(), stagingRoot, keyFile = packagedKey(pin), onProgress, run }) {
88
+ const verdict = { file, bytes: null, bytesMatch: false, sha256: null, sha256Match: false, sidecarSha256: null, sidecarMatch: null, signature: null, ok: false }
89
+ let st
90
+ try {
91
+ st = statSync(file)
92
+ } catch {
93
+ verdict.reason = `${file} is not there`
94
+ return verdict
95
+ }
96
+ verdict.bytes = st.size
97
+ verdict.bytesMatch = st.size === pin.release.bytes
98
+ if (!verdict.bytesMatch) {
99
+ verdict.reason = `${file} is ${st.size.toLocaleString("en-US")} B, the pin says ${pin.release.bytes.toLocaleString("en-US")} B`
100
+ return verdict
101
+ }
102
+ verdict.sha256 = sha256File(file, { onProgress }).sha256
103
+ verdict.sha256Match = verdict.sha256 === pin.release.sha256
104
+ if (checksum) {
105
+ try {
106
+ verdict.sidecarSha256 = readFileSync(checksum, "utf8").trim().split(/\s+/)[0].toLowerCase()
107
+ verdict.sidecarMatch = verdict.sidecarSha256 === pin.release.sha256
108
+ } catch {
109
+ verdict.sidecarSha256 = null
110
+ verdict.sidecarMatch = null
111
+ }
112
+ }
113
+ if (!verdict.sha256Match) {
114
+ verdict.reason = `${file} has digest ${verdict.sha256}, the pin says ${pin.release.sha256}`
115
+ return verdict
116
+ }
117
+ if (!signature) {
118
+ verdict.reason = "no detached signature beside the file"
119
+ return verdict
120
+ }
121
+ const signed = verifySignature({ file, signature, keyFile, stagingRoot, run })
122
+ verdict.signature = signed
123
+ if (signed.state !== "valid") {
124
+ verdict.reason = `the signature did not verify: ${signed.detail}`
125
+ return verdict
126
+ }
127
+ if (signed.fingerprint !== pin.release.signingFingerprint) {
128
+ verdict.reason = `the signature is by ${signed.fingerprint}, the pin says ${pin.release.signingFingerprint}`
129
+ return verdict
130
+ }
131
+ verdict.ok = true
132
+ verdict.reason = `${st.size.toLocaleString("en-US")} B, sha256 ${verdict.sha256}, signed by ${signed.fingerprint}`
133
+ return verdict
134
+ }
@@ -31,7 +31,8 @@ local commit through the transport seam the marketplace tests itself
31
31
  | `update-check.mjs` | A passive npm-release notice during normal terminal use, throttled to one check per day with a one-second network budget. Stores only installed/latest version metadata and time under XDG_STATE_HOME/omakit. Offline, JSON, pipes, help and CI skip it; DISABLE_UPDATE_NOTIFIER disables it. Version precedence and the registry GET are shared with doctor/upgrade. |
32
32
  | `path-hint.mjs` | Is `omakit` reachable as a bare command, and if not, the one line that makes it so for the install that is here: a symlink for a clone, the npm prefix's `bin` on PATH for a package, said for the shell in `$SHELL`. `setup` and `doctor` print it; nothing writes an rc file. |
33
33
  | `usage.mjs` | The help text, as data. |
34
- | `options.mjs` | Every option every command accepts, in one table, and the parser that reads a command line against it before anything runs; `tests/unit/options.test.mjs` holds the help signatures, and through them the completion scripts, to the table. |
34
+ | `options.mjs` | Every option every command accepts, in one table, and the parser that reads a command line against it before anything runs: an option the command does not know, an option without its value or given twice, an empty argument, or one positional too many is refused by name; `tests/unit/options.test.mjs` holds the help signatures, and through them the completion scripts, to the table. |
35
+ | `outcome.mjs` | The one contract every command leaves through: the exit status (0 success, 1 a refusal or failure the tool means, 2 usage, 128 plus the signal for an interrupt), the `--json` envelope `{ command, ok, error, ...document }` with `error.remedy` never null, the text on stdout on exit 0 and on stderr otherwise, and `--out` written on every outcome. `conclude()` is the call; `tests/unit/json-outcomes.test.mjs` holds it per command and per outcome. |
35
36
  | `completion.mjs` | A completion script for bash, zsh or fish, derived from the help data and the pin's form: the subcommands and flags are read out of `COMMANDS`, the categories and tags out of the pinned submission form, and the script says which pin it came from. `setup` installs it for the shell in `$SHELL`, the one file this tool writes outside its own checkout. |
36
37
  | `completion-check.mjs` | Whether tab completion actually works: a frozen probe per shell run interactively, asking the loader to load `omakit` the way TAB does; the one marked block `setup` may append to an rc file after a yes, looked for by its marker first; the installed script's version and pin read from its first line; `doctor`'s `omakit.completion`; and the once-a-day stale notice. |
37
38
  | `banner.mjs` | The wordmark, on a bare `omakit` and in `setup` only. |
@@ -74,6 +75,29 @@ the tree, no verdict:
74
75
  | `inspect/contract.mjs` | The JSON contract of docs/INSPECT.md as a validator, run by the unit tests over every fixture document. |
75
76
  | `inspect/report.mjs` | The report for a person, drawn with `style.mjs` only: `░ info` for a fact, `▒ ?` for one that could not be read, `▓ note` for a pattern row, `▔ skip` under `--offline`, and the closing word `INSPECTED`. |
76
77
 
78
+ `tools/lab/` is `omakit lab`, the fourth job (docs/LAB.md): a suite proven
79
+ in a disposable Omarchy guest, never on the desktop, the guest identified
80
+ in the document:
81
+
82
+ | File | Purpose |
83
+ | --- | --- |
84
+ | `lab/pin.json`, `lab/pin.mjs` | The release pin: Omarchy 4.0.3, its URL, exact bytes, SHA-256, signer fingerprint, expected guest package; the toolchain commit and the digests of its harness before and after the patch; the measured costs. Refuses a URL that resolves latest. `bytesBoth` prints every size in GB and GiB. |
85
+ | `lab/omarchy.gpg` | The Omarchy public signing key, 632 bytes of armoured text, pinned by digest. |
86
+ | `lab/patches/omarchy-iso-test.patch` | The working harness of the reference host against the pinned omarchy-iso commit: the 4.0.3 greeter, no host package install, the host-test extensions. Applied by a person, never by omakit. |
87
+ | `lab/paths.mjs` | The two lab roots under the user cache and state, the layout, and `inLab`, the guard every write goes through; the one rename and the one stream copy in the tree. |
88
+ | `lab/host.mjs` | Read-only probes: KVM, the commands a run, a build and a verification need (`--version`, never `ssh-keygen` bare), the OVMF firmware, free disk, memory, the CPU count. Installs nothing. |
89
+ | `lab/verify.mjs` | SHA-256 streamed in Node, the signature check in a throwaway keyring, and `judgeRelease`: byte count, digest, sidecar, signature, fingerprint, in that order, every field reported. |
90
+ | `lab/inspect.mjs` | The read-only view: the download and its verification record, the base's state from its manifest (`ready`, `mismatch`, `invalid`, `missing`), the toolchain by its harness's hash, staging with QMP liveness, the lock, the totals, what is missing with its cost and command. |
91
+ | `lab/qemu.mjs` | QEMU's argument list, pure; QMP over the Unix socket from Node; the qcode table and `typeText` for the greeter. |
92
+ | `lab/guest.mjs` | SSH to 127.0.0.1 with the base's key and no forwarding; the session preamble; the login loop; the startup-notification dismissal; the guest's identity (`pacman -Q omarchy`, the kernel, whether the session is linked). |
93
+ | `lab/run.mjs` | The lifecycle: the lock, `withGuest` (overlay, the run's firmware copy, QEMU as a child, SSH, login, identity, the body, power-off, the overlay measured and removed, the base checked unchanged), `preflightRun`, `runSuite` with the harness as a child and the document's provenance. |
94
+ | `lab/setup.mjs` | The plan and the disclosure, the resumable literal GET through `github.mjs`'s one call site, the import of a local file, verification and promotion of the ISO, the toolchain record, the build through a copy of the toolchain's harness under staging, the verification boot, the manifest, the promotion; the listed plugins for the evidence suite. |
95
+ | `lab/prune.mjs` | The inventory of what the lab owns with allocated bytes, the refusal while a QEMU answers, the removal of exactly the targets, recovered and remaining bytes. |
96
+ | `lab/suites.mjs` | The four suites as data: host body, files needed, document, assertion, timeout, arguments; the listed plugin ids for `weigh-evidence`, their commits the pinned catalog's. |
97
+ | `lab/harness.sh`, `lab/suites/*.sh` | The one harness every suite runs through, every value an argument; the Run, Store and weigh bodies. |
98
+ | `lab/qmp-cli.mjs` | QMP from a shell: a screendump or a chord, for the harness. |
99
+ | `lab/report.mjs` | The lab's lines for `doctor`, and `inspect`, `setup`, `run` and `prune` for a person, drawn with `style.mjs`. |
100
+
77
101
  ```text
78
102
  omakit pin
79
103
  omakit inspect /path/to/plugin-repo # what the tree does, as observations; --json for the document
@@ -127,3 +151,16 @@ beyond fetching a reviewer-mode subject, and `tests/parity/offline.mjs` proves
127
151
  it.
128
152
 
129
153
  `watch --list` discovers the signed-in account's open marketplace issues through `github.mjs`; `--user` bypasses the account lookup. `watch --all` runs the existing single-issue watch with four workers and shared repository HEAD promises, preserving each read failure. A bare terminal invocation uses `ask.mjs` to choose one or several issues. `report.mjs` composes the list and batch views; all options remain in `options.mjs` and `usage.mjs`, which also generate completion. The batch JSON retains complete single-issue reports; `current` compares commits and never substitutes for review or publication.
154
+
155
+ ## tools/blocks/
156
+
157
+ `tools/blocks/` is the Run block's plumbing on omakit's side: the block
158
+ itself is `blocks/run/`, files a plugin copies (docs/BLOCKS.md).
159
+
160
+ | Module | What it does |
161
+ | --- | --- |
162
+ | `blocks/registry.mjs` | The shipped blocks read from `blocks/<name>/`: each file's header (block, version, licence, source, body sha256) and body hash; `recogniseBlockFile` for inspect and `add`; `blocks/history.json` for every hash ever shipped; the NOTICE renderer. |
163
+ | `blocks/add.mjs` | `omakit add <block> [plugin-dir]`: the one code path that writes into a plugin tree, held to the registry's names under `omakit/`, checked against the agent-control list first, never over a file without `--update`, never over a modified copy. |
164
+ | `blocks/stamp.mjs` | Maintainer's tool after editing a block: rewrites each header's sha256 to its body's, regenerates NOTICE and appends to history.json, under this checkout's `blocks/` only. |
165
+ | `blocks/record-commit.mjs`, `blocks/commit.json` | The release tool: HEAD written into the record before `npm pack`, so an installed package names the commit its block files come from; null in a checkout, where git is the source. Never a lifecycle hook. |
166
+
@@ -194,6 +194,25 @@ export function frame(layout, band, revealed = band, { colour = true } = {}) {
194
194
  })
195
195
  }
196
196
 
197
+ /**
198
+ * The tagline as the lines drawn under the wordmark: one when it fits the
199
+ * wordmark's width, else the narrowest wrap that takes two lines, and the
200
+ * text on one line when no width under its own length does.
201
+ *
202
+ * @param {string} text
203
+ * @param {number} width the wordmark's width in cells
204
+ * @returns {string[]}
205
+ */
206
+ export function taglineLines(text, width) {
207
+ const plain = String(text)
208
+ if ([...plain].length <= width) return [plain]
209
+ for (let columns = width; columns < [...plain].length; columns += 1) {
210
+ const lines = wrap(plain, { width: columns })
211
+ if (lines.length <= 2) return lines
212
+ }
213
+ return [plain]
214
+ }
215
+
197
216
  /**
198
217
  * @param {{ word?: string, tagline?: string, stream?: NodeJS.WriteStream,
199
218
  * enabled?: boolean, animate?: boolean, shines?: number,
@@ -217,9 +236,11 @@ export async function banner(options = {}) {
217
236
  // The tagline is centred under the wordmark, not set flush left: the rule
218
237
  // is exactly as wide as the letters, so a shorter line starting at column
219
238
  // 0 reads as slid to the left. The padding is spaces, no escape, so the
220
- // line is centred under NO_COLOR and in a pipe alike.
239
+ // line is centred under NO_COLOR and in a pipe alike. A tagline wider
240
+ // than the wordmark is wrapped as narrow as two lines allow, so it sits
241
+ // under the letters instead of running past them.
221
242
  const tagline = options.tagline
222
- ? " ".repeat(Math.max(0, Math.floor((width - [...String(options.tagline)].length) / 2))) + c(code("prose"), options.tagline)
243
+ ? taglineLines(options.tagline, width).map((line) => " ".repeat(Math.max(0, Math.floor((width - [...line].length) / 2))) + c(code("prose"), line)).join("\n")
223
244
  : null
224
245
 
225
246
  // Nothing at all when it is not a terminal. There is no plain-text substitute