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.
- 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
|
@@ -8,17 +8,21 @@
|
|
|
8
8
|
// omakit parity [--count n] prove the local transport equals the GitHub transport
|
|
9
9
|
// omakit weigh <plugin> | --all what a plugin weighs on the shell, measured by restarting it
|
|
10
10
|
// omakit inspect <plugin-dir> what a plugin tree does, as observations; decides nothing
|
|
11
|
+
// omakit add run|store [dir] copy a block into the plugin's omakit/ directory
|
|
12
|
+
// omakit lab prove|inspect|setup|prune a suite proved in a disposable Omarchy guest; docs/LAB.md
|
|
11
13
|
//
|
|
12
14
|
// Nothing here writes to the marketplace. There is no POST, PATCH, PUT or
|
|
13
15
|
// DELETE anywhere in this repository, and `tests/unit/read-only.test.mjs`
|
|
14
16
|
// proves it. `weigh` is the one command that changes the user's own machine,
|
|
15
17
|
// their shell and its configuration for the duration of a measurement, and
|
|
16
18
|
// it confirms first; docs/WEIGH.md says what it writes and how it restores.
|
|
19
|
+
// `add` is the one command that writes into a plugin tree: the block's own
|
|
20
|
+
// files under omakit/, never over a modified copy; docs/BLOCKS.md says what.
|
|
17
21
|
|
|
18
|
-
import {
|
|
22
|
+
import { readdirSync, readFileSync } from "node:fs"
|
|
19
23
|
import { dirname, resolve } from "node:path"
|
|
20
24
|
import { fileURLToPath } from "node:url"
|
|
21
|
-
import { ensurePin,
|
|
25
|
+
import { ensurePin, requirePin } from "./pin.mjs"
|
|
22
26
|
import { marketplaceBaselineSection } from "./verify.mjs"
|
|
23
27
|
import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
|
|
24
28
|
import { submitPreflight } from "./submit.mjs"
|
|
@@ -34,17 +38,24 @@ import { upgrade } from "./upgrade.mjs"
|
|
|
34
38
|
import { updateCheckEnabled, updateNotice } from "./update-check.mjs"
|
|
35
39
|
import { progress } from "./progress.mjs"
|
|
36
40
|
import { banner, bannerEnabled } from "./banner.mjs"
|
|
37
|
-
import {
|
|
38
|
-
import { action, AUDIT_VERDICTS, colourEnabled, GUTTER, labelled, mark, outputColumns, styler,
|
|
41
|
+
import { renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
|
|
42
|
+
import { action, AUDIT_VERDICTS, colourEnabled, GUTTER, labelled, mark, outputColumns, styler, withOutputStream, wrap } from "./style.mjs"
|
|
43
|
+
import { conclude, Exit, exitFor, failure, failureBlock, leave, optionValue, SIGNAL_EXIT, signalExit, verdictBlock } from "./outcome.mjs"
|
|
39
44
|
import { omakitCacheDir, withHomeAbbreviated } from "./paths.mjs"
|
|
40
45
|
import { DEFAULTS as WEIGH_DEFAULTS, measureWeigh, planWeigh } from "../weigh/audit.mjs"
|
|
41
46
|
import { confirmationQuestion, renderList, renderWeigh, renderPlan } from "../weigh/report.mjs"
|
|
42
47
|
import { listWeighings } from "../weigh/list.mjs"
|
|
43
48
|
import { askYes } from "../weigh/confirm.mjs"
|
|
44
49
|
import { auditInstalled } from "../audit/audit.mjs"
|
|
45
|
-
import { renderAudit } from "../audit/report.mjs"
|
|
50
|
+
import { auditSummary, renderAudit } from "../audit/report.mjs"
|
|
46
51
|
import { inspectPlugin, NOT_READABLE } from "../inspect/inspect.mjs"
|
|
47
52
|
import { renderInspect } from "../inspect/report.mjs"
|
|
53
|
+
import { addBlock } from "../blocks/add.mjs"
|
|
54
|
+
import { inspectLab } from "../lab/inspect.mjs"
|
|
55
|
+
import { runSuite } from "../lab/run.mjs"
|
|
56
|
+
import { CONSENT_QUESTION, planSetup, recordToolchain, setupLab } from "../lab/setup.mjs"
|
|
57
|
+
import { planPrune, prune } from "../lab/prune.mjs"
|
|
58
|
+
import { renderLab, renderPrunePlan, renderPruneResult, renderRunIdentity, renderRunResult, renderSetupPlan, renderSetupResult } from "../lab/report.mjs"
|
|
48
59
|
|
|
49
60
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
|
|
50
61
|
|
|
@@ -61,63 +72,99 @@ const REMEDY = Object.freeze({
|
|
|
61
72
|
"dirty-worktree": "Commit the changes, or pass --allow-dirty to read HEAD as committed; uncommitted edits are not read.",
|
|
62
73
|
"subject-not-found": "Pass a local Git repository path, or <https url>@<40-char sha>.",
|
|
63
74
|
"not-a-git-repository": "Pass a local Git repository path, or <https url>@<40-char sha>.",
|
|
75
|
+
"not-a-directory": "Pass the plugin's repository directory, not a file inside it.",
|
|
64
76
|
"commit-not-found": "Commit first; the checks read the tree at an exact commit, never the working copy.",
|
|
65
|
-
"network-unavailable": "Connect to the network, then run it again.",
|
|
77
|
+
"network-unavailable": "Connect to the network, then run it again; `omakit watch --user <login>` reads a public account, and needs the network too.",
|
|
66
78
|
"github-unavailable": "Wait for GitHub, then run it again. `gh auth login` raises the rate limit if that is what ran out.",
|
|
67
79
|
"not-found": "Check the issue URL: it has to be an existing issue on the marketplace repository.",
|
|
68
80
|
"head-unreadable": "Check that the plugin repository is public and its URL is right.",
|
|
69
81
|
"login-required": "gh auth login",
|
|
70
82
|
"not-confirmed": "Run it again and answer y, or pass --yes when the person whose shell it is has agreed.",
|
|
71
83
|
"interrupted": "shell.json was restored; run it again when the desktop is yours to restart.",
|
|
84
|
+
"refused": "Fix what the report names, then run it again.",
|
|
85
|
+
"unknown": "Read what could not be compared in the report above; each row says why.",
|
|
86
|
+
"drift": "Return each plugin to its validated commit with the git checkout printed beside it, or validate the newer commit through the form the report names.",
|
|
87
|
+
"not-compared": "Run it in the desktop session whose shell runs these plugins: omarchy-plugin-catalog named no readable source directory for them.",
|
|
88
|
+
"problems": "omakit setup",
|
|
89
|
+
"not-proved": "Read the suite's log under the run's record directory, then run it again.",
|
|
90
|
+
"lab-blocked": "omakit lab inspect names what the host lacks, with the one command for each.",
|
|
91
|
+
"setup-incomplete": "Read the lines above; each failed step names its fix, and `omakit doctor` re-checks.",
|
|
92
|
+
"backup-present": "Compare the backup with shell.json, copy it over if the difference is not yours, then remove it and run weigh again.",
|
|
93
|
+
"exists": "omakit add run [<plugin-dir>] --update",
|
|
94
|
+
"modified": "Keep your copy, or move it aside and run add again; omakit/NOTICE is where modifications are listed.",
|
|
95
|
+
"not-a-plugin": "Pass the plugin's directory, the one with its manifest.json.",
|
|
96
|
+
"plugin-dir-not-found": "Pass the plugin's directory, the one with its manifest.json.",
|
|
97
|
+
"unknown-block": "omakit add run [<plugin-dir>], or omakit add store [<plugin-dir>]",
|
|
98
|
+
"no-source-commit": "Run omakit from a checkout of the repository, or install an artifact the release step packed (`npm run pack:release` in the checkout, or the registry's release of this version once it is published); a raw `npm pack` names no commit.",
|
|
99
|
+
"lab-not-ready": "omakit lab inspect",
|
|
100
|
+
"lab-busy": "omakit lab inspect",
|
|
101
|
+
"iso-mismatch": "omakit lab prune, then omakit lab setup",
|
|
102
|
+
"size-mismatch": "The object at the pinned URL is not the pinned release; a pin update is a reviewed change, and docs/LAB.md says how.",
|
|
103
|
+
"sidecar-mismatch": "The published checksum is not the pin's; a pin update is a reviewed change, and docs/LAB.md says how.",
|
|
104
|
+
"key-mismatch": "The packaged signing key is not the one the pin names: reinstall omakit from the registry (`omakit upgrade`).",
|
|
105
|
+
"toolchain-missing": "omakit lab inspect prints the one command that prepares the toolchain.",
|
|
106
|
+
"toolchain-mismatch": "omakit lab inspect prints the one command that prepares the toolchain.",
|
|
107
|
+
"guest-mismatch": "omakit lab prune removes the staged base; a pin update is a reviewed change, and docs/LAB.md says how.",
|
|
108
|
+
"build-failed": "Read build.log under the lab's staging directory, then omakit lab prune and omakit lab setup again.",
|
|
109
|
+
"qemu-failed": "Read qemu.log in the run directory; omakit lab inspect names what the host lacks.",
|
|
110
|
+
"overlay-failed": "omakit lab inspect: the base must be ready and the disk must have room for one overlay.",
|
|
111
|
+
"no-port": "Free a port between 2222 and 2271 on 127.0.0.1, or wait for the run that holds one.",
|
|
112
|
+
"guest-exited": "Read qemu.log and serial.log in the run directory, then run it again.",
|
|
113
|
+
"guest-timeout": "Read qemu.log and serial.log in the run directory, then run it again.",
|
|
114
|
+
"session-timeout": "Read qemu.log in the run directory and the screenshots beside it, then run it again.",
|
|
115
|
+
"plugin-fetch-failed": "Connect to the network, then omakit lab setup --plugins again.",
|
|
116
|
+
"prune-refused": "Remove the symbolic link by hand; the lab wrote none and follows none.",
|
|
72
117
|
})
|
|
73
118
|
|
|
74
119
|
/*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
120
|
+
* Every command ends in `conclude()` (outcome.mjs): the one place that
|
|
121
|
+
* writes the document under --json, the text on the stream the exit status
|
|
122
|
+
* chooses, and the --out file, and sets the exit status. A command that has
|
|
123
|
+
* written its result leaves through `process.exitCode`, never
|
|
124
|
+
* `process.exit()`: stdout is an API, and on a pipe whose reader has not
|
|
125
|
+
* started reading yet the exit cuts the output (measured on 0.4.1:
|
|
78
126
|
* `omakit submit <listed plugin> --json | (sleep 2; cat)` delivered 8,192 of
|
|
79
|
-
* 14,033 bytes
|
|
80
|
-
*
|
|
81
|
-
* and their callers use them the way a throw is used.
|
|
127
|
+
* 14,033 bytes). A failure state throws `Exit`, which the dispatcher at the
|
|
128
|
+
* bottom catches, drains both streams for, and only then exits with.
|
|
82
129
|
*/
|
|
83
130
|
|
|
131
|
+
/** The command being run and its arguments, for the envelope: set by the dispatcher before anything runs. */
|
|
132
|
+
const CONTEXT = { command: null, args: [] }
|
|
133
|
+
|
|
84
134
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* message
|
|
135
|
+
* A failure state: the document under --json, the block on stderr, the
|
|
136
|
+
* --out file, and the exit. `usage` errors carry the signature that was
|
|
137
|
+
* expected, so the remedy is the reference and not a restatement of the
|
|
138
|
+
* message. `body` is extra labelled lines between the message and the
|
|
139
|
+
* arrow, for a usage error that has values to list; `extra` rides in the
|
|
140
|
+
* document beside the code (`missing`, `usage`).
|
|
89
141
|
*/
|
|
90
|
-
function fail(code, message, exit =
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
...(remedy ? action(remedy, c) : []),
|
|
95
|
-
])
|
|
96
|
-
process.stderr.write(`${lines.join("\n")}\n`)
|
|
97
|
-
process.exit(exit)
|
|
142
|
+
function fail(code, message, exit = exitFor(code), remedy = REMEDY[code], body = () => [], extra = {}, render = null) {
|
|
143
|
+
const error = failure({ code, message, remedy, table: REMEDY, ...extra })
|
|
144
|
+
conclude({ command: CONTEXT.command, args: CONTEXT.args, exit, error, render: render || ((problem, c) => failureBlock(problem, c, { body })) })
|
|
145
|
+
throw new Exit(exit)
|
|
98
146
|
}
|
|
99
147
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
102
|
-
|
|
148
|
+
/**
|
|
149
|
+
* A thrown error becomes a failure state when it carries a code; anything
|
|
150
|
+
* else is a bug and keeps its stack. The exit status follows the code the
|
|
151
|
+
* same way for every command: 2 for a usage error and for a question a
|
|
152
|
+
* pipe could not answer, the signal's own status for an interrupt, 1 for
|
|
153
|
+
* the rest, an error the operating system raised included, whose remedy
|
|
154
|
+
* comes from its errno. An error that names what is missing (`missing`:
|
|
155
|
+
* what, what it costs, the one command) lists it between the sentence and
|
|
156
|
+
* the arrow.
|
|
157
|
+
*/
|
|
158
|
+
function failFrom(error, render = null) {
|
|
159
|
+
if (error?.code && typeof error.code === "string") {
|
|
160
|
+
fail(error.code, error.message, exitFor(error.code, error.signal), error.remedy || REMEDY[error.code], () => [], error.missing?.length ? { missing: error.missing } : {}, render)
|
|
161
|
+
}
|
|
103
162
|
throw error
|
|
104
163
|
}
|
|
105
164
|
|
|
106
|
-
/**
|
|
107
|
-
* The value of a valued option, written either way the table accepts,
|
|
108
|
-
* `--name value` or `--name=value`, the last occurrence winning as it does
|
|
109
|
-
* in options.mjs. Measured on 0.4.1: the table accepted `--out=FILE` and the
|
|
110
|
-
* value was looked up as the token after `--out`, so `doctor --out=x` wrote
|
|
111
|
-
* nothing and exited 0, and `submit --category=Widgets` said the flag was
|
|
112
|
-
* missing.
|
|
113
|
-
*/
|
|
165
|
+
/** The value of a valued option, `--name value` or `--name=value`; a repeat with another value is refused by the table before this reads it. */
|
|
114
166
|
function option(args, name) {
|
|
115
|
-
|
|
116
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
117
|
-
if (args[index] === name) value = args[index + 1]
|
|
118
|
-
else if (args[index].startsWith(`${name}=`)) value = args[index].slice(name.length + 1)
|
|
119
|
-
}
|
|
120
|
-
return value
|
|
167
|
+
return optionValue(args, name)
|
|
121
168
|
}
|
|
122
169
|
|
|
123
170
|
/** The bare arguments, with every valued option's value (options.mjs, one table) left out. */
|
|
@@ -138,22 +185,14 @@ function spinnerFor(args) {
|
|
|
138
185
|
return args.includes("--json") ? SILENT : progress()
|
|
139
186
|
}
|
|
140
187
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
mkdirSync(dirname(resolve(out)), { recursive: true })
|
|
145
|
-
writeFileSync(resolve(out), text.endsWith("\n") ? text : `${text}\n`)
|
|
146
|
-
const c = styler(colourEnabled())
|
|
147
|
-
process.stdout.write(`${mark("pass", c)}wrote ${resolve(out)}\n`)
|
|
148
|
-
} else {
|
|
149
|
-
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`)
|
|
150
|
-
}
|
|
188
|
+
/** A success: the document, the report for a person, exit 0, through the one layer. */
|
|
189
|
+
function succeed(args, document, human = null) {
|
|
190
|
+
return conclude({ command: CONTEXT.command, args, exit: 0, document, human })
|
|
151
191
|
}
|
|
152
192
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return
|
|
156
|
-
() => render(result, { ...options, ...(out ? { colour: false } : {}) }))
|
|
193
|
+
/** A refusal the tool means, with the report that explains it: the document and the report, exit 1, the error beside them. */
|
|
194
|
+
function refuse(args, document, human, error) {
|
|
195
|
+
return conclude({ command: CONTEXT.command, args, exit: 1, document, human, error: failure({ ...error, table: REMEDY }) })
|
|
157
196
|
}
|
|
158
197
|
|
|
159
198
|
async function cmdSubmit(args) {
|
|
@@ -192,26 +231,26 @@ async function cmdSubmit(args) {
|
|
|
192
231
|
spinner.done()
|
|
193
232
|
if (error?.code === "usage" && error.usage) {
|
|
194
233
|
const usage = error.usage
|
|
195
|
-
if (json) {
|
|
196
|
-
process.stdout.write(`${JSON.stringify({ usage }, null, 2)}\n`)
|
|
197
|
-
process.exitCode = 2
|
|
198
|
-
return
|
|
199
|
-
}
|
|
200
234
|
const flags = usage.missing.join(" and ")
|
|
201
235
|
fail("usage", `submit needs ${flags}: ${usage.missing.length === 1 ? "it is" : "they are"} an editorial choice nobody else can make, from the pinned form's own lists.`, 2,
|
|
202
236
|
`omakit submit ${target} --category <c> --tags <a,b>`,
|
|
203
237
|
(c) => [
|
|
204
238
|
...labelled("categories", usage.categories.join(", "), c),
|
|
205
239
|
...labelled(`tags, 1 to ${usage.maximumTags}`, usage.tags.join(", "), c),
|
|
206
|
-
])
|
|
240
|
+
], { usage })
|
|
207
241
|
}
|
|
208
242
|
failFrom(error)
|
|
209
243
|
}
|
|
210
244
|
spinner.done()
|
|
211
|
-
|
|
245
|
+
const human = (colour) => renderSubmit(result, { colour })
|
|
212
246
|
// Three outcomes, two exit codes: `ready` and `listed` are both healthy
|
|
213
247
|
// states, and only a refusal is a 1.
|
|
214
|
-
|
|
248
|
+
if (result.outcome === "refused") {
|
|
249
|
+
const blocking = result.blocking || []
|
|
250
|
+
refuse(args, result, human, { code: "refused", message: `${blocking.length} blocking check${blocking.length === 1 ? "" : "s"} failed (${blocking.join(", ")}), so no body is produced` })
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
succeed(args, result, human)
|
|
215
254
|
}
|
|
216
255
|
|
|
217
256
|
async function cmdWatch(args) {
|
|
@@ -249,8 +288,20 @@ async function cmdWatch(args) {
|
|
|
249
288
|
}
|
|
250
289
|
spinner.done()
|
|
251
290
|
const render = result.mode === "list" ? renderWatchList : result.mode === "all" ? renderWatchAll : renderWatch
|
|
252
|
-
|
|
253
|
-
|
|
291
|
+
const human = (colour) => render(result, { colour })
|
|
292
|
+
// A comparison that could not be made is a refusal the tool means, exit
|
|
293
|
+
// 1: the validated commit is not known to be current. Stale is a fact
|
|
294
|
+
// about the marketplace, not a failure, and exits 0. Measured on
|
|
295
|
+
// 2026-09-19: an unknown verdict exited 2, the usage status.
|
|
296
|
+
if (result.verdict?.state === "unknown") {
|
|
297
|
+
refuse(args, result, human, { code: "unknown", message: result.verdict.summary, remedy: result.verdict.action || REMEDY.unknown })
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
if (result.summary?.unknown > 0) {
|
|
301
|
+
refuse(args, result, human, { code: "unknown", message: `${result.summary.unknown} of ${result.summary.total} comparison${result.summary.total === 1 ? "" : "s"} could not be made` })
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
succeed(args, result, human)
|
|
254
305
|
}
|
|
255
306
|
|
|
256
307
|
async function cmdFrontDoor() {
|
|
@@ -260,33 +311,48 @@ async function cmdFrontDoor() {
|
|
|
260
311
|
const drew = bannerEnabled()
|
|
261
312
|
await banner({ tagline: TAGLINE, effect: true })
|
|
262
313
|
process.stdout.write(renderSummary({ heading: !drew }))
|
|
314
|
+
process.exitCode = 0
|
|
263
315
|
}
|
|
264
316
|
|
|
265
317
|
async function cmdSetup(args) {
|
|
266
318
|
// `--completion` is the one step on its own: write the script and prove
|
|
267
319
|
// it in a new shell, never the rc question. `upgrade` runs it through the
|
|
268
320
|
// freshly installed omakit, so the script carries the new version.
|
|
321
|
+
// setup narrates as it goes, on stdout, and asks its one question at a
|
|
322
|
+
// terminal; what it could not do is the failure, on stderr, at the end.
|
|
269
323
|
if (args.includes("--completion")) {
|
|
270
324
|
const identity = requirePin(ROOT).identity
|
|
271
325
|
const result = await completionStep({ repoRoot: ROOT, pin: identity.commit, version: VERSION, askRc: false })
|
|
272
|
-
|
|
326
|
+
if (result.state === "ok") return succeed(args, result)
|
|
327
|
+
refuse(args, result, null, { code: "setup-incomplete", message: `tab completion is ${result.state}${result.error ? `: ${result.error}` : ""}` })
|
|
273
328
|
return
|
|
274
329
|
}
|
|
275
330
|
const result = await setup({ repoRoot: ROOT, entryPoint: resolve(ROOT, "bin/omakit"), yes: args.includes("--yes") })
|
|
276
|
-
|
|
331
|
+
if (result.ok) return succeed(args, result)
|
|
332
|
+
refuse(args, result, null, { code: "setup-incomplete", message: "setup did not complete every step; the lines above name the one that failed" })
|
|
277
333
|
}
|
|
278
334
|
|
|
279
335
|
async function cmdUpgrade(args) {
|
|
280
|
-
|
|
281
|
-
|
|
336
|
+
// upgrade writes its account of the update into a buffer, so the text
|
|
337
|
+
// lands on the stream the outcome chooses: stdout when it applied or had
|
|
338
|
+
// nothing to do, stderr when it refused.
|
|
339
|
+
const buffer = { text: "", write(chunk) { this.text += chunk; return true } }
|
|
340
|
+
const result = await upgrade({ repoRoot: ROOT, stream: buffer, dryRun: args.includes("--dry-run") })
|
|
341
|
+
if (result.ok) return succeed(args, result, buffer.text.trimEnd())
|
|
342
|
+
refuse(args, result, buffer.text.trimEnd(), { code: "refused", message: result.reason || "the upgrade was refused" })
|
|
282
343
|
}
|
|
283
344
|
|
|
284
345
|
async function cmdDoctor(args) {
|
|
285
346
|
const spinner = spinnerFor(args)
|
|
286
347
|
const result = await doctor({ repoRoot: ROOT, offline: args.includes("--offline"), onPhase: spinner.phase })
|
|
287
348
|
spinner.done()
|
|
288
|
-
|
|
289
|
-
|
|
349
|
+
const human = (colour) => renderDoctor(result, { colour })
|
|
350
|
+
if (result.problems) {
|
|
351
|
+
const failed = result.checks.filter((check) => check.state === "problem")
|
|
352
|
+
refuse(args, result, human, { code: "problems", message: `${failed.length} check${failed.length === 1 ? "" : "s"} found a problem: ${failed.map((check) => check.id).join(", ")}`, remedy: failed.find((check) => check.action)?.action || REMEDY.problems })
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
succeed(args, result, human)
|
|
290
356
|
}
|
|
291
357
|
|
|
292
358
|
async function cmdVerify(args) {
|
|
@@ -319,23 +385,19 @@ async function cmdVerify(args) {
|
|
|
319
385
|
marketplaceBaseline: section,
|
|
320
386
|
}
|
|
321
387
|
// The JSON is the document itself, byte for byte what verify always
|
|
322
|
-
// printed
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
emit(args, `${JSON.stringify(document, null, 2)}\n`)
|
|
326
|
-
return
|
|
327
|
-
}
|
|
328
|
-
const blockingRules = section.invoked && section.official && !section.official.error
|
|
388
|
+
// printed under the envelope; a person at the terminal gets the report,
|
|
389
|
+
// in the register submit uses for its checks.
|
|
390
|
+
const blockingRules = !args.includes("--json") && section.invoked && section.official && !section.official.error
|
|
329
391
|
? (await consequence(requirePin(ROOT).dir, section.official)).selectivelyBlockingRules
|
|
330
392
|
: []
|
|
331
|
-
|
|
393
|
+
succeed(args, document, (colour) => renderVerify(document, { colour, blockingRules }))
|
|
332
394
|
}
|
|
333
395
|
|
|
334
396
|
async function cmdParity(args) {
|
|
335
397
|
// The runner takes everything as arguments. Before 0.1.8 this handed over
|
|
336
398
|
// four PARITY_* variables and an OMAKIT_ROOT through the process
|
|
337
399
|
// environment, and OMAKIT_ROOT was the one OMAKIT_* name in the tree.
|
|
338
|
-
let
|
|
400
|
+
let run
|
|
339
401
|
try {
|
|
340
402
|
// Imported here, not at the top: the runner ships with the package, but
|
|
341
403
|
// no other command needs it, and a copy of bin/ and tools/ alone runs
|
|
@@ -343,29 +405,33 @@ async function cmdParity(args) {
|
|
|
343
405
|
const { runParity } = await import("../../tests/parity/run.mjs")
|
|
344
406
|
const count = option(args, "--count")
|
|
345
407
|
const offset = option(args, "--offset")
|
|
346
|
-
|
|
408
|
+
if (count !== undefined && !(/^\d+$/.test(count) && Number(count) >= 1)) fail("usage", `--count needs an integer of at least 1, not ${JSON.stringify(count)}.`, 2, "omakit parity [--count <n>] [--offset <n>] [--out FILE]")
|
|
409
|
+
if (offset !== undefined && !/^\d+$/.test(offset)) fail("usage", `--offset needs an integer of at least 0, not ${JSON.stringify(offset)}.`, 2, "omakit parity [--count <n>] [--offset <n>] [--out FILE]")
|
|
410
|
+
run = await runParity({
|
|
347
411
|
repoRoot: ROOT,
|
|
348
412
|
count: count ? Number(count) : undefined,
|
|
349
413
|
offset: offset ? Number(offset) : undefined,
|
|
350
414
|
out: option(args, "--out") || null,
|
|
351
|
-
})
|
|
415
|
+
})
|
|
352
416
|
} catch (error) {
|
|
353
417
|
failFrom(error)
|
|
354
418
|
}
|
|
355
|
-
|
|
419
|
+
// The evidence file the runner wrote is the document; with --out it is
|
|
420
|
+
// rewritten there under the envelope, the same bytes every other command
|
|
421
|
+
// puts in its --out.
|
|
422
|
+
const { summary, outFile, ok } = run
|
|
423
|
+
const document = { ...summary, evidence: outFile }
|
|
424
|
+
const words = `identical ${summary.identical} of ${summary.corpusSize}, ${summary.mismatches} mismatch${summary.mismatches === 1 ? "" : "es"}, ${summary.failures} failure${summary.failures === 1 ? "" : "s"}; evidence at ${withHomeAbbreviated(outFile)}`
|
|
425
|
+
if (ok) return succeed(args, document, (colour) => `${mark("pass", styler(colour))}PARITY ${words}`)
|
|
426
|
+
refuse(args, document, (colour) => `${mark("fail", styler(colour))}NOT PARITY ${words}`, { code: "parity-mismatch", message: `the local transport and the GitHub transport did not agree: ${words}`, remedy: "Read the evidence file's rows with differingKeys or an error; docs/UPSTREAM_CONTRACT.md says what a mismatch means for the pin." })
|
|
356
427
|
}
|
|
357
428
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const lines = verdict("fail", AUDIT_VERDICTS.unavailable, message, c)
|
|
361
|
-
if (remedy) lines.push(...action(remedy, c, { indent: 0 }))
|
|
362
|
-
process.stderr.write(`${lines.join("\n")}\n`)
|
|
363
|
-
process.exit(exit)
|
|
364
|
-
}
|
|
429
|
+
/** Every way audit stops without a comparison, in its own register: `█ NOT AUDITED sentence`, then the one action. */
|
|
430
|
+
const notAuditedRegister = (error, c) => verdictBlock(AUDIT_VERDICTS.unavailable, error, c)
|
|
365
431
|
|
|
366
432
|
async function cmdAudit(args) {
|
|
367
433
|
const parsed = checkArgs(args, ACCEPTED.audit)
|
|
368
|
-
if (parsed.offending !== null)
|
|
434
|
+
if (parsed.offending !== null) fail("usage", `${parsed.reason}. Accepted: ${acceptedWords("audit")}.`, 2, "omakit audit [<plugin-id-or-dir>] [--drift] [--json] [--out FILE] [--offline]", () => [], {}, notAuditedRegister)
|
|
369
435
|
let document
|
|
370
436
|
try {
|
|
371
437
|
document = await auditInstalled({
|
|
@@ -375,18 +441,14 @@ async function cmdAudit(args) {
|
|
|
375
441
|
offline: parsed.options.has("--offline"),
|
|
376
442
|
})
|
|
377
443
|
} catch (error) {
|
|
378
|
-
|
|
379
|
-
throw error
|
|
380
|
-
}
|
|
381
|
-
const json = `${JSON.stringify(document, null, 2)}\n`
|
|
382
|
-
const out = parsed.options.get("--out")
|
|
383
|
-
if (out) {
|
|
384
|
-
mkdirSync(dirname(resolve(out)), { recursive: true })
|
|
385
|
-
writeFileSync(resolve(out), json)
|
|
444
|
+
failFrom(error, notAuditedRegister)
|
|
386
445
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
446
|
+
const human = (colour) => renderAudit(document, { colour })
|
|
447
|
+
if (document.ok) return succeed(args, document, human)
|
|
448
|
+
// Drift is a refusal the tool means; a row it could not compare is not
|
|
449
|
+
// drift, and is said as such (audit/report.mjs, one sentence for both).
|
|
450
|
+
const drifted = document.counts.drift.value > 0
|
|
451
|
+
refuse(args, document, human, { code: drifted ? "drift" : "not-compared", message: auditSummary(document) })
|
|
390
452
|
}
|
|
391
453
|
|
|
392
454
|
/**
|
|
@@ -395,7 +457,7 @@ async function cmdAudit(args) {
|
|
|
395
457
|
* manifest), in the one failure register. There is no exit status for
|
|
396
458
|
* "found something", because finding something is the normal outcome.
|
|
397
459
|
* `--out` writes the document to a file beside whatever stdout gets, the
|
|
398
|
-
* way
|
|
460
|
+
* way every command's does.
|
|
399
461
|
*/
|
|
400
462
|
async function cmdInspect(args) {
|
|
401
463
|
const parsed = checkArgs(args, ACCEPTED.inspect)
|
|
@@ -421,29 +483,52 @@ async function cmdInspect(args) {
|
|
|
421
483
|
throw error
|
|
422
484
|
}
|
|
423
485
|
spinner.done()
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
486
|
+
succeed(args, document, (colour) => renderInspect(document, { full: parsed.options.has("--full"), colour }))
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* `omakit add <block> [plugin-dir]`: the one command that writes into a
|
|
491
|
+
* plugin tree, and only the block's files under omakit/. Every refusal is
|
|
492
|
+
* a failure state before anything is written; the report is one line per
|
|
493
|
+
* file with what happened to it, or the document under --json.
|
|
494
|
+
*/
|
|
495
|
+
async function cmdAdd(args) {
|
|
496
|
+
const parsed = checkArgs(args, ACCEPTED.add)
|
|
497
|
+
if (parsed.offending !== null) fail("usage", `${parsed.reason}. Accepted: ${acceptedWords("add")}.`, 2, "omakit add <block> [<plugin-dir>] [--update] [--json]")
|
|
498
|
+
const [block, dir] = parsed.positionals
|
|
499
|
+
if (!block) fail("usage", "add needs a block: `omakit add run [<plugin-dir>]` or `omakit add store [<plugin-dir>]`", 2, "omakit add <block> [<plugin-dir>] [--update] [--json]")
|
|
500
|
+
let result
|
|
501
|
+
try {
|
|
502
|
+
result = addBlock({ repoRoot: ROOT, block, dir: dir || ".", update: parsed.options.has("--update") })
|
|
503
|
+
} catch (error) {
|
|
504
|
+
failFrom(error)
|
|
505
|
+
}
|
|
506
|
+
succeed(args, result, (colour) => {
|
|
507
|
+
const c = styler(colour)
|
|
508
|
+
const lines = []
|
|
509
|
+
for (const file of [...result.files, result.notice]) {
|
|
510
|
+
const state = file.state === "current" ? "info" : "pass"
|
|
511
|
+
const from = file.from ? ` (from ${file.block} ${file.from})` : ""
|
|
512
|
+
const other = file.block && file.block !== result.block ? ` (${file.block}, which ${result.block} uses)` : ""
|
|
513
|
+
lines.push(`${mark(state, c)}${c("label", file.state.padEnd(8))} ${file.path}${from}${other}`)
|
|
514
|
+
}
|
|
515
|
+
lines.push(...labelled("block", `${result.block} ${result.version}${result.requires.length ? `, with ${result.requires.join(", ")}` : ""}, from omakit commit ${result.commit}`, c))
|
|
516
|
+
lines.push(...labelled("into", withHomeAbbreviated(result.dir), c))
|
|
517
|
+
if (result.files.some((file) => file.state !== "current")) lines.push(...action(result.block === "store" ? "import \"omakit\" in the QML that keeps state, and use Store { } there; docs/BLOCKS.md is the contract" : "import \"omakit\" in the QML that starts a process, and use Run { } there; docs/BLOCKS.md is the contract", c))
|
|
518
|
+
return lines.join("\n")
|
|
519
|
+
})
|
|
433
520
|
}
|
|
434
521
|
|
|
435
522
|
/**
|
|
436
523
|
* Every way `weigh` stops without weighing, in one register: the closing
|
|
437
524
|
* word a report would have ended with, negated, then the sentence naming
|
|
438
525
|
* what is missing, then the one thing to do. Exit 2 for a usage error and
|
|
439
|
-
* an unanswered confirmation,
|
|
526
|
+
* an unanswered confirmation, the signal's status for an interrupt, 1 for
|
|
527
|
+
* the rest.
|
|
440
528
|
*/
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if (remedy) lines.push(...action(remedy, c, { indent: 0 }))
|
|
445
|
-
process.stderr.write(`${lines.join("\n")}\n`)
|
|
446
|
-
process.exit(exit)
|
|
529
|
+
const notWeighedRegister = (error, c) => verdictBlock("NOT WEIGHED", error, c)
|
|
530
|
+
function notWeighed(code, message, remedy, exit = exitFor(code)) {
|
|
531
|
+
fail(code, message, exit, remedy, () => [], {}, notWeighedRegister)
|
|
447
532
|
}
|
|
448
533
|
|
|
449
534
|
async function cmdWeigh(args) {
|
|
@@ -465,10 +550,9 @@ async function cmdWeigh(args) {
|
|
|
465
550
|
try {
|
|
466
551
|
list = listWeighings()
|
|
467
552
|
} catch (error) {
|
|
468
|
-
|
|
469
|
-
throw error
|
|
553
|
+
failFrom(error, notWeighedRegister)
|
|
470
554
|
}
|
|
471
|
-
|
|
555
|
+
succeed(args, list.rows, (colour) => renderList(list, { colour }))
|
|
472
556
|
return
|
|
473
557
|
}
|
|
474
558
|
if (!target && !all) notWeighed("usage", "weigh needs a plugin: `omakit weigh <plugin-id-or-dir>`, or `omakit weigh --all` for every enabled third-party plugin.", "omakit weigh <plugin-id-or-dir>", 2)
|
|
@@ -486,8 +570,7 @@ async function cmdWeigh(args) {
|
|
|
486
570
|
try {
|
|
487
571
|
plan = planWeigh({ target, all, runs, windowSeconds, settleSeconds, out: parsed.options.get("--out") })
|
|
488
572
|
} catch (error) {
|
|
489
|
-
|
|
490
|
-
throw error
|
|
573
|
+
failFrom(error, notWeighedRegister)
|
|
491
574
|
}
|
|
492
575
|
// The narration: what was backed up and with which md5, and what was
|
|
493
576
|
// restored. For a person it is part of the report, on stdout; under
|
|
@@ -499,53 +582,262 @@ async function cmdWeigh(args) {
|
|
|
499
582
|
// what was agreed to; the question is asked only at a terminal on both
|
|
500
583
|
// ends, and --yes is the only other way past it.
|
|
501
584
|
narrate.write(`${withOutputStream(narrate, () => renderPlan(plan, { colour: colourEnabled(narrate) })).join("\n")}\n`)
|
|
502
|
-
|
|
585
|
+
// The consent is a value handed to the measurement, not a flag it reads
|
|
586
|
+
// for itself: `--yes`, or a `y` typed at a terminal on both ends. Without
|
|
587
|
+
// it measureWeigh refuses, and so does every write under tools/weigh/.
|
|
588
|
+
let consent = parsed.options.has("--yes") ? { consented: true, how: "--yes" } : null
|
|
589
|
+
if (!consent) {
|
|
503
590
|
const interactive = !json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)
|
|
504
591
|
if (!interactive) notWeighed("not-confirmed", `this restarts the shell ${plan.restarts} times and edits shell.json for the duration; a pipe, an agent or --json cannot answer for the person whose shell it is.`, REMEDY["not-confirmed"], 2)
|
|
505
592
|
const agreed = await askYes({ question: confirmationQuestion(plan) })
|
|
506
593
|
if (!agreed) notWeighed("not-confirmed", "not confirmed; nothing was changed.", REMEDY["not-confirmed"], 2)
|
|
594
|
+
consent = { consented: true, how: "answered y at the terminal" }
|
|
507
595
|
}
|
|
508
596
|
narrate.write("\n")
|
|
509
597
|
// An interrupt is a request to stop, not a reason to leave the user's
|
|
510
598
|
// shell on a measurement configuration: the signal aborts the run, the
|
|
511
599
|
// measurement's own finally restores shell.json and restarts the shell,
|
|
512
|
-
// and only then does the process exit,
|
|
600
|
+
// and only then does the process exit, with the signal's own status (130
|
|
601
|
+
// for SIGINT, 143 for SIGTERM, 129 for SIGHUP, a terminal that closed).
|
|
513
602
|
const controller = new AbortController()
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
603
|
+
let stoppedBy = null
|
|
604
|
+
const interrupt = (signal) => {
|
|
605
|
+
stoppedBy = stoppedBy || signal
|
|
606
|
+
if (!controller.signal.aborted) narrate.write(`\n${mark("advisory", c)}interrupted (${signal}): restoring shell.json before exiting\n`)
|
|
607
|
+
controller.abort(signal)
|
|
517
608
|
}
|
|
518
|
-
process.on(
|
|
519
|
-
process.on("SIGTERM", interrupt)
|
|
609
|
+
for (const signal of Object.keys(SIGNAL_EXIT)) process.on(signal, interrupt)
|
|
520
610
|
const spinner = spinnerFor(args)
|
|
521
611
|
let document
|
|
522
612
|
try {
|
|
523
|
-
document = await measureWeigh(plan, { signal: controller.signal, omakitVersion: VERSION, onPhase: spinner.phase, onLine: (line) => { spinner.done(); say(line) } })
|
|
613
|
+
document = await measureWeigh(plan, { consent, signal: controller.signal, omakitVersion: VERSION, onPhase: spinner.phase, onLine: (line) => { spinner.done(); say(line) } })
|
|
524
614
|
} catch (error) {
|
|
525
615
|
spinner.done()
|
|
526
|
-
if (error?.code === "interrupted") notWeighed("interrupted",
|
|
527
|
-
|
|
528
|
-
throw error
|
|
616
|
+
if (error?.code === "interrupted") notWeighed("interrupted", `interrupted by ${error.signal || stoppedBy || "a signal"} before the measurement completed.`, REMEDY.interrupted, signalExit(error.signal || stoppedBy))
|
|
617
|
+
failFrom(error, notWeighedRegister)
|
|
529
618
|
} finally {
|
|
530
|
-
process.off(
|
|
531
|
-
process.off("SIGTERM", interrupt)
|
|
619
|
+
for (const signal of Object.keys(SIGNAL_EXIT)) process.off(signal, interrupt)
|
|
532
620
|
}
|
|
533
621
|
spinner.done()
|
|
534
|
-
if (json)
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
622
|
+
if (!json) narrate.write("\n")
|
|
623
|
+
succeed(args, document, (colour) => renderWeigh(document, { colour }))
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* `omakit lab <prove|inspect|setup|prune>`: one command surface, four
|
|
628
|
+
* actions, docs/LAB.md the contract. Every refusal is the failure
|
|
629
|
+
* register with the one command; a missing thing is listed with its cost
|
|
630
|
+
* before the arrow. `setup` and `prune` ask once at a terminal and take
|
|
631
|
+
* `--yes` anywhere else; `run` and `inspect` never ask.
|
|
632
|
+
*/
|
|
633
|
+
async function cmdLab(args) {
|
|
634
|
+
const parsed = checkArgs(args, ACCEPTED.lab)
|
|
635
|
+
const signature = "omakit lab prove <suite> | inspect [--verify] | setup [--from <file>] [--toolchain <dir>] [--plugins] [--yes] | prune [--keep-iso] [--records] [--yes]"
|
|
636
|
+
if (parsed.offending !== null) fail("usage", `${parsed.reason}. Accepted: ${acceptedWords("lab")}.`, 2, signature)
|
|
637
|
+
const [what, suite] = parsed.positionals
|
|
638
|
+
if (!["prove", "inspect", "setup", "prune"].includes(what || "")) fail("usage", `lab needs one of prove, inspect, setup or prune${what ? `, not ${JSON.stringify(what)}` : ""}.`, 2, signature)
|
|
639
|
+
const json = parsed.options.has("--json")
|
|
640
|
+
const interactive = !json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)
|
|
641
|
+
const c = styler(colourEnabled(json ? process.stderr : process.stdout))
|
|
642
|
+
const narrate = json ? process.stderr : process.stdout
|
|
643
|
+
const say = (line) => narrate.write(`${line.state === "prose" ? " ".repeat(GUTTER) : mark(line.state, c)}${withHomeAbbreviated(line.text)}\n`)
|
|
644
|
+
const spinner = spinnerFor(args)
|
|
645
|
+
const controller = new AbortController()
|
|
646
|
+
let stoppedBy = null
|
|
647
|
+
const interrupt = (signal) => {
|
|
648
|
+
stoppedBy = stoppedBy || signal
|
|
649
|
+
if (!controller.signal.aborted) narrate.write(`\n${mark("advisory", c)}interrupted (${signal}): ending the guest and cleaning up before exiting\n`)
|
|
650
|
+
controller.abort(signal)
|
|
651
|
+
}
|
|
652
|
+
const listen = () => { for (const signal of Object.keys(SIGNAL_EXIT)) process.on(signal, interrupt) }
|
|
653
|
+
const unlisten = () => { for (const signal of Object.keys(SIGNAL_EXIT)) process.off(signal, interrupt) }
|
|
654
|
+
/** A lab error that stopped for a signal carries the signal, so the exit status follows it. */
|
|
655
|
+
const stopped = (error) => {
|
|
656
|
+
if (error?.code === "interrupted" && !error.signal) error.signal = stoppedBy
|
|
657
|
+
return error
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (what === "inspect") {
|
|
661
|
+
if (suite) fail("usage", `inspect takes no suite, so ${JSON.stringify(suite)} is one argument more than it takes.`, 2, signature)
|
|
662
|
+
let lab
|
|
663
|
+
try {
|
|
664
|
+
spinner.phase(parsed.options.has("--verify") ? "hashing the ISO and checking its signature" : "reading the lab")
|
|
665
|
+
lab = await inspectLab({ verify: parsed.options.has("--verify") })
|
|
666
|
+
} catch (error) {
|
|
667
|
+
spinner.done()
|
|
668
|
+
failFrom(error)
|
|
669
|
+
}
|
|
670
|
+
spinner.done()
|
|
671
|
+
const human = (colour) => renderLab(lab, { colour })
|
|
672
|
+
if (!lab.missing.length) return succeed(args, lab, human)
|
|
673
|
+
// The inventory is complete and the lab is not ready: the report says
|
|
674
|
+
// what is missing, and the exit says a run would refuse.
|
|
675
|
+
refuse(args, lab, human, { code: "lab-not-ready", message: `the lab is not ready: ${lab.missing.map((item) => item.what).join(", ")} missing`, remedy: lab.missing.find((item) => item.command)?.command || REMEDY["lab-not-ready"], missing: lab.missing })
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (what === "prove") {
|
|
680
|
+
if (!suite) fail("usage", "prove needs a suite: `omakit lab prove <run|store|weigh|weigh-evidence>`", 2, signature)
|
|
681
|
+
const runs = parsed.options.has("--runs") ? Number(parsed.options.get("--runs")) : undefined
|
|
682
|
+
if (parsed.options.has("--runs") && !(Number.isInteger(runs) && runs >= 1)) fail("usage", `--runs needs an integer of at least 1, not ${JSON.stringify(parsed.options.get("--runs"))}.`, 2, signature)
|
|
683
|
+
listen()
|
|
684
|
+
let record
|
|
685
|
+
try {
|
|
686
|
+
record = await runSuite({
|
|
687
|
+
suiteName: suite,
|
|
688
|
+
repoRoot: ROOT,
|
|
689
|
+
options: { runs },
|
|
690
|
+
signal: controller.signal,
|
|
691
|
+
onPhase: spinner.phase,
|
|
692
|
+
onLine: (line) => {
|
|
693
|
+
spinner.done()
|
|
694
|
+
// The identity block, once the guest has been read: the line
|
|
695
|
+
// packaging/LAB_PLAN.md says every run prints before its suite.
|
|
696
|
+
if (line.record) {
|
|
697
|
+
narrate.write(`${withOutputStream(narrate, () => renderRunIdentity(line.record, { colour: colourEnabled(narrate) }))}\n\n`)
|
|
698
|
+
return
|
|
699
|
+
}
|
|
700
|
+
say(line)
|
|
701
|
+
},
|
|
702
|
+
})
|
|
703
|
+
} catch (error) {
|
|
704
|
+
spinner.done()
|
|
705
|
+
failFrom(stopped(error))
|
|
706
|
+
} finally {
|
|
707
|
+
unlisten()
|
|
708
|
+
}
|
|
709
|
+
spinner.done()
|
|
710
|
+
if (!json) narrate.write("\n")
|
|
711
|
+
const human = (colour) => renderRunResult(record, { colour })
|
|
712
|
+
if (record.ok) return succeed(args, record, human)
|
|
713
|
+
refuse(args, record, human, { code: "not-proved", message: `${record.suite}: ${record.assertion?.reason || "the suite did not pass"}` })
|
|
714
|
+
return
|
|
538
715
|
}
|
|
716
|
+
|
|
717
|
+
if (what === "setup") {
|
|
718
|
+
if (suite) fail("usage", `setup takes no suite, so ${JSON.stringify(suite)} is one argument more than it takes.`, 2, signature)
|
|
719
|
+
try {
|
|
720
|
+
if (parsed.options.has("--toolchain")) {
|
|
721
|
+
const recorded = recordToolchain({ dir: parsed.options.get("--toolchain") })
|
|
722
|
+
narrate.write(`${mark("pass", c)}toolchain recorded: ${withHomeAbbreviated(recorded.dir)} (harness sha256 ${recorded.sha256.slice(0, 12)}, the pinned patched one)\n`)
|
|
723
|
+
}
|
|
724
|
+
} catch (error) {
|
|
725
|
+
failFrom(error)
|
|
726
|
+
}
|
|
727
|
+
let plan
|
|
728
|
+
try {
|
|
729
|
+
plan = planSetup({ from: parsed.options.get("--from") || null, plugins: parsed.options.has("--plugins"), repoRoot: ROOT })
|
|
730
|
+
} catch (error) {
|
|
731
|
+
failFrom(error)
|
|
732
|
+
}
|
|
733
|
+
// The plan is narrated before the question, so the record says what
|
|
734
|
+
// was agreed to; a plan that cannot run is the failure, on stderr.
|
|
735
|
+
if (plan.blockers.length) {
|
|
736
|
+
refuse(args, plan, (colour) => renderSetupPlan(plan, { colour }), { code: "lab-blocked", message: `setup cannot start: ${plan.blockers.join("; ")}` })
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
if (!plan.steps.length) return succeed(args, plan, (colour) => renderSetupPlan(plan, { colour }))
|
|
740
|
+
// A pipe without --yes is known to refuse before the plan is shown, so
|
|
741
|
+
// the plan is the refusal's text, on stderr, with the document; only a
|
|
742
|
+
// terminal sees the plan first and is then asked.
|
|
743
|
+
let consented = parsed.options.has("--yes")
|
|
744
|
+
if (!consented && !interactive) {
|
|
745
|
+
conclude({ command: CONTEXT.command, args, exit: 2, document: plan, human: (colour) => renderSetupPlan(plan, { colour }), error: failure({ code: "not-confirmed", message: "not confirmed: a pipe, an agent or --json cannot answer for the person whose disk this is; nothing was fetched, nothing was built", remedy: "omakit lab setup --yes" }) })
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
narrate.write(`${withOutputStream(narrate, () => renderSetupPlan(plan, { colour: colourEnabled(narrate) }))}\n`)
|
|
749
|
+
if (!consented) {
|
|
750
|
+
narrate.write("\n")
|
|
751
|
+
consented = await askYes({ question: CONSENT_QUESTION })
|
|
752
|
+
if (!consented) failFrom(Object.assign(new Error("not confirmed; nothing was fetched, nothing was built"), { code: "not-confirmed", remedy: "omakit lab setup --yes" }))
|
|
753
|
+
}
|
|
754
|
+
narrate.write("\n")
|
|
755
|
+
listen()
|
|
756
|
+
let result
|
|
757
|
+
let lastProgress = 0
|
|
758
|
+
try {
|
|
759
|
+
result = await setupLab({
|
|
760
|
+
plan,
|
|
761
|
+
consented,
|
|
762
|
+
repoRoot: ROOT,
|
|
763
|
+
signal: controller.signal,
|
|
764
|
+
onPhase: spinner.phase,
|
|
765
|
+
onLine: (line) => { spinner.done(); say(line) },
|
|
766
|
+
onProgress: (read, total) => {
|
|
767
|
+
const now = Date.now()
|
|
768
|
+
if (now - lastProgress < 1000) return
|
|
769
|
+
lastProgress = now
|
|
770
|
+
spinner.phase(`${read.toLocaleString("en-US")} of ${total.toLocaleString("en-US")} B (${((read / total) * 100).toFixed(1)}%)`)
|
|
771
|
+
},
|
|
772
|
+
})
|
|
773
|
+
} catch (error) {
|
|
774
|
+
spinner.done()
|
|
775
|
+
failFrom(stopped(error))
|
|
776
|
+
} finally {
|
|
777
|
+
unlisten()
|
|
778
|
+
}
|
|
779
|
+
spinner.done()
|
|
780
|
+
succeed(args, result, (colour) => renderSetupResult(result, { colour }))
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// prune
|
|
785
|
+
if (suite) fail("usage", `prune takes no suite, so ${JSON.stringify(suite)} is one argument more than it takes.`, 2, signature)
|
|
786
|
+
let plan
|
|
787
|
+
try {
|
|
788
|
+
plan = await planPrune({ keepIso: parsed.options.has("--keep-iso"), runs: parsed.options.has("--records") })
|
|
789
|
+
} catch (error) {
|
|
790
|
+
failFrom(error)
|
|
791
|
+
}
|
|
792
|
+
if (plan.blockers.length) {
|
|
793
|
+
refuse(args, plan, (colour) => renderPrunePlan(plan, { colour }), { code: "lab-busy", message: `prune refuses while ${plan.blockers.join("; ")}` })
|
|
794
|
+
return
|
|
795
|
+
}
|
|
796
|
+
// Nothing to remove is a result with a document, like every other outcome.
|
|
797
|
+
if (!plan.targets.length) return succeed(args, { targets: [], total: 0, remaining: plan.remaining }, (colour) => renderPrunePlan(plan, { colour }))
|
|
798
|
+
let consented = parsed.options.has("--yes")
|
|
799
|
+
if (!consented && !interactive) {
|
|
800
|
+
conclude({ command: CONTEXT.command, args, exit: 2, document: plan, human: (colour) => renderPrunePlan(plan, { colour }), error: failure({ code: "not-confirmed", message: `not confirmed: ${plan.targets.length} target${plan.targets.length === 1 ? "" : "s"}, ${plan.total.toLocaleString("en-US")} B, and a pipe cannot answer for the person whose disk this is; nothing was removed`, remedy: "omakit lab prune --yes" }) })
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
narrate.write(`${withOutputStream(narrate, () => renderPrunePlan(plan, { colour: colourEnabled(narrate) }))}\n`)
|
|
804
|
+
if (!consented) {
|
|
805
|
+
narrate.write("\n")
|
|
806
|
+
consented = await askYes({ question: `Remove these ${plan.targets.length} lab-owned target${plan.targets.length === 1 ? "" : "s"}?` })
|
|
807
|
+
if (!consented) failFrom(Object.assign(new Error("not confirmed; nothing was removed"), { code: "not-confirmed", remedy: "omakit lab prune --yes" }))
|
|
808
|
+
}
|
|
809
|
+
let result
|
|
810
|
+
try {
|
|
811
|
+
result = prune(plan)
|
|
812
|
+
} catch (error) {
|
|
813
|
+
failFrom(error)
|
|
814
|
+
}
|
|
815
|
+
narrate.write("\n")
|
|
816
|
+
succeed(args, { removed: result.removed, recovered: result.recovered, remaining: result.remaining }, (colour) => renderPruneResult(result, { colour }))
|
|
539
817
|
}
|
|
540
818
|
|
|
541
819
|
const VERSION = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8")).version
|
|
542
820
|
|
|
543
821
|
const [command, ...rest] = process.argv.slice(2)
|
|
822
|
+
CONTEXT.command = command === "lab" && ["prove", "inspect", "setup", "prune"].includes(rest.find((arg) => !arg.startsWith("-"))) ? `lab ${rest.find((arg) => !arg.startsWith("-"))}` : command || null
|
|
823
|
+
CONTEXT.args = rest
|
|
544
824
|
|
|
825
|
+
// A reader that closes early (`omakit audit --json | head`) is not an
|
|
826
|
+
// error worth a trace: the write fails with EPIPE and the command is over,
|
|
827
|
+
// with whatever exit code it had decided on.
|
|
828
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
829
|
+
stream.on("error", (error) => {
|
|
830
|
+
if (error?.code === "EPIPE") process.exit(process.exitCode ?? 0)
|
|
831
|
+
throw error
|
|
832
|
+
})
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
try {
|
|
545
836
|
// Every token checked against the command's table before anything runs
|
|
546
837
|
// (options.mjs): an option the command does not know, an option without its
|
|
547
|
-
// value,
|
|
548
|
-
//
|
|
838
|
+
// value, an empty argument, a valued option given twice, or one positional
|
|
839
|
+
// too many is a usage error naming the token and the accepted list. `weigh`
|
|
840
|
+
// reports it in its own register, below.
|
|
549
841
|
{
|
|
550
842
|
const name = command === "marketplace-pin" ? "pin" : command
|
|
551
843
|
const table = name === "weigh" ? null : ACCEPTED[name] || ((command === "--help" || command === "-h") ? ACCEPTED.pin : null)
|
|
@@ -553,7 +845,7 @@ const [command, ...rest] = process.argv.slice(2)
|
|
|
553
845
|
const parsed = checkArgs(rest, table)
|
|
554
846
|
if (parsed.offending !== null) {
|
|
555
847
|
const accepted = acceptedWords(name)
|
|
556
|
-
fail("usage", `${parsed.reason}.${accepted ? ` Accepted: ${accepted}.` : ` \`omakit ${name}\` takes no options.`}`, 2, "omakit help")
|
|
848
|
+
fail("usage", `${parsed.reason}.${accepted ? ` Accepted: ${accepted}.` : ` \`omakit ${name}\` takes no options.`}`, 2, "omakit help", () => [], {}, name === "audit" ? notAuditedRegister : null)
|
|
557
849
|
}
|
|
558
850
|
}
|
|
559
851
|
}
|
|
@@ -580,8 +872,9 @@ if (command === "setup") {
|
|
|
580
872
|
} else if (command === "pin" || command === "marketplace-pin") {
|
|
581
873
|
const c = styler(colourEnabled())
|
|
582
874
|
const spinner = progress()
|
|
875
|
+
let pinned
|
|
583
876
|
try {
|
|
584
|
-
ensurePin(ROOT, (line) => {
|
|
877
|
+
pinned = ensurePin(ROOT, (line) => {
|
|
585
878
|
// ensurePin narrates: a state line to keep, then a fetch it is about to
|
|
586
879
|
// start. The fetch is the slow part, so it gets the progress line.
|
|
587
880
|
if (line.state === "fetching") spinner.phase(line.text)
|
|
@@ -592,6 +885,7 @@ if (command === "setup") {
|
|
|
592
885
|
fail(error?.code || "marketplace-unavailable", error.message, 1, error?.remedy || REMEDY[error?.code])
|
|
593
886
|
}
|
|
594
887
|
spinner.done()
|
|
888
|
+
succeed(rest, { dir: pinned.dir, commit: pinned.identity.commit, fetched: pinned.fetched })
|
|
595
889
|
} else if (command === "submit") {
|
|
596
890
|
await cmdSubmit(rest)
|
|
597
891
|
} else if (command === "upgrade") {
|
|
@@ -610,6 +904,10 @@ if (command === "setup") {
|
|
|
610
904
|
await cmdWeigh(rest)
|
|
611
905
|
} else if (command === "inspect") {
|
|
612
906
|
await cmdInspect(rest)
|
|
907
|
+
} else if (command === "add") {
|
|
908
|
+
await cmdAdd(rest)
|
|
909
|
+
} else if (command === "lab") {
|
|
910
|
+
await cmdLab(rest)
|
|
613
911
|
} else if (command === "help" || command === "--help" || command === "-h" || command === undefined) {
|
|
614
912
|
if (rest.includes("--agent")) {
|
|
615
913
|
// The skills ship in the npm package, so this works from a global install
|
|
@@ -634,6 +932,7 @@ if (command === "setup") {
|
|
|
634
932
|
// of the screen before anyone has read it, so it gets none.
|
|
635
933
|
process.stdout.write(renderUsage())
|
|
636
934
|
}
|
|
935
|
+
process.exitCode = 0
|
|
637
936
|
} else {
|
|
638
937
|
// The short list on a typo, not 53 lines of reference, in the same register
|
|
639
938
|
// as every other failure: what happened, what it means, what to run.
|
|
@@ -645,5 +944,9 @@ if (command === "setup") {
|
|
|
645
944
|
"",
|
|
646
945
|
renderSummary({ stream: process.stderr, colour: colourEnabled(process.stderr), heading: false }),
|
|
647
946
|
].join("\n")))
|
|
648
|
-
|
|
947
|
+
throw new Exit(2)
|
|
948
|
+
}
|
|
949
|
+
} catch (error) {
|
|
950
|
+
if (error instanceof Exit) await leave(error.exit)
|
|
951
|
+
throw error
|
|
649
952
|
}
|