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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/bin/omakit +3 -0
  4. package/package.json +40 -0
  5. package/skills/omarchy-plugin-submit/SKILL.md +87 -0
  6. package/skills/omarchy-plugin-validation-watch/SKILL.md +62 -0
  7. package/tests/parity/corpus.mjs +62 -0
  8. package/tests/parity/run.mjs +194 -0
  9. package/tools/marketplace/README.md +64 -0
  10. package/tools/marketplace/agent-control.mjs +72 -0
  11. package/tools/marketplace/banner.mjs +307 -0
  12. package/tools/marketplace/cli.mjs +283 -0
  13. package/tools/marketplace/completion.mjs +263 -0
  14. package/tools/marketplace/doctor.mjs +156 -0
  15. package/tools/marketplace/effect.mjs +115 -0
  16. package/tools/marketplace/form.mjs +209 -0
  17. package/tools/marketplace/github.mjs +206 -0
  18. package/tools/marketplace/issue.mjs +80 -0
  19. package/tools/marketplace/local-transport.mjs +162 -0
  20. package/tools/marketplace/parity-output.mjs +26 -0
  21. package/tools/marketplace/paths.mjs +10 -0
  22. package/tools/marketplace/pin.mjs +203 -0
  23. package/tools/marketplace/plugin.mjs +64 -0
  24. package/tools/marketplace/preflight.mjs +164 -0
  25. package/tools/marketplace/progress.mjs +96 -0
  26. package/tools/marketplace/registry.mjs +216 -0
  27. package/tools/marketplace/report.mjs +192 -0
  28. package/tools/marketplace/run-baseline.mjs +72 -0
  29. package/tools/marketplace/setup.mjs +136 -0
  30. package/tools/marketplace/style.mjs +443 -0
  31. package/tools/marketplace/submit.mjs +341 -0
  32. package/tools/marketplace/tree.mjs +50 -0
  33. package/tools/marketplace/upgrade.mjs +179 -0
  34. package/tools/marketplace/usage.mjs +191 -0
  35. package/tools/marketplace/verify.mjs +70 -0
  36. package/tools/marketplace/watch.mjs +262 -0
  37. package/tools/marketplace/yaml.mjs +164 -0
  38. 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
+ }