omakit 0.1.5 → 0.1.7

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 CHANGED
@@ -105,9 +105,9 @@ owner's behalf. Zero dependencies, plain ESM, one entry point, no build step.
105
105
 
106
106
  ```bash
107
107
  omakit setup # the environment, the pin, tab completion, and what to try first
108
- omakit submit <plugin-repo> --category <c> --tags <a,b>
108
+ omakit submit <plugin-repo> # every check, the issue title and body; asks for a category and tags at a terminal
109
109
  omakit watch <issue-url> # the commit the marketplace validated, against the plugin's current HEAD
110
- omakit verify <plugin-repo> # the official security baseline over the local transport, reported verbatim
110
+ omakit verify <plugin-repo> # the official security baseline over the local transport; --json for the document
111
111
  omakit parity # the baseline over GitHub versus the local transport, on real listings; writes the evidence
112
112
  omakit doctor # what is installed, what is pinned, and what has moved
113
113
  omakit pin # what setup does for the pin, on its own
@@ -126,6 +126,41 @@ that commit. The completion script knows the subcommands and their flags,
126
126
  completes a directory for `<target>`, and offers the categories and tags the
127
127
  pin's submission form actually has.
128
128
 
129
+ `omakit verify` prints the official baseline result alone, with no Omakit
130
+ check around it: the subject, the pin, the transport and what the local
131
+ adapter assumes, then the marketplace's own outcome, each finding as a block
132
+ with its rule id, whether it blocks publication under the pinned policy, the
133
+ file and line, and the official text verbatim, then the marketplace's own
134
+ statement. `--json` prints the document itself, unchanged from earlier
135
+ releases, and `--out <file>` writes it; agents and the skills use those.
136
+
137
+ `omakit submit` reads the marketplace's registry first, and a run has three
138
+ outcomes. `READY`, exit 0: every blocking check passed and the title and body
139
+ follow. `REFUSED`, exit 1: a blocking check failed and no body is produced.
140
+ `LISTED`, exit 0: the plugin is already listed by its own repository (the
141
+ manifest id is in the catalog, and the listing's repository is the subject's
142
+ declared `origin`, compared as owner and name), so the submission form is not
143
+ the route. Nothing is wrong and nothing was refused: `identity.available`
144
+ passes with the listing's record (since when, which commit, verified or not),
145
+ the five checks that exist only for the body are omitted, nothing is asked,
146
+ and the closing block names the commit the marketplace lists, the local
147
+ commit, whether they are the same, and the marketplace's verification form
148
+ with the choice that lists a newer commit, read from the pin's
149
+ `verify-plugin.yml`. Measured on 0.1.6: this state printed `FAIL
150
+ identity.available`, `REFUSED`, and "Fix it, then run submit again" under a
151
+ remedy that said there was nothing to submit. An id taken by another
152
+ repository, a retired id or a reserved one is still refused. In `--json`, the
153
+ outcome is `outcome: "ready" | "refused" | "listed"`, `ready` stays a boolean
154
+ that is true for the first only, and a listed run carries a `listing` object.
155
+
156
+ An unlisted plugin needs a category and tags, and they are an editorial
157
+ choice nobody else can make: at a terminal it asks, once each, with the form's
158
+ own lists numbered and the marketplace's own default for the manifest's kinds
159
+ offered where it is on the list; in a pipe, from an agent, or with `--json` it
160
+ is the usage error with the same lists, exit 2. A listed plugin is asked for
161
+ neither. A `READY` or `REFUSED` report ends with the command line that repeats
162
+ the run without asking, and `--json` carries it as `reproduce`.
163
+
129
164
  There is nothing to authenticate. If you have `gh auth login` done, omakit
130
165
  reads that credential for GET requests and stores nothing; a token in
131
166
  `GH_TOKEN` or `GITHUB_TOKEN` reaches it the same way, because `gh` honours
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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, posts nothing, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Maarten Tolhuijs",
@@ -12,6 +12,19 @@ the issue is a separate, explicit act that needs the plugin owner's approval
12
12
  first, which is what the marketplace's own agent instructions require. Show the
13
13
  owner the title, the body and the verdicts, ask, and only then post it.
14
14
 
15
+ ## If omakit is not installed
16
+
17
+ ```bash
18
+ npm install --global omakit # Omarchy ships Node and npm through mise
19
+ omakit doctor # node, git, the pin, the credential source, and whether omakit is on PATH
20
+ ```
21
+
22
+ If `omakit` is not found after the install, run
23
+ `"$(npm prefix --global)/bin/omakit" setup`: it prints the one line that puts
24
+ npm's bin on PATH for the shell in `$SHELL`. Keep it current with
25
+ `omakit upgrade`, which updates the tool through npm at the exact version the
26
+ registry names and never moves the marketplace pin.
27
+
15
28
  ## Run it
16
29
 
17
30
  ```bash
@@ -19,14 +32,32 @@ omakit pin # once, and after any pin change: fetches the pinned marketplace
19
32
  omakit submit <path-to-the-plugin-repo> --category <category> --tags <a,b>
20
33
  ```
21
34
 
35
+ The pin is a sparse read-only checkout of one marketplace commit under
36
+ `$XDG_CACHE_HOME/omakit/marketplace` (or `~/.cache/omakit/marketplace`). Every
37
+ rule is read from it; nothing about the format is written in the tool.
38
+
22
39
  The plugin's name and id come from the root `manifest.json`. The repository URL
23
40
  comes from `origin`. You supply the category and the tags, because nobody else
24
41
  can: they are an editorial choice about where the plugin belongs.
25
42
 
26
43
  If you do not know which category and tags are allowed, run the command without
27
- them: that is a usage error (exit 2) whose message lists the controlled values,
28
- read from the pinned form, before any check runs. With `--json` the same lists
29
- come back under `usage`.
44
+ them: for you, in a pipe or with `--json`, that is a usage error (exit 2) whose
45
+ message lists the controlled values, read from the pinned form; with `--json`
46
+ the same lists come back under `usage`. A person at a terminal is asked
47
+ instead, once each, with the marketplace's own default for the manifest's
48
+ kinds. It is decided after the registry is read: a plugin that is already
49
+ listed is asked for nothing. A `READY` or `REFUSED` report ends with the
50
+ command line that repeats the run without asking, and `--json` carries it as
51
+ `reproduce`.
52
+
53
+ To see the official baseline result alone, with no Omakit check around it:
54
+
55
+ ```bash
56
+ omakit verify <path-to-the-plugin-repo> --json
57
+ ```
58
+
59
+ `verify` without `--json` prints a report for a person; `--json` is the
60
+ document, unchanged between releases.
30
61
 
31
62
  Useful flags: `--notes` for the Maintainer notes field, `--suggest-tag` for the
32
63
  optional suggestion, `--name` when the manifest has no name, `--json` for a
@@ -41,9 +72,34 @@ HEAD, tell the owner and choose another id.
41
72
 
42
73
  ## Reading the result
43
74
 
44
- Exit code 0 means every blocking check passed and the output contains the issue
45
- title and body. Exit code 1 means it refused, and no body was produced. Exit
46
- code 2 is a usage error: nothing was checked.
75
+ A run ends one of three ways; `--json` carries it as `outcome`.
76
+
77
+ - `READY`, exit 0, `outcome: "ready"`: every blocking check passed and the
78
+ output contains the issue title and body.
79
+ - `REFUSED`, exit 1, `outcome: "refused"`: a blocking check failed and no body
80
+ was produced.
81
+ - `LISTED`, exit 0, `outcome: "listed"`: the plugin is already listed by its
82
+ own repository. Nothing is wrong and nothing was refused, and there is no
83
+ body, because the submission form is not the route. See below.
84
+
85
+ Exit code 2 is a usage error: nothing was checked.
86
+
87
+ **If the outcome is `listed`, stop.** Do not open a submission issue, and
88
+ never change the plugin id to get past it: the id is listed by this very
89
+ repository, and a renamed id would be a second listing of the same plugin.
90
+ Tell the owner the update route, which the output states: the marketplace
91
+ lists `verificationCommit`; the local commit is `localCommit`, and
92
+ `sameCommit` says whether they are the same; to get a newer commit listed,
93
+ open the marketplace's verification form (its name and the choice to pick,
94
+ "Verify and publish a newer upstream commit", are printed from the pin's own
95
+ form, under `listing.updateRoute` in `--json`), and `omakit watch <the
96
+ submission issue>` shows which commit is listed now. A listed plugin is asked
97
+ for no category and no tags, and the five body checks are omitted rather than
98
+ shown as waiting.
99
+
100
+ An id listed by a *different* repository is a refusal, `identity.available`
101
+ names that repository, and the remedy is another id. That is the one case
102
+ where changing the id is the fix.
47
103
 
48
104
  A check drawn as `▒ ?` did not run because one it depends on failed; its detail
49
105
  names that check. It is not a failure of its own, and the closing refusal lists
@@ -29,6 +29,12 @@ push came after a review comment had also commented: engaged, and stuck.
29
29
  omakit watch https://github.com/omacom/omarchy-plugin-marketplace/issues/<number>
30
30
  ```
31
31
 
