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 +37 -2
- package/package.json +1 -1
- package/skills/omarchy-plugin-submit/SKILL.md +62 -6
- package/skills/omarchy-plugin-validation-watch/SKILL.md +11 -1
- package/tools/marketplace/README.md +9 -7
- package/tools/marketplace/ask.mjs +140 -0
- package/tools/marketplace/cli.mjs +49 -29
- package/tools/marketplace/form.mjs +36 -4
- package/tools/marketplace/registry.mjs +112 -7
- package/tools/marketplace/report.mjs +141 -8
- package/tools/marketplace/submit.mjs +124 -32
- package/tools/marketplace/usage.mjs +10 -2
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>
|
|
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
|
|
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.
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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 {
|
|
21
|
-
import {
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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", `${
|
|
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
|
-
`${
|
|
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
|
-
|
|
119
|
+
path = new URL(text).pathname
|
|
77
120
|
} catch {
|
|
78
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
:
|
|
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,
|
|
230
|
-
const tags = resolveTags(contract,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
{
|