omakit 0.1.2 → 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 +39 -19
- package/package.json +1 -1
- package/skills/omarchy-plugin-submit/SKILL.md +17 -3
- package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -0
- package/tools/marketplace/README.md +9 -6
- package/tools/marketplace/cli.mjs +29 -5
- package/tools/marketplace/doctor.mjs +98 -19
- package/tools/marketplace/path-hint.mjs +53 -0
- package/tools/marketplace/registry.mjs +211 -21
- package/tools/marketplace/report.mjs +13 -6
- package/tools/marketplace/setup.mjs +9 -10
- package/tools/marketplace/submit.mjs +103 -14
- package/tools/marketplace/upgrade.mjs +123 -13
- package/tools/marketplace/usage.mjs +7 -6
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
// The plugin-id and repository universe
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// retired ids out of `registry.json`.
|
|
1
|
+
// The plugin-id and repository universe. Like the submission contract, nothing
|
|
2
|
+
// here is hardcoded: the reserved namespace is read out of the marketplace's
|
|
3
|
+
// own catalog builder, the listed ids out of the published catalog and the
|
|
4
|
+
// registry sources, and the retired ids out of `registry.json`.
|
|
6
5
|
//
|
|
7
6
|
// Measured reason these three checks exist (docs/MEASUREMENTS.md M2): the
|
|
8
7
|
// marketplace refuses a submission whose id is already listed
|
|
@@ -13,15 +12,31 @@
|
|
|
13
12
|
// median submission-to-publication time rose sevenfold between 2026-W33 and
|
|
14
13
|
// 2026-W36, and it is knowable before submitting from data the marketplace
|
|
15
14
|
// publishes.
|
|
15
|
+
//
|
|
16
|
+
// Two kinds of file, two sources (docs/MEASUREMENTS.md M7). The catalog
|
|
17
|
+
// builder is code, and code is only ever read from the pin: it moves a few
|
|
18
|
+
// times a month and must never be fetched and executed unreviewed. The
|
|
19
|
+
// registry and the catalog are data, and the pin's copy of them is stale
|
|
20
|
+
// within hours: registry.json changed in 4,201 of the marketplace's 4,293
|
|
21
|
+
// commits in the 30 days to 2026-09-13, about 140 a day. So those two files
|
|
22
|
+
// are read from the marketplace's current default-branch HEAD when the network
|
|
23
|
+
// is there, at the exact commit `defaultBranchHead()` resolved so they cannot
|
|
24
|
+
// disagree with each other, and from the pin when it is not or when the caller
|
|
25
|
+
// asks for --offline. Every result says which, with the commit.
|
|
16
26
|
|
|
17
|
-
import { readFileSync } from "node:fs"
|
|
18
|
-
import { join } from "node:path"
|
|
19
|
-
import { requirePin } from "./pin.mjs"
|
|
27
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
28
|
+
import { dirname, join } from "node:path"
|
|
29
|
+
import { MARKETPLACE_PIN, requirePin } from "./pin.mjs"
|
|
30
|
+
import { defaultBranchHead, getJson } from "./github.mjs"
|
|
31
|
+
import { omakitCacheDir } from "./paths.mjs"
|
|
20
32
|
|
|
21
33
|
export const CATALOG_PATH = "site/catalog.json"
|
|
22
34
|
export const REGISTRY_PATH = "registry.json"
|
|
23
35
|
export const CATALOG_BUILDER_PATH = "scripts/build-catalog.mjs"
|
|
24
36
|
|
|
37
|
+
/** The only two marketplace files ever read from HEAD. Everything else comes from the pin. */
|
|
38
|
+
export const LIVE_PATHS = Object.freeze([REGISTRY_PATH, CATALOG_PATH])
|
|
39
|
+
|
|
25
40
|
export class RegistryError extends Error {
|
|
26
41
|
constructor(code, message) {
|
|
27
42
|
super(message)
|
|
@@ -65,18 +80,181 @@ function repositorySlug(value) {
|
|
|
65
80
|
}
|
|
66
81
|
|
|
67
82
|
/**
|
|
68
|
-
*
|
|
83
|
+
* The one URL shape a live registry file is read from: one of LIVE_PATHS at
|
|
84
|
+
* one explicit 40-character commit on the marketplace's raw file host. Never
|
|
85
|
+
* a branch name, so the two files always come from the same commit and the
|
|
86
|
+
* commit named in the output is the one they came from; never a path outside
|
|
87
|
+
* LIVE_PATHS, so nothing executable can arrive this way.
|
|
88
|
+
*/
|
|
89
|
+
export function liveFileUrl(commit, path) {
|
|
90
|
+
if (!/^[0-9a-f]{40}$/.test(String(commit))) {
|
|
91
|
+
throw new RegistryError("usage", `a live registry file is read at a 40-character commit, not "${commit}"`)
|
|
92
|
+
}
|
|
93
|
+
if (!LIVE_PATHS.includes(path)) {
|
|
94
|
+
throw new RegistryError("usage", `${path} is never read from HEAD; only ${LIVE_PATHS.join(" and ")} are`)
|
|
95
|
+
}
|
|
96
|
+
const raw = MARKETPLACE_PIN.repository.replace(/^https:\/\/github\.com\//, "https://raw.githubusercontent.com/")
|
|
97
|
+
return `${raw}/${commit}/${path}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Where the live files for one commit are kept: beside the pin, never inside it. */
|
|
101
|
+
export function liveCacheDir(commit, cacheRoot = omakitCacheDir("registry")) {
|
|
102
|
+
return join(cacheRoot, commit)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A cached commit is one whose meta.json, written last, names every file present. */
|
|
106
|
+
function readCached(liveCache) {
|
|
107
|
+
try {
|
|
108
|
+
const meta = JSON.parse(readFileSync(join(liveCache, "meta.json"), "utf8"))
|
|
109
|
+
if (typeof meta.fetchedAt !== "string" || JSON.stringify(meta.paths) !== JSON.stringify(LIVE_PATHS)) return null
|
|
110
|
+
const files = {}
|
|
111
|
+
for (const path of LIVE_PATHS) files[path] = JSON.parse(readFileSync(join(liveCache, path), "utf8"))
|
|
112
|
+
return { fetchedAt: meta.fetchedAt, files }
|
|
113
|
+
} catch {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* One directory per commit, the files first and meta.json last, so a run that
|
|
120
|
+
* dies mid-write leaves a directory readCached() does not accept. Older
|
|
121
|
+
* commits are removed: at about 140 registry commits a day, keeping every one
|
|
122
|
+
* would grow the cache by the measured 13 MB per run.
|
|
123
|
+
*/
|
|
124
|
+
function writeCached(cacheRoot, commit, fetchedAt, files) {
|
|
125
|
+
const liveCache = liveCacheDir(commit, cacheRoot)
|
|
126
|
+
rmSync(liveCache, { recursive: true, force: true })
|
|
127
|
+
for (const path of LIVE_PATHS) {
|
|
128
|
+
mkdirSync(dirname(join(liveCache, path)), { recursive: true })
|
|
129
|
+
writeFileSync(join(liveCache, path), JSON.stringify(files[path]))
|
|
130
|
+
}
|
|
131
|
+
writeFileSync(join(liveCache, "meta.json"), `${JSON.stringify({ commit, fetchedAt, paths: LIVE_PATHS }, null, 2)}\n`)
|
|
132
|
+
for (const entry of readdirSync(cacheRoot, { withFileTypes: true })) {
|
|
133
|
+
if (entry.isDirectory() && entry.name !== commit) rmSync(join(cacheRoot, entry.name), { recursive: true, force: true })
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The registry and the catalog, from the marketplace's current default-branch
|
|
139
|
+
* HEAD when the network is there and from the pin when it is not. Never
|
|
140
|
+
* throws for a network reason: a HEAD that cannot be read falls back to the pin
|
|
141
|
+
* and says so in `reason`. The pinned checkout is never written to; a fetched
|
|
142
|
+
* pair is cached under `cacheRoot/<commit>/`.
|
|
143
|
+
*
|
|
144
|
+
* `resolveHead`, `fetchJson`, `cacheRoot` and `now` are injectable for tests;
|
|
145
|
+
* the defaults are the tool's one HEAD resolver and its one GET call site.
|
|
146
|
+
*
|
|
147
|
+
* @param {{ repoRoot?: string, pinDir?: string, offline?: boolean,
|
|
148
|
+
* resolveHead?: (url: string) => Promise<{ commit: string }>,
|
|
149
|
+
* fetchJson?: (url: string) => Promise<object>, cacheRoot?: string,
|
|
150
|
+
* now?: () => string }} [options]
|
|
151
|
+
* @returns {Promise<{ source: "head"|"pin", commit: string, fetchedAt: string|null,
|
|
152
|
+
* reason: string|null, fallback: { what: string, code: string, message: string }|null,
|
|
153
|
+
* registry: object, catalog: object }>}
|
|
154
|
+
* `reason` is the whole story, for `--json`; `fallback` is its parts, for
|
|
155
|
+
* the one line a person reads (registrySourceDetail).
|
|
156
|
+
*/
|
|
157
|
+
export async function liveRegistry(options = {}) {
|
|
158
|
+
const pinDir = options.pinDir || requirePin(options.repoRoot).dir
|
|
159
|
+
const resolveHead = options.resolveHead || defaultBranchHead
|
|
160
|
+
const fetchJson = options.fetchJson || getJson
|
|
161
|
+
const cacheRoot = options.cacheRoot || omakitCacheDir("registry")
|
|
162
|
+
const now = options.now || (() => new Date().toISOString())
|
|
163
|
+
const fromPin = (reason, fallback = null) => ({
|
|
164
|
+
source: "pin",
|
|
165
|
+
commit: MARKETPLACE_PIN.commit,
|
|
166
|
+
fetchedAt: null,
|
|
167
|
+
reason,
|
|
168
|
+
fallback,
|
|
169
|
+
registry: readJson(pinDir, REGISTRY_PATH),
|
|
170
|
+
catalog: readJson(pinDir, CATALOG_PATH),
|
|
171
|
+
})
|
|
172
|
+
const failed = (what, error) => {
|
|
173
|
+
const code = error?.code || "error"
|
|
174
|
+
const message = String(error?.message || error)
|
|
175
|
+
return fromPin(`${what} (${code}): ${message}`, { what, code, message })
|
|
176
|
+
}
|
|
177
|
+
if (options.offline) return fromPin("--offline")
|
|
178
|
+
|
|
179
|
+
let commit
|
|
180
|
+
try {
|
|
181
|
+
commit = String((await resolveHead(MARKETPLACE_PIN.repository)).commit).toLowerCase()
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return failed("HEAD unreadable", error)
|
|
184
|
+
}
|
|
185
|
+
if (commit === MARKETPLACE_PIN.commit) {
|
|
186
|
+
// HEAD is the pin, so the pin's files are HEAD's files: nothing to fetch.
|
|
187
|
+
return { ...fromPin(null), source: "head", fetchedAt: now() }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const cached = readCached(liveCacheDir(commit, cacheRoot))
|
|
191
|
+
if (cached) {
|
|
192
|
+
return { source: "head", commit, fetchedAt: cached.fetchedAt, reason: null, fallback: null, registry: cached.files[REGISTRY_PATH], catalog: cached.files[CATALOG_PATH] }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const files = {}
|
|
196
|
+
try {
|
|
197
|
+
for (const path of LIVE_PATHS) files[path] = await fetchJson(liveFileUrl(commit, path))
|
|
198
|
+
} catch (error) {
|
|
199
|
+
return failed(`registry at ${commit} unreadable`, error)
|
|
200
|
+
}
|
|
201
|
+
const fetchedAt = now()
|
|
202
|
+
try {
|
|
203
|
+
mkdirSync(cacheRoot, { recursive: true })
|
|
204
|
+
writeCached(cacheRoot, commit, fetchedAt, files)
|
|
205
|
+
} catch {
|
|
206
|
+
// A cache that cannot be written costs the next run a refetch, nothing else.
|
|
207
|
+
}
|
|
208
|
+
return { source: "head", commit, fetchedAt, reason: null, fallback: null, registry: files[REGISTRY_PATH], catalog: files[CATALOG_PATH] }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* How a check names where its registry data came from. Short hash for the
|
|
213
|
+
* pin, which the docs name that way; the full commit for HEAD, which nothing
|
|
214
|
+
* else names.
|
|
215
|
+
*
|
|
216
|
+
* A fallback is one clause, not the whole story: the transport error's own
|
|
217
|
+
* parenthetical and its "while reading <path>" tail are dropped and the
|
|
218
|
+
* failure code takes their place, so the person reads
|
|
219
|
+
* "registry at the pin 38060f89; HEAD unreadable: github.com did not answer
|
|
220
|
+
* (network-unavailable)" on one line. Measured before this: the full text
|
|
221
|
+
* nested three sets of parentheses and wrapped to three lines at 80 columns.
|
|
222
|
+
* `--json` keeps the whole text under `registry.reason`.
|
|
223
|
+
*/
|
|
224
|
+
export function registrySourceDetail(live) {
|
|
225
|
+
if (live.source === "head") return `registry at ${live.commit}, read ${live.fetchedAt}`
|
|
226
|
+
const pin = `registry at the pin ${live.commit.slice(0, 8)}`
|
|
227
|
+
if (!live.fallback) return live.reason === "--offline" ? `${pin} (offline)` : pin
|
|
228
|
+
const { what, code, message } = live.fallback
|
|
229
|
+
const clause = message.replace(/ while reading .*$/, "").replace(/\s*\([^()]*\)\s*$/, "").trim()
|
|
230
|
+
const short = what.replace(/^registry at ([0-9a-f]{40}) unreadable$/, (_, sha) => `HEAD ${sha.slice(0, 7)} unreadable`)
|
|
231
|
+
return `${pin}; ${short}: ${clause} (${code})`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @param {{ repoRoot?: string, pinDir?: string, registry?: object, catalog?: object }} [options]
|
|
236
|
+
* `registry` and `catalog` are the parsed files from liveRegistry(); without
|
|
237
|
+
* them the pin's copies are read.
|
|
69
238
|
* @returns {{ reservedPrefix: string, listedIds: Set<string>, retiredIds: Set<string>,
|
|
70
|
-
* 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.
|
|
71
243
|
*/
|
|
72
244
|
export function idUniverse(options = {}) {
|
|
73
245
|
const pinDir = options.pinDir || requirePin(options.repoRoot).dir
|
|
74
|
-
const catalog = readJson(pinDir, CATALOG_PATH)
|
|
75
|
-
const registry = readJson(pinDir, REGISTRY_PATH)
|
|
246
|
+
const catalog = options.catalog || readJson(pinDir, CATALOG_PATH)
|
|
247
|
+
const registry = options.registry || readJson(pinDir, REGISTRY_PATH)
|
|
76
248
|
|
|
77
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
|
+
}
|
|
78
256
|
for (const plugin of Array.isArray(catalog.plugins) ? catalog.plugins : []) {
|
|
79
|
-
if (typeof plugin?.id === "string" && plugin.id)
|
|
257
|
+
if (typeof plugin?.id === "string" && plugin.id) listed(plugin.id, plugin.repo)
|
|
80
258
|
}
|
|
81
259
|
const catalogIds = listedIds.size
|
|
82
260
|
|
|
@@ -86,17 +264,17 @@ export function idUniverse(options = {}) {
|
|
|
86
264
|
const slug = repositorySlug(source?.repo)
|
|
87
265
|
if (slug) listedRepositories.add(slug)
|
|
88
266
|
if (source?.plugins && typeof source.plugins === "object" && !Array.isArray(source.plugins)) {
|
|
89
|
-
for (const id of Object.keys(source.plugins))
|
|
267
|
+
for (const id of Object.keys(source.plugins)) listed(id, source.repo)
|
|
90
268
|
}
|
|
91
269
|
if (Array.isArray(source?.plugins)) {
|
|
92
270
|
for (const entry of source.plugins) {
|
|
93
271
|
const id = typeof entry === "string" ? entry : entry?.id
|
|
94
|
-
if (id)
|
|
272
|
+
if (id) listed(id, source.repo)
|
|
95
273
|
}
|
|
96
274
|
}
|
|
97
|
-
if (typeof source?.catalog?.id === "string")
|
|
275
|
+
if (typeof source?.catalog?.id === "string") listed(source.catalog.id, source.repo)
|
|
98
276
|
for (const id of source?.automatedSecurityBaseline?.pluginIds || []) {
|
|
99
|
-
if (typeof id === "string")
|
|
277
|
+
if (typeof id === "string") listed(id, source.repo)
|
|
100
278
|
}
|
|
101
279
|
}
|
|
102
280
|
|
|
@@ -112,6 +290,7 @@ export function idUniverse(options = {}) {
|
|
|
112
290
|
listedIds,
|
|
113
291
|
retiredIds,
|
|
114
292
|
listedRepositories,
|
|
293
|
+
listedBy,
|
|
115
294
|
counts: {
|
|
116
295
|
catalogPlugins: catalogIds,
|
|
117
296
|
registrySources: sources.length,
|
|
@@ -185,7 +364,11 @@ export function figure(n) {
|
|
|
185
364
|
|
|
186
365
|
/**
|
|
187
366
|
* @param {{ id: string, repositoryUrl?: string|null }} subject
|
|
188
|
-
* @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.
|
|
189
372
|
*/
|
|
190
373
|
export function checkIdentity(universe, subject) {
|
|
191
374
|
const problems = []
|
|
@@ -194,6 +377,7 @@ export function checkIdentity(universe, subject) {
|
|
|
194
377
|
problems.push({ code: "plugin-id-missing", detail: "the root manifest declares no id" })
|
|
195
378
|
return { ok: false, problems }
|
|
196
379
|
}
|
|
380
|
+
const slug = repositorySlug(subject.repositoryUrl)
|
|
197
381
|
if (id.toLowerCase().startsWith(universe.reservedPrefix)) {
|
|
198
382
|
problems.push({
|
|
199
383
|
code: "reserved-plugin-id",
|
|
@@ -204,11 +388,17 @@ export function checkIdentity(universe, subject) {
|
|
|
204
388
|
problems.push({ code: "plugin-id-retired", detail: `"${id}" was used by a previous listing (registry.json retiredPluginIds)` })
|
|
205
389
|
}
|
|
206
390
|
if (universe.listedIds.has(id)) {
|
|
207
|
-
|
|
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
|
+
})
|
|
208
399
|
}
|
|
209
|
-
const slug = repositorySlug(subject.repositoryUrl)
|
|
210
400
|
if (slug && universe.listedRepositories.has(slug)) {
|
|
211
|
-
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 })
|
|
212
402
|
}
|
|
213
403
|
return { ok: problems.length === 0, problems }
|
|
214
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
|
}
|
|
@@ -11,8 +11,6 @@
|
|
|
11
11
|
// and stops, which is the same contract every other command here keeps.
|
|
12
12
|
|
|
13
13
|
import { execFileSync } from "node:child_process"
|
|
14
|
-
import { existsSync } from "node:fs"
|
|
15
|
-
import { delimiter, join } from "node:path"
|
|
16
14
|
import { banner } from "./banner.mjs"
|
|
17
15
|
import { credential, UNAUTHENTICATED_LIMIT } from "./github.mjs"
|
|
18
16
|
import { ensurePin, marketplacePinDir, pinDiskUsage } from "./pin.mjs"
|
|
@@ -21,6 +19,7 @@ import { action, colourEnabled, GUTTER, mark, styler, wrap } from "./style.mjs"
|
|
|
21
19
|
import { TAGLINE } from "./usage.mjs"
|
|
22
20
|
import { installCompletion } from "./completion.mjs"
|
|
23
21
|
import { submissionContract } from "./form.mjs"
|
|
22
|
+
import { pathHint } from "./path-hint.mjs"
|
|
24
23
|
|
|
25
24
|
function version(command) {
|
|
26
25
|
try {
|
|
@@ -32,11 +31,6 @@ function version(command) {
|
|
|
32
31
|
}
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
/** Is `omakit` reachable as a bare command, without asking a shell? */
|
|
36
|
-
export function onPath(name = "omakit", env = process.env) {
|
|
37
|
-
return (env.PATH || "").split(delimiter).some((dir) => dir && existsSync(join(dir, name)))
|
|
38
|
-
}
|
|
39
|
-
|
|
40
34
|
/**
|
|
41
35
|
* @param {{ repoRoot: string, entryPoint: string, stream?: NodeJS.WriteStream }} options
|
|
42
36
|
*/
|
|
@@ -101,9 +95,14 @@ export async function setup({ repoRoot, entryPoint, stream = process.stdout }) {
|
|
|
101
95
|
for (const line of wrap("Every rule omakit checks is read from that checkout, at that exact commit. It never moves on its own. `omakit doctor` says when it is behind.", {}, c)) out(line)
|
|
102
96
|
out()
|
|
103
97
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
98
|
+
// The hint is for the install that is here: a symlink for a clone, the npm
|
|
99
|
+
// prefix's bin on PATH for a package, in the shell in $SHELL (path-hint.mjs).
|
|
100
|
+
const reach = pathHint({ repoRoot, entryPoint })
|
|
101
|
+
if (!reach.reachable) {
|
|
102
|
+
step("info", reach.line
|
|
103
|
+
? `${reach.reason} This puts it there${reach.where ? `; keep it in ${reach.where}` : ""}:`
|
|
104
|
+
: reach.reason)
|
|
105
|
+
if (reach.line) fix(reach.line)
|
|
107
106
|
out()
|
|
108
107
|
}
|
|
109
108
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
|
|
15
15
|
import { requirePin } from "./pin.mjs"
|
|
16
16
|
import { submissionContract, resolveCategory, resolveTags } from "./form.mjs"
|
|
17
|
-
import { idUniverse, checkIdentity, baselineFigures, figure } from "./registry.mjs"
|
|
17
|
+
import { idUniverse, checkIdentity, baselineFigures, figure, liveRegistry, registrySourceDetail } 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,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,23 +65,56 @@ 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,
|
|
51
|
-
* allowDirty?: boolean, offline?: boolean
|
|
114
|
+
* allowDirty?: boolean, offline?: boolean,
|
|
115
|
+
* readRegistry?: typeof liveRegistry }} options
|
|
116
|
+
* `readRegistry` is injectable for tests; the default reads the marketplace's
|
|
117
|
+
* current HEAD, or the pin with `offline`.
|
|
52
118
|
*/
|
|
53
119
|
export async function submitPreflight(options) {
|
|
54
120
|
const { repoRoot } = options
|
|
@@ -57,11 +123,14 @@ export async function submitPreflight(options) {
|
|
|
57
123
|
const phase = options.onPhase || (() => {})
|
|
58
124
|
|
|
59
125
|
phase("verifying the pinned marketplace checkout")
|
|
60
|
-
const { identity: pinIdentity } = requirePin(repoRoot)
|
|
126
|
+
const { dir: pinDir, identity: pinIdentity } = requirePin(repoRoot)
|
|
61
127
|
phase("reading the submission contract from the pin")
|
|
62
128
|
const contract = await submissionContract({ repoRoot })
|
|
63
|
-
phase("reading the listed and retired plugin ids")
|
|
64
|
-
const
|
|
129
|
+
phase(options.offline ? "reading the listed and retired plugin ids from the pin" : "reading the marketplace's current registry")
|
|
130
|
+
const live = await (options.readRegistry || liveRegistry)({ repoRoot, offline: options.offline === true })
|
|
131
|
+
const universe = idUniverse({ repoRoot, registry: live.registry, catalog: live.catalog })
|
|
132
|
+
// The documented figures are the pin's by design: they are cited in prose
|
|
133
|
+
// that a test holds to the pin, so they never move between two runs.
|
|
65
134
|
const figures = baselineFigures({ repoRoot })
|
|
66
135
|
|
|
67
136
|
phase("resolving the subject commit")
|
|
@@ -143,14 +212,15 @@ export async function submitPreflight(options) {
|
|
|
143
212
|
// --- identity -------------------------------------------------------------
|
|
144
213
|
|
|
145
214
|
const identity = checkIdentity(universe, { id: tree.pluginId, repositoryUrl: subject.repository.url })
|
|
215
|
+
const identityRemedy = identity.ok ? null : identityRemedies(identity, universe, await newerCommitAction(pinDir))
|
|
146
216
|
checks.push(check("identity.available", {
|
|
147
217
|
source: "marketplace-pin",
|
|
148
|
-
why: `The marketplace refuses \`plugin-id-listed\`, \`plugin-id-retired\`, \`reserved-plugin-id\` and \`submission-repository-listed\`. Checked here against ${universe.counts.listedIds} listed ids, ${universe.counts.retiredIds} retired ids and ${universe.counts.listedRepositories} listed repositories
|
|
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.`,
|
|
149
219
|
verdict: identity.ok,
|
|
150
|
-
detail: identity.ok
|
|
220
|
+
detail: `${identity.ok
|
|
151
221
|
? `id "${tree.pluginId}" is unused, outside the reserved ${universe.reservedPrefix}* namespace, and the repository is not listed`
|
|
152
|
-
: identity.problems.map((problem) => `${problem.code}: ${problem.detail}`).join("; ")
|
|
153
|
-
remedy:
|
|
222
|
+
: identity.problems.map((problem) => `${problem.code}: ${problem.detail}`).join("; ")}; ${registrySourceDetail(live)}`,
|
|
223
|
+
remedy: identityRemedy,
|
|
154
224
|
}))
|
|
155
225
|
|
|
156
226
|
// --- the submission itself ------------------------------------------------
|
|
@@ -185,9 +255,17 @@ export async function submitPreflight(options) {
|
|
|
185
255
|
remedy: tags.ok ? null : "Pass --tags with 1 to 3 comma-separated values from the list.",
|
|
186
256
|
}))
|
|
187
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)
|
|
188
266
|
let issue = null
|
|
189
267
|
let parsed = null
|
|
190
|
-
if (
|
|
268
|
+
if (!bodyWaitsOn.length) {
|
|
191
269
|
issue = renderIssue(contract, {
|
|
192
270
|
pluginName,
|
|
193
271
|
repositoryUrl: subject.repository.url,
|
|
@@ -211,7 +289,8 @@ export async function submitPreflight(options) {
|
|
|
211
289
|
source: "marketplace-pin",
|
|
212
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.`,
|
|
213
291
|
verdict: Boolean(issue),
|
|
214
|
-
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,
|
|
215
294
|
}))
|
|
216
295
|
|
|
217
296
|
checks.push(check("submission.checklist", {
|
|
@@ -219,6 +298,7 @@ export async function submitPreflight(options) {
|
|
|
219
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.`,
|
|
220
299
|
verdict: Boolean(issue),
|
|
221
300
|
detail: issue ? `${contract.checklist.length} items rendered with the form's exact text, all checked` : "not rendered",
|
|
301
|
+
waitedOn: bodyWaitsOn,
|
|
222
302
|
}))
|
|
223
303
|
|
|
224
304
|
checks.push(check("submission.official-parser", {
|
|
@@ -230,6 +310,7 @@ export async function submitPreflight(options) {
|
|
|
230
310
|
? `accepted: repo ${parsed.submission.repo}, category ${parsed.submission.category}, tags ${parsed.submission.tags.join(", ")}`
|
|
231
311
|
: `refused by the marketplace's own parser: ${parsed.code}, ${parsed.message}`
|
|
232
312
|
: "not run: no body was rendered",
|
|
313
|
+
waitedOn: bodyWaitsOn,
|
|
233
314
|
}))
|
|
234
315
|
|
|
235
316
|
// --- the commit the marketplace will actually validate ---------------------
|
|
@@ -297,6 +378,7 @@ export async function submitPreflight(options) {
|
|
|
297
378
|
|
|
298
379
|
const blocking = checks.filter((entry) => entry.severity === "blocking" && entry.verdict === "fail")
|
|
299
380
|
const advisory = checks.filter((entry) => entry.severity === "advisory" && entry.verdict === "fail")
|
|
381
|
+
const unknown = checks.filter((entry) => entry.verdict === "unknown")
|
|
300
382
|
const ready = blocking.length === 0
|
|
301
383
|
|
|
302
384
|
return {
|
|
@@ -313,6 +395,12 @@ export async function submitPreflight(options) {
|
|
|
313
395
|
commit: subject.commit,
|
|
314
396
|
cleanTree: subject.clean,
|
|
315
397
|
},
|
|
398
|
+
registry: {
|
|
399
|
+
source: live.source,
|
|
400
|
+
commit: live.commit,
|
|
401
|
+
fetchedAt: live.fetchedAt,
|
|
402
|
+
reason: live.reason,
|
|
403
|
+
},
|
|
316
404
|
validationCommit: {
|
|
317
405
|
local: subject.commit,
|
|
318
406
|
defaultBranchHead: head?.commit || null,
|
|
@@ -325,6 +413,7 @@ export async function submitPreflight(options) {
|
|
|
325
413
|
ready,
|
|
326
414
|
blocking: blocking.map((entry) => entry.id),
|
|
327
415
|
advisory: advisory.map((entry) => entry.id),
|
|
416
|
+
unknown: unknown.map((entry) => entry.id),
|
|
328
417
|
issue: ready ? issue : null,
|
|
329
418
|
baseline: preflight.invoked
|
|
330
419
|
? {
|