32
+ If `omakit` is not installed: `npm install --global omakit` (Omarchy ships Node
33
+ and npm through mise), then `omakit doctor`; if the command is not found after
34
+ the install, `"$(npm prefix --global)/bin/omakit" setup` prints the one line
35
+ that puts npm's bin on PATH. `omakit upgrade` keeps it current through npm.
36
+ Add `--json` for a machine-readable result.
37
+
32
38
  Read-only. It does not comment, label, or edit anything, and it cannot. It reads
33
39
  the default branch through the REST API when a credential is available, taking
34
40
  it from the operator's `gh` login (which itself honours `GH_TOKEN` and
@@ -38,7 +44,11 @@ it from the operator's `gh` login (which itself honours `GH_TOKEN` and
38
44
  If the diagnosis ends in a resubmission under a new id, run `omakit submit`
39
45
  online: never pass `--offline` to get around a listed id, because that reads
40
46
  the listed ids from the pin, which is stale within hours, and the marketplace
41
- refuses against its current registry.
47
+ refuses against its current registry. If `omakit submit` ends `LISTED`
48
+ (`outcome: "listed"` in `--json`), the plugin is already listed by its own
49
+ repository: stop, do not change the id, and tell the owner the update route
50
+ the output names, the marketplace's verification form with the choice "Verify
51
+ and publish a newer upstream commit", read from the pin.
42
52
 
43
53
  ## Acting on each verdict
44
54
 
@@ -10,34 +10,36 @@ local commit through the transport seam the marketplace tests itself
10
10
  | `pin.mjs` | The pin identity (one home) and the reproducible setup: `omakit pin` fetches exactly that commit into `$XDG_CACHE_HOME/omakit/marketplace` and refuses a modified checkout. |
11
11
  | `local-transport.mjs` | Answers the four request shapes the official resolver makes, from a local clone at the exact commit. No network, no credentials, no writes. |
12
12
  | `run-baseline.mjs` | Runs the pinned official baseline over either transport and reports the pin identity beside the result. |
13
- | `verify.mjs` | Builds the `marketplaceBaseline` section: pin, transport, assumptions, the official result verbatim, the statement. |
13
+ | `verify.mjs` | Builds the `marketplaceBaseline` section: pin, transport, assumptions, the official result verbatim, the statement. `omakit verify` renders it for a person (`renderVerify` in `report.mjs`) and prints the document itself behind `--json` and `--out`. |
14
14
  | `preflight.mjs` | Translates that result into what it will cause on submission, using the pinned policy, and renders the marketplace's own report text with its attestation marker stripped and asserted absent. |
15
15
  | `yaml.mjs` | A deliberately small YAML reader for the pinned issue form. Accepts that subset and throws on anything else. |
16
- | `form.mjs` | The submission contract, read from the form and cross-checked against the marketplace's own constants. |
17
- | `registry.mjs` | The plugin-id and repository universe: the reserved namespace from the pinned catalog builder, the listed and retired ids and listed repositories from `registry.json` and `site/catalog.json` at the marketplace's current HEAD when the network is there (cached under `$XDG_CACHE_HOME/omakit/registry/<commit>/`, never in the pin) and at the pin with `--offline`; `liveFileUrl()` is the only way to the raw file host, at a 40-character commit, for those two files. |
16
+ | `form.mjs` | The submission contract, read from the form and cross-checked against the marketplace's own constants. Also the route for a plugin that is already listed: the marketplace's verification form and its "newer commit" choice, read from `verify-plugin.yml` at the pin and cross-checked against `plugin-verification-request.mjs`. |
17
+ | `registry.mjs` | The plugin-id and repository universe: the reserved namespace from the pinned catalog builder, the listed and retired ids and listed repositories from `registry.json` and `site/catalog.json` at the marketplace's current HEAD when the network is there (cached under `$XDG_CACHE_HOME/omakit/registry/<commit>/`, never in the pin) and at the pin with `--offline`; `liveFileUrl()` is the only way to the raw file host, at a 40-character commit, for those two files. `sameRepository()` is the one rule for "the subject's own repository" (owner and name, case-insensitively, a trailing `.git` ignored), and `listingOf()` is what the catalog records about a listing: since when, which commit, verified or not, checked when. |
18
18
  | `tree.mjs` | The installable tree of a subject at one exact commit, from the Git object database. |
19
19
  | `plugin.mjs` | The root files the submission contract needs, and the declared plugin identity. |
20
20
  | `agent-control.mjs` | The recursive agent-control warning, and its remedy. |
21
21
  | `issue.mjs` | Renders the issue the way the form would, then has the marketplace's own parser judge it. |
