omakit 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
<img src="docs/media/banner.gif" alt="omakit" width="440">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
+
[](https://github.com/tcballard/omarchy-badges)
|
|
6
|
+
|
|
5
7
|
**Everything knowable about an Omarchy Quattro plugin submission, checked
|
|
6
8
|
before you post it:** the tree, the manifest, the form, the commit, and the
|
|
7
9
|
marketplace's own security baseline with its outcome reported as it is. A
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omakit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
|
@@ -24,7 +24,9 @@ comes from `origin`. You supply the category and the tags, because nobody else
|
|
|
24
24
|
can: they are an editorial choice about where the plugin belongs.
|
|
25
25
|
|
|
26
26
|
If you do not know which category and tags are allowed, run the command without
|
|
27
|
-
them
|
|
27
|
+
them: that is a usage error (exit 2) whose message lists the controlled values,
|
|
28
|
+
read from the pinned form, before any check runs. With `--json` the same lists
|
|
29
|
+
come back under `usage`.
|
|
28
30
|
|
|
29
31
|
Useful flags: `--notes` for the Maintainer notes field, `--suggest-tag` for the
|
|
30
32
|
optional suggestion, `--name` when the manifest has no name, `--json` for a
|
|
@@ -40,7 +42,12 @@ HEAD, tell the owner and choose another id.
|
|
|
40
42
|
## Reading the result
|
|
41
43
|
|
|
42
44
|
Exit code 0 means every blocking check passed and the output contains the issue
|
|
43
|
-
title and body. Exit code 1 means it refused, and no body was produced.
|
|
45
|
+
title and body. Exit code 1 means it refused, and no body was produced. Exit
|
|
46
|
+
code 2 is a usage error: nothing was checked.
|
|
47
|
+
|
|
48
|
+
A check drawn as `▒ ?` did not run because one it depends on failed; its detail
|
|
49
|
+
names that check. It is not a failure of its own, and the closing refusal lists
|
|
50
|
+
root causes only. Fix those.
|
|
44
51
|
|
|
45
52
|
Each check names a source. `[marketplace-pin]` is the marketplace's own rule, read
|
|
46
53
|
from a pinned checkout. `[omakit]` is this tool's own check, derived from public
|
|
@@ -17,7 +17,8 @@ import { fileURLToPath } from "node:url"
|
|
|
17
17
|
import { ensurePin, MARKETPLACE_PIN } from "./pin.mjs"
|
|
18
18
|
import { marketplaceBaselineSection } from "./verify.mjs"
|
|
19
19
|
import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
|
|
20
|
-
import { submitPreflight } from "./submit.mjs"
|
|
20
|
+
import { missingSubmitFlags, submitPreflight } from "./submit.mjs"
|
|
21
|
+
import { submissionContract } from "./form.mjs"
|
|
21
22
|
import { validationWatch } from "./watch.mjs"
|
|
22
23
|
import { renderSubmit, renderWatch, renderDoctor } from "./report.mjs"
|
|
23
24
|
import { doctor } from "./doctor.mjs"
|
|
@@ -26,7 +27,7 @@ import { upgrade } from "./upgrade.mjs"
|
|
|
26
27
|
import { progress } from "./progress.mjs"
|
|
27
28
|
import { banner, bannerEnabled } from "./banner.mjs"
|
|
28
29
|
import { COMMANDS, renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
|
|
29
|
-
import { action, colourEnabled, GUTTER, mark, styler, wrap } from "./style.mjs"
|
|
30
|
+
import { action, colourEnabled, GUTTER, labelled, mark, styler, wrap } from "./style.mjs"
|
|
30
31
|
import { omakitCacheDir } from "./paths.mjs"
|
|
31
32
|
import { parityOutput } from "./parity-output.mjs"
|
|
32
33
|
|
|
@@ -55,11 +56,12 @@ const REMEDY = Object.freeze({
|
|
|
55
56
|
/**
|
|
56
57
|
* Every failure, in one register, on stderr. `usage` errors carry the
|
|
57
58
|
* signature that was expected, so the remedy is the reference and not a
|
|
58
|
-
* restatement of the message.
|
|
59
|
+
* restatement of the message. `body` is extra labelled lines between the
|
|
60
|
+
* message and the arrow, for a usage error that has values to list.
|
|
59
61
|
*/
|
|
60
|
-
function fail(code, message, exit = 1, remedy = REMEDY[code]) {
|
|
62
|
+
function fail(code, message, exit = 1, remedy = REMEDY[code], body = () => []) {
|
|
61
63
|
const c = styler(colourEnabled(process.stderr))
|
|
62
|
-
const lines = [`${mark("fail", c)}${c("name", code)}`, ...wrap(message, { indent: GUTTER }, c)]
|
|
64
|
+
const lines = [`${mark("fail", c)}${c("name", code)}`, ...wrap(message, { indent: GUTTER }, c), ...body(c)]
|
|
63
65
|
if (remedy) lines.push(...action(remedy, c))
|
|
64
66
|
process.stderr.write(`${lines.join("\n")}\n`)
|
|
65
67
|
process.exit(exit)
|
|
@@ -96,6 +98,28 @@ function emit(args, text) {
|
|
|
96
98
|
async function cmdSubmit(args) {
|
|
97
99
|
const target = positionals(args)[0]
|
|
98
100
|
if (!target) fail("usage", "submit needs a target: `omakit submit <target> --category <c> --tags <a,b>`", 2)
|
|
101
|
+
// A missing category or tag list is a usage error, decided before any check
|
|
102
|
+
// runs and before the spinner starts: nothing can be rendered without them,
|
|
103
|
+
// and the values the form accepts are the answer, read from the pin.
|
|
104
|
+
let usage = null
|
|
105
|
+
try {
|
|
106
|
+
usage = missingSubmitFlags(await submissionContract({ repoRoot: ROOT }), { category: option(args, "--category"), tags: option(args, "--tags") })
|
|
107
|
+
} catch (error) {
|
|
108
|
+
failFrom(error)
|
|
109
|
+
}
|
|
110
|
+
if (usage) {
|
|
111
|
+
if (args.includes("--json")) {
|
|
112
|
+
process.stdout.write(`${JSON.stringify({ usage }, null, 2)}\n`)
|
|
113
|
+
process.exit(2)
|
|
114
|
+
}
|
|
115
|
+
const flags = usage.missing.join(" and ")
|
|
116
|
+
fail("usage", `submit needs ${flags}: ${usage.missing.length === 1 ? "it is" : "they are"} an editorial choice nobody else can make, from the pinned form's own lists.`, 2,
|
|
117
|
+
`omakit submit ${target} --category <c> --tags <a,b>`,
|
|
118
|
+
(c) => [
|
|
119
|
+
...labelled("categories", usage.categories.join(", "), c),
|
|
120
|
+
...labelled(`tags, 1 to ${usage.maximumTags}`, usage.tags.join(", "), c),
|
|
121
|
+
])
|
|
122
|
+
}
|
|
99
123
|
const spinner = args.includes("--json") ? { phase: () => {}, done: () => {} } : progress()
|
|
100
124
|
let result
|
|
101
125
|
try {
|
|
@@ -236,7 +236,10 @@ export function registrySourceDetail(live) {
|
|
|
236
236
|
* `registry` and `catalog` are the parsed files from liveRegistry(); without
|
|
237
237
|
* them the pin's copies are read.
|
|
238
238
|
* @returns {{ reservedPrefix: string, listedIds: Set<string>, retiredIds: Set<string>,
|
|
239
|
-
* listedRepositories: Set<string>, counts: object }}
|
|
239
|
+
* listedRepositories: Set<string>, listedBy: Map<string, string>, counts: object }}
|
|
240
|
+
* `listedBy` names the repository slug that lists each id, where the
|
|
241
|
+
* registry or catalog says which, so a taken id can be blamed on a
|
|
242
|
+
* repository and told apart from the subject's own listing.
|
|
240
243
|
*/
|
|
241
244
|
export function idUniverse(options = {}) {
|
|
242
245
|
const pinDir = options.pinDir || requirePin(options.repoRoot).dir
|
|
@@ -244,8 +247,14 @@ export function idUniverse(options = {}) {
|
|
|
244
247
|
const registry = options.registry || readJson(pinDir, REGISTRY_PATH)
|
|
245
248
|
|
|
246
249
|
const listedIds = new Set()
|
|
250
|
+
const listedBy = new Map()
|
|
251
|
+
const listed = (id, repo) => {
|
|
252
|
+
listedIds.add(id)
|
|
253
|
+
const slug = repositorySlug(repo)
|
|
254
|
+
if (slug && !listedBy.has(id)) listedBy.set(id, slug)
|
|
255
|
+
}
|
|
247
256
|
for (const plugin of Array.isArray(catalog.plugins) ? catalog.plugins : []) {
|
|
248
|
-
if (typeof plugin?.id === "string" && plugin.id)
|
|
257
|
+
if (typeof plugin?.id === "string" && plugin.id) listed(plugin.id, plugin.repo)
|
|
249
258
|
}
|
|
250
259
|
const catalogIds = listedIds.size
|
|
251
260
|
|
|
@@ -255,17 +264,17 @@ export function idUniverse(options = {}) {
|
|
|
255
264
|
const slug = repositorySlug(source?.repo)
|
|
256
265
|
if (slug) listedRepositories.add(slug)
|
|
257
266
|
if (source?.plugins && typeof source.plugins === "object" && !Array.isArray(source.plugins)) {
|
|
258
|
-
for (const id of Object.keys(source.plugins))
|
|
267
|
+
for (const id of Object.keys(source.plugins)) listed(id, source.repo)
|
|
259
268
|
}
|
|
260
269
|
if (Array.isArray(source?.plugins)) {
|
|
261
270
|
for (const entry of source.plugins) {
|
|
262
271
|
const id = typeof entry === "string" ? entry : entry?.id
|
|
263
|
-
if (id)
|
|
272
|
+
if (id) listed(id, source.repo)
|
|
264
273
|
}
|
|
265
274
|
}
|
|
266
|
-
if (typeof source?.catalog?.id === "string")
|
|
275
|
+
if (typeof source?.catalog?.id === "string") listed(source.catalog.id, source.repo)
|
|
267
276
|
for (const id of source?.automatedSecurityBaseline?.pluginIds || []) {
|
|
268
|
-
if (typeof id === "string")
|
|
277
|
+
if (typeof id === "string") listed(id, source.repo)
|
|
269
278
|
}
|
|
270
279
|
}
|
|
271
280
|
|
|
@@ -281,6 +290,7 @@ export function idUniverse(options = {}) {
|
|
|
281
290
|
listedIds,
|
|
282
291
|
retiredIds,
|
|
283
292
|
listedRepositories,
|
|
293
|
+
listedBy,
|
|
284
294
|
counts: {
|
|
285
295
|
catalogPlugins: catalogIds,
|
|
286
296
|
registrySources: sources.length,
|
|
@@ -354,7 +364,11 @@ export function figure(n) {
|
|
|
354
364
|
|
|
355
365
|
/**
|
|
356
366
|
* @param {{ id: string, repositoryUrl?: string|null }} subject
|
|
357
|
-
* @returns {{ ok: boolean, problems: Array<{ code: string, detail: string }> }}
|
|
367
|
+
* @returns {{ ok: boolean, problems: Array<{ code: string, detail: string, repository?: string|null, sameRepository?: boolean }> }}
|
|
368
|
+
* A `plugin-id-listed` problem names the repository that lists the id
|
|
369
|
+
* (`repository`, a slug, or null when the registry does not say) and
|
|
370
|
+
* whether that is the subject's own (`sameRepository`), because the two
|
|
371
|
+
* have different remedies: another id, or nothing to submit at all.
|
|
358
372
|
*/
|
|
359
373
|
export function checkIdentity(universe, subject) {
|
|
360
374
|
const problems = []
|
|
@@ -363,6 +377,7 @@ export function checkIdentity(universe, subject) {
|
|
|
363
377
|
problems.push({ code: "plugin-id-missing", detail: "the root manifest declares no id" })
|
|
364
378
|
return { ok: false, problems }
|
|
365
379
|
}
|
|
380
|
+
const slug = repositorySlug(subject.repositoryUrl)
|
|
366
381
|
if (id.toLowerCase().startsWith(universe.reservedPrefix)) {
|
|
367
382
|
problems.push({
|
|
368
383
|
code: "reserved-plugin-id",
|
|
@@ -373,11 +388,17 @@ export function checkIdentity(universe, subject) {
|
|
|
373
388
|
problems.push({ code: "plugin-id-retired", detail: `"${id}" was used by a previous listing (registry.json retiredPluginIds)` })
|
|
374
389
|
}
|
|
375
390
|
if (universe.listedIds.has(id)) {
|
|
376
|
-
|
|
391
|
+
const repository = universe.listedBy?.get(id) || null
|
|
392
|
+
const sameRepository = Boolean(repository && slug && repository === slug)
|
|
393
|
+
problems.push({
|
|
394
|
+
code: "plugin-id-listed",
|
|
395
|
+
detail: `"${id}" is already listed${repository ? ` by ${repository}` : ""}`,
|
|
396
|
+
repository,
|
|
397
|
+
sameRepository,
|
|
398
|
+
})
|
|
377
399
|
}
|
|
378
|
-
const slug = repositorySlug(subject.repositoryUrl)
|
|
379
400
|
if (slug && universe.listedRepositories.has(slug)) {
|
|
380
|
-
problems.push({ code: "submission-repository-listed", detail: `${slug} is already listed
|
|
401
|
+
problems.push({ code: "submission-repository-listed", detail: `${slug} is already listed`, repository: slug, sameRepository: true })
|
|
381
402
|
}
|
|
382
403
|
return { ok: problems.length === 0, problems }
|
|
383
404
|
}
|
|
@@ -23,9 +23,10 @@ import {
|
|
|
23
23
|
|
|
24
24
|
const body = " ".repeat(GUTTER)
|
|
25
25
|
|
|
26
|
-
/** The state a check renders in: an advisory failure is a note, not a FAIL. */
|
|
26
|
+
/** The state a check renders in: an advisory failure is a note, not a FAIL, and a check that waited on another is a question. */
|
|
27
27
|
function stateOf(check) {
|
|
28
28
|
if (check.verdict === "pass") return "pass"
|
|
29
|
+
if (check.verdict === "unknown") return "unknown"
|
|
29
30
|
return check.severity === "advisory" ? "advisory" : "fail"
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -53,7 +54,7 @@ function checkBlock(check, c) {
|
|
|
53
54
|
? `${body}${c("fail", "-")} ${c("placeholder", line.trimStart().slice(2))}`
|
|
54
55
|
: `${" ".repeat(GUTTER + STEP)}${c("placeholder", line.trimStart())}`)))
|
|
55
56
|
}
|
|
56
|
-
|
|
57
|
+
for (const remedy of [].concat(check.remedy || [])) out.push(...action(remedy, c))
|
|
57
58
|
// The measured reason is the point of the check, so it is not dimmed: only
|
|
58
59
|
// its label is grey, and on a low-contrast theme the number still reads.
|
|
59
60
|
out.push(...labelled("why", check.why, c))
|
|
@@ -69,12 +70,14 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
|
|
|
69
70
|
out.push(...field("marketplace", `${result.pin.commit}, baseline ${result.pin.baselineVersion}, ${result.pin.enforcementMode}`, c))
|
|
70
71
|
out.push("")
|
|
71
72
|
|
|
72
|
-
// Passing checks run together
|
|
73
|
+
// Passing checks run together, and so does a check that waited on another:
|
|
74
|
+
// both are two quiet lines. Anything else gets a blank line on each side,
|
|
73
75
|
// collapsed where two blocks meet.
|
|
76
|
+
const quiet = (state) => state === "pass" || state === "unknown"
|
|
74
77
|
let previous = "pass"
|
|
75
78
|
for (const [index, check] of result.checks.entries()) {
|
|
76
79
|
const state = stateOf(check)
|
|
77
|
-
if (index > 0 && (state
|
|
80
|
+
if (index > 0 && (!quiet(state) || !quiet(previous))) out.push("")
|
|
78
81
|
out.push(...checkBlock(check, c))
|
|
79
82
|
previous = state
|
|
80
83
|
}
|
|
@@ -89,13 +92,17 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
|
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
if (!result.ready) {
|
|
95
|
+
// Root causes only: a check that waited on a failed one is not listed,
|
|
96
|
+
// and the count says how many waited.
|
|
92
97
|
const failed = result.checks.filter((check) => result.blocking.includes(check.id))
|
|
98
|
+
const waited = result.checks.filter((check) => check.verdict === "unknown").length
|
|
93
99
|
const count = failed.length === 1 ? "1 blocking check" : `${failed.length} blocking checks`
|
|
94
|
-
|
|
100
|
+
const waiting = waited ? ` ${waited === 1 ? "1 check" : `${waited} checks`} could not run until ${failed.length === 1 ? "it passes" : "they pass"}.` : ""
|
|
101
|
+
out.push(...verdict("fail", "REFUSED", `${count} failed, so no submission body is produced.${waiting}`, c))
|
|
95
102
|
out.push("")
|
|
96
103
|
for (const check of failed) {
|
|
97
104
|
out.push(`${body}${c("name", check.id)}`)
|
|
98
|
-
if (check.remedy) out.push(...action(
|
|
105
|
+
if (check.remedy) for (const remedy of [].concat(check.remedy)) out.push(...action(remedy, c))
|
|
99
106
|
else out.push(...wrap(check.detail, { indent: GUTTER }, c))
|
|
100
107
|
out.push("")
|
|
101
108
|
}
|
|
@@ -23,6 +23,39 @@ 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
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One arrow per cause, in this order, so a person fixes the thing that is
|
|
42
|
+
* actually wrong. Measured before this: an id listed by its own repository
|
|
43
|
+
* was told to "choose an unused plugin id outside the reserved namespace",
|
|
44
|
+
* which is the remedy for a different failure, and an author who followed
|
|
45
|
+
* it would have renamed a plugin the marketplace already lists.
|
|
46
|
+
*/
|
|
47
|
+
function identityRemedies(identity, universe, newerCommit) {
|
|
48
|
+
const remedies = []
|
|
49
|
+
const codes = new Set(identity.problems.map((problem) => problem.code))
|
|
50
|
+
if (codes.has("reserved-plugin-id")) remedies.push(`Choose a plugin id outside the reserved ${universe.reservedPrefix}* namespace.`)
|
|
51
|
+
if (codes.has("plugin-id-retired")) remedies.push("That id was retired by the marketplace and cannot be reused; choose another.")
|
|
52
|
+
const taken = identity.problems.find((problem) => problem.code === "plugin-id-listed" && !problem.sameRepository)
|
|
53
|
+
if (taken) remedies.push(taken.repository ? `That id is taken by ${taken.repository}; choose another.` : "That id is already listed; choose another.")
|
|
54
|
+
if (identity.problems.some((problem) => problem.sameRepository)) {
|
|
55
|
+
remedies.push(`This plugin is already listed, so there is nothing to submit. To get a newer commit listed, use the marketplace's verification form and choose "${newerCommit}"; \`omakit watch <the submission issue>\` shows which commit is listed now.`)
|
|
56
|
+
}
|
|
57
|
+
return remedies
|
|
58
|
+
}
|
|
26
59
|
|
|
27
60
|
export class SubmitError extends Error {
|
|
28
61
|
constructor(code, message) {
|
|
@@ -32,19 +65,49 @@ export class SubmitError extends Error {
|
|
|
32
65
|
}
|
|
33
66
|
}
|
|
34
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A check has three verdicts. `pass` and `fail` are its own. `unknown` is
|
|
70
|
+
* for a check that could not run because one it depends on failed: it is
|
|
71
|
+
* rendered as a question, its detail names what it waited on, and it counts
|
|
72
|
+
* in neither `blocking` nor `advisory`, so a refusal lists root causes only.
|
|
73
|
+
* Measured before this: a run with no --category and no --tags on a listed
|
|
74
|
+
* plugin said "6 blocking checks failed" for two causes, because headings,
|
|
75
|
+
* checklist and official-parser each failed for want of a body nobody could
|
|
76
|
+
* render yet, and the closing refusal listed them with "not rendered" where
|
|
77
|
+
* a remedy goes.
|
|
78
|
+
*/
|
|
35
79
|
function check(id, fields) {
|
|
80
|
+
const waitedOn = (fields.waitedOn || []).filter(Boolean)
|
|
36
81
|
return {
|
|
37
82
|
id,
|
|
38
83
|
source: fields.source,
|
|
39
84
|
why: fields.why,
|
|
40
85
|
severity: fields.severity || "blocking",
|
|
41
|
-
verdict: fields.verdict ? "pass" : "fail",
|
|
42
|
-
detail: fields.detail || "",
|
|
86
|
+
verdict: waitedOn.length ? "unknown" : fields.verdict ? "pass" : "fail",
|
|
87
|
+
detail: waitedOn.length ? `not checked: it needs ${waitedOn.join(" and ")} to pass first` : fields.detail || "",
|
|
43
88
|
paths: fields.paths || [],
|
|
44
|
-
|
|
89
|
+
// One arrow, or one per cause in the order they should be read.
|
|
90
|
+
remedy: waitedOn.length ? null : Array.isArray(fields.remedy) ? (fields.remedy.length ? fields.remedy : null) : fields.remedy || null,
|
|
45
91
|
}
|
|
46
92
|
}
|
|
47
93
|
|
|
94
|
+
/**
|
|
95
|
+
* What `submit` needs on the command line, decided before any check runs:
|
|
96
|
+
* the category and the tags are editorial choices nobody else can make, and
|
|
97
|
+
* a run without them has nothing to render. The controlled values come from
|
|
98
|
+
* the form at the pin, so the usage message lists exactly what the form
|
|
99
|
+
* accepts. Null when nothing is missing.
|
|
100
|
+
*
|
|
101
|
+
* @returns {{ missing: string[], categories: string[], tags: string[], maximumTags: number }|null}
|
|
102
|
+
*/
|
|
103
|
+
export function missingSubmitFlags(contract, { category, tags } = {}) {
|
|
104
|
+
const missing = []
|
|
105
|
+
if (!String(category ?? "").trim()) missing.push("--category")
|
|
106
|
+
if (!(Array.isArray(tags) ? tags : String(tags ?? "").split(",")).some((value) => String(value).trim())) missing.push("--tags")
|
|
107
|
+
if (!missing.length) return null
|
|
108
|
+
return { missing, categories: [...contract.categories], tags: [...contract.tagLabels], maximumTags: contract.maximumTags }
|
|
109
|
+
}
|
|
110
|
+
|
|
48
111
|
/**
|
|
49
112
|
* @param {{ repoRoot: string, target: string, category?: string, tags?: string|string[],
|
|
50
113
|
* notes?: string, suggestedTag?: string, pluginName?: string,
|
|
@@ -60,7 +123,7 @@ export async function submitPreflight(options) {
|
|
|
60
123
|
const phase = options.onPhase || (() => {})
|
|
61
124
|
|
|
62
125
|
phase("verifying the pinned marketplace checkout")
|
|
63
|
-
const { identity: pinIdentity } = requirePin(repoRoot)
|
|
126
|
+
const { dir: pinDir, identity: pinIdentity } = requirePin(repoRoot)
|
|
64
127
|
phase("reading the submission contract from the pin")
|
|
65
128
|
const contract = await submissionContract({ repoRoot })
|
|
66
129
|
phase(options.offline ? "reading the listed and retired plugin ids from the pin" : "reading the marketplace's current registry")
|
|
@@ -149,6 +212,7 @@ export async function submitPreflight(options) {
|
|
|
149
212
|
// --- identity -------------------------------------------------------------
|
|
150
213
|
|
|
151
214
|
const identity = checkIdentity(universe, { id: tree.pluginId, repositoryUrl: subject.repository.url })
|
|
215
|
+
const identityRemedy = identity.ok ? null : identityRemedies(identity, universe, await newerCommitAction(pinDir))
|
|
152
216
|
checks.push(check("identity.available", {
|
|
153
217
|
source: "marketplace-pin",
|
|
154
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.`,
|
|
@@ -156,7 +220,7 @@ export async function submitPreflight(options) {
|
|
|
156
220
|
detail: `${identity.ok
|
|
157
221
|
? `id "${tree.pluginId}" is unused, outside the reserved ${universe.reservedPrefix}* namespace, and the repository is not listed`
|
|
158
222
|
: identity.problems.map((problem) => `${problem.code}: ${problem.detail}`).join("; ")}; ${registrySourceDetail(live)}`,
|
|
159
|
-
remedy:
|
|
223
|
+
remedy: identityRemedy,
|
|
160
224
|
}))
|
|
161
225
|
|
|
162
226
|
// --- the submission itself ------------------------------------------------
|
|
@@ -191,9 +255,17 @@ export async function submitPreflight(options) {
|
|
|
191
255
|
remedy: tags.ok ? null : "Pass --tags with 1 to 3 comma-separated values from the list.",
|
|
192
256
|
}))
|
|
193
257
|
|
|
258
|
+
// The body needs every field above and the repository URL below; the three
|
|
259
|
+
// checks that read it wait on whichever of those failed.
|
|
260
|
+
const bodyWaitsOn = [
|
|
261
|
+
!pluginName && "submission.title",
|
|
262
|
+
!category.ok && "submission.category",
|
|
263
|
+
!tags.ok && "submission.tags",
|
|
264
|
+
!subject.repository.url && "submission.repository-url",
|
|
265
|
+
].filter(Boolean)
|
|
194
266
|
let issue = null
|
|
195
267
|
let parsed = null
|
|
196
|
-
if (
|
|
268
|
+
if (!bodyWaitsOn.length) {
|
|
197
269
|
issue = renderIssue(contract, {
|
|
198
270
|
pluginName,
|
|
199
271
|
repositoryUrl: subject.repository.url,
|
|
@@ -217,7 +289,8 @@ export async function submitPreflight(options) {
|
|
|
217
289
|
source: "marketplace-pin",
|
|
218
290
|
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.`,
|
|
219
291
|
verdict: Boolean(issue),
|
|
220
|
-
detail: issue ? `${contract.headings.length} headings rendered in form order` : "not rendered
|
|
292
|
+
detail: issue ? `${contract.headings.length} headings rendered in form order` : "not rendered",
|
|
293
|
+
waitedOn: bodyWaitsOn,
|
|
221
294
|
}))
|
|
222
295
|
|
|
223
296
|
checks.push(check("submission.checklist", {
|
|
@@ -225,6 +298,7 @@ export async function submitPreflight(options) {
|
|
|
225
298
|
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.`,
|
|
226
299
|
verdict: Boolean(issue),
|
|
227
300
|
detail: issue ? `${contract.checklist.length} items rendered with the form's exact text, all checked` : "not rendered",
|
|
301
|
+
waitedOn: bodyWaitsOn,
|
|
228
302
|
}))
|
|
229
303
|
|
|
230
304
|
checks.push(check("submission.official-parser", {
|
|
@@ -236,6 +310,7 @@ export async function submitPreflight(options) {
|
|
|
236
310
|
? `accepted: repo ${parsed.submission.repo}, category ${parsed.submission.category}, tags ${parsed.submission.tags.join(", ")}`
|
|
237
311
|
: `refused by the marketplace's own parser: ${parsed.code}, ${parsed.message}`
|
|
238
312
|
: "not run: no body was rendered",
|
|
313
|
+
waitedOn: bodyWaitsOn,
|
|
239
314
|
}))
|
|
240
315
|
|
|
241
316
|
// --- the commit the marketplace will actually validate ---------------------
|
|
@@ -303,6 +378,7 @@ export async function submitPreflight(options) {
|
|
|
303
378
|
|
|
304
379
|
const blocking = checks.filter((entry) => entry.severity === "blocking" && entry.verdict === "fail")
|
|
305
380
|
const advisory = checks.filter((entry) => entry.severity === "advisory" && entry.verdict === "fail")
|
|
381
|
+
const unknown = checks.filter((entry) => entry.verdict === "unknown")
|
|
306
382
|
const ready = blocking.length === 0
|
|
307
383
|
|
|
308
384
|
return {
|
|
@@ -337,6 +413,7 @@ export async function submitPreflight(options) {
|
|
|
337
413
|
ready,
|
|
338
414
|
blocking: blocking.map((entry) => entry.id),
|
|
339
415
|
advisory: advisory.map((entry) => entry.id),
|
|
416
|
+
unknown: unknown.map((entry) => entry.id),
|
|
340
417
|
issue: ready ? issue : null,
|
|
341
418
|
baseline: preflight.invoked
|
|
342
419
|
? {
|