omakit 0.1.5 → 0.1.6
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 +21 -2
- package/package.json +1 -1
- package/skills/omarchy-plugin-submit/SKILL.md +34 -3
- package/skills/omarchy-plugin-validation-watch/SKILL.md +6 -0
- package/tools/marketplace/README.md +6 -4
- package/tools/marketplace/ask.mjs +140 -0
- package/tools/marketplace/cli.mjs +46 -28
- package/tools/marketplace/registry.mjs +36 -0
- package/tools/marketplace/report.mjs +121 -7
- package/tools/marketplace/submit.mjs +75 -7
- package/tools/marketplace/usage.mjs +7 -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,25 @@ 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. A plugin that is
|
|
138
|
+
already listed is refused there and asked for nothing (measured on 0.1.5: it
|
|
139
|
+
exited 2 asking for `--category` and `--tags`, then would have said there was
|
|
140
|
+
nothing to submit). An unlisted plugin needs both, and they are an editorial
|
|
141
|
+
choice nobody else can make: at a terminal it asks, once each, with the form's
|
|
142
|
+
own lists numbered and the marketplace's own default for the manifest's kinds
|
|
143
|
+
offered where it is on the list; in a pipe, from an agent, or with `--json` it
|
|
144
|
+
is the usage error with the same lists, exit 2. Either way the report ends
|
|
145
|
+
with the command line that repeats the run without asking, and `--json`
|
|
146
|
+
carries it as `reproduce`.
|
|
147
|
+
|
|
129
148
|
There is nothing to authenticate. If you have `gh auth login` done, omakit
|
|
130
149
|
reads that credential for GET requests and stores nothing; a token in
|
|
131
150
|
`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.6",
|
|
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 refused at `identity.available` and asked for nothing. Every report
|
|
50
|
+
ends with the command line that repeats the run without asking, and `--json`
|
|
51
|
+
carries it as `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
|
|
@@ -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
|
|
@@ -10,7 +10,7 @@ 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
16
|
| `form.mjs` | The submission contract, read from the form and cross-checked against the marketplace's own constants. |
|
|
@@ -19,11 +19,12 @@ local commit through the transport seam the marketplace tests itself
|
|
|
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. Decides the category and tags after the registry: a listed plugin needs 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. |
|
|
@@ -34,10 +35,11 @@ local commit through the transport seam the marketplace tests itself
|
|
|
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,9 +126,24 @@ 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()
|
|
@@ -207,7 +214,7 @@ async function cmdVerify(args) {
|
|
|
207
214
|
fail(error?.code === "marketplace-unavailable" ? error.code : "baseline-unavailable", error.message)
|
|
208
215
|
}
|
|
209
216
|
spinner.done()
|
|
210
|
-
|
|
217
|
+
const document = {
|
|
211
218
|
subject: {
|
|
212
219
|
repository: subject.repository,
|
|
213
220
|
commit: subject.commit,
|
|
@@ -215,7 +222,18 @@ async function cmdVerify(args) {
|
|
|
215
222
|
mode: subject.mode,
|
|
216
223
|
},
|
|
217
224
|
marketplaceBaseline: section,
|
|
218
|
-
}
|
|
225
|
+
}
|
|
226
|
+
// The JSON is the document itself, byte for byte what verify always
|
|
227
|
+
// printed, for --json and for --out; a person at the terminal gets the
|
|
228
|
+
// report, in the register submit uses for its checks.
|
|
229
|
+
if (args.includes("--json") || option(args, "--out")) {
|
|
230
|
+
emit(args, `${JSON.stringify(document, null, 2)}\n`)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
const blockingRules = section.invoked && section.official && !section.official.error
|
|
234
|
+
? (await consequence(requirePin(ROOT).dir, section.official)).selectivelyBlockingRules
|
|
235
|
+
: []
|
|
236
|
+
emit(args, renderVerify(document, { blockingRules }))
|
|
219
237
|
}
|
|
220
238
|
|
|
221
239
|
async function cmdParity(args) {
|
|
@@ -71,6 +71,42 @@ 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
|
+
|
|
74
110
|
function repositorySlug(value) {
|
|
75
111
|
try {
|
|
76
112
|
return new URL(String(value)).pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLowerCase()
|
|
@@ -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 = []
|
|
@@ -106,7 +137,12 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
|
|
|
106
137
|
else out.push(...wrap(check.detail, { indent: GUTTER }, c))
|
|
107
138
|
out.push("")
|
|
108
139
|
}
|
|
109
|
-
|
|
140
|
+
if (result.reproduce) {
|
|
141
|
+
out.push(failed.length === 1 ? "Fix it, then run submit again:" : "Fix them, then run submit again:")
|
|
142
|
+
out.push(...commandLines(result.reproduce, c))
|
|
143
|
+
} else {
|
|
144
|
+
out.push(failed.length === 1 ? "Fix it, then run submit again." : "Fix them, then run submit again.")
|
|
145
|
+
}
|
|
110
146
|
return out.join("\n")
|
|
111
147
|
}
|
|
112
148
|
|
|
@@ -131,6 +167,11 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
|
|
|
131
167
|
out.push(`${step}${c("typeable", "--body-file <the body above>")}`)
|
|
132
168
|
out.push("")
|
|
133
169
|
out.push(...wrap(`After it is created: ${result.afterSubmitting}`, {}, c))
|
|
170
|
+
if (result.reproduce) {
|
|
171
|
+
out.push("")
|
|
172
|
+
out.push("The same run, without prompting:")
|
|
173
|
+
out.push(...commandLines(result.reproduce, c))
|
|
174
|
+
}
|
|
134
175
|
return out.join("\n")
|
|
135
176
|
}
|
|
136
177
|
|
|
@@ -176,6 +217,79 @@ export function renderWatch(result, { colour = colourEnabled() } = {}) {
|
|
|
176
217
|
return out.join("\n")
|
|
177
218
|
}
|
|
178
219
|
|
|
220
|
+
/** A list of paths at fault, the way a failing check prints them: a tinted dash, the path in the placeholder tint. */
|
|
221
|
+
function pathLines(paths, c, dash = "fail") {
|
|
222
|
+
return paths.flatMap((path) => wrap(`- ${path}`, { indent: GUTTER + STEP, first: GUTTER })
|
|
223
|
+
.map((line, index) => (index === 0
|
|
224
|
+
? `${body}${c(dash, "-")} ${c("placeholder", line.trimStart().slice(2))}`
|
|
225
|
+
: `${" ".repeat(GUTTER + STEP)}${c("placeholder", line.trimStart())}`)))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* `omakit verify` for a person: the subject, the pin, the transport and its
|
|
230
|
+
* assumptions, then the official result in the register submit uses for its
|
|
231
|
+
* checks, and the statement last. Nothing here is Omakit's judgement: the
|
|
232
|
+
* outcome, the disposition, each finding's title, reason and actions, and a
|
|
233
|
+
* refusal's message are the marketplace's own text verbatim; the one thing
|
|
234
|
+
* added is the tag on each finding, blocks publication or review-required,
|
|
235
|
+
* which is read from the pinned policy's selectively blocking rules
|
|
236
|
+
* (`blockingRules`). `--json` prints the document itself.
|
|
237
|
+
*/
|
|
238
|
+
export function renderVerify(document, { colour = colourEnabled(), blockingRules = [] } = {}) {
|
|
239
|
+
const c = styler(colour)
|
|
240
|
+
const out = []
|
|
241
|
+
const { subject, marketplaceBaseline: section } = document
|
|
242
|
+
const tree = subject.cleanTree?.clean
|
|
243
|
+
? `clean tree, proof ${subject.cleanTree.proof}`
|
|
244
|
+
: c("advisory", "dirty worktree")
|
|
245
|
+
out.push(...field("subject", subject.repository?.url || "no declared GitHub repository URL", c, { wrapValue: false }))
|
|
246
|
+
out.push(...field("commit", subject.commit, c, { wrapValue: false }))
|
|
247
|
+
out.push(...continuation(`${tree}, ${subject.mode} mode`, c))
|
|
248
|
+
out.push(...field("marketplace", `${section.pin.commit}, baseline ${section.pin.baselineVersion}, ${section.pin.enforcementMode}`, c))
|
|
249
|
+
out.push(...field("transport", section.transport, c))
|
|
250
|
+
if (section.assumedByAdapter?.length) out.push(...field("assumed", section.assumedByAdapter.join(", "), c))
|
|
251
|
+
out.push("")
|
|
252
|
+
|
|
253
|
+
const official = section.official
|
|
254
|
+
if (!section.invoked) {
|
|
255
|
+
out.push(`${head("unknown", "not run", "marketplace-pin", c)}`)
|
|
256
|
+
out.push(...wrap(section.skipReason || "the official baseline was not invoked", { indent: GUTTER }, c))
|
|
257
|
+
} else if (official?.error) {
|
|
258
|
+
// The official code refused the snapshot: that refusal is the result.
|
|
259
|
+
out.push(`${head("fail", official.error.code, "marketplace-pin", c)}`)
|
|
260
|
+
out.push(...wrap(official.error.message, { indent: GUTTER }, c))
|
|
261
|
+
for (const [key, value] of Object.entries(official.error)) {
|
|
262
|
+
if (key === "code" || key === "message") continue
|
|
263
|
+
out.push(...wrap(`- ${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`, { indent: GUTTER + STEP, first: GUTTER }, c))
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
const findings = official.findings || []
|
|
267
|
+
const capabilities = official.capabilities || []
|
|
268
|
+
const state = official.outcome === "passed" ? "pass" : official.blocksApproval ? "fail" : "advisory"
|
|
269
|
+
out.push(`${head(state, official.outcome, "marketplace-pin", c)}`)
|
|
270
|
+
out.push(...wrap(official.outcome === "passed"
|
|
271
|
+
? "no findings and no capabilities"
|
|
272
|
+
: `disposition ${official.disposition}, enforcement ${official.enforcementMode}, blocksApproval ${official.blocksApproval}`, { indent: GUTTER }, c))
|
|
273
|
+
for (const finding of findings) {
|
|
274
|
+
const blocks = blockingRules.includes(finding.ruleId)
|
|
275
|
+
out.push("")
|
|
276
|
+
out.push(head(blocks ? "fail" : "advisory", finding.ruleId, blocks ? "blocks publication" : "review-required", c))
|
|
277
|
+
out.push(...wrap([finding.title, finding.why].filter(Boolean).join(". ").replace(/\.\.\s/g, ". "), { indent: GUTTER }, c))
|
|
278
|
+
out.push(...pathLines((finding.evidence || []).map((entry) => `${entry.path}:${entry.line}`), c, blocks ? "fail" : "advisory"))
|
|
279
|
+
for (const remedy of finding.actions || []) out.push(...action(remedy, c))
|
|
280
|
+
}
|
|
281
|
+
for (const capability of capabilities) {
|
|
282
|
+
out.push("")
|
|
283
|
+
out.push(head("info", capability.id, "capability", c))
|
|
284
|
+
out.push(...wrap([capability.title, capability.why].filter(Boolean).join(". ").replace(/\.\.\s/g, ". "), { indent: GUTTER }, c))
|
|
285
|
+
out.push(...pathLines((capability.evidence || []).map((entry) => `${entry.path}:${entry.line}`), c, "info"))
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
out.push("")
|
|
289
|
+
out.push(...wrap(section.statement, {}, c))
|
|
290
|
+
return out.join("\n")
|
|
291
|
+
}
|
|
292
|
+
|
|
179
293
|
const DOCTOR_STATE = { ok: "pass", advice: "advisory", problem: "fail", info: "info", unknown: "unknown" }
|
|
180
294
|
|
|
181
295
|
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, resolveCategory, resolveTags, tagSlug } from "./form.mjs"
|
|
17
|
+
import { idUniverse, checkIdentity, 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"
|
|
@@ -108,13 +108,42 @@ export function missingSubmitFlags(contract, { category, tags } = {}) {
|
|
|
108
108
|
return { missing, categories: [...contract.categories], tags: [...contract.tagLabels], maximumTags: contract.maximumTags }
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/** A shell word: quoted when it holds anything a shell would read. */
|
|
112
|
+
function shellWord(value) {
|
|
113
|
+
const text = String(value)
|
|
114
|
+
return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(text) ? text : `"${text.replace(/(["\\$`])/g, "\\$1")}"`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The command line that repeats this run without asking anything: the target
|
|
119
|
+
* as given, the category and tags the run used wherever they came from, and
|
|
120
|
+
* every other flag that was given. Printed at the end of the report and
|
|
121
|
+
* carried in --json as `reproduce`, so the next run needs no prompt.
|
|
122
|
+
*/
|
|
123
|
+
export function reproduceCommand({ target, category, tags, pluginName, notes, suggestedTag, allowDirty, offline }) {
|
|
124
|
+
const parts = ["omakit", "submit", shellWord(target)]
|
|
125
|
+
if (category) parts.push("--category", shellWord(category))
|
|
126
|
+
if (tags?.length) parts.push("--tags", shellWord(tags.map(tagSlug).join(",")))
|
|
127
|
+
if (pluginName) parts.push("--name", shellWord(pluginName))
|
|
128
|
+
if (notes) parts.push("--notes", shellWord(notes))
|
|
129
|
+
if (suggestedTag) parts.push("--suggest-tag", shellWord(suggestedTag))
|
|
130
|
+
if (allowDirty) parts.push("--allow-dirty")
|
|
131
|
+
if (offline) parts.push("--offline")
|
|
132
|
+
return parts.join(" ")
|
|
133
|
+
}
|
|
134
|
+
|
|
111
135
|
/**
|
|
112
136
|
* @param {{ repoRoot: string, target: string, category?: string, tags?: string|string[],
|
|
113
137
|
* notes?: string, suggestedTag?: string, pluginName?: string,
|
|
114
138
|
* allowDirty?: boolean, offline?: boolean,
|
|
115
|
-
* readRegistry?: typeof liveRegistry
|
|
139
|
+
* readRegistry?: typeof liveRegistry,
|
|
140
|
+
* chooser?: (question: { contract: object, defaults: object, missing: string[] }) => Promise<{ category?: string, tags?: string[] }> }} options
|
|
116
141
|
* `readRegistry` is injectable for tests; the default reads the marketplace's
|
|
117
|
-
* current HEAD, or the pin with `offline`.
|
|
142
|
+
* current HEAD, or the pin with `offline`. `chooser` answers for a missing
|
|
143
|
+
* --category or --tags on an unlisted plugin; without one, that is a usage
|
|
144
|
+
* error (SubmitError "usage", with the form's lists under `usage`), decided
|
|
145
|
+
* after the registry so a listed plugin is never asked for a choice that
|
|
146
|
+
* does not matter.
|
|
118
147
|
*/
|
|
119
148
|
export async function submitPreflight(options) {
|
|
120
149
|
const { repoRoot } = options
|
|
@@ -213,6 +242,30 @@ export async function submitPreflight(options) {
|
|
|
213
242
|
|
|
214
243
|
const identity = checkIdentity(universe, { id: tree.pluginId, repositoryUrl: subject.repository.url })
|
|
215
244
|
const identityRemedy = identity.ok ? null : identityRemedies(identity, universe, await newerCommitAction(pinDir))
|
|
245
|
+
const listed = identity.problems.some((problem) => problem.code === "plugin-id-listed" || problem.code === "submission-repository-listed")
|
|
246
|
+
|
|
247
|
+
// The category and the tags are decided here, after the registry. Measured
|
|
248
|
+
// before this: a listed plugin was asked for both and then told there was
|
|
249
|
+
// nothing to submit. A listed plugin needs neither; an unlisted plugin with
|
|
250
|
+
// either missing is asked through `chooser` when there is one, and is a
|
|
251
|
+
// usage error otherwise. The default offered is the marketplace's own
|
|
252
|
+
// presentation for the manifest's kinds, read from the pinned catalog
|
|
253
|
+
// builder.
|
|
254
|
+
let chosenCategory = options.category
|
|
255
|
+
let chosenTags = options.tags
|
|
256
|
+
const missing = missingSubmitFlags(contract, { category: chosenCategory, tags: chosenTags })
|
|
257
|
+
const moot = Boolean(missing) && listed
|
|
258
|
+
if (missing && !listed) {
|
|
259
|
+
if (!options.chooser) {
|
|
260
|
+
const error = new SubmitError("usage", `submit needs ${missing.missing.join(" and ")}`)
|
|
261
|
+
error.usage = missing
|
|
262
|
+
throw error
|
|
263
|
+
}
|
|
264
|
+
const defaults = defaultPresentation(catalogPresentation(pinDir), tree.manifest?.kinds)
|
|
265
|
+
const answers = await options.chooser({ contract, defaults, missing: missing.missing })
|
|
266
|
+
if (answers?.category) chosenCategory = answers.category
|
|
267
|
+
if (answers?.tags) chosenTags = answers.tags
|
|
268
|
+
}
|
|
216
269
|
checks.push(check("identity.available", {
|
|
217
270
|
source: "marketplace-pin",
|
|
218
271
|
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.`,
|
|
@@ -226,8 +279,11 @@ export async function submitPreflight(options) {
|
|
|
226
279
|
// --- the submission itself ------------------------------------------------
|
|
227
280
|
|
|
228
281
|
const pluginName = String(options.pluginName || tree.pluginName || "").trim()
|
|
229
|
-
const category = resolveCategory(contract,
|
|
230
|
-
const tags = resolveTags(contract,
|
|
282
|
+
const category = resolveCategory(contract, chosenCategory)
|
|
283
|
+
const tags = resolveTags(contract, chosenTags)
|
|
284
|
+
// For a listed plugin the editorial choice is moot: these wait on the
|
|
285
|
+
// identity check rather than failing for a flag nobody needed to give.
|
|
286
|
+
const mootWaitsOn = moot ? ["identity.available"] : []
|
|
231
287
|
|
|
232
288
|
checks.push(check("submission.title", {
|
|
233
289
|
source: "marketplace-pin",
|
|
@@ -245,6 +301,7 @@ export async function submitPreflight(options) {
|
|
|
245
301
|
verdict: category.ok,
|
|
246
302
|
detail: category.ok ? `category: ${category.value}` : `${category.reason}. Choose one of: ${contract.categories.join(", ")}`,
|
|
247
303
|
remedy: category.ok ? null : "Pass --category with one of the listed values.",
|
|
304
|
+
waitedOn: mootWaitsOn,
|
|
248
305
|
}))
|
|
249
306
|
|
|
250
307
|
checks.push(check("submission.tags", {
|
|
@@ -253,11 +310,12 @@ export async function submitPreflight(options) {
|
|
|
253
310
|
verdict: tags.ok,
|
|
254
311
|
detail: tags.ok ? `tags: ${tags.value.join(", ")}` : `${tags.reason}. Choose from: ${contract.tagLabels.join(", ")}`,
|
|
255
312
|
remedy: tags.ok ? null : "Pass --tags with 1 to 3 comma-separated values from the list.",
|
|
313
|
+
waitedOn: mootWaitsOn,
|
|
256
314
|
}))
|
|
257
315
|
|
|
258
316
|
// The body needs every field above and the repository URL below; the three
|
|
259
317
|
// checks that read it wait on whichever of those failed.
|
|
260
|
-
const bodyWaitsOn = [
|
|
318
|
+
const bodyWaitsOn = moot ? mootWaitsOn : [
|
|
261
319
|
!pluginName && "submission.title",
|
|
262
320
|
!category.ok && "submission.category",
|
|
263
321
|
!tags.ok && "submission.tags",
|
|
@@ -409,6 +467,16 @@ export async function submitPreflight(options) {
|
|
|
409
467
|
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
468
|
},
|
|
411
469
|
plugin: { id: tree.pluginId, name: pluginName },
|
|
470
|
+
reproduce: reproduceCommand({
|
|
471
|
+
target: options.target,
|
|
472
|
+
category: category.ok ? category.value : null,
|
|
473
|
+
tags: tags.ok ? tags.value : null,
|
|
474
|
+
pluginName: options.pluginName,
|
|
475
|
+
notes: options.notes,
|
|
476
|
+
suggestedTag: options.suggestedTag,
|
|
477
|
+
allowDirty: options.allowDirty === true,
|
|
478
|
+
offline: options.offline === true,
|
|
479
|
+
}),
|
|
412
480
|
checks,
|
|
413
481
|
ready,
|
|
414
482
|
blocking: blocking.map((entry) => entry.id),
|
|
@@ -45,6 +45,10 @@ 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
|
+
"A listed plugin is refused at identity and needs no category or tags.",
|
|
49
|
+
"An unlisted one without them is asked at a terminal, with the form's",
|
|
50
|
+
"lists numbered; in a pipe or with --json that is a usage error, exit 2.",
|
|
51
|
+
"The report ends with the command line that repeats the run unasked.",
|
|
48
52
|
],
|
|
49
53
|
},
|
|
50
54
|
{
|
|
@@ -56,10 +60,11 @@ export const COMMANDS = Object.freeze([
|
|
|
56
60
|
],
|
|
57
61
|
},
|
|
58
62
|
{
|
|
59
|
-
signature: "omakit verify <target> [--allow-dirty] [--out <file>]",
|
|
63
|
+
signature: "omakit verify <target> [--allow-dirty] [--json] [--out <file>]",
|
|
60
64
|
lines: [
|
|
61
65
|
"The official marketplace security baseline over the local Git transport,",
|
|
62
|
-
"reported verbatim beside the pin identity.",
|
|
66
|
+
"reported verbatim beside the pin identity. A report for a person; --json",
|
|
67
|
+
"prints the document itself, and --out writes it to a file.",
|
|
63
68
|
],
|
|
64
69
|
},
|
|
65
70
|
{
|