22
- | `submit.mjs` | Assembles every check with its measured reason, and withholds the body when a blocking check fails. |
22
+ | `submit.mjs` | Assembles every check with its measured reason, and withholds the body when a blocking check fails. Three outcomes: `ready` (the body), `refused` (a blocking check failed) and `listed` (the plugin is already listed by its own repository: `identity.available` passes with the listing's record, the five body checks are omitted rather than drawn as waiting, no body exists on purpose, and `listing` carries the listed commit against the local one and the form to use for a newer commit). Decides the category and tags after the registry: a listed plugin, own or taken, is asked for neither; an unlisted one without them is asked through `ask.mjs` at a terminal, and is a usage error otherwise. Ends with `reproduce`, the command line that repeats the run without asking. |
23
+ | `ask.mjs` | The two questions `submit` asks a person at a terminal, and only there: category and tags, numbered from the pinned form, with the marketplace's own presentation for the manifest's kinds (read from the pinned catalog builder) as the default where it is on the list. Prompts on stderr, nothing persisted. |
23
24
  | `watch.mjs` | The validation watch: validated commit versus current default-branch HEAD, and the one action that refreshes it. |
24
25
  | `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. |
25
26
  | `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. `docs/TUI.md` explains it. |
26
- | `report.mjs` | Text rendering of submit, watch and doctor for the agent that runs this tool, and the person reading over its shoulder. |
27
+ | `report.mjs` | Text rendering of submit, watch, doctor and verify for the agent that runs this tool, and the person reading over its shoulder. |
27
28
  | `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. |
28
29
  | `usage.mjs` | The help text, as data. |
29
30
  | `completion.mjs` | A completion script for bash, zsh or fish, derived from the help data and the pin's form: the subcommands and flags are read out of `COMMANDS`, the categories and tags out of the pinned submission form, and the script says which pin it came from. `setup` installs it for the shell in `$SHELL`, the one file this tool writes outside its own checkout. |
30
31
  | `banner.mjs` | The wordmark, on a bare `omakit` and in `setup` only. |
31
32
  | `effect.mjs` | The one text effect: the wordmark through `ttfx` where it is drawn, with frozen arguments, a hard budget, no colour of its own, and nothing at all when `ttfx` is not there. |
32
33
  | `progress.mjs` | The progress line, on stderr, only when a person is looking. |
33
- | `cli.mjs` | The one entry point behind `bin/omakit`, and the one register every failure is reported in. |
34
+ | `cli.mjs` | The one entry point behind `bin/omakit`, and the one register every failure is reported in. `submit` exits on the outcome: 1 for `refused`, 0 for `ready` and `listed`. |
34
35
 
35
36
  ```text
36
37
  omakit pin
38
+ omakit submit /path/to/plugin-repo # asks for the category and tags at a terminal
37
39
  omakit submit /path/to/plugin-repo --category Widgets --tags bar,quickshell
38
40
  omakit submit https://github.com/owner/repo@<40-char sha> --category System --tags system
39
41
  omakit watch https://github.com/omacom/omarchy-plugin-marketplace/issues/4829
40
- omakit verify /path/to/plugin-repo
42
+ omakit verify /path/to/plugin-repo # --json for the document
41
43
  omakit parity --count 30
42
44
  ```
43
45
 
@@ -0,0 +1,140 @@
1
+ // The two questions `omakit submit` asks a person at a terminal, and only
2
+ // there: the category and the tags, which are an editorial choice nobody else
3
+ // can make. Measured before this: `omakit submit <a listed plugin>` exited 2
4
+ // asking for --category and --tags, and would then have refused at
5
+ // identity.available with "nothing to submit"; and an unlisted plugin got the
6
+ // same exit 2 from a person sitting at a terminal who could simply have been
7
+ // asked. So the questions come after the registry, and only when stdin and
8
+ // stdout are both terminals and --json is absent. An agent, a pipe or --json
9
+ // gets the usage error, unchanged.
10
+ //
11
+ // The prompt goes to stderr, so stdout stays the report. The default offered
12
+ // is the marketplace's own presentation for the manifest's kinds, read from
13
+ // the pinned catalog builder (registry.mjs catalogPresentation), and offered
14
+ // only when it is on the form's list. Nothing typed here is written anywhere:
15
+ // the report ends with the command line that repeats the run without asking.
16
+
17
+ import { createInterface } from "node:readline"
18
+ import { colourEnabled, STEP, action, styler, wrap } from "./style.mjs"
19
+ import { resolveCategory, resolveTags } from "./form.mjs"
20
+
21
+ export class AskError extends Error {
22
+ constructor(message) {
23
+ super(message)
24
+ this.name = "AskError"
25
+ this.code = "usage"
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Lines from stdin, one at a time, whether they arrive as they are typed or
31
+ * were all there before the first question (a scripted run). readline emits
32
+ * every buffered line at once, so they are queued rather than dropped; the
33
+ * end of stdin answers null.
34
+ */
35
+ function reader(input) {
36
+ const rl = createInterface({ input, terminal: false })
37
+ const pending = []
38
+ const waiting = []
39
+ let closed = false
40
+ rl.on("line", (line) => (waiting.length ? waiting.shift()(line) : pending.push(line)))
41
+ rl.on("close", () => {
42
+ closed = true
43
+ while (waiting.length) waiting.shift()(null)
44
+ })
45
+ return {
46
+ next: () => (pending.length ? Promise.resolve(pending.shift()) : closed ? Promise.resolve(null) : new Promise((resolve) => waiting.push(resolve))),
47
+ close: () => rl.close(),
48
+ }
49
+ }
50
+
51
+ /** 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
+ const step = " ".repeat(STEP)
54
+ output.write(`${wrap(heading, {}, c).join("\n")}\n`)
55
+ for (const [index, option] of options.entries()) {
56
+ output.write(`${step}${c("typeable", String(index + 1).padStart(2))} ${option}\n`)
57
+ }
58
+ const fallback = defaultIndexes.length ? defaultIndexes.map((index) => index + 1).join(",") : null
59
+ const prompt = `${action(fallback ? `${name} [${fallback}]:` : `${name}:`, c, { indent: 0 })[0]} `
60
+ for (;;) {
61
+ output.write(prompt)
62
+ const raw = await lines.next()
63
+ if (raw === null) {
64
+ output.write("\n")
65
+ throw new AskError(`stdin ended before the ${name} was answered; pass --${name} on the command line`)
66
+ }
67
+ const text = raw.trim() || (fallback ?? "")
68
+ const parsed = parse(text)
69
+ if (parsed.ok) return parsed.value
70
+ output.write(`${wrap(parsed.reason, {}, c).join("\n")}\n`)
71
+ }
72
+ }
73
+
74
+ /** A comma-separated answer of numbers or names, resolved against a list. */
75
+ function pick(text, options) {
76
+ const parts = String(text).split(",").map((part) => part.trim()).filter(Boolean)
77
+ const chosen = []
78
+ for (const part of parts) {
79
+ const number = /^\d+$/.test(part) ? Number(part) : null
80
+ const option = number !== null
81
+ ? options[number - 1]
82
+ : options.find((candidate) => candidate.toLowerCase() === part.toLowerCase())
83
+ if (!option) return { ok: false, reason: `"${part}" is not a number from 1 to ${options.length}, nor one of the names.` }
84
+ if (!chosen.includes(option)) chosen.push(option)
85
+ }
86
+ return { ok: true, value: chosen }
87
+ }
88
+
89
+ /**
90
+ * @param {{ contract: object, defaults: { category: string|null, tags: string[]|null }, missing: string[],
91
+ * input?: NodeJS.ReadStream, output?: NodeJS.WriteStream, colour?: boolean }} options
92
+ * `defaults` are the marketplace's own presentation for the manifest, offered
93
+ * only where they are on the form's list. `missing` names which of
94
+ * `--category` and `--tags` to ask for.
95
+ * @returns {Promise<{ category?: string, tags?: string[] }>}
96
+ */
97
+ export async function askChoices({ contract, defaults, missing, input = process.stdin, output = process.stderr, colour = colourEnabled(output) }) {
98
+ const c = styler(colour)
99
+ const lines = reader(input)
100
+ const answers = {}
101
+ try {
102
+ if (missing.includes("--category")) {
103
+ const known = defaults.category ? resolveCategory(contract, defaults.category) : { ok: false }
104
+ const index = known.ok ? contract.categories.indexOf(known.value) : -1
105
+ answers.category = await question(lines, output, c, {
106
+ name: "category",
107
+ heading: `category: one of the form's ${contract.categories.length}${index >= 0 ? `; the marketplace's own choice for this manifest's kinds is ${known.value}` : ""}`,
108
+ options: contract.categories,
109
+ defaultIndexes: index >= 0 ? [index] : [],
110
+ parse: (text) => {
111
+ if (!text) return { ok: false, reason: `There is no default for this manifest; answer with a number from 1 to ${contract.categories.length}.` }
112
+ const picked = pick(text, contract.categories)
113
+ if (!picked.ok) return picked
114
+ if (picked.value.length !== 1) return { ok: false, reason: "Exactly one category." }
115
+ return { ok: true, value: picked.value[0] }
116
+ },
117
+ })
118
+ }
119
+ if (missing.includes("--tags")) {
120
+ const known = defaults.tags?.length ? resolveTags(contract, defaults.tags.filter((tag) => resolveTags(contract, [tag]).ok)) : { ok: false }
121
+ const indexes = known.ok ? known.value.map((label) => contract.tagLabels.indexOf(label)) : []
122
+ answers.tags = await question(lines, output, c, {
123
+ name: "tags",
124
+ heading: `tags: 1 to ${contract.maximumTags} of the form's ${contract.tagLabels.length}, comma-separated${indexes.length ? `; the marketplace's own choice for this manifest's kinds is ${known.value.join(", ")}` : ""}`,
125
+ options: contract.tagLabels,
126
+ defaultIndexes: indexes,
127
+ parse: (text) => {
128
+ if (!text) return { ok: false, reason: `There is no default for this manifest; answer with 1 to ${contract.maximumTags} numbers, comma-separated.` }
129
+ const picked = pick(text, contract.tagLabels)
130
+ if (!picked.ok) return picked
131
+ const resolved = resolveTags(contract, picked.value)
132
+ return resolved.ok ? { ok: true, value: resolved.value } : { ok: false, reason: `${resolved.reason}.` }
133
+ },
134
+ })
135
+ }
136
+ } finally {
137
+ lines.close()
138
+ }
139
+ return answers
140
+ }
@@ -14,13 +14,14 @@
14
14
  import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
15
15
  import { dirname, resolve } from "node:path"
16
16
  import { fileURLToPath } from "node:url"
17
- import { ensurePin, MARKETPLACE_PIN } from "./pin.mjs"
17
+ import { ensurePin, MARKETPLACE_PIN, requirePin } from "./pin.mjs"
18
18
  import { marketplaceBaselineSection } from "./verify.mjs"
19
19
  import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
20
- import { missingSubmitFlags, submitPreflight } from "./submit.mjs"
21
- import { submissionContract } from "./form.mjs"
20
+ import { submitPreflight } from "./submit.mjs"
21
+ import { askChoices } from "./ask.mjs"
22
22
  import { validationWatch } from "./watch.mjs"
23
- import { renderSubmit, renderWatch, renderDoctor } from "./report.mjs"
23
+ import { renderSubmit, renderWatch, renderDoctor, renderVerify } from "./report.mjs"
24
+ import { consequence } from "./preflight.mjs"
24
25
  import { doctor } from "./doctor.mjs"
25
26
  import { setup } from "./setup.mjs"
26
27
  import { upgrade } from "./upgrade.mjs"
@@ -98,29 +99,20 @@ function emit(args, text) {
98
99
  async function cmdSubmit(args) {
99
100
  const target = positionals(args)[0]
100
101
  if (!target) fail("usage", "submit needs a target: `omakit submit <target> --category <c> --tags <a,b>`", 2)
101
- // A missing category or tag list is a usage error, decided before any check
102
- // runs and before the spinner starts: nothing can be rendered without them,
103
- // and the values the form accepts are the answer, read from the pin.
104
- let usage = null
105
- try {
106
- usage = missingSubmitFlags(await submissionContract({ repoRoot: ROOT }), { category: option(args, "--category"), tags: option(args, "--tags") })
107
- } catch (error) {
108
- failFrom(error)
109
- }
110
- if (usage) {
111
- if (args.includes("--json")) {
112
- process.stdout.write(`${JSON.stringify({ usage }, null, 2)}\n`)
113
- process.exit(2)
102
+ const json = args.includes("--json")
103
+ const spinner = json ? { phase: () => {}, done: () => {} } : progress()
104
+ // A missing --category or --tags on an unlisted plugin is asked for, once
105
+ // each, when a person is at a terminal on both ends and no machine is
106
+ // reading the result. Anything else, a pipe, an agent, --json, gets the
107
+ // usage error with the form's lists, exit 2. Decided after the registry:
108
+ // a listed plugin is never asked for a choice that does not matter.
109
+ const interactive = !json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)
110
+ const chooser = interactive
111
+ ? async (asked) => {
112
+ spinner.done()
113
+ return askChoices({ ...asked, input: process.stdin, output: process.stderr })
114
114
  }
115
- const flags = usage.missing.join(" and ")
116
- 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,
117
- `omakit submit ${target} --category <c> --tags <a,b>`,
118
- (c) => [
119
- ...labelled("categories", usage.categories.join(", "), c),
120
- ...labelled(`tags, 1 to ${usage.maximumTags}`, usage.tags.join(", "), c),
121
- ])
122
- }
123
- const spinner = args.includes("--json") ? { phase: () => {}, done: () => {} } : progress()
115
+ : undefined
124
116
  let result
125
117
  try {
126
118
  result = await submitPreflight({
@@ -134,14 +126,31 @@ async function cmdSubmit(args) {
134
126
  pluginName: option(args, "--name"),
135
127
  allowDirty: args.includes("--allow-dirty"),
136
128
  offline: args.includes("--offline"),
129
+ chooser,
137
130
  })
138
131
  } catch (error) {
139
132
  spinner.done()
133
+ if (error?.code === "usage" && error.usage) {
134
+ const usage = error.usage
135
+ if (json) {
136
+ process.stdout.write(`${JSON.stringify({ usage }, null, 2)}\n`)
137
+ process.exit(2)
138
+ }
139
+ const flags = usage.missing.join(" and ")
140
+ 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,
141
+ `omakit submit ${target} --category <c> --tags <a,b>`,
142
+ (c) => [
143
+ ...labelled("categories", usage.categories.join(", "), c),
144
+ ...labelled(`tags, 1 to ${usage.maximumTags}`, usage.tags.join(", "), c),
145
+ ])
146
+ }
140
147
  failFrom(error)
141
148
  }
142
149
  spinner.done()
143
150
  emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderSubmit(result))
