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,203 @@
1
+ // The one home of the marketplace pin (docs/MARKETPLACE.md). The checkout is a
2
+ // read-only clone at this exact commit in the user's XDG cache; `omakit pin`
3
+ // creates or verifies it.
4
+ //
5
+ // It fetches only what omakit reads. The marketplace at this commit is 325 MB,
6
+ // of which 168 MB is preview imagery and 151 MB is history, and omakit reads
7
+ // seven files out of it. A blob-filtered, sparsely checked out fetch of just
8
+ // those paths is 15 MB and takes 2 seconds instead of 17. PIN_PATHS below is the
9
+ // whole list, and tests/unit/pin.test.mjs fails if any module starts reading a
10
+ // path outside it, because on a partial clone such a read would quietly reach
11
+ // for the network instead of failing.
12
+ import { execFileSync } from "node:child_process"
13
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
14
+ import { dirname, join, resolve } from "node:path"
15
+ import { omakitCacheDir } from "./paths.mjs"
16
+
17
+ /** Raised for every way the pin can be missing or wrong; the code is what the CLI keys its remedy on. */
18
+ export class PinError extends Error {
19
+ constructor(message, { code = "marketplace-unavailable", remedy = null } = {}) {
20
+ super(message)
21
+ this.name = "PinError"
22
+ this.code = code
23
+ this.remedy = remedy
24
+ }
25
+ }
26
+
27
+ export const MARKETPLACE_PIN = Object.freeze({
28
+ repository: "https://github.com/omacom/omarchy-plugin-marketplace",
29
+ commit: "38060f89d2a10b1f9b6b5afe8e226451e8a5b3f6",
30
+ commitSubject: "Add Plugin updates plugin (#6374)",
31
+ baselineVersion: "3",
32
+ enforcementMode: "selective",
33
+ })
34
+
35
+ /**
36
+ * Everything omakit reads out of the pinned checkout, as sparse-checkout
37
+ * patterns. Anything not listed here is never fetched.
38
+ *
39
+ * /scripts/ the official submission parser, baseline
40
+ * scanner, policy, report and record modules,
41
+ * and build-catalog.mjs read as text for the
42
+ * reserved plugin-id namespace. Taken whole
43
+ * because those modules import each other.
44
+ * /registry.json retired plugin ids, listed repositories
45
+ * /site/catalog.json listed plugin ids
46
+ * /.github/ISSUE_TEMPLATE/ the submission form: the whole contract
47
+ */
48
+ export const PIN_PATHS = Object.freeze([
49
+ "/scripts/",
50
+ "/registry.json",
51
+ "/site/catalog.json",
52
+ "/.github/ISSUE_TEMPLATE/",
53
+ ])
54
+
55
+ /**
56
+ * The user-writable pin location: `$XDG_CACHE_HOME/omakit/marketplace`, or
57
+ * `~/.cache/omakit/marketplace`. omakit reads no variable of its own; a test or
58
+ * an unusual install that wants the pin elsewhere sets XDG_CACHE_HOME, which is
59
+ * the same switch every user has.
60
+ */
61
+ export function marketplacePinDir(_repoRoot, env = process.env) {
62
+ return omakitCacheDir("marketplace", env)
63
+ }
64
+
65
+ /** The location used before 0.1.0 packaging made the tool installable read-only. */
66
+ export function legacyMarketplacePinDir(repoRoot) {
67
+ return join(resolve(repoRoot), ".cache/marketplace")
68
+ }
69
+
70
+ function shellQuote(value) {
71
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
72
+ }
73
+
74
+ function pinMigration(repoRoot, env = process.env) {
75
+ const oldDir = legacyMarketplacePinDir(repoRoot)
76
+ const newDir = marketplacePinDir(repoRoot, env)
77
+ if (!existsSync(join(oldDir, ".git")) || existsSync(newDir)) return null
78
+ const remedy = `mkdir -p -- ${shellQuote(dirname(newDir))} && mv -- ${shellQuote(oldDir)} ${shellQuote(newDir)}`
79
+ return new PinError(
80
+ `the marketplace pin is still at the old in-repository location ${oldDir}; the user-writable location ${newDir} does not exist. Omakit will not move the measured 15 MB checkout without you.`,
81
+ { code: "marketplace-pin-migration-required", remedy },
82
+ )
83
+ }
84
+
85
+ function git(dir, args, options = {}) {
86
+ return execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...options })
87
+ }
88
+
89
+ /** Identity of the checkout at `dir`: commit plus the policy constants read from the pinned source. */
90
+ function readPinIdentity(dir) {
91
+ const commit = git(dir, ["rev-parse", "HEAD"]).trim()
92
+ const policy = git(dir, ["show", `${commit}:scripts/security-baseline-policy.mjs`])
93
+ const version = policy.match(/securityBaselineVersion\s*=\s*"?([^";\s]+)"?/)?.[1] || "unknown"
94
+ const mode = policy.match(/securityBaselineEnforcementMode\s*=\s*"([^"]+)"/)?.[1] || "unknown"
95
+ const dirty = git(dir, ["status", "--porcelain"]).trim().length > 0
96
+ return { commit, baselineVersion: version, enforcementMode: mode, dirty }
97
+ }
98
+
99
+ /**
100
+ * Verify the pinned checkout without touching the network. Throws with a
101
+ * stable message when it is missing, at another commit, or modified.
102
+ */
103
+ export function requirePin(repoRoot, env = process.env) {
104
+ const migration = pinMigration(repoRoot, env)
105
+ if (migration) throw migration
106
+ const dir = marketplacePinDir(repoRoot, env)
107
+ if (!existsSync(join(dir, "scripts/security-baseline-scanner.mjs"))) {
108
+ throw new PinError(`no pinned marketplace checkout at ${dir}. Every rule omakit checks is read from that checkout, so nothing can run without it.`)
109
+ }
110
+ const identity = readPinIdentity(dir)
111
+ if (identity.commit !== MARKETPLACE_PIN.commit) {
112
+ throw new PinError(`${dir} is at ${identity.commit}, expected pin ${MARKETPLACE_PIN.commit}`)
113
+ }
114
+ if (identity.dirty) {
115
+ throw new PinError(`${dir} has local modifications; the pin must stay unmodified`)
116
+ }
117
+ return { dir, identity }
118
+ }
119
+
120
+ /** A checkout that was initialised but never fetched (a run that lost the network) has a .git and no HEAD. */
121
+ function hasCommit(dir) {
122
+ try {
123
+ git(dir, ["rev-parse", "--verify", "-q", "HEAD"])
124
+ return true
125
+ } catch {
126
+ return false
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Reproducible setup: fetch exactly the pinned commit (depth 1) into
132
+ * the XDG cache and check it out detached. Idempotent; never rewrites
133
+ * an existing checkout that already sits at the pin.
134
+ *
135
+ * `log` is told what is happening as `{ state, text }`: a `pass` or `info`
136
+ * line to keep, or `fetching` for the slow step about to start, which the CLI
137
+ * draws as a progress line rather than a line of output.
138
+ */
139
+ export function ensurePin(repoRoot, log = () => {}, env = process.env) {
140
+ const migration = pinMigration(repoRoot, env)
141
+ if (migration) throw migration
142
+ const dir = marketplacePinDir(repoRoot, env)
143
+ if (existsSync(join(dir, ".git")) && hasCommit(dir)) {
144
+ const identity = readPinIdentity(dir)
145
+ if (identity.commit === MARKETPLACE_PIN.commit && !identity.dirty) {
146
+ log({ state: "pass", text: `marketplace pin ${identity.commit.slice(0, 7)} present at ${dir}` })
147
+ return { dir, identity, fetched: false }
148
+ }
149
+ if (identity.dirty) throw new PinError(`${dir} has local modifications; remove the directory and run again`)
150
+ log({ state: "info", text: `${dir} is at ${identity.commit}, not the pin` })
151
+ } else if (!existsSync(join(dir, ".git"))) {
152
+ mkdirSync(dir, { recursive: true })
153
+ execFileSync("git", ["init", "-q", dir], { encoding: "utf8" })
154
+ git(dir, ["remote", "add", "origin", MARKETPLACE_PIN.repository])
155
+ }
156
+ log({ state: "fetching", text: `fetching the pinned marketplace checkout, ${MARKETPLACE_PIN.commit.slice(0, 7)}, about 15 MB` })
157
+ // Written as plumbing rather than through `git sparse-checkout`, so the
158
+ // result does not depend on the git version's cone-mode defaults.
159
+ git(dir, ["config", "core.sparseCheckout", "true"])
160
+ mkdirSync(join(dir, ".git/info"), { recursive: true })
161
+ writeFileSync(join(dir, ".git/info/sparse-checkout"), `${PIN_PATHS.join("\n")}\n`)
162
+ try {
163
+ // git's own stderr is captured, not inherited: a network failure ends up
164
+ // as one failure state in the tool's register, not two voices on stderr.
165
+ git(dir, ["fetch", "-q", "--depth", "1", "--filter=blob:none", "origin", MARKETPLACE_PIN.commit])
166
+ } catch (error) {
167
+ const reason = String(error?.stderr || error?.message || "").trim().split("\n").filter((line) => /^fatal:/.test(line)).pop()
168
+ || String(error?.message || "git fetch failed").trim().split("\n")[0]
169
+ const offline = /unable to access|Could not resolve|Could not connect|Connection refused|Network is unreachable/i.test(reason)
170
+ const failure = new PinError(`the pinned marketplace checkout could not be fetched: ${reason.replace(/^fatal:\s*/, "")}`)
171
+ if (offline) failure.code = "network-unavailable"
172
+ throw failure
173
+ }
174
+ git(dir, ["checkout", "-q", "--detach", MARKETPLACE_PIN.commit])
175
+ const identity = readPinIdentity(dir)
176
+ if (identity.commit !== MARKETPLACE_PIN.commit) throw new PinError(`checkout ended at ${identity.commit}`)
177
+ log({ state: "pass", text: `marketplace pin ${identity.commit.slice(0, 7)} (baseline ${identity.baselineVersion}, ${identity.enforcementMode}) at ${dir}, ${pinDiskUsage(dir)}` })
178
+ return { dir, identity, fetched: true }
179
+ }
180
+
181
+ /** Human-readable size of the pinned checkout, for `omakit pin` and `omakit doctor`. */
182
+ export function pinDiskUsage(dir) {
183
+ // -H follows a symlink given on the command line (POSIX; GNU's -D). Measured
184
+ // without it: a checkout reached through a symlink reported 0.0 MB, the size
185
+ // of the link, while the directory behind it was 15 MB.
186
+ try {
187
+ const output = execFileSync("du", ["-skH", dir], { encoding: "utf8" }).split(/\s+/)[0]
188
+ const mib = Number(output) / 1024
189
+ return `${mib < 10 ? mib.toFixed(1) : Math.round(mib)} MB on disk`
190
+ } catch {
191
+ return "size unknown"
192
+ }
193
+ }
194
+
195
+ /** True when the checkout was fetched with only PIN_PATHS, as a fresh one is. */
196
+ export function pinIsSparse(dir) {
197
+ try {
198
+ const enabled = execFileSync("git", ["-C", dir, "config", "--get", "core.sparseCheckout"], { encoding: "utf8" }).trim()
199
+ return enabled === "true"
200
+ } catch {
201
+ return false
202
+ }
203
+ }
@@ -0,0 +1,64 @@
1
+ // What the installable tree must contain at the root, and the plugin identity
2
+ // the submission is about.
3
+ //
4
+ // Omakit does not re-validate the manifest. The marketplace validates schema,
5
+ // required fields, id characters, kinds, entry points and symlinks itself, and
6
+ // its validator (`scripts/build-catalog.mjs`) cannot be imported without the
7
+ // marketplace's own native dependencies. Re-implementing those rules here would
8
+ // be copied policy that silently drifts from the pin, which is the failure this
9
+ // repository is built to avoid. So this module checks only what the submission
10
+ // contract needs: that the three root files exist, that there is exactly one
11
+ // root manifest, and what id it declares.
12
+
13
+ import { isBlob, readText, rootEntries } from "./tree.mjs"
14
+
15
+ // The marketplace's own patterns for a root README and a root license
16
+ // (`validateRepositoryDocs` in scripts/build-catalog.mjs at the pin).
17
+ export const README_PATTERN = /^readme(?:\.[^/]+)?$/i
18
+ export const LICENSE_PATTERN = /^(?:licen[cs]e|copying)(?:\.[^/]+)?$/i
19
+ export const MANIFEST_PATH = "manifest.json"
20
+
21
+ // Keyword probes for the first checklist item, "The repository is public and
22
+ // contains installation and removal instructions." This is a text probe on the
23
+ // README, not a semantic reading, and the output says so.
24
+ export const INSTALL_PATTERN = /\binstall(?:ation|ing|s|ed)?\b/i
25
+ export const REMOVAL_PATTERN = /\b(?:uninstall(?:ation|ing|s|ed)?|remov(?:al|e|es|ed|ing)|delet(?:e|es|ed|ing)|purge)\b/i
26
+
27
+ /**
28
+ * @param {{ dir: string, commit: string, entries: Array }} subject
29
+ */
30
+ export function inspectTree({ dir, entries }) {
31
+ const roots = rootEntries(entries).filter(isBlob)
32
+ const readme = roots.find((entry) => README_PATTERN.test(entry.path)) || null
33
+ const license = roots.find((entry) => LICENSE_PATTERN.test(entry.path)) || null
34
+ const manifests = entries
35
+ .filter((entry) => isBlob(entry) && /^(?:[^/]+\/)?manifest\.json$/i.test(entry.path))
36
+ .map((entry) => entry.path)
37
+ .sort()
38
+ const rootManifest = roots.find((entry) => entry.path === MANIFEST_PATH) || null
39
+
40
+ let manifest = null
41
+ let manifestError = null
42
+ if (rootManifest) {
43
+ try {
44
+ manifest = JSON.parse(readText(dir, rootManifest.sha))
45
+ } catch (error) {
46
+ manifestError = error.message
47
+ }
48
+ }
49
+
50
+ const readmeText = readme ? readText(dir, readme.sha) : ""
51
+
52
+ return {
53
+ readme: readme?.path || null,
54
+ license: license?.path || null,
55
+ manifestPaths: manifests,
56
+ rootManifestPath: rootManifest?.path || null,
57
+ manifest,
58
+ manifestError,
59
+ pluginId: typeof manifest?.id === "string" ? manifest.id.trim() : "",
60
+ pluginName: typeof manifest?.name === "string" ? manifest.name.trim() : "",
61
+ readmeMentionsInstall: INSTALL_PATTERN.test(readmeText),
62
+ readmeMentionsRemoval: REMOVAL_PATTERN.test(readmeText),
63
+ }
64
+ }
@@ -0,0 +1,164 @@
1
+ // Baseline preflight: the official Omarchy marketplace security baseline, run
2
+ // locally over the harvested transport, reported verbatim together with what
3
+ // its outcome will cause on submission.
4
+ //
5
+ // Measured reason this runs before submitting (docs/MEASUREMENTS.md M4): of the
6
+ // 2,916 listed sources with a recorded baseline at the pin, it produced 1,681
7
+ // `passed`, 1,215 `review-required` and 20 `needs-fixes` (counted by
8
+ // registry.mjs baselineFigures, pinned by tests/unit/registry-figures.test.mjs). `review-required` is not a defect and
9
+ // needs no source change, but it does mean a human must look, and it is the
10
+ // single largest determinant of whether a submission waits on a person. An
11
+ // author who knows which of the seven capabilities triggered it before
12
+ // submitting can decide to remove it or to explain it in the maintainer notes
13
+ // instead of finding out after the queue.
14
+ //
15
+ // Everything policy-shaped here is read from the pinned checkout: the outcome
16
+ // derivation, the disposition, the blocking rule set and the enforcement mode.
17
+ // The two disclaimer sentences are rendered by the marketplace's own report
18
+ // builder so they are its words, not Omakit's. The machine-readable baseline
19
+ // marker that builder emits is stripped and asserted absent: Omakit must never
20
+ // produce something that could be pasted into an issue as the bot's own
21
+ // attestation.
22
+
23
+ import { join } from "node:path"
24
+ import { pathToFileURL } from "node:url"
25
+ import { requirePin } from "./pin.mjs"
26
+ import { marketplaceBaselineSection } from "./verify.mjs"
27
+
28
+ export class PreflightError extends Error {
29
+ constructor(code, message) {
30
+ super(message)
31
+ this.name = "PreflightError"
32
+ this.code = code
33
+ }
34
+ }
35
+
36
+ async function loadPolicy(pinDir) {
37
+ return import(pathToFileURL(join(pinDir, "scripts/security-baseline-policy.mjs")).href)
38
+ }
39
+
40
+ async function loadReport(pinDir) {
41
+ return import(pathToFileURL(join(pinDir, "scripts/security-baseline-report.mjs")).href)
42
+ }
43
+
44
+ /**
45
+ * The marketplace's own two closing sentences, read out of the source of its
46
+ * own report builder at the pin. They are not written down in Omakit: the
47
+ * baseline result must never be restated as a safety claim, and the least
48
+ * error-prone way to say so is in the marketplace's words.
49
+ */
50
+ export async function officialDisclaimers(pinDir) {
51
+ const report = await loadReport(pinDir)
52
+ const source = String(report.buildSecurityBaselineReport)
53
+ const sentences = [...source.matchAll(/"(This [^"]{20,300}\.)"/g)].map((match) => match[1])
54
+ if (sentences.length < 2) {
55
+ throw new PreflightError(
56
+ "disclaimer-unreadable",
57
+ "cannot read the marketplace's baseline disclaimer sentences from the pin; refusing to report a baseline result without them",
58
+ )
59
+ }
60
+ return sentences
61
+ }
62
+
63
+ /**
64
+ * Render the marketplace's own baseline detail section for a local result.
65
+ *
66
+ * `buildSecurityBaselineReport` is deliberately not called: it prepends the
67
+ * machine-readable attestation marker the bot posts, and Omakit must never
68
+ * construct something that could be pasted into an issue as the marketplace's
69
+ * own attestation. Only the detail section is rendered, the official closing
70
+ * sentences are appended, and the absence of either marker is asserted.
71
+ */
72
+ export async function officialReportText(pinDir, result) {
73
+ const policy = await loadPolicy(pinDir)
74
+ const report = await loadReport(pinDir)
75
+ const text = [
76
+ "## Automated security baseline",
77
+ "",
78
+ report.buildSecurityBaselineDetails(result),
79
+ "",
80
+ ...(await officialDisclaimers(pinDir)).flatMap((sentence) => [sentence, ""]),
81
+ ].join("\n").trim()
82
+ if (text.includes(policy.securityBaselineMarkerPrefix) || text.includes(policy.securityBaselineErrorMarker)) {
83
+ throw new PreflightError("marker-leak", "refusing to emit a marketplace security-baseline marker")
84
+ }
85
+ return text
86
+ }
87
+
88
+ /**
89
+ * What the outcome will cause on submission, derived from the pinned policy.
90
+ * @param {object} official the verbatim result from the official baseline
91
+ */
92
+ export async function consequence(pinDir, official) {
93
+ const policy = await loadPolicy(pinDir)
94
+ const findings = (official?.findings || []).map((finding) => finding.ruleId || finding.id || String(finding))
95
+ const capabilities = (official?.capabilities || []).map((capability) => capability.id || String(capability))
96
+ const blocking = findings.filter((ruleId) => policy.securityBaselineSelectivelyBlockingRules.includes(ruleId))
97
+ const outcome = official?.outcome ?? policy.securityBaselineOutcome(official?.findings || [], official?.capabilities || [])
98
+ const disposition = official?.disposition ?? policy.securityBaselineDisposition(official)
99
+ const blocksApproval = official?.blocksApproval ?? policy.securityBaselineBlocksApproval(official)
100
+ return {
101
+ outcome,
102
+ disposition,
103
+ blocksApproval: Boolean(blocksApproval),
104
+ enforcementMode: official?.enforcementMode || policy.securityBaselineEnforcementMode,
105
+ findings,
106
+ capabilities,
107
+ selectivelyBlockingRules: [...policy.securityBaselineSelectivelyBlockingRules],
108
+ blockingFindings: blocking,
109
+ knownCapabilities: Object.keys(policy.securityBaselineCapabilityCatalog),
110
+ meaning: describe({ outcome, blocksApproval: Boolean(blocksApproval), blocking, capabilities, policy }),
111
+ }
112
+ }
113
+
114
+ function describe({ outcome, blocksApproval, blocking, capabilities, policy }) {
115
+ if (outcome === "passed") {
116
+ return "No findings and no capabilities. Nothing in the baseline holds this submission back."
117
+ }
118
+ if (outcome === "review-required") {
119
+ const names = capabilities.map((id) => policy.securityBaselineCapabilityCatalog[id]?.title || id)
120
+ return `No findings, but ${capabilities.length} of the ${Object.keys(policy.securityBaselineCapabilityCatalog).length} capabilities are present (${names.join("; ")}), so a maintainer must look at this commit before it can be listed.`
121
+ }
122
+ if (outcome === "needs-fixes" && blocksApproval) {
123
+ return `Findings include ${blocking.join(", ")}, which block publication under the ${policy.securityBaselineEnforcementMode} enforcement mode. These must be fixed in a new commit.`
124
+ }
125
+ if (outcome === "needs-fixes") {
126
+ return `Findings are present but none of them is selectively blocking (${policy.securityBaselineSelectivelyBlockingRules.join(", ")}), so the disposition is review-required: a maintainer may accept them for this exact commit.`
127
+ }
128
+ return `Unrecognised outcome ${JSON.stringify(outcome)} from the pinned policy.`
129
+ }
130
+
131
+ /**
132
+ * Run the official baseline over the local transport for one subject.
133
+ * @param {{ repoRoot: string, subject: object }} options
134
+ */
135
+ export async function baselinePreflight({ repoRoot, subject }) {
136
+ const { dir: pinDir, identity } = requirePin(repoRoot)
137
+ const section = await marketplaceBaselineSection({ repoRoot, subject })
138
+ if (!section.invoked) {
139
+ return { pin: section.pin, invoked: false, skipReason: section.skipReason, official: null, consequence: null, officialReport: null, statement: section.statement }
140
+ }
141
+ if (section.official?.error) {
142
+ return {
143
+ pin: section.pin,
144
+ invoked: true,
145
+ skipReason: null,
146
+ official: section.official,
147
+ consequence: null,
148
+ officialReport: null,
149
+ statement: section.statement,
150
+ refusal: section.official.error,
151
+ }
152
+ }
153
+ return {
154
+ pin: { ...section.pin, baselineVersion: identity.baselineVersion, enforcementMode: identity.enforcementMode },
155
+ invoked: true,
156
+ skipReason: null,
157
+ transport: section.transport,
158
+ assumedByAdapter: section.assumedByAdapter,
159
+ official: section.official,
160
+ consequence: await consequence(pinDir, section.official),
161
+ officialReport: await officialReportText(pinDir, section.official),
162
+ statement: section.statement,
163
+ }
164
+ }
@@ -0,0 +1,96 @@
1
+ // A progress line, on stderr, only when a person is looking.
2
+ //
3
+ // Three rules, and they are the whole design.
4
+ //
5
+ // It writes to stderr, never stdout. An agent piping `omakit submit` gets the
6
+ // same bytes it always got; the recordings in docs/media/ capture stdout and are
7
+ // unaffected. Nothing downstream has to strip anything.
8
+ //
9
+ // It only draws when stderr is a terminal; a pipe or TERM=dumb is what turns
10
+ // it off, and omakit has no switch of its own. NO_COLOR removes the tint and nothing else: a progress line in the
11
+ // terminal's own foreground still says what is happening, and saying what is
12
+ // happening is the point.
13
+ //
14
+ // It says what is happening, not that something is happening. The one genuinely
15
+ // slow step is the official baseline reading a repository snapshot blob by blob;
16
+ // a label naming the current phase is information. A sweep with no phase would
17
+ // be decoration, and decoration in the middle of a security-baseline preview is
18
+ // what makes a tool feel less trustworthy, not more.
19
+
20
+ import { code, colourEnabled, COLUMNS, DENSITY, motionEnabled, MOTION } from "./style.mjs"
21
+
22
+ // A scanner sweeping back and forth over a fixed track: a three-cell head
23
+ // moving across twelve cells, with the rest of the track drawn as a floor so
24
+ // the motion reads as direction instead of blinking. The head and the floor
25
+ // are the same two glyphs the wordmark ends on, so the two animations read as
26
+ // one motif.
27
+ const TRACK = 12
28
+ const HEAD = 3
29
+ const INTERVAL = MOTION.progressFrameMs
30
+
31
+ const ESC = "\u001b["
32
+ const ACCENT = `${ESC}${code("typeable")}m`
33
+ const TRACK_TINT = `${ESC}${code("punctuation")}m`
34
+ const RESET = `${ESC}0m`
35
+ const CLEAR_LINE = `\r${ESC}2K`
36
+
37
+ /** Which cells the head covers at this step, bouncing rather than wrapping. */
38
+ export function headAt(step, track = TRACK, head = HEAD) {
39
+ const span = (track - head) * 2
40
+ const at = ((step % span) + span) % span
41
+ return at <= track - head ? at : span - at
42
+ }
43
+
44
+ export function progressEnabled(stream = process.stderr, env = process.env) {
45
+ return motionEnabled(stream, env)
46
+ }
47
+
48
+ /**
49
+ * @param {{ stream?: NodeJS.WriteStream, enabled?: boolean, colour?: boolean }} [options]
50
+ * @returns {{ phase: (label: string) => void, done: () => void }}
51
+ */
52
+ export function progress(options = {}) {
53
+ const stream = options.stream || process.stderr
54
+ const enabled = options.enabled ?? progressEnabled(stream)
55
+ const colour = options.colour ?? colourEnabled(stream)
56
+ if (!enabled) return { phase: () => {}, done: () => {} }
57
+
58
+ let label = ""
59
+ let step = 0
60
+ let timer = null
61
+
62
+ const clear = () => stream.write(CLEAR_LINE)
63
+ const draw = () => {
64
+ const start = headAt(step)
65
+ // Three runs, not twelve cells: the floor before the head, the head, the
66
+ // floor after it. A frame is one write and carries at most three tints.
67
+ const paint = (glyph, count, tint) => (count > 0 ? (colour ? `${tint}${glyph.repeat(count)}${RESET}` : glyph.repeat(count)) : "")
68
+ const track = paint(DENSITY.floor, start, TRACK_TINT)
69
+ + paint(DENSITY.full, HEAD, ACCENT)
70
+ + paint(DENSITY.floor, TRACK - start - HEAD, TRACK_TINT)
71
+ // One write per frame, clear included: two writes can flicker on a slow
72
+ // terminal, and a partially drawn frame is worse than no animation. The
73
+ // label is cut to the terminal's width first: a line that wraps is a line
74
+ // the clear does not reach, and it stays behind as a stray row.
75
+ const room = (Number.isFinite(stream.columns) && stream.columns > 0 ? stream.columns : COLUMNS) - TRACK - 2
76
+ const shown = label.length > room ? `${label.slice(0, Math.max(0, room - 1))}\u2026` : label
77
+ stream.write(`${CLEAR_LINE}${track} ${shown}`)
78
+ step += 1
79
+ }
80
+
81
+ return {
82
+ phase(next) {
83
+ label = String(next)
84
+ if (!timer) {
85
+ timer = setInterval(draw, INTERVAL)
86
+ timer.unref?.()
87
+ }
88
+ draw()
89
+ },
90
+ done() {
91
+ if (timer) clearInterval(timer)
92
+ timer = null
93
+ clear()
94
+ },
95
+ }
96
+ }