omakit 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -45
- package/blocks/history.json +68 -0
- package/blocks/run/NOTICE +12 -0
- package/blocks/run/Run.qml +242 -0
- package/blocks/run/run-supervisor.py +522 -0
- package/blocks/store/NOTICE +12 -0
- package/blocks/store/Store.qml +157 -0
- package/blocks/store/store-helper.py +431 -0
- package/package.json +12 -5
- package/skills/omarchy-plugin-audit/SKILL.md +11 -5
- package/skills/omarchy-plugin-build/SKILL.md +164 -0
- package/skills/omarchy-plugin-check/SKILL.md +6 -3
- package/skills/omarchy-plugin-submit/SKILL.md +4 -1
- package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
- package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
- package/tests/fixtures/weigh/clean/Widget.qml +19 -0
- package/tests/fixtures/weigh/clean/manifest.json +9 -0
- package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
- package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
- package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
- package/tests/fixtures/weigh/poller/Service.qml +50 -0
- package/tests/fixtures/weigh/poller/manifest.json +9 -0
- package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
- package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
- package/tests/lab/run/harness/scenarios/controls.sh +6 -0
- package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
- package/tests/lab/run/harness/scenarios/forge.sh +11 -0
- package/tests/lab/run/harness/scenarios/holder.sh +5 -0
- package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
- package/tests/lab/run/harness/scenarios/stall.sh +5 -0
- package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
- package/tests/lab/run/harness/scenarios/tree.sh +7 -0
- package/tests/lab/run/harness/shell.qml +84 -0
- package/tests/lab/run/report.py +217 -0
- package/tests/lab/run/suite.sh +106 -0
- package/tests/lab/store/harness/shell.qml +73 -0
- package/tests/lab/store/report.py +133 -0
- package/tests/lab/store/suite.sh +109 -0
- package/tests/parity/corpus.mjs +8 -3
- package/tests/parity/run.mjs +4 -4
- package/tools/audit/audit.mjs +17 -6
- package/tools/audit/git.mjs +3 -3
- package/tools/audit/report.mjs +31 -5
- package/tools/blocks/add.mjs +138 -0
- package/tools/blocks/commit.json +5 -0
- package/tools/blocks/record-commit.mjs +77 -0
- package/tools/blocks/registry.mjs +191 -0
- package/tools/blocks/stamp.mjs +61 -0
- package/tools/inspect/contract.mjs +36 -5
- package/tools/inspect/helpers.mjs +217 -0
- package/tools/inspect/inspect.mjs +68 -3
- package/tools/inspect/patterns.mjs +18 -3
- package/tools/inspect/processes.mjs +38 -5
- package/tools/inspect/report.mjs +18 -2
- package/tools/inspect/writes.mjs +22 -4
- package/tools/lab/guest.mjs +155 -0
- package/tools/lab/harness.sh +119 -0
- package/tools/lab/host.mjs +177 -0
- package/tools/lab/inspect.mjs +240 -0
- package/tools/lab/omarchy.gpg +13 -0
- package/tools/lab/patches/omarchy-iso-test.patch +351 -0
- package/tools/lab/paths.mjs +173 -0
- package/tools/lab/pin.json +42 -0
- package/tools/lab/pin.mjs +64 -0
- package/tools/lab/prune.mjs +68 -0
- package/tools/lab/qemu.mjs +153 -0
- package/tools/lab/qmp-cli.mjs +21 -0
- package/tools/lab/report.mjs +183 -0
- package/tools/lab/run.mjs +344 -0
- package/tools/lab/setup.mjs +430 -0
- package/tools/lab/suites/run.sh +35 -0
- package/tools/lab/suites/store.sh +41 -0
- package/tools/lab/suites/weigh.sh +196 -0
- package/tools/lab/suites.mjs +142 -0
- package/tools/lab/verify.mjs +134 -0
- package/tools/marketplace/README.md +38 -1
- package/tools/marketplace/banner.mjs +23 -2
- package/tools/marketplace/cli.mjs +449 -146
- package/tools/marketplace/completion-check.mjs +27 -1
- package/tools/marketplace/completion.mjs +32 -4
- package/tools/marketplace/doctor.mjs +47 -9
- package/tools/marketplace/github.mjs +52 -6
- package/tools/marketplace/local-transport.mjs +1 -1
- package/tools/marketplace/options.mjs +16 -5
- package/tools/marketplace/outcome.mjs +244 -0
- package/tools/marketplace/pin.mjs +178 -33
- package/tools/marketplace/setup.mjs +16 -15
- package/tools/marketplace/tree.mjs +1 -1
- package/tools/marketplace/upgrade.mjs +5 -5
- package/tools/marketplace/usage.mjs +116 -72
- package/tools/subject/resolve.mjs +19 -6
- package/tools/weigh/audit.mjs +47 -16
- package/tools/weigh/config.mjs +105 -24
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
256
|
-
add("pin.size", sparse ? "ok" : "advice", `${pinDiskUsage(dir)}${sparse ? ", sparse" :
|
|
257
|
-
sparse ? null : `This checkout
|
|
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
|
-
|
|
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",
|
|
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
|
-
/**
|
|
175
|
-
export async function
|
|
176
|
-
|
|
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
+
}
|