144
- process.exit(result.ready ? 0 : 1)
151
+ // Three outcomes, two exit codes: `ready` and `listed` are both healthy
152
+ // states, and only a refusal is a 1.
153
+ process.exit(result.outcome === "refused" ? 1 : 0)
145
154
  }
146
155
 
147
156
  async function cmdWatch(args) {
@@ -207,7 +216,7 @@ async function cmdVerify(args) {
207
216
  fail(error?.code === "marketplace-unavailable" ? error.code : "baseline-unavailable", error.message)
208
217
  }
209
218
  spinner.done()
210
- emit(args, `${JSON.stringify({
219
+ const document = {
211
220
  subject: {
212
221
  repository: subject.repository,
213
222
  commit: subject.commit,
@@ -215,7 +224,18 @@ async function cmdVerify(args) {
215
224
  mode: subject.mode,
216
225
  },
217
226
  marketplaceBaseline: section,
218
- }, null, 2)}\n`)
227
+ }
228
+ // The JSON is the document itself, byte for byte what verify always
229
+ // printed, for --json and for --out; a person at the terminal gets the
230
+ // report, in the register submit uses for its checks.
231
+ if (args.includes("--json") || option(args, "--out")) {
232
+ emit(args, `${JSON.stringify(document, null, 2)}\n`)
233
+ return
234
+ }
235
+ const blockingRules = section.invoked && section.official && !section.official.error
236
+ ? (await consequence(requirePin(ROOT).dir, section.official)).selectivelyBlockingRules
237
+ : []
238
+ emit(args, renderVerify(document, { blockingRules }))
219
239
  }
220
240
 
