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,216 @@
|
|
|
1
|
+
// The plugin-id and repository universe, read from the pinned marketplace
|
|
2
|
+
// checkout. Like the submission contract, nothing here is hardcoded: the
|
|
3
|
+
// reserved namespace is read out of the marketplace's own catalog builder, the
|
|
4
|
+
// listed ids out of the published catalog and the registry sources, and the
|
|
5
|
+
// retired ids out of `registry.json`.
|
|
6
|
+
//
|
|
7
|
+
// Measured reason these three checks exist (docs/MEASUREMENTS.md M2): the
|
|
8
|
+
// marketplace refuses a submission whose id is already listed
|
|
9
|
+
// (`plugin-id-listed`), was used by a previous listing (`plugin-id-retired`),
|
|
10
|
+
// or sits in the reserved namespace (`reserved-plugin-id`), and it refuses a
|
|
11
|
+
// repository that is already listed (`submission-repository-listed`). Each
|
|
12
|
+
// refusal costs the author a full round trip through a human queue whose
|
|
13
|
+
// median submission-to-publication time rose sevenfold between 2026-W33 and
|
|
14
|
+
// 2026-W36, and it is knowable before submitting from data the marketplace
|
|
15
|
+
// publishes.
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from "node:fs"
|
|
18
|
+
import { join } from "node:path"
|
|
19
|
+
import { requirePin } from "./pin.mjs"
|
|
20
|
+
|
|
21
|
+
export const CATALOG_PATH = "site/catalog.json"
|
|
22
|
+
export const REGISTRY_PATH = "registry.json"
|
|
23
|
+
export const CATALOG_BUILDER_PATH = "scripts/build-catalog.mjs"
|
|
24
|
+
|
|
25
|
+
export class RegistryError extends Error {
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
super(message)
|
|
28
|
+
this.name = "RegistryError"
|
|
29
|
+
this.code = code
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readJson(pinDir, relative) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(join(pinDir, relative), "utf8"))
|
|
36
|
+
} catch (error) {
|
|
37
|
+
throw new RegistryError("pin-unreadable", `cannot read ${relative} from the pinned checkout: ${error.message}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The reserved namespace as the marketplace states it, read from the pinned
|
|
43
|
+
* `build-catalog.mjs` next to its own `reserved-plugin-id` check. Read, not
|
|
44
|
+
* copied, so a pin update moves it.
|
|
45
|
+
*/
|
|
46
|
+
export function reservedNamespace(pinDir) {
|
|
47
|
+
const source = readFileSync(join(pinDir, CATALOG_BUILDER_PATH), "utf8")
|
|
48
|
+
const match = source.match(/startsWith\("([A-Za-z0-9.\-_]+\.)"\)\s*\)\s*\{\s*\n\s*checkError\("reserved-plugin-id"/)
|
|
49
|
+
|| source.match(/checkError\("reserved-plugin-id",\s*`[^`]*\$\{[^}]*\}:\s*the ([A-Za-z0-9.\-_]+)\.\*\s+namespace is reserved`/)
|
|
50
|
+
if (!match) {
|
|
51
|
+
throw new RegistryError(
|
|
52
|
+
"pin-unreadable",
|
|
53
|
+
`cannot read the reserved plugin-id namespace from ${CATALOG_BUILDER_PATH} at the pin; refusing to guess it`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
return match[1].endsWith(".") ? match[1] : `${match[1]}.`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function repositorySlug(value) {
|
|
60
|
+
try {
|
|
61
|
+
return new URL(String(value)).pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLowerCase()
|
|
62
|
+
} catch {
|
|
63
|
+
return ""
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {{ repoRoot?: string, pinDir?: string }} [options]
|
|
69
|
+
* @returns {{ reservedPrefix: string, listedIds: Set<string>, retiredIds: Set<string>,
|
|
70
|
+
* listedRepositories: Set<string>, counts: object }}
|
|
71
|
+
*/
|
|
72
|
+
export function idUniverse(options = {}) {
|
|
73
|
+
const pinDir = options.pinDir || requirePin(options.repoRoot).dir
|
|
74
|
+
const catalog = readJson(pinDir, CATALOG_PATH)
|
|
75
|
+
const registry = readJson(pinDir, REGISTRY_PATH)
|
|
76
|
+
|
|
77
|
+
const listedIds = new Set()
|
|
78
|
+
for (const plugin of Array.isArray(catalog.plugins) ? catalog.plugins : []) {
|
|
79
|
+
if (typeof plugin?.id === "string" && plugin.id) listedIds.add(plugin.id)
|
|
80
|
+
}
|
|
81
|
+
const catalogIds = listedIds.size
|
|
82
|
+
|
|
83
|
+
const sources = Array.isArray(registry.sources) ? registry.sources : Object.values(registry.sources || {})
|
|
84
|
+
const listedRepositories = new Set()
|
|
85
|
+
for (const source of sources) {
|
|
86
|
+
const slug = repositorySlug(source?.repo)
|
|
87
|
+
if (slug) listedRepositories.add(slug)
|
|
88
|
+
if (source?.plugins && typeof source.plugins === "object" && !Array.isArray(source.plugins)) {
|
|
89
|
+
for (const id of Object.keys(source.plugins)) listedIds.add(id)
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(source?.plugins)) {
|
|
92
|
+
for (const entry of source.plugins) {
|
|
93
|
+
const id = typeof entry === "string" ? entry : entry?.id
|
|
94
|
+
if (id) listedIds.add(id)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (typeof source?.catalog?.id === "string") listedIds.add(source.catalog.id)
|
|
98
|
+
for (const id of source?.automatedSecurityBaseline?.pluginIds || []) {
|
|
99
|
+
if (typeof id === "string") listedIds.add(id)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const retiredIds = new Set((registry.retiredPluginIds || []).filter((id) => typeof id === "string"))
|
|
104
|
+
|
|
105
|
+
if (!listedIds.size || !listedRepositories.size) {
|
|
106
|
+
throw new RegistryError("pin-unreadable", "the pinned catalog and registry produced no listed ids or repositories")
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
pinDir,
|
|
111
|
+
reservedPrefix: reservedNamespace(pinDir),
|
|
112
|
+
listedIds,
|
|
113
|
+
retiredIds,
|
|
114
|
+
listedRepositories,
|
|
115
|
+
counts: {
|
|
116
|
+
catalogPlugins: catalogIds,
|
|
117
|
+
registrySources: sources.length,
|
|
118
|
+
listedIds: listedIds.size,
|
|
119
|
+
retiredIds: retiredIds.size,
|
|
120
|
+
listedRepositories: listedRepositories.size,
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The recorded baseline over every listed source, counted from registry.json at
|
|
127
|
+
* the pin. This is the one place the figures cited by `baseline.preflight` and
|
|
128
|
+
* docs/MEASUREMENTS.md M4 come from; tests/unit/registry-figures.test.mjs pins
|
|
129
|
+
* the values, so a pin bump changes the printed number rather than leaving a
|
|
130
|
+
* stale literal behind.
|
|
131
|
+
*
|
|
132
|
+
* @param {{ repoRoot?: string, pinDir?: string }} [options]
|
|
133
|
+
* @returns {{ sources: number, withBaseline: number, outcomes: Record<string, number>,
|
|
134
|
+
* capabilities: Record<string, number>, findings: Record<string, number>,
|
|
135
|
+
* findingsTotal: number, superseded: { sources: number, commits: number, most: number },
|
|
136
|
+
* retiredIds: number, catalogPlugins: number }}
|
|
137
|
+
*/
|
|
138
|
+
export function baselineFigures(options = {}) {
|
|
139
|
+
const pinDir = options.pinDir || requirePin(options.repoRoot).dir
|
|
140
|
+
const registry = readJson(pinDir, REGISTRY_PATH)
|
|
141
|
+
const catalog = readJson(pinDir, CATALOG_PATH)
|
|
142
|
+
const sources = Array.isArray(registry.sources) ? registry.sources : Object.values(registry.sources || {})
|
|
143
|
+
const count = (table, key) => { table[key] = (table[key] || 0) + 1 }
|
|
144
|
+
const outcomes = {}
|
|
145
|
+
const capabilities = {}
|
|
146
|
+
const findings = {}
|
|
147
|
+
let withBaseline = 0
|
|
148
|
+
let findingsTotal = 0
|
|
149
|
+
let supersededSources = 0
|
|
150
|
+
let supersededCommits = 0
|
|
151
|
+
let most = 0
|
|
152
|
+
for (const source of sources) {
|
|
153
|
+
const baseline = source?.automatedSecurityBaseline
|
|
154
|
+
if (baseline && typeof baseline.outcome === "string") {
|
|
155
|
+
withBaseline += 1
|
|
156
|
+
count(outcomes, baseline.outcome)
|
|
157
|
+
for (const capability of baseline.capabilities || []) count(capabilities, typeof capability === "string" ? capability : capability?.id)
|
|
158
|
+
for (const finding of baseline.findings || []) {
|
|
159
|
+
count(findings, typeof finding === "string" ? finding : finding?.rule || finding?.id)
|
|
160
|
+
findingsTotal += 1
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const history = Array.isArray(source?.listingValidationHistory) ? source.listingValidationHistory.length : 0
|
|
164
|
+
if (history > 0) supersededSources += 1
|
|
165
|
+
supersededCommits += history
|
|
166
|
+
if (history > most) most = history
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
sources: sources.length,
|
|
170
|
+
withBaseline,
|
|
171
|
+
outcomes,
|
|
172
|
+
capabilities,
|
|
173
|
+
findings,
|
|
174
|
+
findingsTotal,
|
|
175
|
+
superseded: { sources: supersededSources, commits: supersededCommits, most },
|
|
176
|
+
retiredIds: (registry.retiredPluginIds || []).length,
|
|
177
|
+
catalogPlugins: Array.isArray(catalog.plugins) ? catalog.plugins.length : 0,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 1681 -> "1,681", the way the docs print figures. */
|
|
182
|
+
export function figure(n) {
|
|
183
|
+
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {{ id: string, repositoryUrl?: string|null }} subject
|
|
188
|
+
* @returns {{ ok: boolean, problems: Array<{ code: string, detail: string }> }}
|
|
189
|
+
*/
|
|
190
|
+
export function checkIdentity(universe, subject) {
|
|
191
|
+
const problems = []
|
|
192
|
+
const id = String(subject.id || "")
|
|
193
|
+
if (!id) {
|
|
194
|
+
problems.push({ code: "plugin-id-missing", detail: "the root manifest declares no id" })
|
|
195
|
+
return { ok: false, problems }
|
|
196
|
+
}
|
|
197
|
+
if (id.toLowerCase().startsWith(universe.reservedPrefix)) {
|
|
198
|
+
problems.push({
|
|
199
|
+
code: "reserved-plugin-id",
|
|
200
|
+
detail: `"${id}" is inside the reserved ${universe.reservedPrefix}* namespace`,
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
if (universe.retiredIds.has(id)) {
|
|
204
|
+
problems.push({ code: "plugin-id-retired", detail: `"${id}" was used by a previous listing (registry.json retiredPluginIds)` })
|
|
205
|
+
}
|
|
206
|
+
if (universe.listedIds.has(id)) {
|
|
207
|
+
problems.push({ code: "plugin-id-listed", detail: `"${id}" is already listed` })
|
|
208
|
+
}
|
|
209
|
+
const slug = repositorySlug(subject.repositoryUrl)
|
|
210
|
+
if (slug && universe.listedRepositories.has(slug)) {
|
|
211
|
+
problems.push({ code: "submission-repository-listed", detail: `${slug} is already listed` })
|
|
212
|
+
}
|
|
213
|
+
return { ok: problems.length === 0, problems }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export { repositorySlug }
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Text rendering of a submit preflight, a validation watch and a doctor run, for the
|
|
2
|
+
// agent that runs this tool and the person reading over its shoulder. Every
|
|
3
|
+
// check prints its verdict, its source and the measured reason it exists; a
|
|
4
|
+
// failing check prints its paths and the one action that clears it.
|
|
5
|
+
//
|
|
6
|
+
// Everything here is drawn with style.mjs and nothing of its own: the marks,
|
|
7
|
+
// the columns, the arrow and the rule are the same ones every other command
|
|
8
|
+
// uses. Colour is added only when a person is looking at a terminal. The text
|
|
9
|
+
// itself is identical either way, so a piped run and a watched run say exactly
|
|
10
|
+
// the same thing.
|
|
11
|
+
//
|
|
12
|
+
// Two decisions are about scanning rather than reading. Passing checks are
|
|
13
|
+
// dense, two lines each with nothing between them, and a failing check is a
|
|
14
|
+
// block with a blank line on either side, so on a monochrome theme the
|
|
15
|
+
// failures are still the things with air around them. And a refusal ends with
|
|
16
|
+
// the failing checks and their actions again, because after fifteen checks and
|
|
17
|
+
// the marketplace's forty-line report the fail blocks are off the top of the
|
|
18
|
+
// screen, and the last screen is the one a person is looking at.
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
action, colourEnabled, COLUMNS, continuation, field, GUTTER, labelled, mark, section, STEP, styler, verdict, width, wrap,
|
|
22
|
+
} from "./style.mjs"
|
|
23
|
+
|
|
24
|
+
const body = " ".repeat(GUTTER)
|
|
25
|
+
|
|
26
|
+
/** The state a check renders in: an advisory failure is a note, not a FAIL. */
|
|
27
|
+
function stateOf(check) {
|
|
28
|
+
if (check.verdict === "pass") return "pass"
|
|
29
|
+
return check.severity === "advisory" ? "advisory" : "fail"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A check's head line: the mark, the id in bold, and the source in brackets
|
|
34
|
+
* pushed to the right edge, so the sources form a column of their own and the
|
|
35
|
+
* ids form another.
|
|
36
|
+
*/
|
|
37
|
+
function head(state, id, source, c) {
|
|
38
|
+
const left = `${mark(state, c)}${c("name", id)}`
|
|
39
|
+
const tag = c("punctuation", `[${source}]`)
|
|
40
|
+
const gap = Math.max(2, COLUMNS - width(left) - width(tag))
|
|
41
|
+
return `${left}${" ".repeat(gap)}${tag}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function checkBlock(check, c) {
|
|
45
|
+
const state = stateOf(check)
|
|
46
|
+
const out = [head(state, check.id, check.source, c)]
|
|
47
|
+
if (check.detail) out.push(...wrap(check.detail, { indent: GUTTER }, c))
|
|
48
|
+
if (check.verdict === "fail") {
|
|
49
|
+
// A path is the thing at fault, so it is yellow; its reason wraps under it.
|
|
50
|
+
for (const path of check.paths) {
|
|
51
|
+
out.push(...wrap(`- ${path}`, { indent: GUTTER + STEP, first: GUTTER })
|
|
52
|
+
.map((line, index) => (index === 0
|
|
53
|
+
? `${body}${c("fail", "-")} ${c("placeholder", line.trimStart().slice(2))}`
|
|
54
|
+
: `${" ".repeat(GUTTER + STEP)}${c("placeholder", line.trimStart())}`)))
|
|
55
|
+
}
|
|
56
|
+
if (check.remedy) out.push(...action(check.remedy, c))
|
|
57
|
+
// The measured reason is the point of the check, so it is not dimmed: only
|
|
58
|
+
// its label is grey, and on a low-contrast theme the number still reads.
|
|
59
|
+
out.push(...labelled("why", check.why, c))
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderSubmit(result, { colour = colourEnabled() } = {}) {
|
|
65
|
+
const c = styler(colour)
|
|
66
|
+
const out = []
|
|
67
|
+
out.push(...field("subject", result.subject.repository || result.subject.directory, c, { wrapValue: false }))
|
|
68
|
+
out.push(...field("commit", `${result.subject.commit}${result.subject.cleanTree ? "" : c("advisory", " (dirty worktree)")}`, c, { wrapValue: false }))
|
|
69
|
+
out.push(...field("marketplace", `${result.pin.commit}, baseline ${result.pin.baselineVersion}, ${result.pin.enforcementMode}`, c))
|
|
70
|
+
out.push("")
|
|
71
|
+
|
|
72
|
+
// Passing checks run together; anything else gets a blank line on each side,
|
|
73
|
+
// collapsed where two blocks meet.
|
|
74
|
+
let previous = "pass"
|
|
75
|
+
for (const [index, check] of result.checks.entries()) {
|
|
76
|
+
const state = stateOf(check)
|
|
77
|
+
if (index > 0 && (state !== "pass" || previous !== "pass")) out.push("")
|
|
78
|
+
out.push(...checkBlock(check, c))
|
|
79
|
+
previous = state
|
|
80
|
+
}
|
|
81
|
+
out.push("")
|
|
82
|
+
|
|
83
|
+
if (result.baseline?.officialReport) {
|
|
84
|
+
out.push(...section("the marketplace's own baseline report for this commit", c))
|
|
85
|
+
out.push(result.baseline.officialReport)
|
|
86
|
+
out.push("")
|
|
87
|
+
out.push(...wrap(result.baseline.statement, {}, c))
|
|
88
|
+
out.push("")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!result.ready) {
|
|
92
|
+
const failed = result.checks.filter((check) => result.blocking.includes(check.id))
|
|
93
|
+
const count = failed.length === 1 ? "1 blocking check" : `${failed.length} blocking checks`
|
|
94
|
+
out.push(...verdict("fail", "REFUSED", `${count} failed, so no submission body is produced.`, c))
|
|
95
|
+
out.push("")
|
|
96
|
+
for (const check of failed) {
|
|
97
|
+
out.push(`${body}${c("name", check.id)}`)
|
|
98
|
+
if (check.remedy) out.push(...action(check.remedy, c))
|
|
99
|
+
else out.push(...wrap(check.detail, { indent: GUTTER }, c))
|
|
100
|
+
out.push("")
|
|
101
|
+
}
|
|
102
|
+
out.push(failed.length === 1 ? "Fix it, then run submit again." : "Fix them, then run submit again.")
|
|
103
|
+
return out.join("\n")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const validation = result.validationCommit.defaultBranchHead
|
|
107
|
+
? `${result.validationCommit.local} is the ${result.validationCommit.branch || "default"}-branch HEAD, so it is the commit the marketplace will validate.`
|
|
108
|
+
: `${result.validationCommit.local} is the local commit; the marketplace validates the default-branch HEAD it resolves when the issue is opened.`
|
|
109
|
+
out.push(...verdict("pass", "READY", `every blocking check passed. ${validation}`, c))
|
|
110
|
+
out.push("")
|
|
111
|
+
out.push(...section("issue title", c))
|
|
112
|
+
out.push(c("heading", result.issue.title))
|
|
113
|
+
out.push("")
|
|
114
|
+
out.push(...section("issue body", c))
|
|
115
|
+
out.push(result.issue.body.trimEnd())
|
|
116
|
+
out.push("")
|
|
117
|
+
out.push(...wrap("This is not posted. Ask the plugin owner to approve it, then create the issue yourself, for example:", {}, c))
|
|
118
|
+
out.push("")
|
|
119
|
+
// The one action, as a command a person runs. It is not wrapped as prose
|
|
120
|
+
// because a shell command breaks on its backslashes, not on its spaces.
|
|
121
|
+
const step = " ".repeat(STEP)
|
|
122
|
+
out.push(...action("gh issue create --repo omacom/omarchy-plugin-marketplace \\", c, { indent: 0 }))
|
|
123
|
+
out.push(`${step}${c("typeable", `--title ${JSON.stringify(result.issue.title)} \\`)}`)
|
|
124
|
+
out.push(`${step}${c("typeable", "--body-file <the body above>")}`)
|
|
125
|
+
out.push("")
|
|
126
|
+
out.push(...wrap(`After it is created: ${result.afterSubmitting}`, {}, c))
|
|
127
|
+
return out.join("\n")
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function renderWatch(result, { colour = colourEnabled() } = {}) {
|
|
131
|
+
const c = styler(colour)
|
|
132
|
+
const out = []
|
|
133
|
+
out.push(...field("issue", result.read.issue, c, { wrapValue: false }))
|
|
134
|
+
out.push(...field("state", `${result.read.state}${result.read.labels.length ? `; labels ${result.read.labels.join(", ")}` : ""}`, c))
|
|
135
|
+
out.push(...field("title", result.read.title, c))
|
|
136
|
+
out.push(...field("plugin repo", result.plugin.repository || `unreadable: ${result.plugin.repositoryError}`, c, { wrapValue: false }))
|
|
137
|
+
if (result.plugin.form === "verify" || result.plugin.form === "verify-legacy") {
|
|
138
|
+
out.push(...field("form", "plugin update request, read with the marketplace's verification parser", c))
|
|
139
|
+
}
|
|
140
|
+
out.push("")
|
|
141
|
+
if (result.validated) {
|
|
142
|
+
out.push(...field("validated", c("name", result.validated.commit), c, { wrapValue: false }))
|
|
143
|
+
out.push(...continuation(`outcome ${result.validated.outcome}, ${result.validated.findings.length} finding(s), ${result.validated.capabilities.length} capability/ies, checked ${result.validated.checkedAt}`, c))
|
|
144
|
+
} else if (result.validationCommentFallback) {
|
|
145
|
+
out.push(...field("validated", `${result.validationCommentFallback.short} (short form, from the validation comment)`, c))
|
|
146
|
+
} else {
|
|
147
|
+
out.push(...field("validated", "none", c))
|
|
148
|
+
}
|
|
149
|
+
if (result.head) {
|
|
150
|
+
const sha = result.verdict.state === "stale" ? c("fail", result.head.commit) : c("name", result.head.commit)
|
|
151
|
+
out.push(...field("current HEAD", sha, c, { wrapValue: false }))
|
|
152
|
+
out.push(...continuation(`${result.head.branch || "default"} branch, via ${result.head.source}${result.head.committedAt ? `, ${result.head.committedAt}` : ""}`, c))
|
|
153
|
+
} else if (result.headError) {
|
|
154
|
+
out.push(...field("current HEAD", `unreadable: ${result.headError.message}`, c))
|
|
155
|
+
}
|
|
156
|
+
out.push("")
|
|
157
|
+
const state = { current: "pass", stale: "fail", unknown: "unknown" }[result.verdict.state] || "unknown"
|
|
158
|
+
out.push(...verdict(state, `VALIDATION ${result.verdict.state.toUpperCase()}`, result.verdict.summary, c))
|
|
159
|
+
if (result.verdict.action) {
|
|
160
|
+
out.push("")
|
|
161
|
+
out.push(...action(result.verdict.action, c, { indent: 0 }))
|
|
162
|
+
}
|
|
163
|
+
out.push("")
|
|
164
|
+
out.push(...wrap(
|
|
165
|
+
`Read-only. This command did not comment, label or edit anything. Comments on the issue: ${result.read.comments} (${result.read.authorComments} from the author, ${result.read.maintainerComments} from a reviewer).`,
|
|
166
|
+
{},
|
|
167
|
+
c,
|
|
168
|
+
))
|
|
169
|
+
return out.join("\n")
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const DOCTOR_STATE = { ok: "pass", advice: "advisory", problem: "fail", info: "info", unknown: "unknown" }
|
|
173
|
+
|
|
174
|
+
export function renderDoctor(result, { colour = colourEnabled() } = {}) {
|
|
175
|
+
const c = styler(colour)
|
|
176
|
+
const out = []
|
|
177
|
+
let previous = false
|
|
178
|
+
for (const [index, check] of result.checks.entries()) {
|
|
179
|
+
const state = DOCTOR_STATE[check.state] || "unknown"
|
|
180
|
+
const loud = state === "fail" || state === "advisory"
|
|
181
|
+
if (index > 0 && (loud || previous)) out.push("")
|
|
182
|
+
out.push(`${mark(state, c)}${c("name", check.id)}`)
|
|
183
|
+
out.push(...wrap(check.detail, { indent: GUTTER }, c))
|
|
184
|
+
if (check.action) out.push(...action(check.action, c))
|
|
185
|
+
previous = loud
|
|
186
|
+
}
|
|
187
|
+
out.push("")
|
|
188
|
+
out.push(...(result.problems
|
|
189
|
+
? verdict("fail", "NOT READY", `${result.problems === 1 ? "1 problem" : `${result.problems} problems`} to fix before omakit can run.`, c)
|
|
190
|
+
: verdict("pass", "READY", "nothing to fix.", c)))
|
|
191
|
+
return out.join("\n")
|
|
192
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Run the pinned official Omarchy marketplace security baseline over one
|
|
2
|
+
// commit, either through GitHub (the transport the marketplace itself uses) or
|
|
3
|
+
// through the local Git transport in this directory.
|
|
4
|
+
//
|
|
5
|
+
// Omakit never copies or edits marketplace policy: the analysis below is the
|
|
6
|
+
// official code, imported unmodified from a pinned read-only checkout.
|
|
7
|
+
|
|
8
|
+
import { join, resolve } from "node:path"
|
|
9
|
+
import { pathToFileURL } from "node:url"
|
|
10
|
+
import { createLocalTransport, ADAPTER_VERSION, ASSUMED_BY_ADAPTER } from "./local-transport.mjs"
|
|
11
|
+
import { requirePin } from "./pin.mjs"
|
|
12
|
+
import { token } from "./github.mjs"
|
|
13
|
+
|
|
14
|
+
async function loadScanner(pinDir) {
|
|
15
|
+
const url = pathToFileURL(join(pinDir, "scripts/security-baseline-scanner.mjs")).href
|
|
16
|
+
return import(url)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{ repoRoot: string, repoUrl: string, commitSha: string, transport: "local"|"github",
|
|
21
|
+
* repoDir?: string, listedPlugins?: Array, token?: string }} options
|
|
22
|
+
*/
|
|
23
|
+
export async function runBaseline(options) {
|
|
24
|
+
const { dir: pinDir, identity } = requirePin(options.repoRoot)
|
|
25
|
+
const pin = { commit: identity.commit, baselineVersion: identity.baselineVersion, enforcementMode: identity.enforcementMode }
|
|
26
|
+
const { runSecurityBaseline } = await loadScanner(pinDir)
|
|
27
|
+
|
|
28
|
+
const scanOptions = { checkedAt: "1970-01-01T00:00:00.000Z" }
|
|
29
|
+
if (options.listedPlugins) scanOptions.listedPlugins = options.listedPlugins
|
|
30
|
+
let adapter = null
|
|
31
|
+
|
|
32
|
+
if (options.transport === "local") {
|
|
33
|
+
const transport = createLocalTransport({
|
|
34
|
+
repoDir: options.repoDir,
|
|
35
|
+
repoUrl: options.repoUrl,
|
|
36
|
+
commitSha: options.commitSha,
|
|
37
|
+
})
|
|
38
|
+
scanOptions.fetchImpl = transport.fetchImpl
|
|
39
|
+
adapter = {
|
|
40
|
+
transport: "local-git",
|
|
41
|
+
adapterVersion: ADAPTER_VERSION,
|
|
42
|
+
assumedByAdapter: ASSUMED_BY_ADAPTER,
|
|
43
|
+
requests: transport.stats,
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
if (options.token) scanOptions.token = options.token
|
|
47
|
+
adapter = { transport: "github" }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const result = await runSecurityBaseline(options.repoUrl, options.commitSha, scanOptions)
|
|
51
|
+
return { pin, adapter, result }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function arg(name) {
|
|
55
|
+
const prefix = `--${name}=`
|
|
56
|
+
return process.argv.find((value) => value.startsWith(prefix))?.slice(prefix.length)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
60
|
+
if (isMain) {
|
|
61
|
+
const repoRoot = resolve(arg("repo-root") || process.cwd())
|
|
62
|
+
const transport = arg("transport") === "github" ? "github" : "local"
|
|
63
|
+
const out = await runBaseline({
|
|
64
|
+
repoRoot,
|
|
65
|
+
repoUrl: arg("repo-url"),
|
|
66
|
+
commitSha: arg("commit"),
|
|
67
|
+
transport,
|
|
68
|
+
repoDir: arg("dir") ? resolve(arg("dir")) : undefined,
|
|
69
|
+
token: token() ?? undefined,
|
|
70
|
+
})
|
|
71
|
+
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`)
|
|
72
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// `omakit setup`: the first run, in one command.
|
|
2
|
+
//
|
|
3
|
+
// It replaces the three things a newcomer otherwise has to know: that a pinned
|
|
4
|
+
// marketplace checkout has to be fetched before anything works, where it goes,
|
|
5
|
+
// and what to try first. It is idempotent, so running it again on a machine that
|
|
6
|
+
// is already set up just confirms that.
|
|
7
|
+
//
|
|
8
|
+
// It writes exactly one thing: the pinned checkout, through the same `ensurePin`
|
|
9
|
+
// that `omakit pin` uses. It does not create symlinks, edit a shell profile or
|
|
10
|
+
// install anything. Where a step is the user's to take, it prints the command
|
|
11
|
+
// and stops, which is the same contract every other command here keeps.
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from "node:child_process"
|
|
14
|
+
import { existsSync } from "node:fs"
|
|
15
|
+
import { delimiter, join } from "node:path"
|
|
16
|
+
import { banner } from "./banner.mjs"
|
|
17
|
+
import { credential, UNAUTHENTICATED_LIMIT } from "./github.mjs"
|
|
18
|
+
import { ensurePin, marketplacePinDir, pinDiskUsage } from "./pin.mjs"
|
|
19
|
+
import { progress } from "./progress.mjs"
|
|
20
|
+
import { action, colourEnabled, GUTTER, mark, styler, wrap } from "./style.mjs"
|
|
21
|
+
import { TAGLINE } from "./usage.mjs"
|
|
22
|
+
import { installCompletion } from "./completion.mjs"
|
|
23
|
+
import { submissionContract } from "./form.mjs"
|
|
24
|
+
|
|
25
|
+
function version(command) {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync(command, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
|
|
28
|
+
.trim()
|
|
29
|
+
.split("\n")[0]
|
|
30
|
+
} catch {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
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
|
+
/**
|
|
41
|
+
* @param {{ repoRoot: string, entryPoint: string, stream?: NodeJS.WriteStream }} options
|
|
42
|
+
*/
|
|
43
|
+
export async function setup({ repoRoot, entryPoint, stream = process.stdout }) {
|
|
44
|
+
const c = styler(colourEnabled(stream))
|
|
45
|
+
const out = (line = "") => stream.write(`${line}\n`)
|
|
46
|
+
// A step is a status line: the mark, then the fact, wrapped under itself.
|
|
47
|
+
const step = (state, text) => out(`${mark(state, c)}${wrap(text, { indent: GUTTER }, c).join("\n").trimStart()}`)
|
|
48
|
+
// The one action under a step sits in the step's body; under a sentence it
|
|
49
|
+
// sits where the sentence does.
|
|
50
|
+
const fix = (text, indent = GUTTER) => { for (const line of action(text, c, { indent })) out(line) }
|
|
51
|
+
|
|
52
|
+
// The one place the wordmark runs through `ttfx` (effect.mjs): a first run
|
|
53
|
+
// already spending seconds fetching the pin.
|
|
54
|
+
await banner({ stream, tagline: TAGLINE, effect: true })
|
|
55
|
+
|
|
56
|
+
const node = process.versions.node
|
|
57
|
+
const major = Number(node.split(".")[0])
|
|
58
|
+
if (major < 22) {
|
|
59
|
+
step("fail", `node ${node} is too old; omakit needs 22 or newer.`)
|
|
60
|
+
fix("Install Node 22 or newer, then run `omakit setup` again.")
|
|
61
|
+
return { ok: false }
|
|
62
|
+
}
|
|
63
|
+
step("pass", `node ${node}`)
|
|
64
|
+
|
|
65
|
+
const git = version("git")
|
|
66
|
+
if (!git) {
|
|
67
|
+
step("fail", "git was not found on PATH. omakit needs it for the pin and for reading a commit's tree.")
|
|
68
|
+
fix("Install git, then run `omakit setup` again.")
|
|
69
|
+
return { ok: false }
|
|
70
|
+
}
|
|
71
|
+
step("pass", git)
|
|
72
|
+
|
|
73
|
+
// GitHub access, before the pin, because this is the one step a newcomer might
|
|
74
|
+
// otherwise think they have to prepare a token for. They do not.
|
|
75
|
+
const auth = credential({ refresh: true })
|
|
76
|
+
if (auth.source === "gh") {
|
|
77
|
+
step("pass", "GitHub: your `gh` login, read-only. omakit stores nothing.")
|
|
78
|
+
} else {
|
|
79
|
+
step("info", `no GitHub login. \`submit\` and \`verify\` need none at all; \`watch\` and \`parity\` are capped at ${UNAUTHENTICATED_LIMIT} requests an hour without one.`)
|
|
80
|
+
fix("`gh auth login` is enough; omakit reads it read-only and stores nothing.")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const dir = marketplacePinDir(repoRoot)
|
|
84
|
+
const spinner = progress()
|
|
85
|
+
let identity
|
|
86
|
+
try {
|
|
87
|
+
identity = ensurePin(repoRoot, (line) => {
|
|
88
|
+
if (line.state === "fetching") spinner.phase(line.text)
|
|
89
|
+
}).identity
|
|
90
|
+
} catch (error) {
|
|
91
|
+
spinner.done()
|
|
92
|
+
step("fail", error.message)
|
|
93
|
+
fix(error.remedy || (error.code === "network-unavailable"
|
|
94
|
+
? "Connect to the network, then run `omakit setup` again."
|
|
95
|
+
: "Remove the checkout, then run `omakit setup` again."))
|
|
96
|
+
return { ok: false }
|
|
97
|
+
}
|
|
98
|
+
spinner.done()
|
|
99
|
+
step("pass", `marketplace pin ${identity.commit.slice(0, 7)} (baseline ${identity.baselineVersion}, ${identity.enforcementMode}), ${pinDiskUsage(dir)}`)
|
|
100
|
+
out()
|
|
101
|
+
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
|
+
out()
|
|
103
|
+
|
|
104
|
+
if (!onPath()) {
|
|
105
|
+
step("info", "`omakit` is not on your PATH yet. This puts it there:")
|
|
106
|
+
fix(`ln -s ${entryPoint} ~/.local/bin/omakit`)
|
|
107
|
+
out()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Tab completion, installed for the shell in $SHELL where that shell loads
|
|
111
|
+
// it from, so nobody has to know the path. The script carries the pin's
|
|
112
|
+
// categories and tags, so it is rewritten when the pin has moved and left
|
|
113
|
+
// alone otherwise.
|
|
114
|
+
try {
|
|
115
|
+
const contract = await submissionContract({ repoRoot })
|
|
116
|
+
const completion = installCompletion({ contract, pin: identity.commit })
|
|
117
|
+
if (completion.state === "unsupported") {
|
|
118
|
+
step("info", completion.shell
|
|
119
|
+
? `tab completion: no script for ${completion.shell}; there is one for bash, zsh and fish.`
|
|
120
|
+
: "tab completion: $SHELL is not set, so no script was installed.")
|
|
121
|
+
} else {
|
|
122
|
+
const what = { installed: "installed", updated: "updated for this pin", current: "already installed" }[completion.state]
|
|
123
|
+
step("pass", `tab completion for ${completion.shell} ${what} at ${completion.display}${completion.note ? `, ${completion.note}` : ""}.`)
|
|
124
|
+
}
|
|
125
|
+
} catch (error) {
|
|
126
|
+
step("info", `tab completion was not installed: ${error.message}`)
|
|
127
|
+
}
|
|
128
|
+
out()
|
|
129
|
+
|
|
130
|
+
out("Try it on a plugin you have checked out:")
|
|
131
|
+
out()
|
|
132
|
+
fix("omakit submit <plugin-repo> --category Widgets --tags bar,quickshell", 0)
|
|
133
|
+
out()
|
|
134
|
+
for (const line of wrap("It prints the issue title and body. It never posts anything.", {}, c)) out(line)
|
|
135
|
+
return { ok: true }
|
|
136
|
+
}
|