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,191 @@
|
|
|
1
|
+
// The help text, as data rather than one painted string.
|
|
2
|
+
//
|
|
3
|
+
// Kept as structure so it can be coloured without pattern-matching a paragraph,
|
|
4
|
+
// and so the same words serve a terminal and a pipe. A signature is coloured by
|
|
5
|
+
// token: what you type is cyan, what you replace is yellow, the brackets that
|
|
6
|
+
// merely group them stay out of the way.
|
|
7
|
+
|
|
8
|
+
import { UNAUTHENTICATED_LIMIT } from "./github.mjs"
|
|
9
|
+
import { MARKETPLACE_PIN } from "./pin.mjs"
|
|
10
|
+
import { colourEnabled, paintProse, STEP, styler } from "./style.mjs"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* "Safe" means one thing, everywhere it appears: this runs on your own
|
|
14
|
+
* machine, posts nothing, opens no issue and spends nobody's attention. It is
|
|
15
|
+
* never a claim about the security of a plugin or a submission; the baseline's
|
|
16
|
+
* outcome is reported verbatim and is never restated as one.
|
|
17
|
+
*/
|
|
18
|
+
export const TAGLINE = "the safe place to find out"
|
|
19
|
+
|
|
20
|
+
/** The shells `omakit setup` installs tab completion for; completion.mjs holds the scripts. */
|
|
21
|
+
export const COMPLETION_SHELLS = Object.freeze(["bash", "zsh", "fish"])
|
|
22
|
+
|
|
23
|
+
export const COMMANDS = Object.freeze([
|
|
24
|
+
{
|
|
25
|
+
signature: "omakit setup",
|
|
26
|
+
lines: [
|
|
27
|
+
"First run, in one command: check the environment, fetch the pinned",
|
|
28
|
+
"marketplace checkout, and say what to try first. Idempotent.",
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
signature: "omakit pin",
|
|
33
|
+
lines: [
|
|
34
|
+
"Fetch or verify the pinned marketplace checkout in the user cache:",
|
|
35
|
+
"$XDG_CACHE_HOME/omakit/marketplace, or ~/.cache/omakit/marketplace.",
|
|
36
|
+
`Read-only, exact commit ${MARKETPLACE_PIN.commit}.`,
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
signature: [
|
|
41
|
+
"omakit submit <target> --category <c> --tags <a,b> [--notes <text>]",
|
|
42
|
+
" [--suggest-tag <t>] [--name <n>] [--offline]",
|
|
43
|
+
" [--allow-dirty] [--json] [--out <file>]",
|
|
44
|
+
],
|
|
45
|
+
lines: [
|
|
46
|
+
"Every check that is knowable before submitting, the resolved commit, and",
|
|
47
|
+
"the exact issue title and body. Prints them. Never posts anything.",
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
signature: "omakit watch <issue-url> [--json]",
|
|
52
|
+
lines: [
|
|
53
|
+
"Compare the commit the marketplace validated on a submission issue with",
|
|
54
|
+
"the plugin repository's current default-branch HEAD, and say what makes",
|
|
55
|
+
"it validate a newer one. Read-only.",
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
signature: "omakit verify <target> [--allow-dirty] [--out <file>]",
|
|
60
|
+
lines: [
|
|
61
|
+
"The official marketplace security baseline over the local Git transport,",
|
|
62
|
+
"reported verbatim beside the pin identity.",
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
signature: "omakit help --agent",
|
|
67
|
+
lines: [
|
|
68
|
+
"The operating instructions for a coding agent, printed from skills/, so an",
|
|
69
|
+
"agent can read the contract out of the tool instead of the repository.",
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
signature: "omakit upgrade [--dry-run]",
|
|
74
|
+
lines: [
|
|
75
|
+
"Fast-forward this checkout of omakit itself. Refuses a dirty tree, an",
|
|
76
|
+
"unexpected remote and anything that is not a fast-forward. Never moves",
|
|
77
|
+
"the marketplace pin.",
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
signature: "omakit doctor [--offline] [--json]",
|
|
82
|
+
lines: [
|
|
83
|
+
"What is installed, what is pinned, and what has moved since. Reads and",
|
|
84
|
+
"prints; it installs nothing and never moves the pin.",
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
signature: "omakit parity [--count <n>] [--offset <n>] [--out <file>]",
|
|
89
|
+
lines: [
|
|
90
|
+
"The official baseline over GitHub versus the local transport on real",
|
|
91
|
+
"listed repositories; a packaged install requires --out for evidence.",
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
// A command sits one STEP in from the heading; what it does sits one STEP in
|
|
97
|
+
// from the command's name, which starts after "omakit ".
|
|
98
|
+
const INDENT = " ".repeat(STEP)
|
|
99
|
+
const DESCRIPTION = " ".repeat(STEP * 3)
|
|
100
|
+
|
|
101
|
+
export const TARGET_NOTE = "<target> is a local Git repository path, or <https url>@<40-char sha>."
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The answer to "what do I have to set up?" is "nothing", and it is said in
|
|
105
|
+
* so many words. There is no environment section because omakit reads no
|
|
106
|
+
* credential variable of its own: `gh` is the one credential source and honours
|
|
107
|
+
* GH_TOKEN and GITHUB_TOKEN itself, the pin follows XDG unless explicitly overridden, and
|
|
108
|
+
* the terminal's own conventions (a pipe, TERM=dumb, NO_COLOR) are what turn
|
|
109
|
+
* colour and motion off.
|
|
110
|
+
*/
|
|
111
|
+
export const AUTHENTICATION = Object.freeze([
|
|
112
|
+
"Read-only, and optional. omakit uses your `gh` login if you have one, and",
|
|
113
|
+
"otherwise goes unauthenticated. `submit` and `verify` need no network at",
|
|
114
|
+
`all; \`watch\` and \`parity\` are capped at ${UNAUTHENTICATED_LIMIT} requests an hour without a`,
|
|
115
|
+
"login. omakit never writes a credential anywhere.",
|
|
116
|
+
])
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Colour a signature by token: what you type is cyan, what you replace is
|
|
120
|
+
* yellow, the subcommand is bold because that is the word you are scanning for,
|
|
121
|
+
* and the brackets that merely group things stay out of the way.
|
|
122
|
+
*
|
|
123
|
+
* One pass, deliberately. Two passes would let the second one find the escape
|
|
124
|
+
* sequences the first inserted and colour the `[` inside them, which corrupts
|
|
125
|
+
* every sequence downstream of it.
|
|
126
|
+
*/
|
|
127
|
+
const TOKEN = /(^\s*omakit +[a-z][a-z-]*)|(<[^>]+>)|(--[a-z-]+)|(\bomakit\b)|(\b[a-z]+(?:\|[a-z]+)+\b)|([[\]])/g
|
|
128
|
+
|
|
129
|
+
export function paintSignature(signature, c) {
|
|
130
|
+
return signature.replace(TOKEN, (token, lead, placeholder, flag, bare, choice, bracket) => {
|
|
131
|
+
if (lead) {
|
|
132
|
+
const [, indent, name, gap, subcommand] = lead.match(/^(\s*)(omakit)( +)([a-z][a-z-]*)$/)
|
|
133
|
+
return `${indent}${c("typeable.bold", name)}${gap}${c("name", subcommand)}`
|
|
134
|
+
}
|
|
135
|
+
if (placeholder) return c("placeholder", placeholder)
|
|
136
|
+
if (flag) return c("typeable", flag)
|
|
137
|
+
if (bare) return c("typeable.bold", bare)
|
|
138
|
+
// A choice like bash|zsh|fish: each word is one you could type, and the
|
|
139
|
+
// bar between them is grouping.
|
|
140
|
+
if (choice) return choice.split("|").map((word) => c("typeable", word)).join(c("punctuation", "|"))
|
|
141
|
+
return c("punctuation", bracket)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The front door: the commands and nothing else.
|
|
147
|
+
*
|
|
148
|
+
* The measured reason this exists. The full reference is 53 lines and a
|
|
149
|
+
* terminal is not 60 rows tall, so a bare `omakit` scrolled its own first
|
|
150
|
+
* lines off the top of the screen before anyone could read them. A list of
|
|
151
|
+
* what you can run fits, and the reference is one command away.
|
|
152
|
+
*
|
|
153
|
+
* @param {{ colour?: boolean, heading?: boolean }} [options]
|
|
154
|
+
*/
|
|
155
|
+
export function renderSummary({ colour = colourEnabled(), heading = true } = {}) {
|
|
156
|
+
const c = styler(colour)
|
|
157
|
+
const out = heading ? [`${c("typeable.bold", "omakit")}${c("punctuation", ":")} ${TAGLINE}`, ""] : []
|
|
158
|
+
for (const command of COMMANDS) {
|
|
159
|
+
out.push(`${INDENT}${paintSignature([].concat(command.signature)[0], c)}`)
|
|
160
|
+
}
|
|
161
|
+
out.push("")
|
|
162
|
+
out.push(`${INDENT}${paintProse("`omakit help` is the same list with what each command does, and", c)}`)
|
|
163
|
+
out.push(`${INDENT}${paintProse("what it reads. `omakit setup` is the one to run first.", c)}`)
|
|
164
|
+
return `${out.join("\n")}\n`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {{ colour?: boolean, heading?: boolean }} [options] `heading: false`
|
|
169
|
+
* when the line above has already named the tool, as under an unknown
|
|
170
|
+
* command, so the same sentence is not printed twice.
|
|
171
|
+
*/
|
|
172
|
+
export function renderUsage({ colour = colourEnabled(), heading = true } = {}) {
|
|
173
|
+
const c = styler(colour)
|
|
174
|
+
const out = heading ? [`${c("typeable.bold", "omakit")}${c("punctuation", ":")} ${TAGLINE}`, ""] : []
|
|
175
|
+
|
|
176
|
+
for (const command of COMMANDS) {
|
|
177
|
+
for (const line of [].concat(command.signature)) {
|
|
178
|
+
out.push(`${INDENT}${paintSignature(line, c)}`)
|
|
179
|
+
}
|
|
180
|
+
for (const line of command.lines) {
|
|
181
|
+
out.push(`${DESCRIPTION}${paintProse(line, c)}`)
|
|
182
|
+
}
|
|
183
|
+
out.push("")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
out.push(`${INDENT}${paintSignature(TARGET_NOTE, c)}`)
|
|
187
|
+
out.push("")
|
|
188
|
+
out.push(c("heading", "GitHub access:"))
|
|
189
|
+
for (const line of AUTHENTICATION) out.push(`${INDENT}${paintProse(line, c)}`)
|
|
190
|
+
return `${out.join("\n")}\n`
|
|
191
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// `omakit verify <target>`: the pinned official baseline over the local Git
|
|
2
|
+
// transport, reported verbatim beside the pin identity. `omakit submit` builds on
|
|
3
|
+
// this; `verify` exists on its own so the raw official result can be inspected
|
|
4
|
+
// without any Omakit check around it.
|
|
5
|
+
import { runBaseline } from "./run-baseline.mjs"
|
|
6
|
+
import { MARKETPLACE_PIN } from "./pin.mjs"
|
|
7
|
+
import { ASSUMED_BY_ADAPTER } from "./local-transport.mjs"
|
|
8
|
+
|
|
9
|
+
const MARKETPLACE_STATEMENT =
|
|
10
|
+
"Official baseline preview over a local snapshot. The marketplace rescans the public commit itself. This is not approval, listing, verification or a security audit."
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {{ repoRoot: string, subject: { dir: string, commit: string, repository: { url: string|null } }, listedPlugins?: Array }} options
|
|
14
|
+
* @returns the `marketplaceBaseline` section: pin, transport, adapter
|
|
15
|
+
* assumptions, the official result verbatim, and the statement.
|
|
16
|
+
*/
|
|
17
|
+
export async function marketplaceBaselineSection({ repoRoot, subject, listedPlugins }) {
|
|
18
|
+
const pin = {
|
|
19
|
+
repository: MARKETPLACE_PIN.repository,
|
|
20
|
+
commit: MARKETPLACE_PIN.commit,
|
|
21
|
+
baselineVersion: MARKETPLACE_PIN.baselineVersion,
|
|
22
|
+
enforcementMode: MARKETPLACE_PIN.enforcementMode,
|
|
23
|
+
}
|
|
24
|
+
if (!subject.repository.url) {
|
|
25
|
+
return {
|
|
26
|
+
pin,
|
|
27
|
+
transport: "none",
|
|
28
|
+
assumedByAdapter: [],
|
|
29
|
+
invoked: false,
|
|
30
|
+
skipReason: "no declared GitHub repository URL",
|
|
31
|
+
official: null,
|
|
32
|
+
statement: MARKETPLACE_STATEMENT,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
let run
|
|
36
|
+
try {
|
|
37
|
+
run = await runBaseline({
|
|
38
|
+
repoRoot,
|
|
39
|
+
repoUrl: subject.repository.url,
|
|
40
|
+
commitSha: subject.commit,
|
|
41
|
+
transport: "local",
|
|
42
|
+
repoDir: subject.dir,
|
|
43
|
+
listedPlugins,
|
|
44
|
+
})
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error && typeof error.code === "string" && error.code.startsWith("security-baseline-")) {
|
|
47
|
+
// The official code refused the snapshot (scan limit, unreadable file):
|
|
48
|
+
// that refusal is the official result and is reported verbatim.
|
|
49
|
+
return {
|
|
50
|
+
pin,
|
|
51
|
+
transport: "local-git",
|
|
52
|
+
assumedByAdapter: [...ASSUMED_BY_ADAPTER],
|
|
53
|
+
invoked: true,
|
|
54
|
+
skipReason: null,
|
|
55
|
+
official: { error: { code: error.code, message: error.message, ...(error.details || {}) } },
|
|
56
|
+
statement: MARKETPLACE_STATEMENT,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
throw error
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
pin: { ...pin, commit: run.pin.commit, baselineVersion: run.pin.baselineVersion, enforcementMode: run.pin.enforcementMode },
|
|
63
|
+
transport: "local-git",
|
|
64
|
+
assumedByAdapter: [...run.adapter.assumedByAdapter],
|
|
65
|
+
invoked: true,
|
|
66
|
+
skipReason: null,
|
|
67
|
+
official: run.result,
|
|
68
|
+
statement: MARKETPLACE_STATEMENT,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// The validation watch. This is the reason the tool exists.
|
|
2
|
+
//
|
|
3
|
+
// The marketplace validates one exact commit, and the review that follows is
|
|
4
|
+
// of that commit. The only action that makes it validate a newer one is
|
|
5
|
+
// editing the issue body:
|
|
6
|
+
// `route-issue-automation.yml` is the only workflow with a direct `issues`
|
|
7
|
+
// trigger (`opened, edited, reopened, labeled, unlabeled`), there is no
|
|
8
|
+
// `issue_comment` trigger anywhere in the marketplace, and `refresh-catalog.yml`
|
|
9
|
+
// compares branch HEADs only for repositories that are already listed. So
|
|
10
|
+
// pushing a fix does nothing, and commenting "fixed in abc123" does nothing.
|
|
11
|
+
//
|
|
12
|
+
// Measured reason (docs/MEASUREMENTS.md M6): of the 464 submissions parked in
|
|
13
|
+
// the author's court, 73% have a default-branch HEAD ahead of the validated
|
|
14
|
+
// commit. 47% pushed after the maintainer's review without the marketplace ever
|
|
15
|
+
// seeing it, and 82% of those authors also commented, so they are engaged and
|
|
16
|
+
// stuck rather than gone. Of 13 open submissions inspected with no labels left,
|
|
17
|
+
// 9 had passed validation and passed the automated security baseline with zero
|
|
18
|
+
// findings and were blocked solely because their validated commit had fallen
|
|
19
|
+
// behind while they waited. 46% of the maintainer's own requests for a fresh validation never
|
|
20
|
+
// produced one; in the parked group 77% never did. The instruction that would
|
|
21
|
+
// fix this appears 22 times in the failure path of
|
|
22
|
+
// `scripts/submission-feedback.mjs` and zero times in the success path of
|
|
23
|
+
// `scripts/validate-submission.mjs`, which is the path 97 of 100 parked
|
|
24
|
+
// submissions took.
|
|
25
|
+
//
|
|
26
|
+
// This command reads. It never edits the issue, never comments, never labels.
|
|
27
|
+
// The action it names is the author's to take.
|
|
28
|
+
|
|
29
|
+
import { join } from "node:path"
|
|
30
|
+
import { pathToFileURL } from "node:url"
|
|
31
|
+
import { MARKETPLACE_PIN, requirePin } from "./pin.mjs"
|
|
32
|
+
import { defaultBranchHead, issue, issueComments, parseIssueUrl, token, GitHubError } from "./github.mjs"
|
|
33
|
+
|
|
34
|
+
export class WatchError extends Error {
|
|
35
|
+
constructor(code, message) {
|
|
36
|
+
super(message)
|
|
37
|
+
this.name = "WatchError"
|
|
38
|
+
this.code = code
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MARKETPLACE_SLUG = MARKETPLACE_PIN.repository.replace(/^https:\/\/github\.com\//, "").toLowerCase()
|
|
43
|
+
|
|
44
|
+
// The one action that re-runs validation, in the register the marketplace itself
|
|
45
|
+
// uses in its own failure feedback.
|
|
46
|
+
export const REFRESH_ACTION =
|
|
47
|
+
"Edit the issue body. That is the only action that re-runs validation and the security baseline against a new commit: a push does not, and a comment does not."
|
|
48
|
+
|
|
49
|
+
async function loadRecord(pinDir) {
|
|
50
|
+
return import(pathToFileURL(join(pinDir, "scripts/security-baseline-record.mjs")).href)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function loadSubmission(pinDir) {
|
|
54
|
+
return import(pathToFileURL(join(pinDir, "scripts/submission.mjs")).href)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function loadVerification(pinDir) {
|
|
58
|
+
return import(pathToFileURL(join(pinDir, "scripts/plugin-verification-request.mjs")).href)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Which repository an issue is about.
|
|
63
|
+
*
|
|
64
|
+
* The marketplace has two issue forms and they are not interchangeable. A
|
|
65
|
+
* `[Plugin]:` submission is read by `extractRepositoryUrl`; a `[Verify]:` update
|
|
66
|
+
* request has its own headings, and feeding it to the submission parser fails
|
|
67
|
+
* in a misleading way, because "Repository URL" is a heading both forms use and
|
|
68
|
+
* the submission parser then runs the section on until the next heading it
|
|
69
|
+
* happens to recognise. So each form is read by the parser the marketplace
|
|
70
|
+
* itself uses for it, and the issue says which one it is.
|
|
71
|
+
*/
|
|
72
|
+
async function repositoryFor(pinDir, subject) {
|
|
73
|
+
const submission = await loadSubmission(pinDir)
|
|
74
|
+
const verification = await loadVerification(pinDir)
|
|
75
|
+
const title = String(subject.title || "")
|
|
76
|
+
const attempts = title.startsWith("[Verify]")
|
|
77
|
+
? [
|
|
78
|
+
["verify", () => verification.parsePluginVerificationIssue(subject.body).repoUrl],
|
|
79
|
+
["verify-legacy", () => verification.parseLegacyListedSnapshotVerificationIssue(subject.body).repoUrl],
|
|
80
|
+
["submission", () => submission.extractRepositoryUrl(subject.body)],
|
|
81
|
+
]
|
|
82
|
+
: [
|
|
83
|
+
["submission", () => submission.extractRepositoryUrl(subject.body)],
|
|
84
|
+
["verify", () => verification.parsePluginVerificationIssue(subject.body).repoUrl],
|
|
85
|
+
]
|
|
86
|
+
const errors = []
|
|
87
|
+
for (const [kind, read] of attempts) {
|
|
88
|
+
try {
|
|
89
|
+
const url = read()
|
|
90
|
+
if (url) return { url, kind, error: null }
|
|
91
|
+
} catch (error) {
|
|
92
|
+
errors.push(`${kind}: ${error.message}`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { url: null, kind: null, error: errors.join("; ") }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The short commit the validation comment reports, as a fallback when no baseline marker exists. */
|
|
99
|
+
export function validationCommentCommit(comments) {
|
|
100
|
+
const validation = (comments || [])
|
|
101
|
+
.filter((comment) => String(comment.body || "").includes("<!-- marketplace-validation -->"))
|
|
102
|
+
.at(-1)
|
|
103
|
+
if (!validation) return null
|
|
104
|
+
const short = String(validation.body).match(/passed at commit `([0-9a-f]{7,40})`/i)?.[1]
|
|
105
|
+
return short ? { short: short.toLowerCase(), createdAt: validation.created_at || null } : null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {{ repoRoot: string, issueUrl: string }} options
|
|
110
|
+
*/
|
|
111
|
+
export async function validationWatch({ repoRoot, issueUrl, onPhase }) {
|
|
112
|
+
// Optional: told the name of the step about to run, so a terminal can say
|
|
113
|
+
// what is happening while the network answers. Never affects the result.
|
|
114
|
+
const phase = onPhase || (() => {})
|
|
115
|
+
const { dir: pinDir } = requirePin(repoRoot)
|
|
116
|
+
const target = parseIssueUrl(issueUrl)
|
|
117
|
+
if (`${target.owner}/${target.repository}`.toLowerCase() !== MARKETPLACE_SLUG) {
|
|
118
|
+
throw new WatchError(
|
|
119
|
+
"usage",
|
|
120
|
+
`the watch only reads ${MARKETPLACE_PIN.repository} submissions, got ${target.owner}/${target.repository}`,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const record = await loadRecord(pinDir)
|
|
125
|
+
|
|
126
|
+
phase(`reading issue #${target.number}`)
|
|
127
|
+
const subject = await issue(target.owner, target.repository, target.number)
|
|
128
|
+
phase(`reading the comments on issue #${target.number}`)
|
|
129
|
+
const comments = await issueComments(target.owner, target.repository, target.number)
|
|
130
|
+
|
|
131
|
+
const read = await repositoryFor(pinDir, subject)
|
|
132
|
+
const repositoryUrl = read.url
|
|
133
|
+
const repositoryError = read.error
|
|
134
|
+
const issueKind = read.kind
|
|
135
|
+
|
|
136
|
+
let validated = null
|
|
137
|
+
let baselineError = null
|
|
138
|
+
try {
|
|
139
|
+
const marker = record.findLatestSecurityBaseline(comments)
|
|
140
|
+
if (marker) {
|
|
141
|
+
validated = {
|
|
142
|
+
commit: marker.commitSha,
|
|
143
|
+
source: "security-baseline-marker",
|
|
144
|
+
outcome: marker.outcome,
|
|
145
|
+
findings: marker.findings,
|
|
146
|
+
capabilities: marker.capabilities,
|
|
147
|
+
checkedAt: marker.checkedAt,
|
|
148
|
+
pluginIds: [...marker.pluginIds],
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch (error) {
|
|
152
|
+
baselineError = { code: error.code || "baseline-unreadable", message: error.message }
|
|
153
|
+
}
|
|
154
|
+
const fallback = validated ? null : validationCommentCommit(comments)
|
|
155
|
+
|
|
156
|
+
const labels = (subject.labels || []).map((label) => (typeof label === "string" ? label : label?.name)).filter(Boolean)
|
|
157
|
+
const authorComments = comments.filter((comment) => comment?.user?.login === subject.user?.login)
|
|
158
|
+
const maintainerComments = comments.filter(
|
|
159
|
+
(comment) => comment?.user?.login && comment.user.login !== subject.user?.login && comment.user.login !== "github-actions[bot]",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
let head = null
|
|
163
|
+
let headError = null
|
|
164
|
+
if (repositoryUrl) {
|
|
165
|
+
phase("reading the plugin repository's default-branch HEAD")
|
|
166
|
+
try {
|
|
167
|
+
head = await defaultBranchHead(repositoryUrl)
|
|
168
|
+
} catch (error) {
|
|
169
|
+
headError = { code: error.code || "head-unreadable", message: error.message }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const comparable = Boolean(validated?.commit && head?.commit)
|
|
174
|
+
const stale = comparable ? validated.commit !== head.commit : null
|
|
175
|
+
const lastReviewAt = Date.parse(maintainerComments.at(-1)?.created_at || "")
|
|
176
|
+
const headAt = Date.parse(head?.committedAt || "")
|
|
177
|
+
const pushedAfterReview = Boolean(
|
|
178
|
+
stale && Number.isFinite(lastReviewAt) && Number.isFinite(headAt) && headAt > lastReviewAt,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
read: {
|
|
183
|
+
issue: `${MARKETPLACE_PIN.repository}/issues/${target.number}`,
|
|
184
|
+
state: subject.state,
|
|
185
|
+
title: subject.title,
|
|
186
|
+
// Kept in the JSON for callers that need it, not printed: the text
|
|
187
|
+
// rendering ends up pasted into issues, reports and screenshots.
|
|
188
|
+
author: subject.user?.login || null,
|
|
189
|
+
labels,
|
|
190
|
+
createdAt: subject.created_at,
|
|
191
|
+
updatedAt: subject.updated_at,
|
|
192
|
+
bodyEditedAt: subject.body_edited_at || null,
|
|
193
|
+
comments: comments.length,
|
|
194
|
+
authorComments: authorComments.length,
|
|
195
|
+
maintainerComments: maintainerComments.length,
|
|
196
|
+
lastMaintainerCommentAt: maintainerComments.at(-1)?.created_at || null,
|
|
197
|
+
authenticated: Boolean(token()),
|
|
198
|
+
},
|
|
199
|
+
plugin: { repository: repositoryUrl, repositoryError, form: issueKind },
|
|
200
|
+
validated,
|
|
201
|
+
validationCommentFallback: fallback,
|
|
202
|
+
baselineError,
|
|
203
|
+
head,
|
|
204
|
+
headError,
|
|
205
|
+
verdict: validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview }),
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl = "unknown" }) {
|
|
210
|
+
if (baselineError) {
|
|
211
|
+
return {
|
|
212
|
+
state: "unknown",
|
|
213
|
+
summary: `The latest automated baseline on this issue did not complete (${baselineError.code}), so there is no validated commit to compare.`,
|
|
214
|
+
action: REFRESH_ACTION,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (!validated) {
|
|
218
|
+
return {
|
|
219
|
+
state: "unknown",
|
|
220
|
+
summary: fallback
|
|
221
|
+
? `No security-baseline marker on this issue. Validation reported commit ${fallback.short}, which is too short to compare reliably.`
|
|
222
|
+
: "No automated validation or security baseline has run on this issue yet, so there is no validated commit.",
|
|
223
|
+
action: fallback ? REFRESH_ACTION : "Wait for the automated validation to run, or edit the issue body to trigger it.",
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (!repositoryUrl) {
|
|
227
|
+
return {
|
|
228
|
+
state: "unknown",
|
|
229
|
+
summary: `The validated commit is ${validated.commit}, but no plugin repository could be read from this issue, so there is nothing to compare it with.`,
|
|
230
|
+
action: null,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (headError || !head) {
|
|
234
|
+
return {
|
|
235
|
+
state: "unknown",
|
|
236
|
+
summary: `The validated commit is ${validated.commit}, but the repository's current default-branch HEAD could not be read (${headError?.code || "unknown"}).`,
|
|
237
|
+
action: null,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (!comparable) {
|
|
241
|
+
return { state: "unknown", summary: "Not enough information to compare the validated commit.", action: null }
|
|
242
|
+
}
|
|
243
|
+
if (!stale) {
|
|
244
|
+
return {
|
|
245
|
+
state: "current",
|
|
246
|
+
summary: `The validated commit is ${validated.commit}, which is the current ${head.branch || "default"}-branch HEAD. Nothing needs refreshing.`,
|
|
247
|
+
action: null,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
state: "stale",
|
|
252
|
+
summary: [
|
|
253
|
+
`The validated commit is ${validated.commit}.`,
|
|
254
|
+
`The repository's current ${head.branch || "default"}-branch HEAD is ${head.commit}.`,
|
|
255
|
+
"The marketplace has not seen the newer commit. Pushing it did not tell the marketplace, and neither did any comment.",
|
|
256
|
+
pushedAfterReview ? "The newer commit landed after the last human review comment on this issue." : null,
|
|
257
|
+
].filter(Boolean).join(" "),
|
|
258
|
+
action: REFRESH_ACTION,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export { GitHubError }
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// A deliberately small YAML reader for GitHub issue-form documents.
|
|
2
|
+
//
|
|
3
|
+
// Omakit has zero runtime dependencies, and the one YAML document it must read
|
|
4
|
+
// is the pinned marketplace issue form. That form uses a narrow subset: block
|
|
5
|
+
// maps, block sequences, plain and quoted scalars, and one literal block
|
|
6
|
+
// scalar. This parser accepts exactly that subset and throws on anything it
|
|
7
|
+
// does not understand, so an unreadable form is a loud failure instead of a
|
|
8
|
+
// silently wrong submission body.
|
|
9
|
+
//
|
|
10
|
+
// Not supported on purpose: flow collections, anchors, aliases, tags,
|
|
11
|
+
// multi-document streams, folded scalars with chomping indicators other than
|
|
12
|
+
// the two below. If the marketplace ever needs them, this file fails and the
|
|
13
|
+
// pin update is the moment to notice.
|
|
14
|
+
|
|
15
|
+
export class YamlError extends Error {
|
|
16
|
+
constructor(message, line) {
|
|
17
|
+
super(line === undefined ? message : `${message} (line ${line + 1})`)
|
|
18
|
+
this.name = "YamlError"
|
|
19
|
+
this.code = "form-unreadable"
|
|
20
|
+
this.line = line
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const KEY = /^([A-Za-z0-9_][A-Za-z0-9_.\- ]*?)\s*:(?:\s+(.*))?$/
|
|
25
|
+
const ITEM = /^-(\s+|$)/
|
|
26
|
+
|
|
27
|
+
function isBlank(line) {
|
|
28
|
+
return line.trim() === ""
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isComment(line) {
|
|
32
|
+
return /^\s*#/.test(line)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function indentOf(line) {
|
|
36
|
+
return line.length - line.replace(/^ +/, "").length
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function skip(lines, i) {
|
|
40
|
+
while (i < lines.length && (isBlank(lines[i]) || isComment(lines[i]))) i += 1
|
|
41
|
+
return i
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseScalar(token) {
|
|
45
|
+
const text = String(token).trim()
|
|
46
|
+
if (text === "" || text === "~" || text === "null") return text === "" ? "" : null
|
|
47
|
+
if (text === "true") return true
|
|
48
|
+
if (text === "false") return false
|
|
49
|
+
if (/^-?\d+$/.test(text)) return Number(text)
|
|
50
|
+
if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) {
|
|
51
|
+
return text
|
|
52
|
+
.slice(1, -1)
|
|
53
|
+
.replace(/\\n/g, "\n")
|
|
54
|
+
.replace(/\\t/g, "\t")
|
|
55
|
+
.replace(/\\"/g, '"')
|
|
56
|
+
.replace(/\\\\/g, "\\")
|
|
57
|
+
}
|
|
58
|
+
if (text.length >= 2 && text.startsWith("'") && text.endsWith("'")) {
|
|
59
|
+
return text.slice(1, -1).replace(/''/g, "'")
|
|
60
|
+
}
|
|
61
|
+
return text
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readBlockScalar(lines, i, parentIndent, chomp) {
|
|
65
|
+
const out = []
|
|
66
|
+
let base = null
|
|
67
|
+
while (i < lines.length) {
|
|
68
|
+
if (isBlank(lines[i])) {
|
|
69
|
+
out.push("")
|
|
70
|
+
i += 1
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
const indent = indentOf(lines[i])
|
|
74
|
+
if (indent <= parentIndent) break
|
|
75
|
+
if (base === null) base = indent
|
|
76
|
+
out.push(lines[i].slice(Math.min(base, indent)))
|
|
77
|
+
i += 1
|
|
78
|
+
}
|
|
79
|
+
while (out.length && out.at(-1) === "") out.pop()
|
|
80
|
+
const text = out.join("\n")
|
|
81
|
+
return [chomp === "strip" ? text : `${text}\n`, i]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseNode(lines, i, minIndent) {
|
|
85
|
+
i = skip(lines, i)
|
|
86
|
+
if (i >= lines.length) return [null, i]
|
|
87
|
+
const indent = indentOf(lines[i])
|
|
88
|
+
if (indent < minIndent) return [null, i]
|
|
89
|
+
const body = lines[i].slice(indent)
|
|
90
|
+
if (ITEM.test(body)) return parseSequence(lines, i, indent)
|
|
91
|
+
if (KEY.test(body)) return parseMap(lines, i, indent)
|
|
92
|
+
throw new YamlError(`unsupported YAML construct: ${body.slice(0, 40)}`, i)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseSequence(lines, start, indent) {
|
|
96
|
+
const out = []
|
|
97
|
+
let i = start
|
|
98
|
+
for (;;) {
|
|
99
|
+
i = skip(lines, i)
|
|
100
|
+
if (i >= lines.length || indentOf(lines[i]) !== indent) break
|
|
101
|
+
const body = lines[i].slice(indent)
|
|
102
|
+
if (!ITEM.test(body)) break
|
|
103
|
+
const rest = body.replace(ITEM, "")
|
|
104
|
+
if (rest === "") {
|
|
105
|
+
const [value, next] = parseNode(lines, i + 1, indent + 1)
|
|
106
|
+
out.push(value)
|
|
107
|
+
i = next
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
if (KEY.test(rest)) {
|
|
111
|
+
// An inline first key: re-present the line at the item's own indentation
|
|
112
|
+
// so the map parser sees a normal block map. Index arithmetic holds
|
|
113
|
+
// because entry 0 of the view stands for line i.
|
|
114
|
+
const itemIndent = indent + (body.length - rest.length)
|
|
115
|
+
const view = [" ".repeat(itemIndent) + rest, ...lines.slice(i + 1)]
|
|
116
|
+
const [value, consumed] = parseMap(view, 0, itemIndent)
|
|
117
|
+
out.push(value)
|
|
118
|
+
i += consumed
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
out.push(parseScalar(rest))
|
|
122
|
+
i += 1
|
|
123
|
+
}
|
|
124
|
+
return [out, i]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseMap(lines, start, indent) {
|
|
128
|
+
const out = {}
|
|
129
|
+
let i = start
|
|
130
|
+
for (;;) {
|
|
131
|
+
i = skip(lines, i)
|
|
132
|
+
if (i >= lines.length || indentOf(lines[i]) !== indent) break
|
|
133
|
+
const body = lines[i].slice(indent)
|
|
134
|
+
const match = body.match(KEY)
|
|
135
|
+
if (!match) break
|
|
136
|
+
const key = match[1].trim()
|
|
137
|
+
if (Object.hasOwn(out, key)) throw new YamlError(`duplicate key "${key}"`, i)
|
|
138
|
+
const inline = match[2] === undefined ? "" : match[2].trim()
|
|
139
|
+
if (inline === "|" || inline === "|-") {
|
|
140
|
+
const [value, next] = readBlockScalar(lines, i + 1, indent, inline === "|-" ? "strip" : "clip")
|
|
141
|
+
out[key] = value
|
|
142
|
+
i = next
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if (inline === "") {
|
|
146
|
+
const [value, next] = parseNode(lines, i + 1, indent + 1)
|
|
147
|
+
out[key] = value
|
|
148
|
+
i = next
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
out[key] = parseScalar(inline)
|
|
152
|
+
i += 1
|
|
153
|
+
}
|
|
154
|
+
return [out, i]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Parse one YAML document in the issue-form subset described above. */
|
|
158
|
+
export function parseYaml(text) {
|
|
159
|
+
const lines = String(text).replace(/\r\n?/g, "\n").split("\n")
|
|
160
|
+
const [value, next] = parseNode(lines, 0, 0)
|
|
161
|
+
const tail = skip(lines, next)
|
|
162
|
+
if (tail < lines.length) throw new YamlError(`unparsed trailing content: ${lines[tail].slice(0, 40)}`, tail)
|
|
163
|
+
return value
|
|
164
|
+
}
|