221
241
  async function cmdParity(args) {
@@ -27,6 +27,8 @@ import { requirePin } from "./pin.mjs"
27
27
 
28
28
  export const SUBMIT_FORM_PATH = ".github/ISSUE_TEMPLATE/submit-plugin.yml"
29
29
  export const OFFICIAL_SUBMISSION_MODULE = "scripts/submission.mjs"
30
+ export const VERIFY_FORM_PATH = ".github/ISSUE_TEMPLATE/verify-plugin.yml"
31
+ export const OFFICIAL_VERIFICATION_MODULE = "scripts/plugin-verification-request.mjs"
30
32
 
31
33
  export class ContractError extends Error {
32
34
  constructor(code, message) {
@@ -50,8 +52,8 @@ function loadOfficial(pinDir) {
50
52
  return import(pathToFileURL(join(pinDir, OFFICIAL_SUBMISSION_MODULE)).href)
51
53
  }
52
54
 
53
- function fieldsOf(form) {
54
- if (!Array.isArray(form?.body)) throw new ContractError("form-unreadable", `${SUBMIT_FORM_PATH} has no body`)
55
+ function fieldsOf(form, formPath = SUBMIT_FORM_PATH) {
56
+ if (!Array.isArray(form?.body)) throw new ContractError("form-unreadable", `${formPath} has no body`)
55
57
  return form.body
56
58
  .filter((item) => item && item.type !== "markdown")
57
59
  .map((item) => ({
@@ -65,12 +67,12 @@ function fieldsOf(form) {
65
67
  }))
66
68
  }
67
69
 
68
- function only(fields, predicate, what) {
70
+ function only(fields, predicate, what, formPath = SUBMIT_FORM_PATH) {
69
71
  const found = fields.filter(predicate)
70
72
  if (found.length !== 1) {
71
73
  throw new ContractError(
72
74
  "form-shape-changed",
73
- `${SUBMIT_FORM_PATH} no longer has exactly one ${what} (found ${found.length}); the pin changed shape and the submission contract must be re-read before anything is generated`,
75
+ `${formPath} no longer has exactly one ${what} (found ${found.length}); the pin changed shape and the submission contract must be re-read before anything is generated`,
74
76
  )
75
77
  }
76
78
  return found[0]
@@ -135,6 +137,36 @@ export async function submissionContract(options = {}) {
135
137
  return contract
136
138
  }
137
139
 
140
+ /**
141
+ * The route for a plugin that is already listed: the marketplace's other
142
+ * form, and the one choice on it that lists a newer commit. Both read from
143
+ * the pin. The choice text is the option as the form spells it, found by the
144
+ * name the marketplace's own verification module gives that action; the two
145
+ * must agree, the way the submission form and `scripts/submission.mjs` must,
146
+ * because a choice retyped here would drift by a word and send a person to
147
+ * pick something the form no longer offers.
148
+ *
149
+ * @param {{ repoRoot?: string, pinDir?: string }} [options]
150
+ * @returns {Promise<{ formPath: string, name: string, choice: string }>}
151
+ */
152
+ export async function newerCommitChoice(options = {}) {
153
+ const pinDir = options.pinDir || requirePin(options.repoRoot).dir
154
+ const form = parseYaml(readFileSync(join(pinDir, VERIFY_FORM_PATH), "utf8"))
155
+ const fields = fieldsOf(form, VERIFY_FORM_PATH)
156
+ const action = only(fields, (f) => f.type === "dropdown" && !f.multiple && f.required, "required single-select dropdown (verification action)", VERIFY_FORM_PATH)
157
+ const official = await import(pathToFileURL(join(pinDir, OFFICIAL_VERIFICATION_MODULE)).href)
158
+ const choice = (action.options || []).find((option) => option === official.upstreamUpdateVerificationAction)
159
+ if (!choice) {
160
+ throw new ContractError(
161
+ "form-shape-changed",
162
+ `${VERIFY_FORM_PATH} offers no "${official.upstreamUpdateVerificationAction}" under ${action.label}; the form and ${OFFICIAL_VERIFICATION_MODULE} at the pin disagree, and nobody is sent to a choice that is not there`,
163
+ )
164
+ }
165
+ const name = typeof form.name === "string" ? form.name.trim() : ""
166
+ if (!name) throw new ContractError("form-shape-changed", `${VERIFY_FORM_PATH} has no name`)
167
+ return { formPath: VERIFY_FORM_PATH, name, choice }
168
+ }
169
+
138
170
  /**
139
171
  * The form and the marketplace's own constants must describe the same
140
172
  * submission. Anything else means the pin is internally inconsistent, and
@@ -71,12 +71,68 @@ export function reservedNamespace(pinDir) {
71
71
  return match[1].endsWith(".") ? match[1] : `${match[1]}.`
72
72
  }
73
73
 
74
+ /**
75
+ * How the marketplace itself presents a plugin it lists, read out of the
76
+ * pinned `build-catalog.mjs` rather than copied: `categoryFor(kinds)` maps the
77
+ * manifest's kinds to a category in order of its `if` lines, with a fallback,
78
+ * and the tags are the first three kinds lowercased. `omakit submit` offers
79
+ * these as the default answer when it has to ask for a category and tags, so
80
+ * the default is the marketplace's own choice; a mapping that cannot be read
81
+ * offers no default. tests/unit/registry-figures.test.mjs pins what the
82
+ * mapping is at this commit, so a marketplace that changes it fails the suite
83
+ * until the docs follow.
84
+ *
85
+ * @returns {{ rules: Array<{ kinds: string[], category: string }>, fallback: string|null, tagsFromKinds: boolean }}
86
+ */
87
+ export function catalogPresentation(pinDir) {
88
+ const source = readFileSync(join(pinDir, CATALOG_BUILDER_PATH), "utf8")
89
+ const body = source.match(/function categoryFor\([^)]*\)\s*\{([\s\S]*?)\n\}/)?.[1] || ""
90
+ const rules = [...body.matchAll(/if \(((?:kinds\.includes\("[^"]+"\)(?:\s*\|\|\s*)?)+)\) return "([^"]+)";/g)]
91
+ .map((match) => ({ kinds: [...match[1].matchAll(/"([^"]+)"/g)].map((kind) => kind[1]), category: match[2] }))
92
+ const fallback = body.match(/\n\s*return "([^"]+)";\s*$/)?.[1] || null
93
+ const tagsFromKinds = /tags:\s*kinds\.slice\(0,\s*3\)\.map\(\(kind\) => kind\.toLowerCase\(\)\)/.test(source)
94
+ return { rules, fallback, tagsFromKinds }
95
+ }
96
+
97
+ /**
98
+ * The marketplace's own presentation for a manifest's kinds: the category its
99
+ * rules pick and the tags it would derive. Null where the mapping was not
100
+ * readable, so nothing is offered rather than something guessed.
101
+ */
102
+ export function defaultPresentation(presentation, kinds = []) {
103
+ const list = Array.isArray(kinds) ? kinds.filter((kind) => typeof kind === "string") : []
104
+ const rule = presentation.rules.find((candidate) => candidate.kinds.some((kind) => list.includes(kind)))
105
+ const category = presentation.rules.length && presentation.fallback ? (rule ? rule.category : presentation.fallback) : null
106
+ const tags = presentation.tagsFromKinds ? list.slice(0, 3).map((kind) => kind.toLowerCase()) : null
107
+ return { category, tags }
108
+ }
109
+
110
+ /**
111
+ * "https://github.com/Owner/Name.git" and "owner/name" both become
112
+ * "owner/name": the path, without surrounding slashes, without a trailing
113
+ * .git, lowercased. Anything else is "", which matches nothing.
114
+ */
74
115
  function repositorySlug(value) {
116
+ const text = String(value ?? "").trim()
117
+ let path
75
118
  try {
76
- return new URL(String(value)).pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLowerCase()
119
+ path = new URL(text).pathname
77
120
  } catch {
78
- return ""
121
+ path = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(text) ? text : ""
79
122
  }
123
+ return path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLowerCase()
124
+ }
125
+
126
+ /**
127
+ * The one rule for "the same repository": owner and name, compared
128
+ * case-insensitively, with a trailing .git ignored. Measured on 0.1.6: the
129
+ * author's own listed plugin was rendered as a failed check because the
130
+ * listing and the subject were compared as two strings, so this is the rule
131
+ * and its test, not a comparison typed where it is needed.
132
+ */
133
+ export function sameRepository(a, b) {
134
+ const slug = repositorySlug(a)
135
+ return Boolean(slug) && slug === repositorySlug(b)
80
136
  }
81
137
 
82
138
  /**
@@ -364,11 +420,14 @@ export function figure(n) {
364
420
 
365
421
  /**
366
422
  * @param {{ id: string, repositoryUrl?: string|null }} subject
367
- * @returns {{ ok: boolean, problems: Array<{ code: string, detail: string, repository?: string|null, sameRepository?: boolean }> }}
423
+ * @returns {{ ok: boolean, own: boolean, problems: Array<{ code: string, detail: string, repository?: string|null, sameRepository?: boolean }> }}
368
424
  * A `plugin-id-listed` problem names the repository that lists the id
369
425
  * (`repository`, a slug, or null when the registry does not say) and
370
426
  * whether that is the subject's own (`sameRepository`), because the two
371
- * have different remedies: another id, or nothing to submit at all.
427
+ * are different states: another id is needed, or the plugin is listed and
428
+ * there is nothing to submit. `own` is the second state on its own: the id
429
+ * is listed by this repository and no other code fired, so `ok` is false
430
+ * and nothing is wrong.
372
431
  */
373
432
  export function checkIdentity(universe, subject) {
374
433
  const problems = []
@@ -389,18 +448,64 @@ export function checkIdentity(universe, subject) {
389
448
  }
390
449
  if (universe.listedIds.has(id)) {
391
450
  const repository = universe.listedBy?.get(id) || null
392
- const sameRepository = Boolean(repository && slug && repository === slug)
393
451
  problems.push({
394
452
  code: "plugin-id-listed",
395
453
  detail: `"${id}" is already listed${repository ? ` by ${repository}` : ""}`,
396
454
  repository,
397
- sameRepository,
455
+ sameRepository: sameRepository(repository, slug),
398
456
  })
399
457
  }
400
458
  if (slug && universe.listedRepositories.has(slug)) {
401
459
  problems.push({ code: "submission-repository-listed", detail: `${slug} is already listed`, repository: slug, sameRepository: true })
402
460
  }
403
- return { ok: problems.length === 0, problems }
461
+ // The subject's own listing: the id is listed, by this repository, and
462
+ // nothing else is wrong with the id. That is not a problem with the
463
+ // submission, it is the absence of one; the caller reports it as a state.
464
+ const own = problems.length > 0
465
+ && problems.some((problem) => problem.code === "plugin-id-listed" && problem.sameRepository)
466
+ && problems.every((problem) => problem.sameRepository)
467
+ return { ok: problems.length === 0, own, problems }
468
+ }
469
+
470
+ /**
471
+ * What the marketplace records about one listing, for the plugin that is
472
+ * already listed by its own repository: read from the catalog first, which
473
+ * carries the verification fields, and from the registry source when the
474
+ * catalog has no entry. Every field is null when nothing records it; nothing
475
+ * is guessed.
476
+ *
477
+ * @param {{ registry: object, catalog: object }} live
478
+ * @param {string} id
479
+ * @returns {{ repository: string|null, id: string, addedAt: string|null, verificationCommit: string|null,
480
+ * verificationStatus: string|null, verificationCheckedAt: string|null }|null}
481
+ */
482
+ export function listingOf(live, id) {
483
+ const text = (value) => (typeof value === "string" && value ? value : null)
484
+ const plugin = (Array.isArray(live.catalog?.plugins) ? live.catalog.plugins : []).find((entry) => entry?.id === id)
485
+ if (plugin) {
486
+ return {
487
+ repository: text(plugin.repo),
488
+ id,
489
+ addedAt: text(plugin.addedAt) || text(plugin.listedAt),
490
+ verificationCommit: text(plugin.verificationCommit)?.toLowerCase() || text(plugin.listingValidatedCommit)?.toLowerCase() || null,
491
+ verificationStatus: text(plugin.verificationStatus),
492
+ verificationCheckedAt: text(plugin.verificationCheckedAt) || text(plugin.listingValidatedAt),
493
+ }
494
+ }
495
+ const sources = Array.isArray(live.registry?.sources) ? live.registry.sources : Object.values(live.registry?.sources || {})
496
+ const source = sources.find((entry) => {
497
+ const ids = entry?.plugins && typeof entry.plugins === "object" && !Array.isArray(entry.plugins) ? Object.keys(entry.plugins) : []
498
+ return ids.includes(id) || (entry?.automatedSecurityBaseline?.pluginIds || []).includes(id)
499
+ })
500
+ if (!source) return null
501
+ return {
502
+ repository: text(source.repo),
503
+ id,
504
+ addedAt: text(source.addedAt) || text(source.listedAt),
505
+ verificationCommit: text(source.listingValidatedCommit)?.toLowerCase() || null,
506
+ verificationStatus: text(source.automatedSecurityBaseline?.outcome),
507
+ verificationCheckedAt: text(source.listingValidatedAt),
508
+ }
404
509
  }
405
510
 
406
511
  export { repositorySlug }
@@ -48,12 +48,7 @@ function checkBlock(check, c) {
48
48
  if (check.detail) out.push(...wrap(check.detail, { indent: GUTTER }, c))
49
49
  if (check.verdict === "fail") {
50
50
  // A path is the thing at fault, so it is yellow; its reason wraps under it.
51
- for (const path of check.paths) {
52
- out.push(...wrap(`- ${path}`, { indent: GUTTER + STEP, first: GUTTER })
53
- .map((line, index) => (index === 0
54
- ? `${body}${c("fail", "-")} ${c("placeholder", line.trimStart().slice(2))}`
55
- : `${" ".repeat(GUTTER + STEP)}${c("placeholder", line.trimStart())}`)))
56
- }
51
+ out.push(...pathLines(check.paths, c))
57
52
  for (const remedy of [].concat(check.remedy || [])) out.push(...action(remedy, c))
58
53
  // The measured reason is the point of the check, so it is not dimmed: only
59
54
  // its label is grey, and on a low-contrast theme the number still reads.
@@ -62,6 +57,42 @@ function checkBlock(check, c) {
62
57
  return out
63
58
  }
64
59
 
60
+ /**
61
+ * A shell command as an arrow, broken before a flag when it would not fit:
62
+ * a command is not prose, and a line break inside it is only valid with a
63
+ * backslash, the way the issue-creating example at the end of the report
64
+ * is printed.
65
+ */
66
+ function commandLines(command, c) {
67
+ // A flag and its value travel together; a quoted value is one word.
68
+ const words = String(command).match(/"(?:[^"\\]|\\.)*"|\S+/g) || []
69
+ const units = []
70
+ for (const word of words) {
71
+ if (units.length && units[units.length - 1].startsWith("--") && !units[units.length - 1].includes(" ") && !word.startsWith("--")) {
72
+ units[units.length - 1] += ` ${word}`
73
+ } else {
74
+ units.push(word)
75
+ }
76
+ }
77
+ const room = COLUMNS - STEP - " \\".length
78
+ const lines = []
79
+ let line = ""
80
+ for (const unit of units) {
81
+ const next = line ? `${line} ${unit}` : unit
82
+ if (line && next.length > room) {
83
+ lines.push(line)
84
+ line = unit
85
+ } else {
86
+ line = next
87
+ }
88
+ }
89
+ lines.push(line)
90
+ const step = " ".repeat(STEP)
91
+ return lines.flatMap((text, index) => (index === 0
92
+ ? action(`${text}${lines.length > 1 ? " \\" : ""}`, c, { indent: 0 })
93
+ : [`${step}${c("typeable", `${text}${index < lines.length - 1 ? " \\" : ""}`)}`]))
94
+ }
95
+
65
96
  export function renderSubmit(result, { colour = colourEnabled() } = {}) {
66
97
  const c = styler(colour)
67
98
  const out = []
@@ -91,7 +122,26 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
91
122
  out.push("")
92
123
  }
93
124
 
94
- if (!result.ready) {
125
+ if (result.outcome === "listed") {
126
+ // The third outcome. Nothing was refused and nothing is wrong: the plugin
127
+ // is listed by this repository, so there is no body and no reproduce
128
+ // line, and the route to a newer commit is the marketplace's other form.
129
+ // Measured on 0.1.6: this state was drawn as FAIL and REFUSED, and closed
130
+ // with "Fix it, then run submit again" under a remedy that said there was
131
+ // nothing to submit.
132
+ const listing = result.listing
133
+ out.push(...verdict("pass", "LISTED", `${listing.id} is already listed by this repository, so the submission form is not the route.`, c))
134
+ out.push("")
135
+ out.push(...field("listed", listing.verificationCommit ? c("name", listing.verificationCommit) : "no verification commit recorded", c, { wrapValue: false }))
136
+ out.push(...continuation(`${listing.verificationStatus || "status unrecorded"}, checked ${listing.verificationCheckedAt || "at an unrecorded time"}, read from the ${listing.source === "head" ? "marketplace's current HEAD" : "pin"}`, c))
137
+ out.push(...field("local HEAD", c("name", listing.localCommit), c, { wrapValue: false }))
138
+ out.push(...continuation(listing.sameCommit ? "the same commit" : "not the listed commit", c))
139
+ out.push("")
140
+ out.push(...wrap(`${listing.sameCommit ? "To get a newer commit listed later" : "To get it listed"}, open the marketplace's "${listing.updateRoute.form}" form and choose "${listing.updateRoute.choice}". \`omakit watch <the submission issue>\` shows which commit is listed now.`, {}, c))
141
+ return out.join("\n")
142
+ }
143
+
144
+ if (result.outcome === "refused") {
95
145
  // Root causes only: a check that waited on a failed one is not listed,
96
146
  // and the count says how many waited.
97
147
  const failed = result.checks.filter((check) => result.blocking.includes(check.id))
@@ -106,7 +156,12 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
106
156
  else out.push(...wrap(check.detail, { indent: GUTTER }, c))
107
157
  out.push("")
108
158
  }
109
- out.push(failed.length === 1 ? "Fix it, then run submit again." : "Fix them, then run submit again.")
159
+ if (result.reproduce) {
160
+ out.push(failed.length === 1 ? "Fix it, then run submit again:" : "Fix them, then run submit again:")
161
+ out.push(...commandLines(result.reproduce, c))
162
+ } else {
163
+ out.push(failed.length === 1 ? "Fix it, then run submit again." : "Fix them, then run submit again.")
164
+ }
110
165
  return out.join("\n")
111
166
  }
112
167
 
@@ -131,6 +186,11 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
131
186
  out.push(`${step}${c("typeable", "--body-file <the body above>")}`)
132
187
  out.push("")
133
188
  out.push(...wrap(`After it is created: ${result.afterSubmitting}`, {}, c))
189
+ if (result.reproduce) {
190
+ out.push("")
191
+ out.push("The same run, without prompting:")
192
+ out.push(...commandLines(result.reproduce, c))
193
+ }
134
194
  return out.join("\n")
135
195
  }
136
196
 
@@ -176,6 +236,79 @@ export function renderWatch(result, { colour = colourEnabled() } = {}) {
176
236
  return out.join("\n")
177
237
  }
178
238
 
239
+ /** A list of paths at fault, the way a failing check prints them: a tinted dash, the path in the placeholder tint. */
240
+ function pathLines(paths, c, dash = "fail") {
241
+ return paths.flatMap((path) => wrap(`- ${path}`, { indent: GUTTER + STEP, first: GUTTER })
242
+ .map((line, index) => (index === 0
243
+ ? `${body}${c(dash, "-")} ${c("placeholder", line.trimStart().slice(2))}`
244
+ : `${" ".repeat(GUTTER + STEP)}${c("placeholder", line.trimStart())}`)))
245
+ }
246
+
247
+ /**
248
+ * `omakit verify` for a person: the subject, the pin, the transport and its
249
+ * assumptions, then the official result in the register submit uses for its
250
+ * checks, and the statement last. Nothing here is Omakit's judgement: the
251
+ * outcome, the disposition, each finding's title, reason and actions, and a
252
+ * refusal's message are the marketplace's own text verbatim; the one thing
253
+ * added is the tag on each finding, blocks publication or review-required,
254
+ * which is read from the pinned policy's selectively blocking rules
255
+ * (`blockingRules`). `--json` prints the document itself.
256
+ */
257
+ export function renderVerify(document, { colour = colourEnabled(), blockingRules = [] } = {}) {
258
+ const c = styler(colour)
259
+ const out = []
260
+ const { subject, marketplaceBaseline: section } = document
261
+ const tree = subject.cleanTree?.clean
262
+ ? `clean tree, proof ${subject.cleanTree.proof}`
263
+ : c("advisory", "dirty worktree")
264
+ out.push(...field("subject", subject.repository?.url || "no declared GitHub repository URL", c, { wrapValue: false }))
265
+ out.push(...field("commit", subject.commit, c, { wrapValue: false }))
266
+ out.push(...continuation(`${tree}, ${subject.mode} mode`, c))
267
+ out.push(...field("marketplace", `${section.pin.commit}, baseline ${section.pin.baselineVersion}, ${section.pin.enforcementMode}`, c))
268
+ out.push(...field("transport", section.transport, c))
269
+ if (section.assumedByAdapter?.length) out.push(...field("assumed", section.assumedByAdapter.join(", "), c))
270
+ out.push("")
271
+
272
+ const official = section.official
273
+ if (!section.invoked) {
274
+ out.push(`${head("unknown", "not run", "marketplace-pin", c)}`)
275
+ out.push(...wrap(section.skipReason || "the official baseline was not invoked", { indent: GUTTER }, c))
276
+ } else if (official?.error) {
277
+ // The official code refused the snapshot: that refusal is the result.
278
+ out.push(`${head("fail", official.error.code, "marketplace-pin", c)}`)
279
+ out.push(...wrap(official.error.message, { indent: GUTTER }, c))
280
+ for (const [key, value] of Object.entries(official.error)) {
281
+ if (key === "code" || key === "message") continue
282
+ out.push(...wrap(`- ${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`, { indent: GUTTER + STEP, first: GUTTER }, c))
283
+ }
284
+ } else {
285
+ const findings = official.findings || []
286
+ const capabilities = official.capabilities || []
287
+ const state = official.outcome === "passed" ? "pass" : official.blocksApproval ? "fail" : "advisory"
288
+ out.push(`${head(state, official.outcome, "marketplace-pin", c)}`)
289
+ out.push(...wrap(official.outcome === "passed"
290
+ ? "no findings and no capabilities"
291
+ : `disposition ${official.disposition}, enforcement ${official.enforcementMode}, blocksApproval ${official.blocksApproval}`, { indent: GUTTER }, c))
292
+ for (const finding of findings) {
293
+ const blocks = blockingRules.includes(finding.ruleId)
294
+ out.push("")
295
+ out.push(head(blocks ? "fail" : "advisory", finding.ruleId, blocks ? "blocks publication" : "review-required", c))
296
+ out.push(...wrap([finding.title, finding.why].filter(Boolean).join(". ").replace(/\.\.\s/g, ". "), { indent: GUTTER }, c))
297
+ out.push(...pathLines((finding.evidence || []).map((entry) => `${entry.path}:${entry.line}`), c, blocks ? "fail" : "advisory"))
298
+ for (const remedy of finding.actions || []) out.push(...action(remedy, c))
299
+ }
300
+ for (const capability of capabilities) {
301
+ out.push("")
302
+ out.push(head("info", capability.id, "capability", c))
303
+ out.push(...wrap([capability.title, capability.why].filter(Boolean).join(". ").replace(/\.\.\s/g, ". "), { indent: GUTTER }, c))
304
+ out.push(...pathLines((capability.evidence || []).map((entry) => `${entry.path}:${entry.line}`), c, "info"))
305
+ }
306
+ }
307
+ out.push("")
308
+ out.push(...wrap(section.statement, {}, c))
309
+ return out.join("\n")
310
+ }
311
+
179
312
  const DOCTOR_STATE = { ok: "pass", advice: "advisory", problem: "fail", info: "info", unknown: "unknown" }
180
313
 
181
314
  export function renderDoctor(result, { colour = colourEnabled() } = {}) {
@@ -13,8 +13,8 @@
13
13
 
14
14
  import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
15
15
  import { requirePin } from "./pin.mjs"
16
- import { submissionContract, resolveCategory, resolveTags } from "./form.mjs"
17
- import { idUniverse, checkIdentity, baselineFigures, figure, liveRegistry, registrySourceDetail } from "./registry.mjs"
16
+ import { submissionContract, newerCommitChoice, resolveCategory, resolveTags, tagSlug } from "./form.mjs"
17
+ import { idUniverse, checkIdentity, listingOf, baselineFigures, figure, liveRegistry, registrySourceDetail, catalogPresentation, defaultPresentation } from "./registry.mjs"
18
18
  import { readTree } from "./tree.mjs"
19
19
  import { inspectTree } from "./plugin.mjs"
20
20
  import { findAgentControl, REMEDY as AGENT_CONTROL_REMEDY } from "./agent-control.mjs"
@@ -23,26 +23,16 @@ import { renderIssue, verifyAgainstOfficialParser } from "./issue.mjs"
23
23
  import { defaultBranchHead } from "./github.mjs"
24
24
  import { REFRESH_ACTION } from "./watch.mjs"
25
25
  import { omakitCacheDir } from "./paths.mjs"
26
- import { join } from "node:path"
27
- import { pathToFileURL } from "node:url"
28
-
29
- /**
30
- * The marketplace's own name for the verification action that lists a newer
31
- * commit of an already listed plugin, read from the pin rather than typed:
32
- * a form option retyped here would drift by a word, and a person would be
33
- * sent to choose something the form no longer offers.
34
- */
35
- async function newerCommitAction(pinDir) {
36
- const verification = await import(pathToFileURL(join(pinDir, "scripts/plugin-verification-request.mjs")).href)
37
- return verification.upstreamUpdateVerificationAction
38
- }
39
26
 
40
27
  /**
41
28
  * One arrow per cause, in this order, so a person fixes the thing that is
42
29
  * actually wrong. Measured before this: an id listed by its own repository
43
30
  * was told to "choose an unused plugin id outside the reserved namespace",
44
31
  * which is the remedy for a different failure, and an author who followed
45
- * it would have renamed a plugin the marketplace already lists.
32
+ * it would have renamed a plugin the marketplace already lists. The last
33
+ * arrow is for a listed repository whose manifest carries another id, or
34
+ * one with a second cause beside it; a plugin listed by its own repository
35
+ * with nothing else wrong is not a failure at all (see `listing` below).
46
36
  */
47
37
  function identityRemedies(identity, universe, newerCommit) {
48
38
  const remedies = []
@@ -108,13 +98,42 @@ export function missingSubmitFlags(contract, { category, tags } = {}) {
108
98
  return { missing, categories: [...contract.categories], tags: [...contract.tagLabels], maximumTags: contract.maximumTags }
109
99
  }
110
100
 
101
+ /** A shell word: quoted when it holds anything a shell would read. */
102
+ function shellWord(value) {
103
+ const text = String(value)
104
+ return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(text) ? text : `"${text.replace(/(["\\$`])/g, "\\$1")}"`
105
+ }
106
+
107
+ /**
108
+ * The command line that repeats this run without asking anything: the target
109
+ * as given, the category and tags the run used wherever they came from, and
110
+ * every other flag that was given. Printed at the end of the report and
111
+ * carried in --json as `reproduce`, so the next run needs no prompt.
112
+ */
113
+ export function reproduceCommand({ target, category, tags, pluginName, notes, suggestedTag, allowDirty, offline }) {
114
+ const parts = ["omakit", "submit", shellWord(target)]
115
+ if (category) parts.push("--category", shellWord(category))
116
+ if (tags?.length) parts.push("--tags", shellWord(tags.map(tagSlug).join(",")))
117
+ if (pluginName) parts.push("--name", shellWord(pluginName))
118
+ if (notes) parts.push("--notes", shellWord(notes))
119
+ if (suggestedTag) parts.push("--suggest-tag", shellWord(suggestedTag))
120
+ if (allowDirty) parts.push("--allow-dirty")
121
+ if (offline) parts.push("--offline")
122
+ return parts.join(" ")
123
+ }
124
+
111
125
  /**
112
126
  * @param {{ repoRoot: string, target: string, category?: string, tags?: string|string[],
113
127
  * notes?: string, suggestedTag?: string, pluginName?: string,
114
128
  * allowDirty?: boolean, offline?: boolean,
115
- * readRegistry?: typeof liveRegistry }} options
129
+ * readRegistry?: typeof liveRegistry,
130
+ * chooser?: (question: { contract: object, defaults: object, missing: string[] }) => Promise<{ category?: string, tags?: string[] }> }} options
116
131
  * `readRegistry` is injectable for tests; the default reads the marketplace's
117
- * current HEAD, or the pin with `offline`.
132
+ * current HEAD, or the pin with `offline`. `chooser` answers for a missing
133
+ * --category or --tags on an unlisted plugin; without one, that is a usage
134
+ * error (SubmitError "usage", with the form's lists under `usage`), decided
135
+ * after the registry so a listed plugin is never asked for a choice that
136
+ * does not matter.
118
137
  */
119
138
  export async function submitPreflight(options) {
120
139
  const { repoRoot } = options
@@ -212,22 +231,72 @@ export async function submitPreflight(options) {
212
231
  // --- identity -------------------------------------------------------------
213
232
 
214
233
  const identity = checkIdentity(universe, { id: tree.pluginId, repositoryUrl: subject.repository.url })
215
- const identityRemedy = identity.ok ? null : identityRemedies(identity, universe, await newerCommitAction(pinDir))
234
+ const update = await newerCommitChoice({ pinDir })
235
+ const identityRemedy = identity.ok || identity.own ? null : identityRemedies(identity, universe, update.choice)
236
+ const listed = identity.problems.some((problem) => problem.code === "plugin-id-listed" || problem.code === "submission-repository-listed")
237
+
238
+ // The subject's own listing is the third outcome of a run, not a failed
239
+ // check. Measured on 0.1.6: `omakit submit` on the author's own listed
240
+ // plugin printed FAIL identity.available and REFUSED, closed with "Fix it,
241
+ // then run submit again", and the remedy under it said there was nothing to
242
+ // submit. A healthy state was drawn as a failure and the closing line
243
+ // contradicted the remedy. Now the check passes with what the marketplace
244
+ // records about the listing, and the run ends in LISTED.
245
+ const listing = identity.own
246
+ ? (() => {
247
+ const record = listingOf(live, tree.pluginId) || { repository: subject.repository.url, id: tree.pluginId, addedAt: null, verificationCommit: null, verificationStatus: null, verificationCheckedAt: null }
248
+ return {
249
+ ...record,
250
+ localCommit: subject.commit,
251
+ sameCommit: Boolean(record.verificationCommit) && record.verificationCommit === subject.commit.toLowerCase(),
252
+ source: live.source,
253
+ updateRoute: { form: update.name, choice: update.choice },
254
+ }
255
+ })()
256
+ : null
257
+
258
+ // The category and the tags are decided here, after the registry. Measured
259
+ // before this: a listed plugin was asked for both and then told there was
260
+ // nothing to submit. A listed plugin needs neither; an unlisted plugin with
261
+ // either missing is asked through `chooser` when there is one, and is a
262
+ // usage error otherwise. The default offered is the marketplace's own
263
+ // presentation for the manifest's kinds, read from the pinned catalog
264
+ // builder.
265
+ let chosenCategory = options.category
266
+ let chosenTags = options.tags
267
+ const missing = missingSubmitFlags(contract, { category: chosenCategory, tags: chosenTags })
268
+ const moot = Boolean(missing) && listed
269
+ if (missing && !listed) {
270
+ if (!options.chooser) {
271
+ const error = new SubmitError("usage", `submit needs ${missing.missing.join(" and ")}`)
272
+ error.usage = missing
273
+ throw error
274
+ }
275
+ const defaults = defaultPresentation(catalogPresentation(pinDir), tree.manifest?.kinds)
276
+ const answers = await options.chooser({ contract, defaults, missing: missing.missing })
277
+ if (answers?.category) chosenCategory = answers.category
278
+ if (answers?.tags) chosenTags = answers.tags
279
+ }
216
280
  checks.push(check("identity.available", {
217
281
  source: "marketplace-pin",
218
- why: `The marketplace refuses \`plugin-id-listed\`, \`plugin-id-retired\`, \`reserved-plugin-id\` and \`submission-repository-listed\`. Checked here against ${figure(universe.counts.listedIds)} listed ids, ${figure(universe.counts.retiredIds)} retired ids and ${figure(universe.counts.listedRepositories)} listed repositories from the registry and catalog at the commit the detail names, and the reserved namespace from the pinned catalog builder. The registry is read from the marketplace's current HEAD when the network is there because the pin's copy is stale within hours: registry.json changed in 4,201 of the marketplace's 4,293 commits in the 30 days to 2026-09-13, about 140 a day (docs/MEASUREMENTS.md M7). Code and the form are only ever read from the pin.`,
219
- verdict: identity.ok,
282
+ why: `The marketplace refuses \`plugin-id-listed\`, \`plugin-id-retired\`, \`reserved-plugin-id\` and \`submission-repository-listed\`. Checked here against ${figure(universe.counts.listedIds)} listed ids, ${figure(universe.counts.retiredIds)} retired ids and ${figure(universe.counts.listedRepositories)} listed repositories from the registry and catalog at the commit the detail names, and the reserved namespace from the pinned catalog builder. The registry is read from the marketplace's current HEAD when the network is there because the pin's copy is stale within hours: registry.json changed in 4,201 of the marketplace's 4,293 commits in the 30 days to 2026-09-13, about 140 a day (docs/MEASUREMENTS.md M7). Code and the form are only ever read from the pin. A plugin listed by its own repository (owner and name, case-insensitively, a trailing .git ignored) is not refused: it is listed, and this check says since when and at which commit.`,
283
+ verdict: identity.ok || identity.own,
220
284
  detail: `${identity.ok
221
285
  ? `id "${tree.pluginId}" is unused, outside the reserved ${universe.reservedPrefix}* namespace, and the repository is not listed`
222
- : identity.problems.map((problem) => `${problem.code}: ${problem.detail}`).join("; ")}; ${registrySourceDetail(live)}`,
286
+ : listing
287
+ ? `listed by this repository since ${listing.addedAt || "an unrecorded date"}, verification commit ${listing.verificationCommit || "unrecorded"} (${listing.verificationStatus || "status unrecorded"}, checked ${listing.verificationCheckedAt || "at an unrecorded time"})`
288
+ : identity.problems.map((problem) => `${problem.code}: ${problem.detail}`).join("; ")}; ${registrySourceDetail(live)}`,
223
289
  remedy: identityRemedy,
224
290
  }))
225
291
 
226
292
  // --- the submission itself ------------------------------------------------
227
293
 
228
294
  const pluginName = String(options.pluginName || tree.pluginName || "").trim()
229
- const category = resolveCategory(contract, options.category)
230
- const tags = resolveTags(contract, options.tags)
295
+ const category = resolveCategory(contract, chosenCategory)
296
+ const tags = resolveTags(contract, chosenTags)
297
+ // For a listed plugin the editorial choice is moot: these wait on the
298
+ // identity check rather than failing for a flag nobody needed to give.
299
+ const mootWaitsOn = moot ? ["identity.available"] : []
231
300
 
232
301
  checks.push(check("submission.title", {
233
302
  source: "marketplace-pin",
@@ -239,25 +308,31 @@ export async function submitPreflight(options) {
239
308
  remedy: "Give the plugin a name in manifest.json, or pass --name.",
240
309
  }))
241
310
 
242
- checks.push(check("submission.category", {
311
+ // On the subject's own listing no body is rendered, on purpose: the
312
+ // submission form is not the route. The five checks that exist only for the
313
+ // body are omitted rather than drawn as questions waiting on identity,
314
+ // because identity did not fail.
315
+ if (!listing) checks.push(check("submission.category", {
243
316
  source: "marketplace-pin",
244
317
  why: `Exactly one category from the form's controlled list; the marketplace refuses \`submission-category-invalid\` otherwise. The list (${contract.categories.length} options) is read from ${contract.formPath} at the pin.`,
245
318
  verdict: category.ok,
246
319
  detail: category.ok ? `category: ${category.value}` : `${category.reason}. Choose one of: ${contract.categories.join(", ")}`,
247
320
  remedy: category.ok ? null : "Pass --category with one of the listed values.",
321
+ waitedOn: mootWaitsOn,
248
322
  }))
249
323
 
250
- checks.push(check("submission.tags", {
324
+ if (!listing) checks.push(check("submission.tags", {
251
325
  source: "marketplace-pin",
252
326
  why: `1 to ${contract.maximumTags} tags from the form's controlled list; the marketplace refuses \`submission-tag-count-invalid\` and \`submission-tags-invalid\` otherwise. The list (${contract.tagLabels.length} options) and the maximum are read from the pin.`,
253
327
  verdict: tags.ok,
254
328
  detail: tags.ok ? `tags: ${tags.value.join(", ")}` : `${tags.reason}. Choose from: ${contract.tagLabels.join(", ")}`,
255
329
  remedy: tags.ok ? null : "Pass --tags with 1 to 3 comma-separated values from the list.",
330
+ waitedOn: mootWaitsOn,
256
331
  }))
257
332
 
258
333
  // The body needs every field above and the repository URL below; the three
259
334
  // checks that read it wait on whichever of those failed.
260
- const bodyWaitsOn = [
335
+ const bodyWaitsOn = moot ? mootWaitsOn : [
261
336
  !pluginName && "submission.title",
262
337
  !category.ok && "submission.category",
263
338
  !tags.ok && "submission.tags",
@@ -265,7 +340,7 @@ export async function submitPreflight(options) {
265
340
  ].filter(Boolean)
266
341
  let issue = null
267
342
  let parsed = null
268
- if (!bodyWaitsOn.length) {
343
+ if (!bodyWaitsOn.length && !listing) {
269
344
  issue = renderIssue(contract, {
270
345
  pluginName,
271
346
  repositoryUrl: subject.repository.url,
@@ -285,7 +360,7 @@ export async function submitPreflight(options) {
285
360
  remedy: subject.repository.url ? null : "Give the repository a github.com origin remote.",
286
361
  }))
287
362
 
288
- checks.push(check("submission.headings", {
363
+ if (!listing) checks.push(check("submission.headings", {
289
364
  source: "marketplace-pin",
290
365
  why: `The six form headings must appear in exact order: ${contract.headings.join(", ")}. 11 open submissions are malformed in the body and receive "The validation result could not be published to the issue. A maintainer must review the workflow.", which blames the maintainer for the author's mistake; one of them differs from a valid submission by the single word "Suggested" instead of "Suggest". The headings are rendered from the form at the pin, never typed.`,
291
366
  verdict: Boolean(issue),
@@ -293,7 +368,7 @@ export async function submitPreflight(options) {
293
368
  waitedOn: bodyWaitsOn,
294
369
  }))
295
370
 
296
- checks.push(check("submission.checklist", {
371
+ if (!listing) checks.push(check("submission.checklist", {
297
372
  source: "marketplace-pin",
298
373
  why: `All ${contract.checklist.length} checklist items must be present with their exact text and checked; the marketplace refuses \`submission-checklist-unconfirmed\` otherwise. The text is read from the form at the pin, character for character.`,
299
374
  verdict: Boolean(issue),
@@ -301,7 +376,7 @@ export async function submitPreflight(options) {
301
376
  waitedOn: bodyWaitsOn,
302
377
  }))
303
378
 
304
- checks.push(check("submission.official-parser", {
379
+ if (!listing) checks.push(check("submission.official-parser", {
305
380
  source: "marketplace-pin",
306
381
  why: "The strongest available proof that the body is well formed: the marketplace's own `parseCurrentSubmission` from the pinned commit is run over the rendered title and body. If it accepts them here it accepts them there, and 0 of the marketplace's heading, tag and checklist rules are duplicated in Omakit, so none of them can drift. This is the check that closes all 50 measured title-and-body failures at once: 39 on the title prefix plus 11 malformed bodies.",
307
382
  verdict: Boolean(parsed?.ok),
@@ -379,7 +454,12 @@ export async function submitPreflight(options) {
379
454
  const blocking = checks.filter((entry) => entry.severity === "blocking" && entry.verdict === "fail")
380
455
  const advisory = checks.filter((entry) => entry.severity === "advisory" && entry.verdict === "fail")
381
456
  const unknown = checks.filter((entry) => entry.verdict === "unknown")
382
- const ready = blocking.length === 0
457
+ // Three outcomes. `refused`: a blocking check failed and no body exists.
458
+ // `listed`: nothing failed and the plugin is already listed by this
459
+ // repository, so there is no body either, and nothing is wrong. `ready`:
460
+ // the body. `ready` the boolean stays what it was, true for the third only.
461
+ const outcome = blocking.length ? "refused" : listing ? "listed" : "ready"
462
+ const ready = outcome === "ready"
383
463
 
384
464
  return {
385
465
  pin: {
@@ -409,8 +489,20 @@ export async function submitPreflight(options) {
409
489
  note: "The marketplace validates the default-branch HEAD it resolves when the issue is opened or edited. After submitting, use `omakit watch <issue-url>` to see whether that validated commit has fallen behind.",
410
490
  },
411
491
  plugin: { id: tree.pluginId, name: pluginName },
492
+ reproduce: reproduceCommand({
493
+ target: options.target,
494
+ category: category.ok ? category.value : null,
495
+ tags: tags.ok ? tags.value : null,
496
+ pluginName: options.pluginName,
497
+ notes: options.notes,
498
+ suggestedTag: options.suggestedTag,
499
+ allowDirty: options.allowDirty === true,
500
+ offline: options.offline === true,
501
+ }),
412
502
  checks,
503
+ outcome,
413
504
  ready,
505
+ listing,
414
506
  blocking: blocking.map((entry) => entry.id),
415
507
  advisory: advisory.map((entry) => entry.id),
416
508
  unknown: unknown.map((entry) => entry.id),
@@ -45,6 +45,13 @@ export const COMMANDS = Object.freeze([
45
45
  lines: [
46
46
  "Every check that is knowable before submitting, the resolved commit, and",
47
47
  "the exact issue title and body. Prints them. Never posts anything.",
48
+ "Three outcomes: READY (exit 0, the body), REFUSED (exit 1, no body), and",
49
+ "LISTED (exit 0): the plugin is already listed by its own repository, so",
50
+ "the submission form is not the route and nothing is asked. An id taken",
51
+ "by another repository is refused. An unlisted plugin without a category",
52
+ "and tags is asked at a terminal, with the form's lists numbered; in a",
53
+ "pipe or with --json that is a usage error, exit 2. A READY or REFUSED",
54
+ "report ends with the command line that repeats the run unasked.",
48
55
  ],
49
56
  },
50
57
  {
@@ -56,10 +63,11 @@ export const COMMANDS = Object.freeze([
56
63
  ],
57
64
  },
58
65
  {
59
- signature: "omakit verify <target> [--allow-dirty] [--out <file>]",
66
+ signature: "omakit verify <target> [--allow-dirty] [--json] [--out <file>]",
60
67
  lines: [
61
68
  "The official marketplace security baseline over the local Git transport,",
62
- "reported verbatim beside the pin identity.",
69
+ "reported verbatim beside the pin identity. A report for a person; --json",
70
+ "prints the document itself, and --out writes it to a file.",
63
71
  ],
64
72
  },
65
73
  {