omakit 0.1.6 → 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 +23 -7
- package/package.json +1 -1
- package/skills/omarchy-plugin-submit/SKILL.md +31 -6
- package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -1
- package/tools/marketplace/README.md +4 -4
- package/tools/marketplace/cli.mjs +3 -1
- package/tools/marketplace/form.mjs +36 -4
- package/tools/marketplace/registry.mjs +76 -7
- package/tools/marketplace/report.mjs +20 -1
- package/tools/marketplace/submit.mjs +51 -27
- package/tools/marketplace/usage.mjs +7 -4
package/README.md
CHANGED
|
@@ -134,16 +134,32 @@ file and line, and the official text verbatim, then the marketplace's own
|
|
|
134
134
|
statement. `--json` prints the document itself, unchanged from earlier
|
|
135
135
|
releases, and `--out <file>` writes it; agents and the skills use those.
|
|
136
136
|
|
|
137
|
-
`omakit submit` reads the marketplace's registry first
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
141
157
|
choice nobody else can make: at a terminal it asks, once each, with the form's
|
|
142
158
|
own lists numbered and the marketplace's own default for the manifest's kinds
|
|
143
159
|
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.
|
|
145
|
-
with the command line that repeats
|
|
146
|
-
carries it as `reproduce`.
|
|
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`.
|
|
147
163
|
|
|
148
164
|
There is nothing to authenticate. If you have `gh auth login` done, omakit
|
|
149
165
|
reads that credential for GET requests and stores nothing; a token in
|
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",
|
|
@@ -46,9 +46,9 @@ message lists the controlled values, read from the pinned form; with `--json`
|
|
|
46
46
|
the same lists come back under `usage`. A person at a terminal is asked
|
|
47
47
|
instead, once each, with the marketplace's own default for the manifest's
|
|
48
48
|
kinds. It is decided after the registry is read: a plugin that is already
|
|
49
|
-
listed is
|
|
50
|
-
|
|
51
|
-
|
|
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
52
|
|
|
53
53
|
To see the official baseline result alone, with no Omakit check around it:
|
|
54
54
|
|
|
@@ -72,9 +72,34 @@ HEAD, tell the owner and choose another id.
|
|
|
72
72
|
|
|
73
73
|
## Reading the result
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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.
|
|
78
103
|
|
|
79
104
|
A check drawn as `▒ ?` did not run because one it depends on failed; its detail
|
|
80
105
|
names that check. It is not a failure of its own, and the closing refusal lists
|
|
@@ -44,7 +44,11 @@ it from the operator's `gh` login (which itself honours `GH_TOKEN` and
|
|
|
44
44
|
If the diagnosis ends in a resubmission under a new id, run `omakit submit`
|
|
45
45
|
online: never pass `--offline` to get around a listed id, because that reads
|
|
46
46
|
the listed ids from the pin, which is stale within hours, and the marketplace
|
|
47
|
-
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.
|
|
48
52
|
|
|
49
53
|
## Acting on each verdict
|
|
50
54
|
|
|
@@ -13,13 +13,13 @@ local commit through the transport seam the marketplace tests itself
|
|
|
13
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. Decides the category and tags after the registry: a listed plugin
|
|
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
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. |
|
|
24
24
|
| `watch.mjs` | The validation watch: validated commit versus current default-branch HEAD, and the one action that refreshes it. |
|
|
25
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. |
|
|
@@ -31,7 +31,7 @@ local commit through the transport seam the marketplace tests itself
|
|
|
31
31
|
| `banner.mjs` | The wordmark, on a bare `omakit` and in `setup` only. |
|
|
32
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. |
|
|
33
33
|
| `progress.mjs` | The progress line, on stderr, only when a person is looking. |
|
|
34
|
-
| `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`. |
|
|
35
35
|
|
|
36
36
|
```text
|
|
37
37
|
omakit pin
|
|
@@ -148,7 +148,9 @@ async function cmdSubmit(args) {
|
|
|
148
148
|
}
|
|
149
149
|
spinner.done()
|
|
150
150
|
emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderSubmit(result))
|
|
151
|
-
|
|
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)
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
async function cmdWatch(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
|
|
@@ -107,12 +107,32 @@ export function defaultPresentation(presentation, kinds = []) {
|
|
|
107
107
|
return { category, tags }
|
|
108
108
|
}
|
|
109
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
|
+
*/
|
|
110
115
|
function repositorySlug(value) {
|
|
116
|
+
const text = String(value ?? "").trim()
|
|
117
|
+
let path
|
|
111
118
|
try {
|
|
112
|
-
|
|
119
|
+
path = new URL(text).pathname
|
|
113
120
|
} catch {
|
|
114
|
-
|
|
121
|
+
path = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(text) ? text : ""
|
|
115
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)
|
|
116
136
|
}
|
|
117
137
|
|
|
118
138
|
/**
|
|
@@ -400,11 +420,14 @@ export function figure(n) {
|
|
|
400
420
|
|
|
401
421
|
/**
|
|
402
422
|
* @param {{ id: string, repositoryUrl?: string|null }} subject
|
|
403
|
-
* @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 }> }}
|
|
404
424
|
* A `plugin-id-listed` problem names the repository that lists the id
|
|
405
425
|
* (`repository`, a slug, or null when the registry does not say) and
|
|
406
426
|
* whether that is the subject's own (`sameRepository`), because the two
|
|
407
|
-
*
|
|
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.
|
|
408
431
|
*/
|
|
409
432
|
export function checkIdentity(universe, subject) {
|
|
410
433
|
const problems = []
|
|
@@ -425,18 +448,64 @@ export function checkIdentity(universe, subject) {
|
|
|
425
448
|
}
|
|
426
449
|
if (universe.listedIds.has(id)) {
|
|
427
450
|
const repository = universe.listedBy?.get(id) || null
|
|
428
|
-
const sameRepository = Boolean(repository && slug && repository === slug)
|
|
429
451
|
problems.push({
|
|
430
452
|
code: "plugin-id-listed",
|
|
431
453
|
detail: `"${id}" is already listed${repository ? ` by ${repository}` : ""}`,
|
|
432
454
|
repository,
|
|
433
|
-
sameRepository,
|
|
455
|
+
sameRepository: sameRepository(repository, slug),
|
|
434
456
|
})
|
|
435
457
|
}
|
|
436
458
|
if (slug && universe.listedRepositories.has(slug)) {
|
|
437
459
|
problems.push({ code: "submission-repository-listed", detail: `${slug} is already listed`, repository: slug, sameRepository: true })
|
|
438
460
|
}
|
|
439
|
-
|
|
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
|
+
}
|
|
440
509
|
}
|
|
441
510
|
|
|
442
511
|
export { repositorySlug }
|
|
@@ -122,7 +122,26 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
|
|
|
122
122
|
out.push("")
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
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") {
|
|
126
145
|
// Root causes only: a check that waited on a failed one is not listed,
|
|
127
146
|
// and the count says how many waited.
|
|
128
147
|
const failed = result.checks.filter((check) => result.blocking.includes(check.id))
|
|
@@ -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, tagSlug } from "./form.mjs"
|
|
17
|
-
import { idUniverse, checkIdentity, baselineFigures, figure, liveRegistry, registrySourceDetail, catalogPresentation, defaultPresentation } 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 = []
|
|
@@ -241,9 +231,30 @@ export async function submitPreflight(options) {
|
|
|
241
231
|
// --- identity -------------------------------------------------------------
|
|
242
232
|
|
|
243
233
|
const identity = checkIdentity(universe, { id: tree.pluginId, repositoryUrl: subject.repository.url })
|
|
244
|
-
const
|
|
234
|
+
const update = await newerCommitChoice({ pinDir })
|
|
235
|
+
const identityRemedy = identity.ok || identity.own ? null : identityRemedies(identity, universe, update.choice)
|
|
245
236
|
const listed = identity.problems.some((problem) => problem.code === "plugin-id-listed" || problem.code === "submission-repository-listed")
|
|
246
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
|
+
|
|
247
258
|
// The category and the tags are decided here, after the registry. Measured
|
|
248
259
|
// before this: a listed plugin was asked for both and then told there was
|
|
249
260
|
// nothing to submit. A listed plugin needs neither; an unlisted plugin with
|
|
@@ -268,11 +279,13 @@ export async function submitPreflight(options) {
|
|
|
268
279
|
}
|
|
269
280
|
checks.push(check("identity.available", {
|
|
270
281
|
source: "marketplace-pin",
|
|
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.`,
|
|
272
|
-
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,
|
|
273
284
|
detail: `${identity.ok
|
|
274
285
|
? `id "${tree.pluginId}" is unused, outside the reserved ${universe.reservedPrefix}* namespace, and the repository is not listed`
|
|
275
|
-
:
|
|
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)}`,
|
|
276
289
|
remedy: identityRemedy,
|
|
277
290
|
}))
|
|
278
291
|
|
|
@@ -295,7 +308,11 @@ export async function submitPreflight(options) {
|
|
|
295
308
|
remedy: "Give the plugin a name in manifest.json, or pass --name.",
|
|
296
309
|
}))
|
|
297
310
|
|
|
298
|
-
|
|
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", {
|
|
299
316
|
source: "marketplace-pin",
|
|
300
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.`,
|
|
301
318
|
verdict: category.ok,
|
|
@@ -304,7 +321,7 @@ export async function submitPreflight(options) {
|
|
|
304
321
|
waitedOn: mootWaitsOn,
|
|
305
322
|
}))
|
|
306
323
|
|
|
307
|
-
checks.push(check("submission.tags", {
|
|
324
|
+
if (!listing) checks.push(check("submission.tags", {
|
|
308
325
|
source: "marketplace-pin",
|
|
309
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.`,
|
|
310
327
|
verdict: tags.ok,
|
|
@@ -323,7 +340,7 @@ export async function submitPreflight(options) {
|
|
|
323
340
|
].filter(Boolean)
|
|
324
341
|
let issue = null
|
|
325
342
|
let parsed = null
|
|
326
|
-
if (!bodyWaitsOn.length) {
|
|
343
|
+
if (!bodyWaitsOn.length && !listing) {
|
|
327
344
|
issue = renderIssue(contract, {
|
|
328
345
|
pluginName,
|
|
329
346
|
repositoryUrl: subject.repository.url,
|
|
@@ -343,7 +360,7 @@ export async function submitPreflight(options) {
|
|
|
343
360
|
remedy: subject.repository.url ? null : "Give the repository a github.com origin remote.",
|
|
344
361
|
}))
|
|
345
362
|
|
|
346
|
-
checks.push(check("submission.headings", {
|
|
363
|
+
if (!listing) checks.push(check("submission.headings", {
|
|
347
364
|
source: "marketplace-pin",
|
|
348
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.`,
|
|
349
366
|
verdict: Boolean(issue),
|
|
@@ -351,7 +368,7 @@ export async function submitPreflight(options) {
|
|
|
351
368
|
waitedOn: bodyWaitsOn,
|
|
352
369
|
}))
|
|
353
370
|
|
|
354
|
-
checks.push(check("submission.checklist", {
|
|
371
|
+
if (!listing) checks.push(check("submission.checklist", {
|
|
355
372
|
source: "marketplace-pin",
|
|
356
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.`,
|
|
357
374
|
verdict: Boolean(issue),
|
|
@@ -359,7 +376,7 @@ export async function submitPreflight(options) {
|
|
|
359
376
|
waitedOn: bodyWaitsOn,
|
|
360
377
|
}))
|
|
361
378
|
|
|
362
|
-
checks.push(check("submission.official-parser", {
|
|
379
|
+
if (!listing) checks.push(check("submission.official-parser", {
|
|
363
380
|
source: "marketplace-pin",
|
|
364
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.",
|
|
365
382
|
verdict: Boolean(parsed?.ok),
|
|
@@ -437,7 +454,12 @@ export async function submitPreflight(options) {
|
|
|
437
454
|
const blocking = checks.filter((entry) => entry.severity === "blocking" && entry.verdict === "fail")
|
|
438
455
|
const advisory = checks.filter((entry) => entry.severity === "advisory" && entry.verdict === "fail")
|
|
439
456
|
const unknown = checks.filter((entry) => entry.verdict === "unknown")
|
|
440
|
-
|
|
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"
|
|
441
463
|
|
|
442
464
|
return {
|
|
443
465
|
pin: {
|
|
@@ -478,7 +500,9 @@ export async function submitPreflight(options) {
|
|
|
478
500
|
offline: options.offline === true,
|
|
479
501
|
}),
|
|
480
502
|
checks,
|
|
503
|
+
outcome,
|
|
481
504
|
ready,
|
|
505
|
+
listing,
|
|
482
506
|
blocking: blocking.map((entry) => entry.id),
|
|
483
507
|
advisory: advisory.map((entry) => entry.id),
|
|
484
508
|
unknown: unknown.map((entry) => entry.id),
|
|
@@ -45,10 +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
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
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.",
|
|
52
55
|
],
|
|
53
56
|
},
|
|
54
57
|
{
|