omakit 0.3.0 → 0.4.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 +15 -1
- package/package.json +1 -1
- package/skills/omarchy-plugin-validation-watch/SKILL.md +6 -0
- package/tools/marketplace/README.md +5 -1
- package/tools/marketplace/ask.mjs +36 -6
- package/tools/marketplace/banner.mjs +10 -1
- package/tools/marketplace/cli.mjs +59 -18
- package/tools/marketplace/doctor.mjs +12 -6
- package/tools/marketplace/github.mjs +30 -9
- package/tools/marketplace/options.mjs +1 -1
- package/tools/marketplace/report.mjs +52 -3
- package/tools/marketplace/style.mjs +24 -8
- package/tools/marketplace/update-check.mjs +48 -0
- package/tools/marketplace/upgrade.mjs +36 -4
- package/tools/marketplace/usage.mjs +66 -27
- package/tools/marketplace/watch.mjs +71 -1
- package/tools/weigh/confirm.mjs +3 -3
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ See [docs/INSTALL.md](docs/INSTALL.md) for the clone route, PATH, requirements a
|
|
|
25
25
|
| --- | --- |
|
|
26
26
|
| [`omakit setup`](docs/COMMANDS.md) | The environment, the pin, tab completion, and what to try first. |
|
|
27
27
|
| [`omakit submit <plugin-repo>`](docs/SUBMIT.md) | Every check, the issue title and body; asks for a category and tags at a terminal. |
|
|
28
|
-
| [`omakit watch <issue-url
|
|
28
|
+
| [`omakit watch [<issue-url>]`](docs/VALIDATION_WATCH.md) | Pick your marketplace issues, list them, or check all with `--all`. |
|
|
29
29
|
| [`omakit verify <plugin-repo>`](docs/COMMANDS.md) | The official security baseline over the local transport; `--json` for the document. |
|
|
30
30
|
| [`omakit parity`](docs/COMMANDS.md) | The baseline over GitHub versus the local transport, on real listings; writes the evidence. |
|
|
31
31
|
| [`omakit audit [<plugin>]`](docs/AUDIT.md) | Installed third-party commits against the exact commits the marketplace validated. |
|
|
@@ -35,6 +35,15 @@ See [docs/INSTALL.md](docs/INSTALL.md) for the clone route, PATH, requirements a
|
|
|
35
35
|
| [`omakit upgrade`](docs/COMMANDS.md) | Updates omakit through its own installer: npm, or a fast-forward. |
|
|
36
36
|
| [`omakit help --agent`](docs/COMMANDS.md) | The operating instructions, for the agent running this. |
|
|
37
37
|
|
|
38
|
+
Normal terminal use also checks for a newer npm release at most once daily
|
|
39
|
+
and shows the upgrade command. It installs nothing automatically; scripts
|
|
40
|
+
and JSON stay quiet. `DISABLE_UPDATE_NOTIFIER=1` disables the notice, and
|
|
41
|
+
`omakit doctor` checks explicitly. [Update behaviour](docs/INSTALL.md#updating).
|
|
42
|
+
|
|
43
|
+
Terminal help and reports adapt to the available width, up to 120 columns
|
|
44
|
+
for readability. Narrow windows wrap sooner; pipes and text files keep the
|
|
45
|
+
stable eighty-column layout. JSON and exact issue bodies remain intact.
|
|
46
|
+
|
|
38
47
|
### `submit`
|
|
39
48
|
|
|
40
49
|
```bash
|
|
@@ -51,10 +60,15 @@ Read more: [docs/SUBMIT.md](docs/SUBMIT.md).
|
|
|
51
60
|
|
|
52
61
|
```bash
|
|
53
62
|
omakit watch <submission-issue-url>
|
|
63
|
+
omakit watch --all
|
|
64
|
+
omakit watch --list
|
|
65
|
+
omakit watch # choose one or several issues at a terminal
|
|
54
66
|
```
|
|
55
67
|
|
|
56
68
|
It decides whether the marketplace validated the plugin's current commit; [73% of parked submissions have a HEAD the marketplace never saw](docs/MEASUREMENTS.md).
|
|
57
69
|
|
|
70
|
+
Account-wide discovery uses your signed-in `gh` account and reads your open marketplace issues. `--user <login>` reads another public account. Batch output includes baseline results, labels and the latest human discussion; `current` compares commits and does not imply approval or publication. Each command takes one snapshot and posts nothing.
|
|
71
|
+
|
|
58
72
|

|
|
59
73
|
|
|
60
74
|
Read more: [docs/VALIDATION_WATCH.md](docs/VALIDATION_WATCH.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omakit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "The safe place to find out: everything knowable about an Omarchy Quattro plugin submission before you post it, on your own machine. Agent-first, read-only against the marketplace, posts nothing, zero dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Maarten Tolhuijs",
|
|
@@ -27,6 +27,8 @@ push came after a review comment had also commented: engaged, and stuck.
|
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
29
|
omakit watch https://github.com/omacom/omarchy-plugin-marketplace/issues/<number>
|
|
30
|
+
omakit watch --all --json
|
|
31
|
+
omakit watch --list --json
|
|
30
32
|
```
|
|
31
33
|
|
|
32
34
|
If `omakit` is not installed: `npm install --global omakit` (Omarchy ships Node
|
|
@@ -35,6 +37,10 @@ the install, `"$(npm prefix --global)/bin/omakit" setup` prints the one line
|
|
|
35
37
|
that puts npm's bin on PATH. `omakit upgrade` keeps it current through npm.
|
|
36
38
|
Add `--json` for a machine-readable result.
|
|
37
39
|
|
|
40
|
+
Use `--all --json` to discover and check every open marketplace issue authored by the signed-in `gh` account. `--list --json` discovers issue URLs without reading every plugin; use an individual URL for focused follow-up. `--user <login>` selects an explicit public author. A bare `omakit watch` opens a numbered picker only at a terminal; an agent must pass a URL, `--all` or `--list` and never wait on keyboard input.
|
|
41
|
+
|
|
42
|
+
Inspect every batch row, including read errors and unknown results; a successful row cannot stand in for a failed one. `current` only says the commit matches HEAD. Read the baseline outcome, labels and latest discussion to understand outstanding work; never equate it with approval, publication or restored catalog verification. A run is a single read-only snapshot, not a background monitor.
|
|
43
|
+
|
|
38
44
|
Read-only. It does not comment, label, or edit anything, and it cannot. It reads
|
|
39
45
|
the default branch through the REST API when a credential is available, taking
|
|
40
46
|
it from the operator's `gh` login (which itself honours `GH_TOKEN` and
|
|
@@ -26,6 +26,7 @@ local commit through the transport seam the marketplace tests itself
|
|
|
26
26
|
| `github.mjs` | Read-only GitHub access. GET only. The credential is your `gh` login, read through one frozen `gh auth token` call, and is never written anywhere. |
|
|
27
27
|
| `style.mjs` | The visual system, defined once: the palette, the status vocabulary, the block ramp, the columns, the motion budgets, and the composition helpers every command draws with. Six states: `▁ ok`, `█ FAIL`, `▓ note`, `░ info`, `▒ ?` for a check that could not be made, and `▔ skip` for a check a flag said not to make, the floor's ink at the ceiling so it is never read as a pass. `docs/TUI.md` explains it. |
|
|
28
28
|
| `report.mjs` | Text rendering of submit, watch, doctor and verify for the agent that runs this tool, and the person reading over its shoulder. |
|
|
29
|
+
| `update-check.mjs` | A passive npm-release notice during normal terminal use, throttled to one check per day with a one-second network budget. Stores only installed/latest version metadata and time under XDG_STATE_HOME/omakit. Offline, JSON, pipes, help and CI skip it; DISABLE_UPDATE_NOTIFIER disables it. Version precedence and the registry GET are shared with doctor/upgrade. |
|
|
29
30
|
| `path-hint.mjs` | Is `omakit` reachable as a bare command, and if not, the one line that makes it so for the install that is here: a symlink for a clone, the npm prefix's `bin` on PATH for a package, said for the shell in `$SHELL`. `setup` and `doctor` print it; nothing writes an rc file. |
|
|
30
31
|
| `usage.mjs` | The help text, as data. |
|
|
31
32
|
| `options.mjs` | Every option every command accepts, in one table, and the parser that reads a command line against it before anything runs; `tests/unit/options.test.mjs` holds the help signatures, and through them the completion scripts, to the table. |
|
|
@@ -80,7 +81,8 @@ side rather than the findings themselves. The GitHub side uses whatever
|
|
|
80
81
|
credential `github.mjs` resolves (a `gh` login, and only that), read-only.
|
|
81
82
|
|
|
82
83
|
Two rules about the output, stated as rules because each is an exception to
|
|
83
|
-
a wider one.
|
|
84
|
+
a wider one. Pipes use 80 columns; terminals use their available width up to
|
|
85
|
+
120 columns. Text files compose at 80 columns without colour escapes. Text
|
|
84
86
|
that will be posted verbatim, the marketplace's own baseline report and the
|
|
85
87
|
issue body rendered from the pinned form, is never wrapped and may exceed 80,
|
|
86
88
|
because a wrapped body would not be the body. And stdout is the whole result;
|
|
@@ -99,3 +101,5 @@ online, the subject's default-branch HEAD and the marketplace's current
|
|
|
99
101
|
registry, and `--offline` turns both off; `verify` needs no network at all
|
|
100
102
|
beyond fetching a reviewer-mode subject, and `tests/parity/offline.mjs` proves
|
|
101
103
|
it.
|
|
104
|
+
|
|
105
|
+
`watch --list` discovers the signed-in account's open marketplace issues through `github.mjs`; `--user` bypasses the account lookup. `watch --all` runs the existing single-issue watch with four workers and shared repository HEAD promises, preserving each read failure. A bare terminal invocation uses `ask.mjs` to choose one or several issues. `report.mjs` composes the list and batch views; all options remain in `options.mjs` and `usage.mjs`, which also generate completion. The batch JSON retains complete single-issue reports; `current` compares commits and never substitutes for review or publication.
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
// the report ends with the command line that repeats the run without asking.
|
|
16
16
|
|
|
17
17
|
import { createInterface } from "node:readline"
|
|
18
|
-
import { colourEnabled, STEP, action, styler, wrap } from "./style.mjs"
|
|
18
|
+
import { colourEnabled, outputColumns, STEP, action, styler, wrap } from "./style.mjs"
|
|
19
19
|
import { resolveCategory, resolveTags } from "./form.mjs"
|
|
20
|
+
import { watchIssueTitle } from "./watch.mjs"
|
|
20
21
|
|
|
21
22
|
export class AskError extends Error {
|
|
22
23
|
constructor(message) {
|
|
@@ -49,11 +50,13 @@ function reader(input) {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/** Ask one question until an answer resolves; `parse` returns { ok, value } or { ok: false, reason }. */
|
|
52
|
-
async function question(lines, output, c, { name, heading, options, defaultIndexes, parse }) {
|
|
53
|
+
async function question(lines, output, c, { name, heading, options, defaultIndexes, parse, wrapOptions = false, eofRemedy }) {
|
|
53
54
|
const step = " ".repeat(STEP)
|
|
54
|
-
output.write(`${wrap(heading, {}, c).join("\n")}\n`)
|
|
55
|
+
output.write(`${wrap(heading, { width: outputColumns(output) }, c).join("\n")}\n`)
|
|
55
56
|
for (const [index, option] of options.entries()) {
|
|
56
|
-
output.write(
|
|
57
|
+
output.write(wrapOptions
|
|
58
|
+
? `${wrap(`${String(index + 1).padStart(2)} ${option}`, { indent: STEP, width: outputColumns(output) }, c).join("\n")}\n`
|
|
59
|
+
: `${step}${c("typeable", String(index + 1).padStart(2))} ${option}\n`)
|
|
57
60
|
}
|
|
58
61
|
const fallback = defaultIndexes.length ? defaultIndexes.map((index) => index + 1).join(",") : null
|
|
59
62
|
const prompt = `${action(fallback ? `${name} [${fallback}]:` : `${name}:`, c, { indent: 0 })[0]} `
|
|
@@ -62,12 +65,12 @@ async function question(lines, output, c, { name, heading, options, defaultIndex
|
|
|
62
65
|
const raw = await lines.next()
|
|
63
66
|
if (raw === null) {
|
|
64
67
|
output.write("\n")
|
|
65
|
-
throw new AskError(`stdin ended before the ${name} was answered; pass --${name} on the command line`)
|
|
68
|
+
throw new AskError(`stdin ended before the ${name} was answered; ${eofRemedy || `pass --${name} on the command line`}`)
|
|
66
69
|
}
|
|
67
70
|
const text = raw.trim() || (fallback ?? "")
|
|
68
71
|
const parsed = parse(text)
|
|
69
72
|
if (parsed.ok) return parsed.value
|
|
70
|
-
output.write(`${wrap(parsed.reason, {}, c).join("\n")}\n`)
|
|
73
|
+
output.write(`${wrap(parsed.reason, { width: outputColumns(output) }, c).join("\n")}\n`)
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
|
|
@@ -138,3 +141,30 @@ export async function askChoices({ contract, defaults, missing, input = process.
|
|
|
138
141
|
}
|
|
139
142
|
return answers
|
|
140
143
|
}
|
|
144
|
+
|
|
145
|
+
/** Select one or several discovered issues; nothing is written or opened. */
|
|
146
|
+
export async function askWatchIssues({ issues, input = process.stdin, output = process.stderr, colour = colourEnabled(output) }) {
|
|
147
|
+
const c = styler(colour)
|
|
148
|
+
const lines = reader(input)
|
|
149
|
+
try {
|
|
150
|
+
return await question(lines, output, c, {
|
|
151
|
+
name: "issues",
|
|
152
|
+
heading: "Choose list numbers, comma-separated; all checks every issue, q cancels.",
|
|
153
|
+
options: issues.map((issue) => `#${issue.number} ${watchIssueTitle(issue.title)}`),
|
|
154
|
+
defaultIndexes: [],
|
|
155
|
+
wrapOptions: true,
|
|
156
|
+
eofRemedy: "pass an issue URL, --all or --list on the command line",
|
|
157
|
+
parse: (text) => {
|
|
158
|
+
if (text.toLowerCase() === "q") return { ok: true, value: [] }
|
|
159
|
+
if (text.toLowerCase() === "all") return { ok: true, value: issues }
|
|
160
|
+
const indexes = text.split(",").map((value) => value.trim())
|
|
161
|
+
if (indexes.some((value) => !/^\d+$/.test(value) || Number(value) < 1 || Number(value) > issues.length)) {
|
|
162
|
+
return { ok: false, reason: `Answer with list numbers from 1 to ${issues.length}, all, or q.` }
|
|
163
|
+
}
|
|
164
|
+
return { ok: true, value: [...new Set(indexes.map(Number))].map((index) => issues[index - 1]) }
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
} finally {
|
|
168
|
+
lines.close()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
// letters come from the small font below, so renaming the tool is a change to
|
|
50
50
|
// one string and not a redrawing job.
|
|
51
51
|
|
|
52
|
-
import { code, colourEnabled, DENSITY, motionEnabled, MOTION, rule as floorRule } from "./style.mjs"
|
|
52
|
+
import { code, colourEnabled, DENSITY, motionEnabled, MOTION, outputColumns, rule as floorRule, wrap } from "./style.mjs"
|
|
53
53
|
import { effectAvailable, playEffect } from "./effect.mjs"
|
|
54
54
|
|
|
55
55
|
const ESC = "\u001b["
|
|
@@ -227,6 +227,15 @@ export async function banner(options = {}) {
|
|
|
227
227
|
// and a piped run should differ from a watched one only in decoration.
|
|
228
228
|
if (!enabled) return
|
|
229
229
|
|
|
230
|
+
// Never animate artwork across physical terminal rows: cursor-up would
|
|
231
|
+
// redraw the wrong row. A narrow terminal gets a compact wordmark instead.
|
|
232
|
+
if (stream.isTTY && Number.isFinite(stream.columns) && stream.columns > 0 && stream.columns < width) {
|
|
233
|
+
stream.write(`${c(code("name"), word.toUpperCase())}\n`)
|
|
234
|
+
if (options.tagline) stream.write(`${wrap(options.tagline, { width: outputColumns(stream) }, (name, text) => c(code(name), text)).join("\n")}\n`)
|
|
235
|
+
stream.write("\n")
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
230
239
|
// A five-row animation redrawn with cursor-up needs five rows that stay put.
|
|
231
240
|
// In a terminal with no room the screen scrolls under the animation, the
|
|
232
241
|
// cursor-up lands a line off, and a row from an earlier frame is left stranded
|
|
@@ -21,19 +21,20 @@ import { ensurePin, MARKETPLACE_PIN, requirePin } from "./pin.mjs"
|
|
|
21
21
|
import { marketplaceBaselineSection } from "./verify.mjs"
|
|
22
22
|
import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
|
|
23
23
|
import { submitPreflight } from "./submit.mjs"
|
|
24
|
-
import { askChoices } from "./ask.mjs"
|
|
25
|
-
import { validationWatch } from "./watch.mjs"
|
|
26
|
-
import { renderSubmit, renderWatch, renderDoctor, renderVerify } from "./report.mjs"
|
|
24
|
+
import { askChoices, askWatchIssues } from "./ask.mjs"
|
|
25
|
+
import { validationWatch, discoverWatchIssues, validationWatchAll } from "./watch.mjs"
|
|
26
|
+
import { renderSubmit, renderWatch, renderWatchList, renderWatchAll, renderDoctor, renderVerify } from "./report.mjs"
|
|
27
27
|
import { consequence } from "./preflight.mjs"
|
|
28
28
|
import { doctor } from "./doctor.mjs"
|
|
29
29
|
import { completionStep, setup } from "./setup.mjs"
|
|
30
30
|
import { staleCompletionNotice } from "./completion-check.mjs"
|
|
31
31
|
import { ACCEPTED, acceptedWords, checkArgs } from "./options.mjs"
|
|
32
32
|
import { upgrade } from "./upgrade.mjs"
|
|
33
|
+
import { updateCheckEnabled, updateNotice } from "./update-check.mjs"
|
|
33
34
|
import { progress } from "./progress.mjs"
|
|
34
35
|
import { banner, bannerEnabled } from "./banner.mjs"
|
|
35
36
|
import { COMMANDS, renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
|
|
36
|
-
import { action, AUDIT_VERDICTS, colourEnabled, GUTTER, labelled, mark, styler, verdict, wrap } from "./style.mjs"
|
|
37
|
+
import { action, AUDIT_VERDICTS, colourEnabled, GUTTER, labelled, mark, outputColumns, styler, verdict, withOutputStream, wrap } from "./style.mjs"
|
|
37
38
|
import { omakitCacheDir, withHomeAbbreviated } from "./paths.mjs"
|
|
38
39
|
import { DEFAULTS as WEIGH_DEFAULTS, measureWeigh, planWeigh } from "../weigh/audit.mjs"
|
|
39
40
|
import { confirmationQuestion, renderList, renderWeigh, renderPlan } from "../weigh/report.mjs"
|
|
@@ -62,6 +63,7 @@ const REMEDY = Object.freeze({
|
|
|
62
63
|
"github-unavailable": "Wait for GitHub, then run it again. `gh auth login` raises the rate limit if that is what ran out.",
|
|
63
64
|
"not-found": "Check the issue URL: it has to be an existing issue on the marketplace repository.",
|
|
64
65
|
"head-unreadable": "Check that the plugin repository is public and its URL is right.",
|
|
66
|
+
"login-required": "gh auth login",
|
|
65
67
|
"not-confirmed": "Run it again and answer y, or pass --yes when the person whose shell it is has agreed.",
|
|
66
68
|
"interrupted": "shell.json was restored; run it again when the desktop is yours to restart.",
|
|
67
69
|
})
|
|
@@ -74,8 +76,10 @@ const REMEDY = Object.freeze({
|
|
|
74
76
|
*/
|
|
75
77
|
function fail(code, message, exit = 1, remedy = REMEDY[code], body = () => []) {
|
|
76
78
|
const c = styler(colourEnabled(process.stderr))
|
|
77
|
-
const lines =
|
|
78
|
-
|
|
79
|
+
const lines = withOutputStream(process.stderr, () => [
|
|
80
|
+
`${mark("fail", c)}${c("name", code)}`, ...wrap(message, { indent: GUTTER }, c), ...body(c),
|
|
81
|
+
...(remedy ? action(remedy, c) : []),
|
|
82
|
+
])
|
|
79
83
|
process.stderr.write(`${lines.join("\n")}\n`)
|
|
80
84
|
process.exit(exit)
|
|
81
85
|
}
|
|
@@ -121,6 +125,12 @@ function emit(args, text) {
|
|
|
121
125
|
}
|
|
122
126
|
}
|
|
123
127
|
|
|
128
|
+
function reportText(args, render, result, options = {}) {
|
|
129
|
+
const out = option(args, "--out")
|
|
130
|
+
return withOutputStream(out ? { isTTY: false } : process.stdout,
|
|
131
|
+
() => render(result, { ...options, ...(out ? { colour: false } : {}) }))
|
|
132
|
+
}
|
|
133
|
+
|
|
124
134
|
async function cmdSubmit(args) {
|
|
125
135
|
const target = positionals(args)[0]
|
|
126
136
|
if (!target) fail("usage", "submit needs a target: `omakit submit <target> --category <c> --tags <a,b>`", 2)
|
|
@@ -172,7 +182,7 @@ async function cmdSubmit(args) {
|
|
|
172
182
|
failFrom(error)
|
|
173
183
|
}
|
|
174
184
|
spinner.done()
|
|
175
|
-
emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderSubmit
|
|
185
|
+
emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : reportText(args, renderSubmit, result))
|
|
176
186
|
// Three outcomes, two exit codes: `ready` and `listed` are both healthy
|
|
177
187
|
// states, and only a refusal is a 1.
|
|
178
188
|
process.exit(result.outcome === "refused" ? 1 : 0)
|
|
@@ -180,18 +190,41 @@ async function cmdSubmit(args) {
|
|
|
180
190
|
|
|
181
191
|
async function cmdWatch(args) {
|
|
182
192
|
const issueUrl = positionals(args)[0]
|
|
183
|
-
|
|
193
|
+
const all = args.includes("--all")
|
|
194
|
+
const list = args.includes("--list")
|
|
195
|
+
const user = option(args, "--user")
|
|
196
|
+
const interactive = !args.includes("--json") && !option(args, "--out") && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)
|
|
197
|
+
if ((all && list) || (issueUrl && (all || list || user !== undefined))) {
|
|
198
|
+
fail("usage", "Use an issue URL, --all, or --list; --user belongs to account-wide discovery.", 2)
|
|
199
|
+
}
|
|
200
|
+
if (!issueUrl && !all && !list && !interactive) {
|
|
201
|
+
fail("usage", "watch needs an issue or a mode: `omakit watch <issue-url>`, `omakit watch --all`, or `omakit watch --list`. A terminal can pick issues with `omakit watch`.", 2)
|
|
202
|
+
}
|
|
184
203
|
const spinner = spinnerFor(args)
|
|
185
204
|
let result
|
|
186
205
|
try {
|
|
187
|
-
|
|
206
|
+
if (issueUrl) {
|
|
207
|
+
result = await validationWatch({ repoRoot: ROOT, issueUrl, onPhase: spinner.phase })
|
|
208
|
+
} else {
|
|
209
|
+
const discovery = await discoverWatchIssues({ user, onPhase: spinner.phase })
|
|
210
|
+
if (list) {
|
|
211
|
+
result = discovery
|
|
212
|
+
} else {
|
|
213
|
+
if (!all && discovery.issues.length) {
|
|
214
|
+
spinner.done()
|
|
215
|
+
discovery.issues = await askWatchIssues({ issues: discovery.issues })
|
|
216
|
+
}
|
|
217
|
+
result = await validationWatchAll({ repoRoot: ROOT, discovery, onPhase: spinner.phase })
|
|
218
|
+
}
|
|
219
|
+
}
|
|
188
220
|
} catch (error) {
|
|
189
221
|
spinner.done()
|
|
190
222
|
failFrom(error)
|
|
191
223
|
}
|
|
192
224
|
spinner.done()
|
|
193
|
-
|
|
194
|
-
|
|
225
|
+
const render = result.mode === "list" ? renderWatchList : result.mode === "all" ? renderWatchAll : renderWatch
|
|
226
|
+
emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : reportText(args, render, result))
|
|
227
|
+
process.exit(result.verdict?.state === "unknown" || result.summary?.unknown > 0 ? 2 : 0)
|
|
195
228
|
}
|
|
196
229
|
|
|
197
230
|
async function cmdFrontDoor() {
|
|
@@ -225,7 +258,7 @@ async function cmdDoctor(args) {
|
|
|
225
258
|
const spinner = spinnerFor(args)
|
|
226
259
|
const result = await doctor({ repoRoot: ROOT, offline: args.includes("--offline"), onPhase: spinner.phase })
|
|
227
260
|
spinner.done()
|
|
228
|
-
emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderDoctor
|
|
261
|
+
emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : reportText(args, renderDoctor, result))
|
|
229
262
|
process.exit(result.problems ? 1 : 0)
|
|
230
263
|
}
|
|
231
264
|
|
|
@@ -268,7 +301,7 @@ async function cmdVerify(args) {
|
|
|
268
301
|
const blockingRules = section.invoked && section.official && !section.official.error
|
|
269
302
|
? (await consequence(requirePin(ROOT).dir, section.official)).selectivelyBlockingRules
|
|
270
303
|
: []
|
|
271
|
-
emit(args, renderVerify
|
|
304
|
+
emit(args, reportText(args, renderVerify, document, { blockingRules }))
|
|
272
305
|
}
|
|
273
306
|
|
|
274
307
|
async function cmdParity(args) {
|
|
@@ -395,7 +428,7 @@ async function cmdWeigh(args) {
|
|
|
395
428
|
// The confirmation. The plan is printed either way, so the record says
|
|
396
429
|
// what was agreed to; the question is asked only at a terminal on both
|
|
397
430
|
// ends, and --yes is the only other way past it.
|
|
398
|
-
narrate.write(`${renderPlan(plan, { colour: colourEnabled(narrate) }).join("\n")}\n`)
|
|
431
|
+
narrate.write(`${withOutputStream(narrate, () => renderPlan(plan, { colour: colourEnabled(narrate) })).join("\n")}\n`)
|
|
399
432
|
if (!parsed.options.has("--yes")) {
|
|
400
433
|
const interactive = !json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)
|
|
401
434
|
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)
|
|
@@ -460,9 +493,17 @@ const [command, ...rest] = process.argv.slice(2)
|
|
|
460
493
|
// completion script names another omakit, so `omakit we<TAB>` may not know
|
|
461
494
|
// `weigh`. One stat and one short read; never under a pipe, whose stderr
|
|
462
495
|
// stays empty on success, and never for setup, which is the fix.
|
|
496
|
+
if (updateCheckEnabled({ command, args: rest, stdinTTY: process.stdin.isTTY, stdoutTTY: process.stdout.isTTY, stderrTTY: process.stderr.isTTY })) {
|
|
497
|
+
const notice = await updateNotice({ repoRoot: ROOT, version: VERSION })
|
|
498
|
+
if (notice) process.stderr.write(`${wrap(notice, { width: outputColumns(process.stderr) }, styler(colourEnabled(process.stderr))).join("\n")}\n`)
|
|
499
|
+
}
|
|
500
|
+
|
|
463
501
|
if (command !== "setup" && process.stderr.isTTY) {
|
|
464
502
|
const notice = staleCompletionNotice({ version: VERSION })
|
|
465
|
-
if (notice)
|
|
503
|
+
if (notice) {
|
|
504
|
+
const c = styler(colourEnabled(process.stderr))
|
|
505
|
+
process.stderr.write(`${wrap(notice, { width: outputColumns(process.stderr) }).map((line) => c("label", line)).join("\n")}\n`)
|
|
506
|
+
}
|
|
466
507
|
}
|
|
467
508
|
|
|
468
509
|
if (command === "setup") {
|
|
@@ -526,12 +567,12 @@ if (command === "setup") {
|
|
|
526
567
|
// The short list on a typo, not 53 lines of reference, in the same register
|
|
527
568
|
// as every other failure: what happened, what it means, what to run.
|
|
528
569
|
const c = styler(colourEnabled(process.stderr))
|
|
529
|
-
process.stderr.write([
|
|
570
|
+
process.stderr.write(withOutputStream(process.stderr, () => [
|
|
530
571
|
`${mark("fail", c)}${c("name", "unknown command")}`,
|
|
531
572
|
...wrap(`\`${command}\` is not something omakit does. The commands it has are listed below.`, { indent: GUTTER }, c),
|
|
532
573
|
...action("omakit help", c),
|
|
533
574
|
"",
|
|
534
|
-
renderSummary({ colour: colourEnabled(process.stderr), heading: false }),
|
|
535
|
-
].join("\n"))
|
|
575
|
+
renderSummary({ stream: process.stderr, colour: colourEnabled(process.stderr), heading: false }),
|
|
576
|
+
].join("\n")))
|
|
536
577
|
process.exit(2)
|
|
537
578
|
}
|
|
@@ -42,7 +42,7 @@ import { join } from "node:path"
|
|
|
42
42
|
import { MARKETPLACE_PIN, PIN_PATHS, marketplacePinDir, pinDiskUsage, pinIsSparse, requirePin } from "./pin.mjs"
|
|
43
43
|
import { LIVE_PATHS } from "./registry.mjs"
|
|
44
44
|
import { credential, defaultBranchHead, getJson, UNAUTHENTICATED_LIMIT, GitHubError } from "./github.mjs"
|
|
45
|
-
import { NPM_REGISTRY, registryLatest, upgradeCommand } from "./upgrade.mjs"
|
|
45
|
+
import { compareVersions, NPM_REGISTRY, registryLatest, upgradeCommand } from "./upgrade.mjs"
|
|
46
46
|
import { pathHint } from "./path-hint.mjs"
|
|
47
47
|
import { completionStatus } from "./completion-check.mjs"
|
|
48
48
|
|
|
@@ -205,11 +205,17 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
|
|
|
205
205
|
phase("asking the npm registry for the newest published version")
|
|
206
206
|
const published = await latestVersion(self.name)
|
|
207
207
|
if (published.version) {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
208
|
+
const comparison = compareVersions(published.version, self.version)
|
|
209
|
+
if (comparison === null) {
|
|
210
|
+
versionCheck("unknown", `${self.version}; the installed or published version is invalid`)
|
|
211
|
+
} else {
|
|
212
|
+
versionCheck(comparison > 0 ? "advice" : "ok",
|
|
213
|
+
comparison === 0 ? `${self.version}, the newest published version`
|
|
214
|
+
: comparison < 0 ? `${self.version}; ahead of the newest published version ${published.version}`
|
|
215
|
+
: `${self.version}; ${published.version} is published`,
|
|
216
|
+
comparison > 0 ? `run \`${upgradeCommand(repoRoot, self.name)}\`` : null,
|
|
217
|
+
published.version, NPM_REGISTRY)
|
|
218
|
+
}
|
|
213
219
|
} else {
|
|
214
220
|
versionCheck("unknown", `${self.version}; could not read the npm registry (${published.error?.code || "error"})`)
|
|
215
221
|
}
|
|
@@ -109,7 +109,7 @@ export function token() {
|
|
|
109
109
|
/** The one host the borrowed gh credential may be sent to. */
|
|
110
110
|
export const CREDENTIAL_HOST = "api.github.com"
|
|
111
111
|
|
|
112
|
-
async function get(url, { accept } = {}) {
|
|
112
|
+
async function get(url, { accept, signal } = {}) {
|
|
113
113
|
const { host } = new URL(url)
|
|
114
114
|
// The credential is GitHub's and goes to GitHub's API and nowhere else.
|
|
115
115
|
// Measured before this held: `omakit upgrade` and `doctor` sent the gh
|
|
@@ -125,7 +125,7 @@ async function get(url, { accept } = {}) {
|
|
|
125
125
|
if (auth) headers.authorization = `Bearer ${auth}`
|
|
126
126
|
let response
|
|
127
127
|
try {
|
|
128
|
-
response = await fetch(url, { method: "GET", headers, redirect: "follow" })
|
|
128
|
+
response = await fetch(url, { method: "GET", headers, redirect: "follow", ...(signal ? { signal } : {}) })
|
|
129
129
|
} catch (error) {
|
|
130
130
|
// Node reports every transport failure as "fetch failed" with the real
|
|
131
131
|
// reason in `cause`. A person needs the reason, and the CLI keys its
|
|
@@ -150,8 +150,8 @@ async function get(url, { accept } = {}) {
|
|
|
150
150
|
return response
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
export async function getJson(url) {
|
|
154
|
-
return (await get(url)).json()
|
|
153
|
+
export async function getJson(url, options) {
|
|
154
|
+
return (await get(url, options)).json()
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
export async function getText(url, accept) {
|
|
@@ -171,17 +171,38 @@ export async function issue(owner, repository, number) {
|
|
|
171
171
|
return getJson(`https://api.github.com/repos/${owner}/${repository}/issues/${number}`)
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
/** Resolve the account whose credential gh lent us; never persist it. */
|
|
175
|
+
export async function authenticatedUser() {
|
|
176
|
+
if (!token()) throw new GitHubError("login-required", "Account-wide watch needs your GitHub login. Run `gh auth login`, or pass --user <login> to read a public account.")
|
|
177
|
+
const user = await getJson("https://api.github.com/user")
|
|
178
|
+
if (!user?.login) throw new GitHubError("github-unavailable", "GitHub did not return the signed-in account's login")
|
|
179
|
+
return user.login
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Repository issues by their creator. PRs are excluded; pagination never silently truncates. */
|
|
183
|
+
export async function repositoryIssues(owner, repository, creator, { readJson = getJson, maxPages = 100 } = {}) {
|
|
184
|
+
const all = []
|
|
185
|
+
for (let page = 1; page <= maxPages; page += 1) {
|
|
186
|
+
const query = new URLSearchParams({ creator, state: "open", sort: "updated", direction: "desc", per_page: "100", page: String(page) })
|
|
187
|
+
const batch = await readJson(`https://api.github.com/repos/${owner}/${repository}/issues?${query}`)
|
|
188
|
+
if (!Array.isArray(batch)) throw new GitHubError("github-unavailable", "GitHub did not return an issue list")
|
|
189
|
+
all.push(...batch.filter((item) => !item.pull_request))
|
|
190
|
+
if (batch.length < 100) return all
|
|
191
|
+
}
|
|
192
|
+
throw new GitHubError("issue-list-incomplete", `The issue list exceeded ${maxPages} pages; no complete account-wide result is available`)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function issueComments(owner, repository, number, maxPages = 10, readJson = getJson) {
|
|
175
196
|
const all = []
|
|
176
197
|
for (let page = 1; page <= maxPages; page += 1) {
|
|
177
|
-
const batch = await
|
|
198
|
+
const batch = await readJson(
|
|
178
199
|
`https://api.github.com/repos/${owner}/${repository}/issues/${number}/comments?per_page=100&page=${page}`,
|
|
179
200
|
)
|
|
180
|
-
if (!Array.isArray(batch)
|
|
201
|
+
if (!Array.isArray(batch)) throw new GitHubError("github-unavailable", "GitHub did not return issue comments")
|
|
181
202
|
all.push(...batch)
|
|
182
|
-
if (batch.length < 100)
|
|
203
|
+
if (batch.length < 100) return all
|
|
183
204
|
}
|
|
184
|
-
|
|
205
|
+
throw new GitHubError("comments-incomplete", `Issue #${number} exceeded ${maxPages} comment pages; its latest baseline cannot be determined`)
|
|
185
206
|
}
|
|
186
207
|
|
|
187
208
|
/**
|
|
@@ -21,7 +21,7 @@ export const ACCEPTED = Object.freeze({
|
|
|
21
21
|
setup: Object.freeze({ valued: [], flags: ["--yes", "--completion"], positionals: 0 }),
|
|
22
22
|
pin: Object.freeze({ valued: [], flags: [], positionals: 0 }),
|
|
23
23
|
submit: Object.freeze({ valued: ["--category", "--tags", "--notes", "--suggest-tag", "--name", "--out"], flags: ["--offline", "--allow-dirty", "--json"], positionals: 1 }),
|
|
24
|
-
watch: Object.freeze({ valued: ["--out"], flags: ["--json"], positionals: 1 }),
|
|
24
|
+
watch: Object.freeze({ valued: ["--out", "--user"], flags: ["--json", "--all", "--list"], positionals: 1 }),
|
|
25
25
|
verify: Object.freeze({ valued: ["--out"], flags: ["--allow-dirty", "--json"], positionals: 1 }),
|
|
26
26
|
help: Object.freeze({ valued: [], flags: ["--agent"], positionals: 0 }),
|
|
27
27
|
upgrade: Object.freeze({ valued: [], flags: ["--dry-run"], positionals: 0 }),
|
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
// screen, and the last screen is the one a person is looking at.
|
|
19
19
|
|
|
20
20
|
import {
|
|
21
|
-
action, colourEnabled,
|
|
21
|
+
action, colourEnabled, outputColumns, continuation, field, GUTTER, labelled, mark, section, STEP, styler, verdict, width, wrap,
|
|
22
22
|
} from "./style.mjs"
|
|
23
23
|
import { withHomeAbbreviated } from "./paths.mjs"
|
|
24
|
+
import { watchIssueTitle } from "./watch.mjs"
|
|
24
25
|
|
|
25
26
|
const body = " ".repeat(GUTTER)
|
|
26
27
|
|
|
@@ -41,7 +42,11 @@ function stateOf(check) {
|
|
|
41
42
|
export function head(state, id, source, c) {
|
|
42
43
|
const left = `${mark(state, c)}${c("name", id)}`
|
|
43
44
|
const tag = c("punctuation", `[${source}]`)
|
|
44
|
-
const
|
|
45
|
+
const columns = outputColumns()
|
|
46
|
+
if (width(left) + width(tag) + 2 > columns) {
|
|
47
|
+
return `${left}\n${wrap(`[${source}]`, { indent: GUTTER }).map((line) => c("punctuation", line)).join("\n")}`
|
|
48
|
+
}
|
|
49
|
+
const gap = columns - width(left) - width(tag)
|
|
45
50
|
return `${left}${" ".repeat(gap)}${tag}`
|
|
46
51
|
}
|
|
47
52
|
|
|
@@ -77,7 +82,7 @@ function commandLines(command, c) {
|
|
|
77
82
|
units.push(word)
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
|
-
const room =
|
|
85
|
+
const room = outputColumns() - STEP - " \\".length
|
|
81
86
|
const lines = []
|
|
82
87
|
let line = ""
|
|
83
88
|
for (const unit of units) {
|
|
@@ -244,6 +249,50 @@ export function renderWatch(result, { colour = colourEnabled() } = {}) {
|
|
|
244
249
|
return out.join("\n")
|
|
245
250
|
}
|
|
246
251
|
|
|
252
|
+
/** Discovery is cheap: issue metadata and repeatable commands, no plugin HEAD reads. */
|
|
253
|
+
export function renderWatchList(result, { colour = colourEnabled() } = {}) {
|
|
254
|
+
const c = styler(colour)
|
|
255
|
+
const out = [...field("account", result.account, c), ...field("issues", `${result.issues.length} open marketplace issue(s)`, c), ""]
|
|
256
|
+
for (const issue of result.issues) {
|
|
257
|
+
out.push(...wrap(`#${issue.number} ${watchIssueTitle(issue.title)}`, {}, c))
|
|
258
|
+
out.push(...field("state", `${issue.state}${issue.labels.length ? `; labels ${issue.labels.map(watchIssueTitle).join(", ")}` : ""}`, c))
|
|
259
|
+
out.push(...action(`omakit watch ${issue.url}`, c, { indent: 0 }), "")
|
|
260
|
+
}
|
|
261
|
+
if (!result.issues.length) out.push(...wrap("No open marketplace issues found for this account.", {}, c))
|
|
262
|
+
else out.push(...action(`omakit watch --all --user ${result.account}`, c, { indent: 0 }))
|
|
263
|
+
return out.join("\n")
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Compact batch report; exact commits and full discussion remain in JSON. */
|
|
267
|
+
export function renderWatchAll(result, { colour = colourEnabled() } = {}) {
|
|
268
|
+
const c = styler(colour)
|
|
269
|
+
const out = [...field("account", result.account, c), ...field("issues", `${result.summary.total} checked; ${result.summary.current} current, ${result.summary.stale} stale, ${result.summary.unknown} unknown`, c), ""]
|
|
270
|
+
for (const row of result.issues) {
|
|
271
|
+
const state = row.report?.verdict.state || "unknown"
|
|
272
|
+
const style = { current: "pass", stale: "fail", unknown: "unknown" }[state]
|
|
273
|
+
out.push(...verdict(style, state.toUpperCase(), `#${row.issue.number} ${watchIssueTitle(row.report?.read.title || row.issue.title)}`, c))
|
|
274
|
+
out.push(...field("issue", row.issue.url, c, { wrapValue: false }))
|
|
275
|
+
if (row.error) {
|
|
276
|
+
out.push(...field("read error", watchIssueTitle(`${row.error.code}: ${row.error.message}`), c))
|
|
277
|
+
} else {
|
|
278
|
+
const report = row.report
|
|
279
|
+
out.push(...field("baseline", report.validated ? `${report.validated.outcome}; ${report.validated.findings.length} finding(s); capabilities ${report.validated.capabilities.join(", ") || "none"}` : "no complete baseline", c))
|
|
280
|
+
out.push(...field("state", `${report.read.state}; labels ${report.read.labels.map(watchIssueTitle).join(", ") || "none"}`, c))
|
|
281
|
+
if (state !== "current") out.push(...field("comparison", report.verdict.summary, c))
|
|
282
|
+
if (report.discussion) {
|
|
283
|
+
const text = watchIssueTitle(report.discussion.body).replace(/\s+/g, " ").trim()
|
|
284
|
+
out.push(...field("discussion", text.length > 240 ? `${text.slice(0, 240)}…` : text, c))
|
|
285
|
+
if (report.discussion.url) out.push(...field("source", report.discussion.url, c, { wrapValue: false }))
|
|
286
|
+
}
|
|
287
|
+
if (report.verdict.action) out.push(...action(report.verdict.action, c, { indent: 0 }))
|
|
288
|
+
}
|
|
289
|
+
out.push("")
|
|
290
|
+
}
|
|
291
|
+
if (!result.issues.length) out.push(...wrap("No issues selected for checking.", {}, c))
|
|
292
|
+
out.push(...wrap("Current means the validated commit matches HEAD. It does not mean review, approval or publication is complete. Read-only; this run posts nothing.", {}, c))
|
|
293
|
+
return out.join("\n")
|
|
294
|
+
}
|
|
295
|
+
|
|
247
296
|
/** A list of paths at fault, the way a failing check prints them: a tinted dash, the path in the placeholder tint. */
|
|
248
297
|
function pathLines(paths, c, dash = "fail") {
|
|
249
298
|
return paths.flatMap((path) => wrap(`- ${path}`, { indent: GUTTER + STEP, first: GUTTER })
|
|
@@ -115,9 +115,8 @@ export function code(role) {
|
|
|
115
115
|
// --- geometry ---------------------------------------------------------------
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
|
-
* The width
|
|
119
|
-
*
|
|
120
|
-
* and tests/unit/style.test.mjs renders every report and measures. The one
|
|
118
|
+
* The stable width for pipes and files. Terminals use their current column
|
|
119
|
+
* count, up to MAX_COLUMNS so wide windows still have readable prose. The one
|
|
121
120
|
* thing exempt is the marketplace's own baseline report, which is printed
|
|
122
121
|
* verbatim because rewrapping somebody else's attestation would be editing it.
|
|
123
122
|
*
|
|
@@ -125,6 +124,23 @@ export function code(role) {
|
|
|
125
124
|
* `overflows` below is the measurement, and it is the one both test files use.
|
|
126
125
|
*/
|
|
127
126
|
export const COLUMNS = 80
|
|
127
|
+
export const MAX_COLUMNS = 120
|
|
128
|
+
|
|
129
|
+
let compositionStream = null
|
|
130
|
+
|
|
131
|
+
/** Resolve at rendering time, so a resized terminal's next output uses its new width. */
|
|
132
|
+
export function outputColumns(stream = compositionStream || process.stdout) {
|
|
133
|
+
return stream?.isTTY && Number.isFinite(stream.columns) && stream.columns >= 1
|
|
134
|
+
? Math.min(MAX_COLUMNS, Math.floor(stream.columns))
|
|
135
|
+
: COLUMNS
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Scope synchronous composition to its destination, including files and stderr. */
|
|
139
|
+
export function withOutputStream(stream, render) {
|
|
140
|
+
const previous = compositionStream
|
|
141
|
+
compositionStream = stream
|
|
142
|
+
try { return render() } finally { compositionStream = previous }
|
|
143
|
+
}
|
|
128
144
|
|
|
129
145
|
/**
|
|
130
146
|
* The indent scale, in columns. Three stops, and every line in the tool starts
|
|
@@ -327,7 +343,7 @@ export function overflows(line, total = COLUMNS) {
|
|
|
327
343
|
// --- composition ------------------------------------------------------------
|
|
328
344
|
|
|
329
345
|
/**
|
|
330
|
-
* Wrap prose to the
|
|
346
|
+
* Wrap prose to the destination width, and paint it.
|
|
331
347
|
*
|
|
332
348
|
* `indent` is the column the text starts in and is part of the width, which is
|
|
333
349
|
* the bug the earlier version had: it wrapped at 78 and then indented by 7, and
|
|
@@ -346,7 +362,7 @@ export function overflows(line, total = COLUMNS) {
|
|
|
346
362
|
* @param {(name: string, text: string) => string} [c]
|
|
347
363
|
* @returns {string[]} lines, indented and painted
|
|
348
364
|
*/
|
|
349
|
-
export function wrap(text, { indent = 0, width: total =
|
|
365
|
+
export function wrap(text, { indent = 0, width: total = outputColumns(), first = indent } = {}, c = styler(false)) {
|
|
350
366
|
// A word is a run of non-spaces, or a backticked span with whatever
|
|
351
367
|
// punctuation clings to it: "(`omakit pin`)." is one word.
|
|
352
368
|
const words = String(text).match(/[^\s`]*`[^`]*`[^\s`]*|\S+/g) || []
|
|
@@ -422,7 +438,7 @@ export function continuation(value, c) {
|
|
|
422
438
|
* under itself. There is exactly one of these under any failure, and it is the
|
|
423
439
|
* only line in the tool that starts with an arrow, so it can be found by shape.
|
|
424
440
|
*/
|
|
425
|
-
export function action(text, c, { indent = GUTTER, width: total =
|
|
441
|
+
export function action(text, c, { indent = GUTTER, width: total = outputColumns() } = {}) {
|
|
426
442
|
// Painted after wrapping, and all of it cyan: the whole line is the thing
|
|
427
443
|
// to do, so a backticked word inside it has nothing to stand out from.
|
|
428
444
|
const lines = wrap(text, { indent: indent + 2, width: total })
|
|
@@ -449,11 +465,11 @@ export function labelled(label, text, c, { indent = GUTTER } = {}) {
|
|
|
449
465
|
* grey. It is the same shape the wordmark uses (the name, then its rule), so a
|
|
450
466
|
* section of a report and the front door of the tool are drawn by one idea.
|
|
451
467
|
*/
|
|
452
|
-
export function section(title, c, { width: total =
|
|
468
|
+
export function section(title, c, { width: total = outputColumns() } = {}) {
|
|
453
469
|
return [c("heading", title), c("punctuation", DENSITY.floor.repeat(total))]
|
|
454
470
|
}
|
|
455
471
|
|
|
456
472
|
/** A rule with no heading, the same floor, for a wordmark or a block that names itself. */
|
|
457
|
-
export function rule(c, { width: total =
|
|
473
|
+
export function rule(c, { width: total = outputColumns(), tint = "punctuation" } = {}) {
|
|
458
474
|
return c(tint, DENSITY.floor.repeat(total))
|
|
459
475
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// A passive npm-version notice. Only metadata and a throttle stamp are stored;
|
|
2
|
+
// nothing is installed or executed. Explicit doctor checks bypass this throttle.
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import { homedir } from "node:os"
|
|
6
|
+
import { compareVersions, registryLatest, upgradeCommand } from "./upgrade.mjs"
|
|
7
|
+
|
|
8
|
+
export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
9
|
+
export const UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
|
|
10
|
+
export const UPDATE_CHECK_TIMEOUT_MS = 1000
|
|
11
|
+
|
|
12
|
+
export function updateCheckEnabled({ command, args = [], stdinTTY, stdoutTTY, stderrTTY, env = process.env }) {
|
|
13
|
+
return Boolean(stdinTTY && stdoutTTY && stderrTTY)
|
|
14
|
+
&& !env.DISABLE_UPDATE_NOTIFIER && !env.CI
|
|
15
|
+
&& !args.some((arg) => arg === "--offline" || arg === "--json" || arg === "--agent" || arg === "--out" || arg.startsWith("--out="))
|
|
16
|
+
&& (command === undefined || ["watch", "submit", "verify", "audit", "weigh"].includes(command))
|
|
17
|
+
&& !args.includes("--help") && !args.includes("-h")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** At most one registry read and one notice per successful day (failure retries after an hour). */
|
|
21
|
+
export async function updateNotice({ repoRoot, version, name = "omakit", env = process.env, now = Date.now(), latest = registryLatest, timeoutMs = UPDATE_CHECK_TIMEOUT_MS }) {
|
|
22
|
+
if (compareVersions(version, version) === null) return null
|
|
23
|
+
const updateFile = join(env.XDG_STATE_HOME || join(homedir(), ".local/state"), "omakit", "update-check.json")
|
|
24
|
+
try {
|
|
25
|
+
const cached = JSON.parse(readFileSync(updateFile, "utf8"))
|
|
26
|
+
const age = now - cached.checkedAt
|
|
27
|
+
const validMetadata = cached.latest === null || compareVersions(cached.latest, cached.latest) !== null
|
|
28
|
+
if (cached.installed === version && cached.name === name && validMetadata && Number.isFinite(age) && age >= 0
|
|
29
|
+
&& age < (cached.latest ? UPDATE_CHECK_INTERVAL_MS : UPDATE_RETRY_INTERVAL_MS)) return null
|
|
30
|
+
} catch { /* Missing or malformed state triggers a fresh bounded read. */ }
|
|
31
|
+
const controller = new AbortController()
|
|
32
|
+
let timer
|
|
33
|
+
let published
|
|
34
|
+
try {
|
|
35
|
+
published = await Promise.race([
|
|
36
|
+
Promise.resolve().then(() => latest(name, { signal: controller.signal })).catch(() => ({ version: null })),
|
|
37
|
+
new Promise((resolve) => { timer = setTimeout(() => { controller.abort(); resolve({ version: null }) }, timeoutMs) }),
|
|
38
|
+
])
|
|
39
|
+
} finally { clearTimeout(timer) }
|
|
40
|
+
const newest = compareVersions(published?.version, published?.version) === null ? null : published.version
|
|
41
|
+
try {
|
|
42
|
+
mkdirSync(dirname(updateFile), { recursive: true })
|
|
43
|
+
writeFileSync(updateFile, `${JSON.stringify({ name, installed: version, latest: newest, checkedAt: now })}\n`, { mode: 0o600 })
|
|
44
|
+
} catch { /* An unwritable state directory must not break the user's command. */ }
|
|
45
|
+
return newest && compareVersions(newest, version) > 0
|
|
46
|
+
? `Omakit ${newest} is available (installed ${version}). Run \`${upgradeCommand(repoRoot, name)}\`.`
|
|
47
|
+
: null
|
|
48
|
+
}
|
|
@@ -74,9 +74,12 @@ export const NPM_REGISTRY = "https://registry.npmjs.org"
|
|
|
74
74
|
*
|
|
75
75
|
* @returns {Promise<{ version: string|null, error: { code: string, message: string }|null }>}
|
|
76
76
|
*/
|
|
77
|
-
export async function registryLatest(name) {
|
|
77
|
+
export async function registryLatest(name, { signal } = {}) {
|
|
78
78
|
try {
|
|
79
|
-
const meta = await getJson(`${NPM_REGISTRY}/${encodeURIComponent(name)}/latest
|
|
79
|
+
const meta = await getJson(`${NPM_REGISTRY}/${encodeURIComponent(name)}/latest`, { signal })
|
|
80
|
+
if (meta?.version && compareVersions(meta.version, meta.version) === null) {
|
|
81
|
+
return { version: null, error: { code: "invalid-version", message: "the registry did not return a valid semantic version" } }
|
|
82
|
+
}
|
|
80
83
|
return meta?.version
|
|
81
84
|
? { version: meta.version, error: null }
|
|
82
85
|
: { version: null, error: { code: "unpublished", message: "the registry answered without a version" } }
|
|
@@ -85,6 +88,33 @@ export async function registryLatest(name) {
|
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
/** Semantic precedence, including prereleases; invalid versions are never update targets. */
|
|
92
|
+
export function compareVersions(left, right) {
|
|
93
|
+
const parse = (value) => {
|
|
94
|
+
const match = String(value || "").match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/)
|
|
95
|
+
if (!match) return null
|
|
96
|
+
const pre = match[4]?.split(".") || []
|
|
97
|
+
if (pre.some((part) => /^\d+$/.test(part) && part.length > 1 && part.startsWith("0"))) return null
|
|
98
|
+
return { core: match.slice(1, 4).map(BigInt), pre }
|
|
99
|
+
}
|
|
100
|
+
const a = parse(left), b = parse(right)
|
|
101
|
+
if (!a || !b) return null
|
|
102
|
+
for (let index = 0; index < 3; index += 1) {
|
|
103
|
+
if (a.core[index] !== b.core[index]) return a.core[index] > b.core[index] ? 1 : -1
|
|
104
|
+
}
|
|
105
|
+
if (!a.pre.length || !b.pre.length) return a.pre.length === b.pre.length ? 0 : a.pre.length ? -1 : 1
|
|
106
|
+
for (let index = 0; index < Math.max(a.pre.length, b.pre.length); index += 1) {
|
|
107
|
+
const x = a.pre[index], y = b.pre[index]
|
|
108
|
+
if (x === undefined || y === undefined) return x === y ? 0 : x === undefined ? -1 : 1
|
|
109
|
+
if (x === y) continue
|
|
110
|
+
const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y)
|
|
111
|
+
if (xn && yn) return BigInt(x) > BigInt(y) ? 1 : -1
|
|
112
|
+
if (xn !== yn) return xn ? -1 : 1
|
|
113
|
+
return x > y ? 1 : -1
|
|
114
|
+
}
|
|
115
|
+
return 0
|
|
116
|
+
}
|
|
117
|
+
|
|
88
118
|
function installedVersion(repoRoot) {
|
|
89
119
|
try {
|
|
90
120
|
return JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version || null
|
|
@@ -300,8 +330,10 @@ async function upgradeNpm({ repoRoot, stream, dryRun, latest, npmRoot, name, ref
|
|
|
300
330
|
if (!newest) {
|
|
301
331
|
return refuse("the npm registry did not answer, so there is nothing to compare against.", "Connect to the network, then run `omakit upgrade` again.")
|
|
302
332
|
}
|
|
303
|
-
|
|
304
|
-
|
|
333
|
+
const compared = compareVersions(newest, current)
|
|
334
|
+
if (compared === null) return refuse("the installed or published version is invalid, so an exact newer release cannot be selected.")
|
|
335
|
+
if (compared <= 0) {
|
|
336
|
+
ok(compared === 0 ? `already current at ${current}, the newest published version` : `${current} is ahead of the newest published version ${newest}; no downgrade applied`)
|
|
305
337
|
out()
|
|
306
338
|
lines(wrap("The marketplace pin is a separate thing and is never touched here. `omakit doctor` says whether it is behind.", {}, c))
|
|
307
339
|
return { ok: true, changed: false, version: current }
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { UNAUTHENTICATED_LIMIT } from "./github.mjs"
|
|
9
9
|
import { MARKETPLACE_PIN } from "./pin.mjs"
|
|
10
|
-
import { colourEnabled, paintProse, STEP, styler } from "./style.mjs"
|
|
10
|
+
import { colourEnabled, paintProse, STEP, styler, withOutputStream, wrap } from "./style.mjs"
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* "Safe" means one thing, everywhere it appears: this runs on your own
|
|
@@ -69,11 +69,18 @@ export const COMMANDS = Object.freeze([
|
|
|
69
69
|
],
|
|
70
70
|
},
|
|
71
71
|
{
|
|
72
|
-
signature:
|
|
72
|
+
signature: [
|
|
73
|
+
"omakit watch <issue-url> [--json] [--out <file>]",
|
|
74
|
+
"omakit watch [--all | --list] [--user <login>] [--json] [--out <file>]",
|
|
75
|
+
],
|
|
73
76
|
lines: [
|
|
74
77
|
"Compare the commit the marketplace validated on a submission issue with",
|
|
75
78
|
"the plugin repository's current default-branch HEAD, and say what makes",
|
|
76
79
|
"it validate a newer one. Read-only.",
|
|
80
|
+
"--all checks your open marketplace issues; --list lists them first.",
|
|
81
|
+
"Without a URL or either flag, a terminal asks which issues to check.",
|
|
82
|
+
"Uses your gh account, or --user to read another public account. JSON",
|
|
83
|
+
"and pipes never prompt: use --all or --list. Each run is one snapshot.",
|
|
77
84
|
],
|
|
78
85
|
},
|
|
79
86
|
{
|
|
@@ -104,6 +111,8 @@ export const COMMANDS = Object.freeze([
|
|
|
104
111
|
lines: [
|
|
105
112
|
"What is installed, what is pinned, and what has moved since. Reads and",
|
|
106
113
|
"prints; it installs nothing and never moves the pin.",
|
|
114
|
+
"Checks the newest npm release explicitly. Normal terminal use also",
|
|
115
|
+
"checks at most once daily; DISABLE_UPDATE_NOTIFIER=1 disables notices.",
|
|
107
116
|
],
|
|
108
117
|
},
|
|
109
118
|
{
|
|
@@ -199,16 +208,40 @@ export function paintSignature(signature, c) {
|
|
|
199
208
|
*
|
|
200
209
|
* @param {{ colour?: boolean, heading?: boolean }} [options]
|
|
201
210
|
*/
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
211
|
+
function commandSignatures(command) {
|
|
212
|
+
const signatures = []
|
|
213
|
+
for (const line of [].concat(command.signature)) {
|
|
214
|
+
if (line.startsWith("omakit ")) signatures.push(line)
|
|
215
|
+
else signatures[signatures.length - 1] += ` ${line}`
|
|
207
216
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
217
|
+
return signatures
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function signatureLines(signature, c) {
|
|
221
|
+
// A placeholder or bracketed optional argument stays whole, like the
|
|
222
|
+
// backticked typeable spans used by the shared wrapper.
|
|
223
|
+
const grouped = signature.replace(/\[[^\]]+\]|<[^>]+>(?:@<[^>]+>)?/g, (part) => `\`${part}\``)
|
|
224
|
+
return wrap(grouped, { indent: STEP * 3, first: STEP }).map((line) => paintSignature(line, c))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function renderSummary({ stream = process.stdout, colour = colourEnabled(stream), heading = true } = {}) {
|
|
228
|
+
return withOutputStream(stream, () => {
|
|
229
|
+
const c = styler(colour)
|
|
230
|
+
const out = heading ? [`${c("typeable.bold", "omakit")}${c("punctuation", ":")} ${TAGLINE}`, ""] : []
|
|
231
|
+
if (stream.isTTY && heading) out.splice(0, 1, ...wrap(out[0]))
|
|
232
|
+
for (const command of COMMANDS) {
|
|
233
|
+
if (stream.isTTY) out.push(...signatureLines([].concat(command.signature)[0], c))
|
|
234
|
+
else out.push(`${INDENT}${paintSignature([].concat(command.signature)[0], c)}`)
|
|
235
|
+
}
|
|
236
|
+
out.push("")
|
|
237
|
+
if (stream.isTTY) {
|
|
238
|
+
out.push(...wrap("`omakit help` is the same list with what each command does, and what it reads. `omakit setup` is the one to run first.", { indent: STEP }, c))
|
|
239
|
+
} else {
|
|
240
|
+
out.push(`${INDENT}${paintProse("`omakit help` is the same list with what each command does, and", c)}`)
|
|
241
|
+
out.push(`${INDENT}${paintProse("what it reads. `omakit setup` is the one to run first.", c)}`)
|
|
242
|
+
}
|
|
243
|
+
return `${out.join("\n")}\n`
|
|
244
|
+
})
|
|
212
245
|
}
|
|
213
246
|
|
|
214
247
|
/**
|
|
@@ -216,23 +249,29 @@ export function renderSummary({ colour = colourEnabled(), heading = true } = {})
|
|
|
216
249
|
* when the line above has already named the tool, as under an unknown
|
|
217
250
|
* command, so the same sentence is not printed twice.
|
|
218
251
|
*/
|
|
219
|
-
export function renderUsage({ colour = colourEnabled(), heading = true } = {}) {
|
|
220
|
-
|
|
221
|
-
|
|
252
|
+
export function renderUsage({ stream = process.stdout, colour = colourEnabled(stream), heading = true } = {}) {
|
|
253
|
+
return withOutputStream(stream, () => {
|
|
254
|
+
const c = styler(colour)
|
|
255
|
+
const out = heading ? [`${c("typeable.bold", "omakit")}${c("punctuation", ":")} ${TAGLINE}`, ""] : []
|
|
256
|
+
if (stream.isTTY && heading) out.splice(0, 1, ...wrap(out[0]))
|
|
222
257
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
258
|
+
for (const command of COMMANDS) {
|
|
259
|
+
if (stream.isTTY) {
|
|
260
|
+
for (const signature of commandSignatures(command)) out.push(...signatureLines(signature, c))
|
|
261
|
+
out.push(...wrap(command.lines.join(" "), { indent: STEP * 3 }, c))
|
|
262
|
+
} else {
|
|
263
|
+
for (const line of [].concat(command.signature)) out.push(`${INDENT}${paintSignature(line, c)}`)
|
|
264
|
+
for (const line of command.lines) out.push(`${DESCRIPTION}${paintProse(line, c)}`)
|
|
265
|
+
}
|
|
266
|
+
out.push("")
|
|
226
267
|
}
|
|
227
|
-
for (const line of command.lines) {
|
|
228
|
-
out.push(`${DESCRIPTION}${paintProse(line, c)}`)
|
|
229
|
-
}
|
|
230
|
-
out.push("")
|
|
231
|
-
}
|
|
232
268
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
269
|
+
if (stream.isTTY) out.push(...signatureLines(TARGET_NOTE, c))
|
|
270
|
+
else out.push(`${INDENT}${paintSignature(TARGET_NOTE, c)}`)
|
|
271
|
+
out.push("")
|
|
272
|
+
out.push(c("heading", "GitHub access:"))
|
|
273
|
+
if (stream.isTTY) out.push(...wrap(AUTHENTICATION.join(" "), { indent: STEP }, c))
|
|
274
|
+
else for (const line of AUTHENTICATION) out.push(`${INDENT}${paintProse(line, c)}`)
|
|
275
|
+
return `${out.join("\n")}\n`
|
|
276
|
+
})
|
|
238
277
|
}
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
import { join } from "node:path"
|
|
30
30
|
import { pathToFileURL } from "node:url"
|
|
31
31
|
import { MARKETPLACE_PIN, requirePin } from "./pin.mjs"
|
|
32
|
-
import { defaultBranchHead, issue, issueComments, parseIssueUrl, token, GitHubError } from "./github.mjs"
|
|
32
|
+
import { authenticatedUser, repositoryIssues, defaultBranchHead, issue, issueComments, parseIssueUrl, token, GitHubError } from "./github.mjs"
|
|
33
33
|
|
|
34
34
|
export class WatchError extends Error {
|
|
35
35
|
constructor(code, message) {
|
|
@@ -41,6 +41,70 @@ export class WatchError extends Error {
|
|
|
41
41
|
|
|
42
42
|
const MARKETPLACE_SLUG = MARKETPLACE_PIN.repository.replace(/^https:\/\/github\.com\//, "").toLowerCase()
|
|
43
43
|
|
|
44
|
+
/** Text from an issue remains data, including at a terminal. JSON retains the original title. */
|
|
45
|
+
export function watchIssueTitle(value) {
|
|
46
|
+
return String(value || "").replace(/[\p{Cc}\p{Cf}]/gu, " ")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Discover the account's open marketplace issues, without reading every plugin. */
|
|
50
|
+
export async function discoverWatchIssues({ user, github = {}, onPhase = () => {} } = {}) {
|
|
51
|
+
const read = { authenticatedUser, repositoryIssues, ...github }
|
|
52
|
+
if (user !== undefined && !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(user)) {
|
|
53
|
+
throw new WatchError("usage", "--user needs a GitHub login, not a URL or search query")
|
|
54
|
+
}
|
|
55
|
+
onPhase("reading the GitHub account")
|
|
56
|
+
const account = user || await read.authenticatedUser()
|
|
57
|
+
const [owner, repository] = MARKETPLACE_SLUG.split("/")
|
|
58
|
+
onPhase(`reading open marketplace issues for ${account}`)
|
|
59
|
+
const subjects = await read.repositoryIssues(owner, repository, account)
|
|
60
|
+
// Check the response as well as the server-side creator filter. Never turn
|
|
61
|
+
// another author's issue, or a PR, into an account-wide watch target.
|
|
62
|
+
const seen = new Set()
|
|
63
|
+
const issues = subjects.filter((subject) => {
|
|
64
|
+
if (subject.pull_request || subject.state !== "open" || subject.user?.login?.toLowerCase() !== account.toLowerCase()) return false
|
|
65
|
+
if (!Number.isSafeInteger(subject.number) || subject.number < 1 || seen.has(subject.number)) return false
|
|
66
|
+
seen.add(subject.number)
|
|
67
|
+
return true
|
|
68
|
+
}).map((subject) => ({
|
|
69
|
+
number: subject.number,
|
|
70
|
+
url: `${MARKETPLACE_PIN.repository}/issues/${subject.number}`,
|
|
71
|
+
title: subject.title,
|
|
72
|
+
state: subject.state,
|
|
73
|
+
labels: (subject.labels || []).map((label) => typeof label === "string" ? label : label?.name).filter(Boolean),
|
|
74
|
+
updatedAt: subject.updated_at || null,
|
|
75
|
+
}))
|
|
76
|
+
return { mode: "list", account, marketplace: MARKETPLACE_PIN.repository, issues }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A batch keeps independent read failures visible and shares repository HEAD reads. */
|
|
80
|
+
export async function validationWatchAll({ repoRoot, discovery, github = {}, onPhase = () => {} }) {
|
|
81
|
+
if (discovery.issues.length) requirePin(repoRoot)
|
|
82
|
+
const heads = new Map()
|
|
83
|
+
const readHead = github.defaultBranchHead || defaultBranchHead
|
|
84
|
+
const shared = { ...github, defaultBranchHead: (url) => {
|
|
85
|
+
if (!heads.has(url)) heads.set(url, Promise.resolve().then(() => readHead(url)))
|
|
86
|
+
return heads.get(url)
|
|
87
|
+
} }
|
|
88
|
+
const results = new Array(discovery.issues.length)
|
|
89
|
+
let next = 0
|
|
90
|
+
async function worker() {
|
|
91
|
+
while (next < discovery.issues.length) {
|
|
92
|
+
const index = next++
|
|
93
|
+
const target = discovery.issues[index]
|
|
94
|
+
onPhase(`checking issue #${target.number} (${index + 1}/${discovery.issues.length})`)
|
|
95
|
+
try {
|
|
96
|
+
results[index] = { issue: target, report: await validationWatch({ repoRoot, issueUrl: target.url, github: shared }), error: null }
|
|
97
|
+
} catch (error) {
|
|
98
|
+
results[index] = { issue: target, report: null, error: { code: error.code || "watch-unavailable", message: error.message } }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
await Promise.all(Array.from({ length: Math.min(4, discovery.issues.length) }, worker))
|
|
103
|
+
const summary = { total: results.length, current: 0, stale: 0, unknown: 0 }
|
|
104
|
+
for (const result of results) summary[result.report?.verdict.state || "unknown"] += 1
|
|
105
|
+
return { mode: "all", account: discovery.account, marketplace: discovery.marketplace, summary, issues: results }
|
|
106
|
+
}
|
|
107
|
+
|
|
44
108
|
// The one action that re-runs validation, in the register the marketplace itself
|
|
45
109
|
// uses in its own failure feedback.
|
|
46
110
|
export const REFRESH_ACTION =
|
|
@@ -207,6 +271,12 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
|
|
|
207
271
|
baselineError,
|
|
208
272
|
head,
|
|
209
273
|
headError,
|
|
274
|
+
discussion: maintainerComments.length ? {
|
|
275
|
+
body: maintainerComments.at(-1).body || "",
|
|
276
|
+
url: maintainerComments.at(-1).html_url || null,
|
|
277
|
+
createdAt: maintainerComments.at(-1).created_at || null,
|
|
278
|
+
authorAssociation: maintainerComments.at(-1).author_association || null,
|
|
279
|
+
} : null,
|
|
210
280
|
verdict: validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl }),
|
|
211
281
|
}
|
|
212
282
|
}
|
package/tools/weigh/confirm.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// typed here is written anywhere.
|
|
6
6
|
|
|
7
7
|
import { createInterface } from "node:readline"
|
|
8
|
-
import { action, colourEnabled,
|
|
8
|
+
import { action, colourEnabled, outputColumns, styler } from "../marketplace/style.mjs"
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The question as it is written to the terminal: the arrow line, wrapped
|
|
@@ -20,7 +20,7 @@ import { action, colourEnabled, COLUMNS, styler } from "../marketplace/style.mjs
|
|
|
20
20
|
* @param {{ width?: number }} [options]
|
|
21
21
|
* @returns {string} every line, joined, ending in `[y/N]: `
|
|
22
22
|
*/
|
|
23
|
-
export function renderQuestion(question, c, { width =
|
|
23
|
+
export function renderQuestion(question, c, { width = outputColumns() } = {}) {
|
|
24
24
|
return `${action(`${question} [y/N]:`, c, { indent: 0, width }).join("\n")} `
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -28,7 +28,7 @@ export function renderQuestion(question, c, { width = COLUMNS } = {}) {
|
|
|
28
28
|
* @param {{ input?: NodeJS.ReadStream, output?: NodeJS.WriteStream, colour?: boolean, question: string, width?: number }} options
|
|
29
29
|
* @returns {Promise<boolean>} true only for `y` or `yes`, in any case; an empty line and the end of stdin are no
|
|
30
30
|
*/
|
|
31
|
-
export function askYes({ input = process.stdin, output = process.stderr, colour = colourEnabled(output), question, width =
|
|
31
|
+
export function askYes({ input = process.stdin, output = process.stderr, colour = colourEnabled(output), question, width = outputColumns(output) }) {
|
|
32
32
|
const c = styler(colour)
|
|
33
33
|
return new Promise((resolve) => {
|
|
34
34
|
const rl = createInterface({ input, terminal: false })
|