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
@@ -23,6 +23,7 @@ import { spawnSync } from "node:child_process"
23
23
  import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs"
24
24
  import { basename, dirname, join } from "node:path"
25
25
  import { completionInstall, parseCompletionHeader } from "./completion.mjs"
26
+ import { COMMANDS } from "./usage.mjs"
26
27
  import { omakitStateDir } from "./paths.mjs"
27
28
 
28
29
  /** Milliseconds an interactive shell may take to answer a probe; a hung rc file is reported, not waited for. */
@@ -172,7 +173,24 @@ export function installedCompletion(env = process.env) {
172
173
  *
173
174
  * @param {{ version: string, pin: string, env?: NodeJS.ProcessEnv }} options
174
175
  */
175
- export function completionStatus({ version, pin, env = process.env }) {
176
+ /** The subcommands of COMMANDS a script text does not name as a word; a script from another surface lacks some. */
177
+ export function subcommandsMissingFrom(text, commands = COMMANDS) {
178
+ // The names from the signatures alone: subcommandsOf() also lists the
179
+ // shipped blocks for `add`, which reads blocks/, and doctor may run from
180
+ // a tree without it (tests copy bin, tools and package.json alone).
181
+ const names = commands.map((command) => [].concat(command.signature)[0].match(/^omakit +([a-z][a-z-]*)/)?.[1]).filter(Boolean)
182
+ return names.filter((name) => !new RegExp(`(?<![A-Za-z0-9_-])${name}(?![A-Za-z0-9_-])`).test(text))
183
+ }
184
+
185
+ function readScript(path) {
186
+ try {
187
+ return readFileSync(path, "utf8")
188
+ } catch {
189
+ return ""
190
+ }
191
+ }
192
+
193
+ export function completionStatus({ version, pin, env = process.env, commands = COMMANDS }) {
176
194
  const shell = basename(env.SHELL || "")
177
195
  const target = completionInstall(env)
178
196
  if (!target) return { state: "info", shell: shell || null, detail: shell ? `no completion script for ${shell}; there is one for bash, zsh and fish` : "$SHELL is not set, so no completion script is installed", action: null, evidence: { shell: shell || null } }
@@ -186,6 +204,14 @@ export function completionStatus({ version, pin, env = process.env }) {
186
204
  if (installed.version !== version || installed.pin !== pin) {
187
205
  return { state: "advice", shell: target.shell, detail: `${identity}; this omakit is ${version} at pin ${pin.slice(0, 7)}, so the script is stale`, action: "omakit setup", evidence }
188
206
  }
207
+ // The script's content against the current command surface, not its
208
+ // header alone: measured on 2026-09-19 by a first user whose installed
209
+ // script named this version and pin and lacked `add` and `lab`, after a
210
+ // setup that could not replace it (EROFS); doctor called it healthy
211
+ // (docs/evidence/ux/2026-09-19-first-user-test.json, finding 8).
212
+ const missing = subcommandsMissingFrom(readScript(target.path), commands)
213
+ evidence.missingSubcommands = missing
214
+ if (missing.length) return { state: "advice", shell: target.shell, detail: `${identity}; the script does not complete ${missing.join(", ")}, so it is from another command surface`, action: "omakit setup", evidence }
189
215
  if (!probe.ran) return { state: "unknown", shell: target.shell, detail: `${identity}; a new ${target.shell} could not be asked: ${probe.reason}`, action: null, evidence }
190
216
  if (!probe.loader) return { state: "advice", shell: target.shell, detail: `${identity}; a new ${target.shell} has no completion loader, so the script is never read`, action: "omakit setup", evidence }
191
217
  if (probe.spec === "none") return { state: "advice", shell: target.shell, detail: `${identity}; the loader is there but a new ${target.shell} does not load the script`, action: "omakit setup", evidence }
@@ -9,11 +9,16 @@
9
9
  // startup plus a pin read is not something to put behind a TAB. The script says
10
10
  // in its own header which pin it came from and how to regenerate it.
11
11
 
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
12
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
13
13
  import { basename, dirname, join } from "node:path"
14
14
  import { tagSlug } from "./form.mjs"
15
15
  import { COMMANDS, COMPLETION_SHELLS } from "./usage.mjs"
16
16
  import { withHomeAbbreviated } from "./paths.mjs"
17
+ import { shippedBlocks } from "../blocks/registry.mjs"
18
+ import { suiteNames } from "../lab/suites.mjs"
19
+
20
+ /** The words after `omakit lab`, for TAB: the actions, and the suites after `run`. */
21
+ export const LAB_ACTIONS = Object.freeze(["prove", "inspect", "setup", "prune"])
17
22
 
18
23
  /**
19
24
  * The completion model, read out of the help data. A subcommand is the word
@@ -41,8 +46,12 @@ export function subcommandsOf(commands = COMMANDS) {
41
46
  // `<target>` completes as a directory; `<plugin-id-or-dir>` as the ids
42
47
  // the running shell has installed, read at TAB time, with a directory
43
48
  // as the fallback.
44
- const target = /<plugin-id-or-dir>/.test(signature) ? "plugin" : /<target>/.test(signature) ? "directory" : false
45
- return { name, description: sentence, flags, target }
49
+ // `omakit add <block> [<plugin-dir>]`: the block names omakit ships for
50
+ // the first word, a directory for the second.
51
+ // `omakit lab <action> [<suite>]`: the four actions for the first word,
52
+ // the suites for the second after `prove`.
53
+ const target = /^omakit +lab\b/.test(signature) ? "lab" : /^omakit +add\b/.test(signature) ? "block" : /<plugin-id-or-dir>/.test(signature) ? "plugin" : /<target>/.test(signature) ? "directory" : false
54
+ return { name, description: sentence, flags, target, blocks: target === "block" ? shippedBlocks().map((block) => block.name) : [], actions: target === "lab" ? [...LAB_ACTIONS] : [], suites: target === "lab" ? suiteNames() : [] }
46
55
  })
47
56
  }
48
57
 
@@ -171,6 +180,18 @@ function bash({ subcommands, categories, tags, pin, version }) {
171
180
  lines.push(' COMPREPLY=($(compgen -W "$(_omakit_plugin_ids)" -- "$cur"))')
172
181
  lines.push(' if ((${#COMPREPLY[@]} == 0)); then COMPREPLY=($(compgen -d -- "$cur")); compopt -o filenames 2>/dev/null; fi')
173
182
  lines.push(" fi")
183
+ } else if (sub.target === "block") {
184
+ lines.push(' if ((COMP_CWORD == 2)); then')
185
+ lines.push(` COMPREPLY=($(compgen -W ${single(sub.blocks.join(" "))} -- "$cur"))`)
186
+ lines.push(' elif ((COMP_CWORD == 3)); then')
187
+ lines.push(' COMPREPLY=($(compgen -d -- "$cur")); compopt -o filenames 2>/dev/null')
188
+ lines.push(" fi")
189
+ } else if (sub.target === "lab") {
190
+ lines.push(' if ((COMP_CWORD == 2)); then')
191
+ lines.push(` COMPREPLY=($(compgen -W ${single(sub.actions.join(" "))} -- "$cur"))`)
192
+ lines.push(' elif ((COMP_CWORD == 3)) && [[ ${COMP_WORDS[2]} == prove ]]; then')
193
+ lines.push(` COMPREPLY=($(compgen -W ${single(sub.suites.join(" "))} -- "$cur"))`)
194
+ lines.push(" fi")
174
195
  } else if (sub.target) {
175
196
  lines.push(' COMPREPLY=($(compgen -d -- "$cur"))')
176
197
  lines.push(" compopt -o filenames 2>/dev/null")
@@ -239,6 +260,8 @@ function zsh({ subcommands, categories, tags, pin, version }) {
239
260
  return single(flag)
240
261
  })
241
262
  if (sub.target === "plugin") specs.push(single("1:plugin:_omakit_plugins"))
263
+ else if (sub.target === "block") specs.push(single(`1:block:(${sub.blocks.join(" ")})`), single("2:plugin directory:_directories"))
264
+ else if (sub.target === "lab") specs.push(single(`1:action:(${sub.actions.join(" ")})`), single(`2:suite:(${sub.suites.join(" ")})`))
242
265
  else if (sub.target) specs.push(single("1:target:_directories"))
243
266
  if (specs.length) lines.push(` _arguments ${specs.join(" ")}`)
244
267
  lines.push(" ;;")
@@ -281,7 +304,12 @@ function fish({ subcommands, categories, tags, pin, version }) {
281
304
  for (const sub of subcommands) {
282
305
  const when = `-n ${single(`__fish_seen_subcommand_from ${sub.name}`)}`
283
306
  if (sub.target === "plugin") lines.push(`complete -c omakit ${when} -a '(__omakit_plugin_ids)'`)
284
- if (sub.target) lines.push(`complete -c omakit ${when} -a '(__fish_complete_directories)'`)
307
+ if (sub.target === "block") lines.push(`complete -c omakit ${when} -a ${single(sub.blocks.join(" "))}`)
308
+ if (sub.target === "lab") {
309
+ lines.push(`complete -c omakit ${when} -n ${single(`not __fish_seen_subcommand_from ${sub.actions.join(" ")}`)} -a ${single(sub.actions.join(" "))}`)
310
+ lines.push(`complete -c omakit ${when} -n ${single("__fish_seen_subcommand_from prove")} -a ${single(sub.suites.join(" "))}`)
311
+ }
312
+ if (sub.target && sub.target !== "lab") lines.push(`complete -c omakit ${when} -a '(__fish_complete_directories)'`)
285
313
  for (const { flag, value } of sub.flags) {
286
314
  const long = `-l ${flag.slice(2)}`
287
315
  if (value === "category") lines.push(`complete -c omakit ${when} ${long} -x -a ${single(categories.map(fishWord).join(" "))}`)
@@ -37,14 +37,18 @@
37
37
  // nothing the tool uses.
38
38
 
39
39
  import { execFileSync } from "node:child_process"
40
- import { readFileSync } from "node:fs"
40
+ import { existsSync, readFileSync } from "node:fs"
41
41
  import { join } from "node:path"
42
- import { MARKETPLACE_PIN, PIN_PATHS, marketplacePinDir, pinDiskUsage, pinIsSparse, requirePin } from "./pin.mjs"
42
+ import { MARKETPLACE_PIN, PIN_PATHS, marketplacePinDir, pinDiskUsage, pinShape, requirePin } from "./pin.mjs"
43
43
  import { LIVE_PATHS } from "./registry.mjs"
44
- import { credential, defaultBranchHead, getJson, UNAUTHENTICATED_LIMIT, GitHubError } from "./github.mjs"
44
+ import { credential, defaultBranchHead, getJson, GitHubError } from "./github.mjs"
45
45
  import { compareVersions, NPM_REGISTRY, registryLatest, upgradeCommand } from "./upgrade.mjs"
46
+ import { sourceCommit } from "../blocks/add.mjs"
47
+ import { recordedCommit } from "../blocks/record-commit.mjs"
46
48
  import { pathHint } from "./path-hint.mjs"
47
49
  import { completionStatus } from "./completion-check.mjs"
50
+ import { inspectLab } from "../lab/inspect.mjs"
51
+ import { labDoctorChecks } from "../lab/report.mjs"
48
52
 
49
53
  /** "git+https://github.com/owner/name.git" in package.json -> "https://github.com/owner/name", or null. */
50
54
  function repositoryPage(repository) {
@@ -62,9 +66,10 @@ export function tool(repoRoot) {
62
66
  }
63
67
  }
64
68
 
65
- function version(command, args = ["--version"]) {
69
+ /** The first line a command prints for `--version`, or null when it is not there; shared with `setup`. */
70
+ export function version(command, args = ["--version"]) {
66
71
  try {
67
- return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().split("\n")[0]
72
+ return execFileSync(command, args, { timeout: 60_000, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().split("\n")[0]
68
73
  } catch {
69
74
  return null
70
75
  }
@@ -72,7 +77,7 @@ function version(command, args = ["--version"]) {
72
77
 
73
78
  /** The object id a path has at a commit in the pinned checkout: a tree id for a directory, a blob id for a file. Local; a blob-filtered clone still has every tree. */
74
79
  function pinObjectId(pinDir, commit, path) {
75
- return execFileSync("git", ["-C", pinDir, "rev-parse", `${commit}:${path}`], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
80
+ return execFileSync("git", ["-C", pinDir, "rev-parse", `${commit}:${path}`], { timeout: 60_000, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
76
81
  }
77
82
 
78
83
  /**
@@ -221,6 +226,21 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
221
226
  }
222
227
  }
223
228
 
229
+ // Where this omakit's code comes from, and whether `add` can name it: a
230
+ // checkout (git is the source), a package the release step stamped
231
+ // (tools/blocks/commit.json), or a package packed without that step,
232
+ // which names no commit and refuses `add`. Measured on 2026-09-19: a
233
+ // candidate packed with a raw `npm pack` called itself the published
234
+ // version and nothing said it could not add a block.
235
+ {
236
+ const commit = sourceCommit(repoRoot)
237
+ const checkout = existsSync(join(repoRoot, ".git"))
238
+ const recorded = recordedCommit(repoRoot)
239
+ if (checkout && commit) add("omakit.source", "ok", `a checkout at ${commit.slice(0, 7)}; add stamps that commit`, null, { origin: "checkout", commit })
240
+ else if (commit) add("omakit.source", "ok", `a package the release step stamped with commit ${commit.slice(0, 7)}${recorded ? "" : " (npm's gitHead)"}; add stamps that commit`, null, { origin: "package", commit })
241
+ else add("omakit.source", "advice", "a package packed without the release step: it names no source commit, so `omakit add` refuses (no-source-commit)", "install an artifact the release step packed (`npm run pack:release` in a checkout, or the registry's release of this version once published)", { origin: "unstamped", commit: null })
242
+ }
243
+
224
244
  // Reachable as a bare command, or the one line that makes it so for this
225
245
  // install (path-hint.mjs). Measured: an npm prefix whose bin is not on PATH
226
246
  // installs a command nobody can run, and nothing said so.
@@ -246,15 +266,25 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
246
266
  const git = version("git")
247
267
  add("git", git ? "ok" : "problem", git || "git was not found on PATH", git ? null : "Install git.")
248
268
 
269
+ // The blocks start their supervisor and helper through /usr/bin/python3
270
+ // by absolute path (blocks/run/Run.qml), never through PATH, so this is
271
+ // the one path that matters; a stock Omarchy 4.0.3 has it as a
272
+ // dependency of its desktop packages. Advice, not a problem: nothing
273
+ // omakit runs here needs it, and a plugin without a block never will.
274
+ const python = version("/usr/bin/python3")
275
+ add("blocks.python", python ? "ok" : "advice",
276
+ python ? `${python} at /usr/bin/python3, where the Run and Store blocks start it` : "/usr/bin/python3 was not found; a plugin's Run block reports python-missing here",
277
+ python ? null : "Install python (the blocks start /usr/bin/python3 by absolute path).")
278
+
249
279
  const dir = marketplacePinDir(repoRoot)
250
280
  let identity = null
251
281
  try {
252
282
  identity = requirePin(repoRoot).identity
253
283
  add("pin.checkout", "ok",
254
284
  `${identity.commit} (baseline ${identity.baselineVersion}, ${identity.enforcementMode}) at ${dir}`)
255
- const sparse = pinIsSparse(dir)
256
- add("pin.size", sparse ? "ok" : "advice", `${pinDiskUsage(dir)}${sparse ? ", sparse" : ", full checkout"}`,
257
- sparse ? null : `This checkout predates the sparse fetch and is far larger than it needs to be. Remove ${dir} and run \`omakit pin\` to refetch only what omakit reads.`)
285
+ const shape = pinShape(dir)
286
+ add("pin.size", shape.sparse ? "ok" : "advice", `${pinDiskUsage(dir)}${shape.sparse ? ", sparse: the pinned paths and nothing else" : `, a full checkout: ${shape.extra.length} entr${shape.extra.length === 1 ? "y" : "ies"} beyond the pinned paths (${shape.extra.slice(0, 5).join(", ")}${shape.extra.length > 5 ? ", ..." : ""})`}`,
287
+ shape.sparse ? null : `This checkout carries more than the four paths omakit reads. Remove ${dir} and run \`omakit pin\` to refetch only those.`, { sparse: shape.sparse, extra: shape.extra, sparseCheckoutConfig: shape.sparseCheckoutConfig })
258
288
  } catch (error) {
259
289
  add("pin.checkout", "problem", error.message, error.remedy || "omakit pin")
260
290
  }
@@ -296,6 +326,14 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
296
326
  ? "`gh auth login` is enough. omakit reads that login for GET requests only and never copies it anywhere."
297
327
  : "Install GitHub's `gh` CLI and run `gh auth login`. omakit reads that login for GET requests only.")
298
328
 
329
+ // The lab: KVM, QEMU, the firmware, gpg, free disk, the verified ISO and
330
+ // the base, each with its measured reason, every one advice and never a
331
+ // problem, because the lab is optional and doctor installs nothing.
332
+ // Read-only: inspectLab creates no directory, verifies nothing online
333
+ // and starts nothing.
334
+ phase("reading the lab")
335
+ for (const check of labDoctorChecks(await inspectLab({ env }))) checks.push(check)
336
+
299
337
  return { checks, problems: checks.filter((check) => check.state === "problem").length }
300
338
  }
301
339
 
@@ -109,7 +109,17 @@ export function token() {
109
109
  /** The one host the borrowed gh credential may be sent to. */
110
110
  export const CREDENTIAL_HOST = "api.github.com"
111
111
 
112
- async function get(url, { accept, signal } = {}) {
112
+ /**
113
+ * Every request has a deadline. Measured on 2026-09-19 by a first user on
114
+ * a host whose network dropped packets instead of refusing them: a fetch
115
+ * with no signal waited on the kernel's own timeout, minutes, and
116
+ * `npm test` hung with it. A caller's signal wins; without one, the
117
+ * response has to start within GET_DEADLINE_MS (the body of a large
118
+ * object, the ISO, streams on past it under its own progress).
119
+ */
120
+ export const GET_DEADLINE_MS = 20_000
121
+
122
+ async function get(url, { accept, signal, rangeFrom = 0 } = {}) {
113
123
  const { host } = new URL(url)
114
124
  // The credential is GitHub's and goes to GitHub's API and nowhere else.
115
125
  // Measured before this held: `omakit upgrade` and `doctor` sent the gh
@@ -123,14 +133,18 @@ async function get(url, { accept, signal } = {}) {
123
133
  }
124
134
  const auth = authenticated ? token() : null
125
135
  if (auth) headers.authorization = `Bearer ${auth}`
136
+ // A resumed download asks for the rest of the object; the lab's ISO
137
+ // fetch is the one caller, and a server that ignores the range answers
138
+ // 200 from the start, which the caller handles by starting over.
139
+ if (rangeFrom > 0) headers.range = `bytes=${rangeFrom}-`
126
140
  let response
127
141
  try {
128
- response = await fetch(url, { method: "GET", headers, redirect: "follow", ...(signal ? { signal } : {}) })
142
+ response = await fetch(url, { method: "GET", headers, redirect: "follow", signal: signal || AbortSignal.timeout(GET_DEADLINE_MS) })
129
143
  } catch (error) {
130
144
  // Node reports every transport failure as "fetch failed" with the real
131
145
  // reason in `cause`. A person needs the reason, and the CLI keys its
132
146
  // remedy on the code, so both are carried out of here.
133
- const cause = error?.cause?.code || error?.cause?.message || error?.message || "fetch failed"
147
+ const cause = error?.name === "TimeoutError" ? `no answer within ${GET_DEADLINE_MS / 1000} s` : error?.cause?.code || error?.cause?.message || error?.message || "fetch failed"
134
148
  const { host, pathname } = new URL(url)
135
149
  throw new GitHubError("network-unavailable", `${host} did not answer (${cause}) while reading ${pathname}`)
136
150
  }
@@ -158,6 +172,16 @@ export async function getText(url, accept) {
158
172
  return (await get(url, { accept })).text()
159
173
  }
160
174
 
175
+ /**
176
+ * A streamed GET for a large object, the response itself: the lab reads
177
+ * `body` chunk by chunk into a file. `rangeFrom` resumes. Same call site,
178
+ * same literal method, and the credential stays with api.github.com; the
179
+ * ISO origin (iso.omarchy.org) never sees it.
180
+ */
181
+ export async function getStream(url, { rangeFrom = 0, signal } = {}) {
182
+ return get(url, { accept: "application/octet-stream", signal, rangeFrom })
183
+ }
184
+
161
185
  /** Parse a marketplace issue URL into its parts. */
162
186
  export function parseIssueUrl(value) {
163
187
  const match = String(value || "").trim().match(
@@ -171,9 +195,31 @@ export async function issue(owner, repository, number) {
171
195
  return getJson(`https://api.github.com/repos/${owner}/${repository}/issues/${number}`)
172
196
  }
173
197
 
174
- /** Resolve the account whose credential gh lent us; never persist it. */
175
- export async function authenticatedUser() {
176
- if (!token()) throw new GitHubError("login-required", "Account-wide watch needs your GitHub login. Run `gh auth login`, or pass --user <login> to read a public account.")
198
+ /** Whether api.github.com answers at all, unauthenticated, within a short deadline; the one probe that tells a missing network from a missing login. */
199
+ export async function githubReachable({ readJson = getJson, timeoutMs = 5000 } = {}) {
200
+ try {
201
+ await readJson("https://api.github.com/", { signal: AbortSignal.timeout(timeoutMs) })
202
+ return { reachable: true, reason: null }
203
+ } catch (error) {
204
+ if (error?.code === "network-unavailable") return { reachable: false, reason: error.message }
205
+ // Any answer at all (a 4xx included) means the network is there.
206
+ return { reachable: true, reason: null }
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Resolve the account whose credential gh lent us; never persist it. With
212
+ * no credential, the network is asked first: measured on 2026-09-19 by a
213
+ * first user without a connection, `watch --list` said `login-required`
214
+ * and sent them to `gh auth login`, when no login would have helped
215
+ * (docs/evidence/ux/2026-09-19-first-user-test.json, finding 4).
216
+ */
217
+ export async function authenticatedUser({ reachable = githubReachable, hasToken = () => Boolean(token()) } = {}) {
218
+ if (!hasToken()) {
219
+ const probe = await reachable()
220
+ if (!probe.reachable) throw new GitHubError("network-unavailable", `${probe.reason}; an account cannot be read from GitHub while it is unreachable, and no login would change that`)
221
+ throw new GitHubError("login-required", "Account-wide watch needs your GitHub login. Run `gh auth login`, or pass --user <login> to read a public account.")
222
+ }
177
223
  const user = await getJson("https://api.github.com/user")
178
224
  if (!user?.login) throw new GitHubError("github-unavailable", "GitHub did not return the signed-in account's login")
179
225
  return user.login
@@ -25,7 +25,7 @@ export const ASSUMED_BY_ADAPTER = Object.freeze([
25
25
  ])
26
26
 
27
27
  function git(repoDir, args, encoding = "utf8") {
28
- return execFileSync("git", ["-C", repoDir, ...args], {
28
+ return execFileSync("git", ["-C", repoDir, ...args], { timeout: 60_000,
29
29
  encoding,
30
30
  maxBuffer: 512 * 1024 * 1024,
31
31
  stdio: ["ignore", "pipe", "pipe"],
@@ -29,21 +29,24 @@ export const ACCEPTED = Object.freeze({
29
29
  parity: Object.freeze({ valued: ["--count", "--offset", "--out"], flags: [], positionals: 0 }),
30
30
  audit: Object.freeze({ valued: ["--out"], flags: ["--drift", "--json", "--offline"], positionals: 1 }),
31
31
  inspect: Object.freeze({ valued: ["--out"], flags: ["--full", "--json", "--offline", "--allow-dirty"], positionals: 1 }),
32
+ add: Object.freeze({ valued: [], flags: ["--update", "--json"], positionals: 2 }),
32
33
  weigh: Object.freeze({ valued: ["--runs", "--window", "--settle", "--out"], flags: ["--all", "--list", "--json", "--yes"], positionals: 1 }),
34
+ lab: Object.freeze({ valued: ["--runs", "--from", "--toolchain", "--out"], flags: ["--json", "--yes", "--verify", "--plugins", "--keep-iso", "--records"], positionals: 2 }),
33
35
  })
34
36
 
35
37
  /** The accepted options of a command in the words a refusal prints: `--runs N, --all, ...`. */
36
38
  export function acceptedWords(name) {
37
39
  const spec = ACCEPTED[name]
38
- const value = (option) => ({ "--out": "FILE", "--runs": "N", "--count": "N", "--offset": "N", "--window": "S", "--settle": "S", "--category": "C", "--tags": "A,B" }[option] || "TEXT")
40
+ const value = (option) => ({ "--out": "FILE", "--runs": "N", "--count": "N", "--offset": "N", "--window": "S", "--settle": "S", "--category": "C", "--tags": "A,B", "--from": "FILE", "--toolchain": "DIR" }[option] || "TEXT")
39
41
  return [...spec.valued.map((option) => `${option} ${value(option)}`), ...spec.flags].join(", ")
40
42
  }
41
43
 
42
44
  /**
43
45
  * Read a command line against a command's table. Every token is a known
44
- * option (valued or not), the value of a valued option, or a positional up
45
- * to the allowed count; the first token that is none of those is returned
46
- * as `offending` with a reason, so the command refuses before any preflight.
46
+ * option (valued or not, a valued one given once), the value of a valued
47
+ * option (never empty), or a non-empty positional up to the allowed count;
48
+ * the first token that is none of those is returned as `offending` with a
49
+ * reason, so the command refuses before any preflight.
47
50
  *
48
51
  * @param {string[]} args
49
52
  * @param {{ valued: string[], flags: string[], positionals: number }} spec
@@ -54,10 +57,18 @@ export function checkArgs(args, spec) {
54
57
  const positionals = []
55
58
  for (let index = 0; index < args.length; index += 1) {
56
59
  const token = args[index]
60
+ // An empty argument is neither a path, an id nor an option, and is
61
+ // refused everywhere: measured on 2026-09-19, `omakit audit ""` read
62
+ // the empty string as no argument and audited every plugin.
63
+ if (token === "") return { offending: '""', reason: "an empty argument is not a path, an id or an option", options, positionals }
57
64
  const [name, inline] = token.startsWith("--") && token.includes("=") ? [token.slice(0, token.indexOf("=")), token.slice(token.indexOf("=") + 1)] : [token, undefined]
58
65
  if (spec.valued.includes(name)) {
59
66
  const value = inline !== undefined ? inline : args[index + 1]
60
- if (value === undefined || (inline === undefined && value.startsWith("-"))) return { offending: token, reason: `${name} needs a value`, options, positionals }
67
+ if (value === undefined || value === "" || (inline === undefined && value.startsWith("-"))) return { offending: token, reason: `${name} needs a value`, options, positionals }
68
+ // A valued option is given once. Two values would leave one of them
69
+ // silently unread (measured on 2026-09-19: the last won, unsaid); a
70
+ // repeated flag is the flag, and is read once.
71
+ if (options.has(name)) return { offending: token, reason: `${name} is given twice (${JSON.stringify(options.get(name))} and ${JSON.stringify(value)}); pass it once`, options, positionals }
61
72
  options.set(name, value)
62
73
  if (inline === undefined) index += 1
63
74
  } else if (spec.flags.includes(token)) {
@@ -0,0 +1,244 @@
1
+ // The one contract every command leaves through, stated once here and once
2
+ // in docs/COMMANDS.md, and held per command and per outcome by
3
+ // tests/unit/json-outcomes.test.mjs.
4
+ //
5
+ // exit 0 is success; 1 is a refusal or a failure the tool means (a
6
+ // refused submission, drift, a lab that is not ready, a plan a
7
+ // preflight refused, an error the operating system raised); 2 is
8
+ // a usage error, and a question a pipe could not answer; a stop
9
+ // asked for by a signal exits with the signal's own status,
10
+ // 128 plus its number (129 SIGHUP, 130 SIGINT, 143 SIGTERM).
11
+ // --json exactly one document on stdout, whatever the outcome:
12
+ // { command, ok, error, ...the command's own document }, where
13
+ // `error` is null on success and { code, message, remedy } on
14
+ // any other exit, `remedy` never null; the failure's sentence
15
+ // is on stderr too, and nothing else is.
16
+ // stdout the command's text on exit 0; on any other exit the text (a
17
+ // report with its verdict, or the failure block) is on stderr,
18
+ // so stdout carries nothing a parser would mistake for a result.
19
+ // A long-running command's live narration (weigh's restarts, the
20
+ // lab's suite lines) is written as it happens and is not the
21
+ // result: on stdout for a person, on stderr under --json.
22
+ // --out the run's document, the same JSON --json prints, written to the
23
+ // file on every outcome but a usage error, whose command line is
24
+ // not trusted; with --json, stdout then carries nothing at all,
25
+ // and without it the text says where the file went.
26
+ //
27
+ // Measured on 2026-09-19 by an acceptance tester: seven commands shaped
28
+ // their documents seven ways, `lab prune --json` printed nothing, `watch`
29
+ // exited 2 on a verdict, `add` on EACCES carried `remedy: null`, `lab
30
+ // inspect` and `audit` wrote a failing result to stdout only, and a failed
31
+ // `lab prove --json --out` created no file (docs/evidence/ux/
32
+ // 2026-09-19-acceptance.json, finding 2). Every one of those is one place
33
+ // now, this file, and a command that does not pass through it cannot print.
34
+
35
+ import { mkdirSync, writeFileSync } from "node:fs"
36
+ import { dirname, resolve } from "node:path"
37
+ import { action, colourEnabled, GUTTER, labelled, mark, styler, verdict, withOutputStream, wrap } from "./style.mjs"
38
+ import { withHomeAbbreviated } from "./paths.mjs"
39
+
40
+ export const EXIT = Object.freeze({ ok: 0, refused: 1, usage: 2 })
41
+
42
+ /** The shell's convention for a process that stopped on a signal: 128 plus the signal number. */
43
+ export const SIGNAL_EXIT = Object.freeze({ SIGHUP: 129, SIGINT: 130, SIGTERM: 143 })
44
+
45
+ export function signalExit(signal) {
46
+ return SIGNAL_EXIT[signal] ?? SIGNAL_EXIT.SIGINT
47
+ }
48
+
49
+ /** The exit status a failure code means, the same for every command. */
50
+ export function exitFor(code, signal = null) {
51
+ if (code === "usage" || code === "not-confirmed") return EXIT.usage
52
+ if (code === "interrupted") return signalExit(signal)
53
+ return EXIT.refused
54
+ }
55
+
56
+ /**
57
+ * What to do about an error the operating system raised, by its code: the
58
+ * one action, since a remedy is never null. Measured on 2026-09-19: `add`
59
+ * into a read-only directory carried EACCES and `remedy: null`.
60
+ */
61
+ export const OS_REMEDY = Object.freeze({
62
+ EACCES: "Make the directory writable (chmod u+w <dir>), or run it in a directory you own.",
63
+ EPERM: "Make the directory writable (chmod u+w <dir>), or run it in a directory you own.",
64
+ EROFS: "The filesystem is read-only; copy the tree somewhere writable and run it there.",
65
+ ENOSPC: "Free disk space, then run it again.",
66
+ EDQUOT: "Free disk space under your quota, then run it again.",
67
+ ENOENT: "Check the path: it does not exist.",
68
+ ENOTDIR: "Pass a directory where one is meant; a file is in its place.",
69
+ EISDIR: "Pass a file where one is meant; a directory is in its place.",
70
+ EEXIST: "Move what is in the way, then run it again.",
71
+ ENOTEMPTY: "Move what is in the way, then run it again.",
72
+ EBUSY: "Wait for the process that holds it, then run it again.",
73
+ EMFILE: "Close some files or raise the descriptor limit (ulimit -n), then run it again.",
74
+ ENFILE: "Close some files or raise the descriptor limit (ulimit -n), then run it again.",
75
+ ELOOP: "Remove the symbolic link loop in the path, then run it again.",
76
+ ENAMETOOLONG: "Use a shorter path.",
77
+ ETIMEDOUT: "Connect to the network, then run it again.",
78
+ ECONNRESET: "Connect to the network, then run it again.",
79
+ ECONNREFUSED: "Connect to the network, then run it again.",
80
+ ENOTFOUND: "Connect to the network, then run it again.",
81
+ EAI_AGAIN: "Connect to the network, then run it again.",
82
+ ENETUNREACH: "Connect to the network, then run it again.",
83
+ EHOSTUNREACH: "Connect to the network, then run it again.",
84
+ })
85
+
86
+ /** The remedy when no table names one: where to read and what to run. */
87
+ export const GENERAL_REMEDY = "Read the sentence above; `omakit doctor` names what this machine lacks, and docs/COMMANDS.md states the command's contract."
88
+
89
+ /**
90
+ * The remedy for a failure, never null: the module's own, else the
91
+ * command table's, else the operating system's by errno, else the general one.
92
+ */
93
+ export function remedyFor(code, remedy, table = {}) {
94
+ if (typeof remedy === "string" && remedy.trim()) return remedy
95
+ return table[code] || OS_REMEDY[code] || GENERAL_REMEDY
96
+ }
97
+
98
+ /**
99
+ * A message as one sentence: trimmed, ending in exactly one full stop
100
+ * (a question or an exclamation keeps its own mark). Measured on 2026-09-19:
101
+ * `audit` appended a full stop to a sentence that had one, "without it..".
102
+ */
103
+ export function sentence(text) {
104
+ const trimmed = String(text ?? "").trim().replace(/\s+$/, "")
105
+ if (!trimmed) return ""
106
+ if (/[?!]$/.test(trimmed)) return trimmed
107
+ return `${trimmed.replace(/\.+$/, "")}.`
108
+ }
109
+
110
+ /**
111
+ * The document every command prints under --json: the envelope, then the
112
+ * command's own document. A document that is a list is carried as `rows`;
113
+ * the three envelope keys are the envelope's, whatever a document says.
114
+ */
115
+ export function envelope({ command, exit, error = null, document = null }) {
116
+ const ok = exit === 0
117
+ const body = document === null || document === undefined ? {} : Array.isArray(document) ? { rows: document } : typeof document === "object" ? document : { value: document }
118
+ const { command: _command, ok: _ok, error: _error, ...rest } = body
119
+ return { command, ok, error: ok ? null : error, ...rest }
120
+ }
121
+
122
+ /**
123
+ * A failure as an error object for the envelope: the code, the sentence,
124
+ * the remedy (never null), and anything the caller listed beside them
125
+ * (`missing`, `usage`), in that order.
126
+ */
127
+ export function failure({ code, message, remedy, table = {}, ...extra }) {
128
+ return { code, message: sentence(message), remedy: remedyFor(code, remedy, table), ...extra }
129
+ }
130
+
131
+ /** The failure block for a person: `█ FAIL code`, the sentence in the gutter, the listed items, the arrow. */
132
+ export function failureBlock(error, c, { body = () => [] } = {}) {
133
+ const lines = [`${mark("fail", c)}${c("name", error.code)}`, ...wrap(error.message, { indent: GUTTER }, c)]
134
+ for (const item of error.missing || []) {
135
+ lines.push(`${" ".repeat(GUTTER)}${c("name", item.what)}`, ...labelled("costs", withHomeAbbreviated(item.cost), c))
136
+ if (item.command) lines.push(...action(withHomeAbbreviated(item.command), c))
137
+ }
138
+ lines.push(...body(c))
139
+ lines.push(...action(error.remedy, c))
140
+ return lines
141
+ }
142
+
143
+ /** A failure in a verdict register (`█ NOT WEIGHED sentence`, then the arrow), for the commands whose report closes on a word. */
144
+ export function verdictBlock(word, error, c) {
145
+ return [...verdict("fail", word, error.message, c), ...action(error.remedy, c, { indent: 0 })]
146
+ }
147
+
148
+ /** The value of a valued option on a command line, `--name value` or `--name=value`. */
149
+ export function optionValue(args, name) {
150
+ let value
151
+ for (let index = 0; index < args.length; index += 1) {
152
+ if (args[index] === name) value = args[index + 1]
153
+ else if (args[index].startsWith(`${name}=`)) value = args[index].slice(name.length + 1)
154
+ }
155
+ return value
156
+ }
157
+
158
+ /**
159
+ * How a command ends: one call that writes the document, the text and the
160
+ * file the contract above names, and sets the exit status.
161
+ *
162
+ * @param {{ command: string, args: string[], exit: number, document?: object|any[]|null,
163
+ * error?: { code: string, message: string, remedy: string }|null,
164
+ * human?: ((colour: boolean) => string)|string|null,
165
+ * render?: ((error: object, c: Function) => string[])|null,
166
+ * stdout?: NodeJS.WriteStream, stderr?: NodeJS.WriteStream }} outcome
167
+ * `human` is the command's text for a person (a report, or nothing when
168
+ * the failure block is all there is); `render` draws a failure in the
169
+ * command's own register instead of the block. An `error` is required
170
+ * when `exit` is not 0.
171
+ * @returns {number} the exit status, also set on process.exitCode
172
+ */
173
+ export function conclude({ command, args, exit, document = null, error = null, human = null, render = null, stdout = process.stdout, stderr = process.stderr }) {
174
+ const json = args.includes("--json")
175
+ const out = optionValue(args, "--out")
176
+ if (exit !== 0 && !error) throw new Error(`outcome: exit ${exit} for ${command} without an error to report`)
177
+ let doc = envelope({ command, exit, error, document })
178
+ let written = false
179
+ // A usage error refused the command line whole, so no option on it is
180
+ // trusted, --out included: the document stays on stdout. Measured on
181
+ // 2026-09-19 by the acceptance matrix: `audit --out a --out b` was refused
182
+ // for the repeat and still wrote the refusal to b.
183
+ if (out && error?.code !== "usage") {
184
+ try {
185
+ mkdirSync(dirname(resolve(out)), { recursive: true })
186
+ writeFileSync(resolve(out), `${JSON.stringify(doc, null, 2)}\n`)
187
+ written = true
188
+ } catch (problem) {
189
+ // A file that could not be written is a failure of the run, reported
190
+ // like any other and carried in the document that reaches stdout.
191
+ exit = EXIT.refused
192
+ error = failure({ code: problem.code || "out-unwritable", message: `--out ${resolve(out)} could not be written: ${problem.message}` })
193
+ doc = envelope({ command, exit, error, document })
194
+ }
195
+ }
196
+ if (json) {
197
+ // The document goes to the file when there is one; with no file to go
198
+ // to (none asked for, or one that could not be written) it is on stdout.
199
+ if (!written) stdout.write(`${JSON.stringify(doc, null, 2)}\n`)
200
+ if (exit !== 0) {
201
+ const c = styler(colourEnabled(stderr))
202
+ const lines = withOutputStream(stderr, () => (render ? render(error, c) : failureBlock(error, c)))
203
+ stderr.write(`${lines.join("\n")}\n`)
204
+ }
205
+ } else {
206
+ const stream = exit === 0 ? stdout : stderr
207
+ const colour = colourEnabled(stream)
208
+ const c = styler(colour)
209
+ const pieces = []
210
+ if (human !== null && human !== undefined) pieces.push(withOutputStream(stream, () => (typeof human === "function" ? human(colour) : String(human))))
211
+ else if (exit !== 0) pieces.push(withOutputStream(stream, () => (render ? render(error, c) : failureBlock(error, c))).join("\n"))
212
+ if (written) pieces.push(`${mark("pass", c)}wrote ${withHomeAbbreviated(resolve(out))}`)
213
+ const text = pieces.filter((piece) => piece !== "").join("\n")
214
+ if (text) stream.write(text.endsWith("\n") ? text : `${text}\n`)
215
+ }
216
+ process.exitCode = exit
217
+ return exit
218
+ }
219
+
220
+ /**
221
+ * How a failure state leaves: not through `process.exit()` in the middle
222
+ * of a write. A write to a pipe is asynchronous on some platforms, and an
223
+ * exit right behind it drops the bytes (measured on 2026-09-19 by a first
224
+ * user whose `omakit audit --wat` came back with exit 2 and an empty
225
+ * stderr). So a failure throws this, the dispatcher catches it, waits for
226
+ * both streams to drain, and exits with the code.
227
+ */
228
+ export class Exit extends Error {
229
+ constructor(exit) {
230
+ super(`exit ${exit}`)
231
+ this.exit = exit
232
+ }
233
+ }
234
+
235
+ /** Both streams flushed, then the exit; a closed pipe on either is not an error worth a trace. */
236
+ export async function leave(exit) {
237
+ for (const stream of [process.stdout, process.stderr]) {
238
+ await new Promise((resolveDrain) => {
239
+ if (stream.destroyed || stream.writableEnded) return resolveDrain()
240
+ stream.write("", () => resolveDrain())
241
+ }).catch(() => {})
242
+ }
243
+ process.exit(exit)
244
+ }