omakit 0.1.0
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/LICENSE +21 -0
- package/README.md +229 -0
- package/bin/omakit +3 -0
- package/package.json +40 -0
- package/skills/omarchy-plugin-submit/SKILL.md +87 -0
- package/skills/omarchy-plugin-validation-watch/SKILL.md +62 -0
- package/tests/parity/corpus.mjs +62 -0
- package/tests/parity/run.mjs +194 -0
- package/tools/marketplace/README.md +64 -0
- package/tools/marketplace/agent-control.mjs +72 -0
- package/tools/marketplace/banner.mjs +307 -0
- package/tools/marketplace/cli.mjs +283 -0
- package/tools/marketplace/completion.mjs +263 -0
- package/tools/marketplace/doctor.mjs +156 -0
- package/tools/marketplace/effect.mjs +115 -0
- package/tools/marketplace/form.mjs +209 -0
- package/tools/marketplace/github.mjs +206 -0
- package/tools/marketplace/issue.mjs +80 -0
- package/tools/marketplace/local-transport.mjs +162 -0
- package/tools/marketplace/parity-output.mjs +26 -0
- package/tools/marketplace/paths.mjs +10 -0
- package/tools/marketplace/pin.mjs +203 -0
- package/tools/marketplace/plugin.mjs +64 -0
- package/tools/marketplace/preflight.mjs +164 -0
- package/tools/marketplace/progress.mjs +96 -0
- package/tools/marketplace/registry.mjs +216 -0
- package/tools/marketplace/report.mjs +192 -0
- package/tools/marketplace/run-baseline.mjs +72 -0
- package/tools/marketplace/setup.mjs +136 -0
- package/tools/marketplace/style.mjs +443 -0
- package/tools/marketplace/submit.mjs +341 -0
- package/tools/marketplace/tree.mjs +50 -0
- package/tools/marketplace/upgrade.mjs +179 -0
- package/tools/marketplace/usage.mjs +191 -0
- package/tools/marketplace/verify.mjs +70 -0
- package/tools/marketplace/watch.mjs +262 -0
- package/tools/marketplace/yaml.mjs +164 -0
- package/tools/subject/resolve.mjs +124 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// `omakit doctor`: what is installed, what is pinned, and what has moved.
|
|
2
|
+
//
|
|
3
|
+
// It reads and it prints. It installs nothing, updates nothing and touches no
|
|
4
|
+
// checkout, because the two things a person might want "upgraded" here are not
|
|
5
|
+
// the same thing and only one of them may ever move on its own.
|
|
6
|
+
//
|
|
7
|
+
// The tool version belongs to the installer: Git for a checkout, npm for its
|
|
8
|
+
// package, and pacman for the Arch package. A CLI that fetches and executes its
|
|
9
|
+
// own replacement is the supply-chain shape this repository warns about.
|
|
10
|
+
//
|
|
11
|
+
// The pin must not move by itself. Bumping it changes where the submission
|
|
12
|
+
// contract is read from, and the procedure in docs/UPSTREAM_CONTRACT.md requires
|
|
13
|
+
// re-proving transport parity and committing the evidence afterwards. An
|
|
14
|
+
// `upgrade` that quietly advanced the pin would break the one guarantee this
|
|
15
|
+
// tool sells. So doctor reports that the pin is behind and prints the procedure.
|
|
16
|
+
//
|
|
17
|
+
// That the pin goes stale unnoticed is, of course, exactly the defect class
|
|
18
|
+
// `omakit watch` exists to report. It would be poor form not to apply it here.
|
|
19
|
+
|
|
20
|
+
import { execFileSync } from "node:child_process"
|
|
21
|
+
import { readFileSync } from "node:fs"
|
|
22
|
+
import { join } from "node:path"
|
|
23
|
+
import { MARKETPLACE_PIN, marketplacePinDir, pinDiskUsage, pinIsSparse, requirePin } from "./pin.mjs"
|
|
24
|
+
import { credential, defaultBranchHead, getJson, UNAUTHENTICATED_LIMIT, GitHubError } from "./github.mjs"
|
|
25
|
+
import { upgradeCommand } from "./upgrade.mjs"
|
|
26
|
+
|
|
27
|
+
function tool(repoRoot) {
|
|
28
|
+
try {
|
|
29
|
+
const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
|
|
30
|
+
return { name: pkg.name, version: pkg.version, engines: pkg.engines?.node || null }
|
|
31
|
+
} catch {
|
|
32
|
+
return { name: "omakit", version: "unknown", engines: null }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function version(command, args = ["--version"]) {
|
|
37
|
+
try {
|
|
38
|
+
return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().split("\n")[0]
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function latestOnRegistry(name) {
|
|
45
|
+
try {
|
|
46
|
+
const meta = await getJson(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`)
|
|
47
|
+
return meta?.version || null
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Full pin evidence for machines; human output deliberately keeps short hashes. */
|
|
54
|
+
export function pinFreshness(identity, head) {
|
|
55
|
+
const current = head.commit === identity.commit
|
|
56
|
+
return {
|
|
57
|
+
id: "pin.freshness",
|
|
58
|
+
state: current ? "ok" : "advice",
|
|
59
|
+
detail: current
|
|
60
|
+
? `the pin is the marketplace's current ${head.branch || "default"}-branch HEAD`
|
|
61
|
+
: `the pin is ${identity.commit.slice(0, 7)}; the marketplace's ${head.branch || "default"} branch is now at ${head.commit.slice(0, 7)}`,
|
|
62
|
+
action: current ? null : "Bumping the pin is a deliberate change: docs/UPSTREAM_CONTRACT.md has the procedure, which ends in re-proving parity and committing its evidence. Nothing here does it for you.",
|
|
63
|
+
evidence: {
|
|
64
|
+
pinCommit: identity.commit,
|
|
65
|
+
marketplaceHead: head.commit,
|
|
66
|
+
branch: head.branch || "default",
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {{ repoRoot: string, offline?: boolean }} options
|
|
73
|
+
*/
|
|
74
|
+
export async function doctor({ repoRoot, offline = false, onPhase }) {
|
|
75
|
+
// Optional: told what is being read while the network answers. Never
|
|
76
|
+
// affects the result.
|
|
77
|
+
const phase = onPhase || (() => {})
|
|
78
|
+
const checks = []
|
|
79
|
+
const add = (id, state, detail, action = null, evidence = null) => checks.push({
|
|
80
|
+
id,
|
|
81
|
+
state,
|
|
82
|
+
detail,
|
|
83
|
+
action,
|
|
84
|
+
...(evidence ? { evidence } : {}),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const self = tool(repoRoot)
|
|
88
|
+
add("omakit.version", "info", `${self.name} ${self.version}`)
|
|
89
|
+
|
|
90
|
+
const node = process.versions.node
|
|
91
|
+
const major = Number(node.split(".")[0])
|
|
92
|
+
add("node", major >= 22 ? "ok" : "problem", `node ${node}${self.engines ? ` (needs ${self.engines})` : ""}`,
|
|
93
|
+
major >= 22 ? null : "Install Node 22 or newer.")
|
|
94
|
+
|
|
95
|
+
const git = version("git")
|
|
96
|
+
add("git", git ? "ok" : "problem", git || "git was not found on PATH", git ? null : "Install git.")
|
|
97
|
+
|
|
98
|
+
const dir = marketplacePinDir(repoRoot)
|
|
99
|
+
let identity = null
|
|
100
|
+
try {
|
|
101
|
+
identity = requirePin(repoRoot).identity
|
|
102
|
+
add("pin.checkout", "ok",
|
|
103
|
+
`${identity.commit} (baseline ${identity.baselineVersion}, ${identity.enforcementMode}) at ${dir}`)
|
|
104
|
+
const sparse = pinIsSparse(dir)
|
|
105
|
+
add("pin.size", sparse ? "ok" : "advice", `${pinDiskUsage(dir)}${sparse ? ", sparse" : ", full checkout"}`,
|
|
106
|
+
sparse ? null : `This checkout predates the sparse fetch and is far larger than it needs to be. Remove ${dir} and run \`omakit pin\` to refetch only what omakit reads.`)
|
|
107
|
+
} catch (error) {
|
|
108
|
+
add("pin.checkout", "problem", error.message, error.remedy || "omakit pin")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!offline && identity) {
|
|
112
|
+
phase("reading the marketplace's current default-branch HEAD")
|
|
113
|
+
try {
|
|
114
|
+
const head = await defaultBranchHead(MARKETPLACE_PIN.repository)
|
|
115
|
+
checks.push(pinFreshness(identity, head))
|
|
116
|
+
} catch (error) {
|
|
117
|
+
add("pin.freshness", "unknown", `could not read the marketplace's HEAD (${error.code || "error"})`,
|
|
118
|
+
error.code === "network-unavailable" ? "Connect to the network, or pass --offline to skip the two checks that need it." : null,
|
|
119
|
+
{ pinCommit: identity.commit, marketplaceHead: null, branch: null })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
phase("asking the npm registry for the newest published version")
|
|
123
|
+
const latest = await latestOnRegistry(self.name)
|
|
124
|
+
if (latest) {
|
|
125
|
+
const current = latest === self.version
|
|
126
|
+
add("omakit.latest", current ? "ok" : "advice",
|
|
127
|
+
current ? `${latest} is the newest published version` : `${latest} is published, this is ${self.version}`,
|
|
128
|
+
current ? null : upgradeCommand(repoRoot, self.name))
|
|
129
|
+
} else {
|
|
130
|
+
add("omakit.latest", "unknown", "the npm registry did not answer, or this version is unpublished")
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Where the credential comes from, said out loud. Borrowing someone's `gh`
|
|
135
|
+
// login is the right default and a bad secret: a tool that quietly picks up a
|
|
136
|
+
// credential is a tool you cannot audit by reading its help text, so doctor
|
|
137
|
+
// names the source every time.
|
|
138
|
+
// Just "gh version 2.62.0": the build date gh prints after it would nest a
|
|
139
|
+
// second parenthetical inside this line.
|
|
140
|
+
phase("reading the GitHub credential from gh")
|
|
141
|
+
const cli = version("gh")?.replace(/\s*\(.*\)\s*$/, "") || null
|
|
142
|
+
const auth = credential({ refresh: true })
|
|
143
|
+
add("github.auth", auth.value ? "ok" : "info",
|
|
144
|
+
auth.source === "gh"
|
|
145
|
+
? `read-only, from your \`gh\` login${cli ? ` (${cli})` : ""}; omakit stores nothing`
|
|
146
|
+
: `${auth.detail}. \`submit\` and \`verify\` need none at all; \`watch\` and \`parity\` are capped without one`,
|
|
147
|
+
auth.value
|
|
148
|
+
? null
|
|
149
|
+
: cli
|
|
150
|
+
? "`gh auth login` is enough. omakit reads that login for GET requests only and never copies it anywhere."
|
|
151
|
+
: "Install GitHub's `gh` CLI and run `gh auth login`. omakit reads that login for GET requests only.")
|
|
152
|
+
|
|
153
|
+
return { checks, problems: checks.filter((check) => check.state === "problem").length }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export { GitHubError }
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The one text effect: the wordmark through `ttfx`, where the wordmark is
|
|
2
|
+
// drawn (a bare `omakit` and `omakit setup`), and only into a terminal.
|
|
3
|
+
//
|
|
4
|
+
// `ttfx` (github.com/omacom/ttfx) is the Rust port of TerminalTextEffects that
|
|
5
|
+
// Omarchy's own screensaver draws with, so its vocabulary reads as native
|
|
6
|
+
// here. It is an enhancement and never a requirement: absent, unexecutable or
|
|
7
|
+
// over budget, the wordmark looks exactly as it does without it, and the probe is a
|
|
8
|
+
// spawn that fails silently, the way the `gh` credential lookup does.
|
|
9
|
+
//
|
|
10
|
+
// Colour stays omakit's. Measured before this was decided, the wordmark with
|
|
11
|
+
// omakit's palette escapes piped through `ttfx` in each of its
|
|
12
|
+
// `--existing-color-handling` modes: `ignore` paints its own truecolor
|
|
13
|
+
// gradient, `always` and `dynamic` honour the input tint but re-encode palette
|
|
14
|
+
// index 36 as the fixed truecolor 0;128;128 and drop 39, so the Omarchy theme
|
|
15
|
+
// no longer decides what cyan is. `--xterm-colors` is the same in 256-colour,
|
|
16
|
+
// which this repository bans just the same. So the effect runs with
|
|
17
|
+
// `--no-color`, over the glyphs alone, whose density split survives because it
|
|
18
|
+
// is carried by the characters, and omakit repaints the finished wordmark in
|
|
19
|
+
// its own tints when the effect is over.
|
|
20
|
+
//
|
|
21
|
+
// The arguments are frozen and asserted by tests/unit/read-only.test.mjs:
|
|
22
|
+
// stdin only, no input file, no path, one pinned effect, one seed, so the
|
|
23
|
+
// recorded setup.gif is reproducible.
|
|
24
|
+
|
|
25
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
26
|
+
import { MOTION } from "./style.mjs"
|
|
27
|
+
|
|
28
|
+
export const TTFX = "ttfx"
|
|
29
|
+
|
|
30
|
+
/** The probe: does `ttfx` start and say its name. */
|
|
31
|
+
export const TTFX_PROBE = Object.freeze(["--version"])
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The effect. `--no-restore-cursor` leaves the cursor where an ordinary
|
|
35
|
+
* program would, on the line under the wordmark, which is where the repaint
|
|
36
|
+
* starts from; measured, the default leaves it on the wordmark's last row.
|
|
37
|
+
*/
|
|
38
|
+
export const TTFX_ARGS = Object.freeze([
|
|
39
|
+
"--no-color",
|
|
40
|
+
"--no-restore-cursor",
|
|
41
|
+
"--seed", "1",
|
|
42
|
+
"--frame-rate", String(MOTION.effectFrameRate),
|
|
43
|
+
"expand",
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
/** Milliseconds a probe may take: `ttfx` starts in half a millisecond, so this is generous. */
|
|
47
|
+
const PROBE_MS = 200
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether `ttfx` is on PATH and runs. A spawn that fails for any reason is
|
|
51
|
+
* "no", silently.
|
|
52
|
+
*
|
|
53
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
54
|
+
*/
|
|
55
|
+
export function effectAvailable(env = process.env) {
|
|
56
|
+
try {
|
|
57
|
+
const probe = spawnSync(TTFX, [...TTFX_PROBE], { encoding: "utf8", env, timeout: PROBE_MS, stdio: ["ignore", "pipe", "ignore"] })
|
|
58
|
+
return probe.status === 0 && /^ttfx \d/.test(probe.stdout)
|
|
59
|
+
} catch {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run the effect over `rows`, relaying its frames to `stream` as they come.
|
|
66
|
+
* Resolves to what happened, so the caller knows what is on the screen:
|
|
67
|
+
*
|
|
68
|
+
* - "played": exit 0 inside the budget; the cursor is on the line under the
|
|
69
|
+
* rows, hidden, and the rows are drawn plain
|
|
70
|
+
* - "absent": nothing reached the screen; draw as if `ttfx` were not there
|
|
71
|
+
* - "broken": something reached the screen and then it failed or ran out of
|
|
72
|
+
* budget; the cursor is somewhere inside the rows
|
|
73
|
+
*
|
|
74
|
+
* @param {string[]} rows
|
|
75
|
+
* @param {{ write: (chunk: string|Buffer) => unknown }} stream
|
|
76
|
+
* @param {{ budgetMs?: number, env?: NodeJS.ProcessEnv }} [options]
|
|
77
|
+
* @returns {Promise<"played"|"absent"|"broken">}
|
|
78
|
+
*/
|
|
79
|
+
export function playEffect(rows, stream, { budgetMs = MOTION.effectBudgetMs, env = process.env } = {}) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
let child
|
|
82
|
+
try {
|
|
83
|
+
child = spawn(TTFX, [...TTFX_ARGS], { env, stdio: ["pipe", "pipe", "ignore"] })
|
|
84
|
+
} catch {
|
|
85
|
+
resolve("absent")
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
let written = 0
|
|
89
|
+
let settled = false
|
|
90
|
+
let overBudget = false
|
|
91
|
+
const settle = (outcome) => {
|
|
92
|
+
if (settled) return
|
|
93
|
+
settled = true
|
|
94
|
+
clearTimeout(timer)
|
|
95
|
+
resolve(outcome)
|
|
96
|
+
}
|
|
97
|
+
// Over budget the process is killed and the promise settles when it has
|
|
98
|
+
// exited, not when its pipe closes: a child it left behind could hold the
|
|
99
|
+
// pipe open. In budget it settles on `close`, after the last relayed
|
|
100
|
+
// chunk, so the caller never repaints under a late frame.
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
overBudget = true
|
|
103
|
+
child.once("exit", () => settle(written ? "broken" : "absent"))
|
|
104
|
+
child.kill("SIGKILL")
|
|
105
|
+
}, budgetMs)
|
|
106
|
+
child.on("error", () => settle(written ? "broken" : "absent"))
|
|
107
|
+
child.stdin.on("error", () => {})
|
|
108
|
+
child.stdout.on("data", (chunk) => {
|
|
109
|
+
written += chunk.length
|
|
110
|
+
stream.write(chunk)
|
|
111
|
+
})
|
|
112
|
+
child.on("close", (code) => settle(code === 0 && !overBudget && written ? "played" : written ? "broken" : "absent"))
|
|
113
|
+
child.stdin.end(`${rows.join("\n")}\n`)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// The submission contract, read from the pinned marketplace checkout.
|
|
2
|
+
//
|
|
3
|
+
// Nothing about the submission format is written down in Omakit. The title
|
|
4
|
+
// prefix, the field order, the controlled category and tag lists and the exact
|
|
5
|
+
// checklist text all come from the form an author actually fills in,
|
|
6
|
+
// `.github/ISSUE_TEMPLATE/submit-plugin.yml`, at the pinned commit. Updating
|
|
7
|
+
// the pin changes the contract; no Omakit source changes.
|
|
8
|
+
//
|
|
9
|
+
// Measured reason this is derived rather than copied (docs/MEASUREMENTS.md M2):
|
|
10
|
+
// 39 submissions fell out on the title prefix alone, 14 of them still open and
|
|
11
|
+
// 7 rescued by hand; 11 more open submissions are malformed in the body and get
|
|
12
|
+
// "The validation result could not be published to the issue. A maintainer must
|
|
13
|
+
// review the workflow.", which blames the maintainer for the author's mistake.
|
|
14
|
+
// One of those 11 differs from a valid submission by the single word
|
|
15
|
+
// "Suggested" instead of "Suggest". A contract that drifts from the form by one
|
|
16
|
+
// word reproduces exactly that failure, so the form is the only source.
|
|
17
|
+
//
|
|
18
|
+
// The derived contract is then cross-checked against the marketplace's own
|
|
19
|
+
// constants in `scripts/submission.mjs` at the same commit. Both halves must
|
|
20
|
+
// agree; divergence is a loud failure, never a silent guess.
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from "node:fs"
|
|
23
|
+
import { join } from "node:path"
|
|
24
|
+
import { pathToFileURL } from "node:url"
|
|
25
|
+
import { parseYaml } from "./yaml.mjs"
|
|
26
|
+
import { requirePin } from "./pin.mjs"
|
|
27
|
+
|
|
28
|
+
export const SUBMIT_FORM_PATH = ".github/ISSUE_TEMPLATE/submit-plugin.yml"
|
|
29
|
+
export const OFFICIAL_SUBMISSION_MODULE = "scripts/submission.mjs"
|
|
30
|
+
|
|
31
|
+
export class ContractError extends Error {
|
|
32
|
+
constructor(code, message) {
|
|
33
|
+
super(message)
|
|
34
|
+
this.name = "ContractError"
|
|
35
|
+
this.code = code
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The marketplace lowercases a submitted tag and joins words with a hyphen
|
|
41
|
+
* (`normalizeTag` in scripts/submission.mjs). Omakit does not assume that
|
|
42
|
+
* mapping: every label it derives is checked against the official
|
|
43
|
+
* `allowedTags` below, so a form label that stops mapping is an error.
|
|
44
|
+
*/
|
|
45
|
+
export function tagSlug(label) {
|
|
46
|
+
return String(label).trim().toLowerCase().replace(/\s+/g, "-")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function loadOfficial(pinDir) {
|
|
50
|
+
return import(pathToFileURL(join(pinDir, OFFICIAL_SUBMISSION_MODULE)).href)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fieldsOf(form) {
|
|
54
|
+
if (!Array.isArray(form?.body)) throw new ContractError("form-unreadable", `${SUBMIT_FORM_PATH} has no body`)
|
|
55
|
+
return form.body
|
|
56
|
+
.filter((item) => item && item.type !== "markdown")
|
|
57
|
+
.map((item) => ({
|
|
58
|
+
id: item.id || "",
|
|
59
|
+
type: item.type || "",
|
|
60
|
+
label: item.attributes?.label || "",
|
|
61
|
+
description: item.attributes?.description || "",
|
|
62
|
+
options: Array.isArray(item.attributes?.options) ? item.attributes.options : null,
|
|
63
|
+
multiple: item.attributes?.multiple === true,
|
|
64
|
+
required: item.validations?.required === true,
|
|
65
|
+
}))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function only(fields, predicate, what) {
|
|
69
|
+
const found = fields.filter(predicate)
|
|
70
|
+
if (found.length !== 1) {
|
|
71
|
+
throw new ContractError(
|
|
72
|
+
"form-shape-changed",
|
|
73
|
+
`${SUBMIT_FORM_PATH} 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
|
+
)
|
|
75
|
+
}
|
|
76
|
+
return found[0]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read the submission contract from the pinned checkout.
|
|
81
|
+
* @param {{ repoRoot?: string, pinDir?: string }} [options]
|
|
82
|
+
*/
|
|
83
|
+
export async function submissionContract(options = {}) {
|
|
84
|
+
const pinDir = options.pinDir || requirePin(options.repoRoot).dir
|
|
85
|
+
const raw = readFileSync(join(pinDir, SUBMIT_FORM_PATH), "utf8")
|
|
86
|
+
const form = parseYaml(raw)
|
|
87
|
+
const fields = fieldsOf(form)
|
|
88
|
+
|
|
89
|
+
const titleTemplate = typeof form.title === "string" ? form.title : ""
|
|
90
|
+
if (!titleTemplate.trim()) throw new ContractError("form-shape-changed", `${SUBMIT_FORM_PATH} has no title template`)
|
|
91
|
+
|
|
92
|
+
const repository = only(fields, (f) => f.type === "input" && f.required, "required repository input")
|
|
93
|
+
const category = only(fields, (f) => f.type === "dropdown" && !f.multiple, "single-select dropdown (category)")
|
|
94
|
+
const tags = only(fields, (f) => f.type === "dropdown" && f.multiple, "multi-select dropdown (tags)")
|
|
95
|
+
const checklist = only(fields, (f) => f.type === "checkboxes", "checkboxes group (submission checklist)")
|
|
96
|
+
|
|
97
|
+
const official = await loadOfficial(pinDir)
|
|
98
|
+
|
|
99
|
+
const headings = fields.map((field) => field.label)
|
|
100
|
+
if (headings.some((heading) => !heading)) throw new ContractError("form-shape-changed", `${SUBMIT_FORM_PATH} has a field without a label`)
|
|
101
|
+
|
|
102
|
+
const checklistItems = (checklist.options || []).map((option) =>
|
|
103
|
+
typeof option === "string" ? { label: option, required: false } : { label: option.label, required: option.required === true },
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const contract = {
|
|
107
|
+
formPath: SUBMIT_FORM_PATH,
|
|
108
|
+
titleTemplate,
|
|
109
|
+
titlePrefix: titleTemplate.trimEnd(),
|
|
110
|
+
labels: Array.isArray(form.labels) ? [...form.labels] : [],
|
|
111
|
+
headings,
|
|
112
|
+
fields,
|
|
113
|
+
headingFor: {
|
|
114
|
+
repository: repository.label,
|
|
115
|
+
category: category.label,
|
|
116
|
+
tags: tags.label,
|
|
117
|
+
checklist: checklist.label,
|
|
118
|
+
},
|
|
119
|
+
categories: [...(category.options || [])],
|
|
120
|
+
tagLabels: [...(tags.options || [])],
|
|
121
|
+
checklist: checklistItems,
|
|
122
|
+
maximumTags: official.maximumSubmissionTags,
|
|
123
|
+
official: {
|
|
124
|
+
titlePrefix: official.submissionTitlePrefix,
|
|
125
|
+
allowedCategories: [...official.allowedCategories],
|
|
126
|
+
allowedTags: [...official.allowedTags],
|
|
127
|
+
maximumSubmissionTags: official.maximumSubmissionTags,
|
|
128
|
+
checklist: [...official.submissionChecklist],
|
|
129
|
+
},
|
|
130
|
+
parseCurrentSubmission: official.parseCurrentSubmission,
|
|
131
|
+
extractRepositoryUrl: official.extractRepositoryUrl,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
assertContractAgrees(contract)
|
|
135
|
+
return contract
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The form and the marketplace's own constants must describe the same
|
|
140
|
+
* submission. Anything else means the pin is internally inconsistent, and
|
|
141
|
+
* generating a body from half of it would produce the malformed submissions
|
|
142
|
+
* this tool exists to prevent.
|
|
143
|
+
*/
|
|
144
|
+
export function assertContractAgrees(contract) {
|
|
145
|
+
const divergence = []
|
|
146
|
+
if (!contract.titlePrefix.startsWith(contract.official.titlePrefix)) {
|
|
147
|
+
divergence.push(`form title ${JSON.stringify(contract.titleTemplate)} does not start with the official prefix ${JSON.stringify(contract.official.titlePrefix)}`)
|
|
148
|
+
}
|
|
149
|
+
const categories = contract.categories.join("|")
|
|
150
|
+
const officialCategories = contract.official.allowedCategories.join("|")
|
|
151
|
+
if (categories !== officialCategories) {
|
|
152
|
+
divergence.push(`form categories [${categories}] differ from allowedCategories [${officialCategories}]`)
|
|
153
|
+
}
|
|
154
|
+
const slugs = contract.tagLabels.map(tagSlug)
|
|
155
|
+
const unmapped = slugs.filter((slug) => !contract.official.allowedTags.includes(slug))
|
|
156
|
+
if (unmapped.length) {
|
|
157
|
+
divergence.push(`form tags do not map onto allowedTags: ${unmapped.join(", ")}`)
|
|
158
|
+
}
|
|
159
|
+
const checklist = contract.checklist.map((item) => item.label).join("\n")
|
|
160
|
+
const officialChecklist = contract.official.checklist.join("\n")
|
|
161
|
+
if (checklist !== officialChecklist) {
|
|
162
|
+
divergence.push("form checklist text differs from submissionChecklist")
|
|
163
|
+
}
|
|
164
|
+
if (!Number.isInteger(contract.maximumTags) || contract.maximumTags < 1) {
|
|
165
|
+
divergence.push(`maximumSubmissionTags is not a positive integer: ${contract.maximumTags}`)
|
|
166
|
+
}
|
|
167
|
+
if (divergence.length) {
|
|
168
|
+
throw new ContractError(
|
|
169
|
+
"contract-divergent",
|
|
170
|
+
`the pinned marketplace commit is internally inconsistent, so no submission is generated:\n - ${divergence.join("\n - ")}`,
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Resolve one author-supplied category against the form's controlled list. */
|
|
176
|
+
export function resolveCategory(contract, value) {
|
|
177
|
+
const text = String(value ?? "").trim()
|
|
178
|
+
if (!text) return { ok: false, reason: "no category given" }
|
|
179
|
+
const exact = contract.categories.find((option) => option === text)
|
|
180
|
+
if (exact) return { ok: true, value: exact }
|
|
181
|
+
const loose = contract.categories.find((option) => option.toLowerCase() === text.toLowerCase())
|
|
182
|
+
if (loose) return { ok: true, value: loose }
|
|
183
|
+
return { ok: false, reason: `"${text}" is not one of the form's categories` }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Resolve author-supplied tags against the form's controlled list, accepting
|
|
188
|
+
* either the form's display labels or the marketplace's normalised slugs, and
|
|
189
|
+
* always emitting the display labels the form itself would write.
|
|
190
|
+
*/
|
|
191
|
+
export function resolveTags(contract, values) {
|
|
192
|
+
const given = (Array.isArray(values) ? values : String(values ?? "").split(","))
|
|
193
|
+
.map((value) => String(value).trim())
|
|
194
|
+
.filter(Boolean)
|
|
195
|
+
if (!given.length) return { ok: false, reason: "no tags given", unknown: [] }
|
|
196
|
+
const bySlug = new Map(contract.tagLabels.map((label) => [tagSlug(label), label]))
|
|
197
|
+
const resolved = []
|
|
198
|
+
const unknown = []
|
|
199
|
+
for (const value of given) {
|
|
200
|
+
const label = bySlug.get(tagSlug(value))
|
|
201
|
+
if (!label) unknown.push(value)
|
|
202
|
+
else if (!resolved.includes(label)) resolved.push(label)
|
|
203
|
+
}
|
|
204
|
+
if (unknown.length) return { ok: false, reason: `not on the form's tag list: ${unknown.join(", ")}`, unknown }
|
|
205
|
+
if (resolved.length < 1 || resolved.length > contract.maximumTags) {
|
|
206
|
+
return { ok: false, reason: `the form takes 1 to ${contract.maximumTags} tags, got ${resolved.length}`, unknown: [] }
|
|
207
|
+
}
|
|
208
|
+
return { ok: true, value: resolved, unknown: [] }
|
|
209
|
+
}
|