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,194 @@
1
+ // Parity: the pinned official baseline over the GitHub transport must equal the
2
+ // same code over Omakit's local Git transport, for real listed plugin
3
+ // repositories at their exact commits. Read-only: clones and GET requests only.
4
+ //
5
+ // The committed evidence records, per repository, a digest of each transport's
6
+ // comparable result rather than the results themselves. Two reasons. Equal
7
+ // digests are the whole parity claim, and anyone can recompute them from the
8
+ // repository, the commit and the pin. And findings about a specific third-party
9
+ // plugin are not this project's to publish: the aggregate rule distribution in
10
+ // the summary carries no repository names, and the per-repository rows carry no
11
+ // findings, no capabilities, no file paths and no source snippets.
12
+
13
+ import { createHash } from "node:crypto"
14
+ import { execFileSync } from "node:child_process"
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
16
+ import { join, resolve } from "node:path"
17
+ import { runBaseline } from "../../tools/marketplace/run-baseline.mjs"
18
+ import { requirePin } from "../../tools/marketplace/pin.mjs"
19
+ import { parityCorpus, strataSizes } from "./corpus.mjs"
20
+ import { subjectSlug } from "../../tools/subject/resolve.mjs"
21
+ import { token } from "../../tools/marketplace/github.mjs"
22
+ import { omakitCacheDir } from "../../tools/marketplace/paths.mjs"
23
+ import { parityOutput } from "../../tools/marketplace/parity-output.mjs"
24
+
25
+ const repoRoot = resolve(process.env.OMAKIT_ROOT || process.cwd())
26
+ const pinDir = requirePin(repoRoot).dir
27
+ const cacheDir = omakitCacheDir("parity")
28
+ const output = parityOutput({ repoRoot, out: process.env.PARITY_OUT || null })
29
+ const count = Number(process.env.PARITY_COUNT || 30)
30
+ const offset = Number(process.env.PARITY_OFFSET || 0)
31
+
32
+ function shallowClone(repoUrl, commit) {
33
+ const dir = join(cacheDir, subjectSlug(repoUrl))
34
+ mkdirSync(dir, { recursive: true })
35
+ const run = (args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
36
+ try {
37
+ run(["rev-parse", commit + "^{commit}"])
38
+ return dir
39
+ } catch {
40
+ /* not fetched yet */
41
+ }
42
+ // Test for the clone's own .git instead of relying on an outer repository.
43
+ if (!existsSync(join(dir, ".git"))) {
44
+ execFileSync("git", ["init", "-q", dir], { encoding: "utf8" })
45
+ run(["remote", "add", "origin", repoUrl])
46
+ }
47
+ run(["fetch", "-q", "--depth", "1", "origin", commit])
48
+ return dir
49
+ }
50
+
51
+ function comparable(result) {
52
+ return JSON.stringify({
53
+ outcome: result.outcome,
54
+ disposition: result.disposition,
55
+ blocksApproval: result.blocksApproval,
56
+ findings: result.findings,
57
+ capabilities: result.capabilities,
58
+ })
59
+ }
60
+
61
+ /** A digest of the comparable payload: proves equality without publishing it. */
62
+ function digest(result) {
63
+ return createHash("sha256").update(comparable(result)).digest("hex").slice(0, 32)
64
+ }
65
+
66
+ function pinCommit(dir) {
67
+ try {
68
+ return execFileSync("git", ["-C", dir, "rev-parse", "HEAD"], { encoding: "utf8" }).trim()
69
+ } catch {
70
+ return "unknown"
71
+ }
72
+ }
73
+
74
+ const corpus = parityCorpus(pinDir, count, offset)
75
+ const rows = []
76
+ const ruleTotals = {}
77
+ const capabilityTotals = {}
78
+ let mismatches = 0
79
+ let failures = 0
80
+
81
+ for (const target of corpus) {
82
+ const row = {
83
+ repo: target.repo,
84
+ commit: target.commit,
85
+ type: target.type,
86
+ registryOutcome: target.registryOutcome,
87
+ registryOutcomeAtSameCommit: target.registryOutcomeCommit === target.commit,
88
+ }
89
+ try {
90
+ const dir = shallowClone(target.repo, target.commit)
91
+ const local = await runBaseline({
92
+ repoRoot,
93
+ repoUrl: target.repo,
94
+ commitSha: target.commit,
95
+ transport: "local",
96
+ repoDir: dir,
97
+ })
98
+ row.localOutcome = local.result.outcome
99
+ row.requests = local.adapter.requests
100
+ row.localDigest = digest(local.result)
101
+ // Rule and capability ids are counted in the summary, unattributed; they are
102
+ // deliberately not recorded against this repository.
103
+ for (const finding of local.result.findings || []) {
104
+ const id = finding.ruleId || finding.id
105
+ if (id) ruleTotals[id] = (ruleTotals[id] || 0) + 1
106
+ }
107
+ for (const capability of local.result.capabilities || []) {
108
+ if (capability.id) capabilityTotals[capability.id] = (capabilityTotals[capability.id] || 0) + 1
109
+ }
110
+ try {
111
+ const github = await runBaseline({
112
+ repoRoot,
113
+ repoUrl: target.repo,
114
+ commitSha: target.commit,
115
+ transport: "github",
116
+ token: token() ?? undefined,
117
+ })
118
+ row.githubOutcome = github.result.outcome
119
+ row.githubDigest = digest(github.result)
120
+ row.identical = row.localDigest === row.githubDigest
121
+ if (!row.identical) {
122
+ mismatches += 1
123
+ // A mismatch is the one case worth describing, and it is described as a
124
+ // difference between two transports of the same code, not as a finding
125
+ // about the plugin: which keys differ, never their contents.
126
+ const localPayload = JSON.parse(comparable(local.result))
127
+ const githubPayload = JSON.parse(comparable(github.result))
128
+ row.differingKeys = Object.keys(localPayload).filter(
129
+ (key) => JSON.stringify(localPayload[key]) !== JSON.stringify(githubPayload[key]),
130
+ )
131
+ }
132
+ } catch (error) {
133
+ row.githubError = (error.code || "error") + ": " + error.message
134
+ failures += 1
135
+ }
136
+ } catch (error) {
137
+ row.localError = (error.code || "error") + ": " + error.message
138
+ failures += 1
139
+ }
140
+ rows.push(row)
141
+ console.log(
142
+ [
143
+ row.identical === true ? "ok " : row.identical === false ? "DIFF" : "?? ",
144
+ target.repo.replace("https://github.com/", "").padEnd(44),
145
+ "local=" + (row.localOutcome || row.localError || "-"),
146
+ "github=" + (row.githubOutcome || row.githubError || "-"),
147
+ "registry=" + (row.registryOutcome || "-"),
148
+ ].join(" "),
149
+ )
150
+ }
151
+
152
+ function generatorIdentity() {
153
+ try {
154
+ return { omakitCommit: execFileSync("git", ["-C", repoRoot, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() }
155
+ } catch {
156
+ const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
157
+ return { omakitPackage: `${pkg.name}@${pkg.version}` }
158
+ }
159
+ }
160
+
161
+ const summary = {
162
+ generator: generatorIdentity(),
163
+ pinnedMarketplaceCommit: pinCommit(pinDir),
164
+ corpusSize: corpus.length,
165
+ requestedCount: count,
166
+ offset,
167
+ strata: strataSizes(count),
168
+ outcomes: rows.reduce((acc, row) => {
169
+ const key = row.localOutcome || "error"
170
+ acc[key] = (acc[key] || 0) + 1
171
+ return acc
172
+ }, {}),
173
+ identical: rows.filter((row) => row.identical === true).length,
174
+ mismatches,
175
+ failures,
176
+ // Unattributed aggregates. No repository name is attached to any rule.
177
+ ruleTotals,
178
+ capabilityTotals,
179
+ publication: {
180
+ perRepositoryDetail: "omitted",
181
+ reason: "Findings about a specific third-party plugin are not published. Equality is evidenced by the sha256 digests below, which anyone can recompute from the repository, the commit and the pinned marketplace commit.",
182
+ digest: "sha256 of {outcome, disposition, blocksApproval, findings, capabilities}, first 32 hex characters",
183
+ },
184
+ rows,
185
+ }
186
+ const date = new Date().toISOString().slice(0, 10)
187
+ const outputIsFile = process.env.PARITY_OUT_EXPLICIT === "1"
188
+ const outputDir = outputIsFile ? resolve(output, "..") : output
189
+ mkdirSync(outputDir, { recursive: true })
190
+ let outFile = outputIsFile ? output : join(outputDir, `${date}-local-vs-github.json`)
191
+ for (let n = 2; !outputIsFile && existsSync(outFile); n += 1) outFile = join(outputDir, `${date}-local-vs-github-${n}.json`)
192
+ writeFileSync(outFile, JSON.stringify(summary, null, 2) + "\n")
193
+ console.log("\nidentical " + summary.identical + "/" + corpus.length + ", mismatches " + mismatches + ", failures " + failures + " -> " + outFile)
194
+ process.exit(mismatches === 0 && failures === 0 ? 0 : 1)
@@ -0,0 +1,64 @@
1
+ # Module map
2
+
3
+ Omakit never copies marketplace policy. It imports the official analysis from a
4
+ read-only checkout of the pinned marketplace commit and feeds it a snapshot of a
5
+ local commit through the transport seam the marketplace tests itself
6
+ (`resolveSecuritySnapshot(..., { fetchImpl })`).
7
+
8
+ | File | Purpose |
9
+ | --- | --- |
10
+ | `pin.mjs` | The pin identity (one home) and the reproducible setup: `omakit pin` fetches exactly that commit into `$XDG_CACHE_HOME/omakit/marketplace` and refuses a modified checkout. |
11
+ | `local-transport.mjs` | Answers the four request shapes the official resolver makes, from a local clone at the exact commit. No network, no credentials, no writes. |
12
+ | `run-baseline.mjs` | Runs the pinned official baseline over either transport and reports the pin identity beside the result. |
13
+ | `verify.mjs` | Builds the `marketplaceBaseline` section: pin, transport, assumptions, the official result verbatim, the statement. |
14
+ | `preflight.mjs` | Translates that result into what it will cause on submission, using the pinned policy, and renders the marketplace's own report text with its attestation marker stripped and asserted absent. |
15
+ | `yaml.mjs` | A deliberately small YAML reader for the pinned issue form. Accepts that subset and throws on anything else. |
16
+ | `form.mjs` | The submission contract, read from the form and cross-checked against the marketplace's own constants. |
17
+ | `registry.mjs` | The plugin-id and repository universe, and the reserved namespace, read from the pinned registry, catalog and catalog builder. |
18
+ | `tree.mjs` | The installable tree of a subject at one exact commit, from the Git object database. |
19
+ | `plugin.mjs` | The root files the submission contract needs, and the declared plugin identity. |
20
+ | `agent-control.mjs` | The recursive agent-control warning, and its remedy. |
21
+ | `issue.mjs` | Renders the issue the way the form would, then has the marketplace's own parser judge it. |
22
+ | `submit.mjs` | Assembles every check with its measured reason, and withholds the body when a blocking check fails. |
23
+ | `watch.mjs` | The validation watch: validated commit versus current default-branch HEAD, and the one action that refreshes it. |
24
+ | `github.mjs` | Read-only GitHub access. GET only. The credential is your `gh` login, read through one frozen `gh auth token` call, and is never written anywhere. |
25
+ | `style.mjs` | The visual system, defined once: the palette, the status vocabulary, the block ramp, the columns, the motion budgets, and the composition helpers every command draws with. `docs/TUI.md` explains it. |
26
+ | `report.mjs` | Text rendering of submit, watch and doctor for the agent that runs this tool, and the person reading over its shoulder. |
27
+ | `usage.mjs` | The help text, as data. |
28
+ | `completion.mjs` | A completion script for bash, zsh or fish, derived from the help data and the pin's form: the subcommands and flags are read out of `COMMANDS`, the categories and tags out of the pinned submission form, and the script says which pin it came from. `setup` installs it for the shell in `$SHELL`, the one file this tool writes outside its own checkout. |
29
+ | `banner.mjs` | The wordmark, on a bare `omakit` and in `setup` only. |
30
+ | `effect.mjs` | The one text effect: the wordmark through `ttfx` where it is drawn, with frozen arguments, a hard budget, no colour of its own, and nothing at all when `ttfx` is not there. |
31
+ | `progress.mjs` | The progress line, on stderr, only when a person is looking. |
32
+ | `cli.mjs` | The one entry point behind `bin/omakit`, and the one register every failure is reported in. |
33
+
34
+ ```text
35
+ omakit pin
36
+ omakit submit /path/to/plugin-repo --category Widgets --tags bar,quickshell
37
+ omakit submit https://github.com/owner/repo@<40-char sha> --category System --tags system
38
+ omakit watch https://github.com/omacom/omarchy-plugin-marketplace/issues/4829
39
+ omakit verify /path/to/plugin-repo
40
+ omakit parity --count 30
41
+ ```
42
+
43
+ A local repository needs an `origin` on github.com for the official code to name
44
+ the repository; without one the baseline section records `transport: "none"` and
45
+ the reason. Reviewer targets are fetched read-only into
46
+ `.cache/subjects/<owner>__<repo>/` and never executed.
47
+
48
+ Metadata that cannot be known from a local clone (repository visibility, archived
49
+ and disabled state, tree truncation) is answered with the minimal valid value and
50
+ listed in `assumedByAdapter`. The marketplace rescans the public commit itself; a
51
+ local run is a preview, never an authority.
52
+
53
+ Parity is proven, not assumed: `omakit parity` compares both transports over real
54
+ listed repositories at their listing-validated commits, stratified on the outcome
55
+ the registry recorded so the corpus always contains repositories that are not
56
+ `passed`. Results land in `docs/evidence/parity/`, recording a digest of each
57
+ side rather than the findings themselves. The GitHub side uses whatever
58
+ credential `github.mjs` resolves (a `gh` login, and only that), read-only.
59
+
60
+ `parity` and `watch` are the only commands that reach the network, and they do it
61
+ with Node's built-in `fetch`, which does not read proxy environment variables by
62
+ default. Behind a proxy, run them with `NODE_USE_ENV_PROXY=1`. `submit` and
63
+ `verify` need no network at all beyond fetching a reviewer-mode subject, and
64
+ `tests/parity/offline.mjs` proves it.
@@ -0,0 +1,72 @@
1
+ // The agent-control check.
2
+ //
3
+ // Measured reason (docs/MEASUREMENTS.md M3): 103 marketplace issues mention
4
+ // agent-control files. The maintainer treats an instruction file inside an
5
+ // installed plugin as a prompt-injection surface and blocks listing on it; in a
6
+ // 328-comment sample of his review writing, 24 comments are about exactly this.
7
+ // It is not detected by the marketplace's automated baseline, so an author
8
+ // learns about it only from a human review round, which is the most expensive
9
+ // round there is.
10
+ //
11
+ // This is an Omakit check, derived from public issue text, not a marketplace
12
+ // rule. The verdict field says so, and the wording never reuses the
13
+ // marketplace's outcome vocabulary.
14
+
15
+ import { isBlob } from "./tree.mjs"
16
+
17
+ // Names an agent reads as instructions when the plugin is installed.
18
+ export const AGENT_CONTROL_FILES = Object.freeze(["AGENTS.md", "CLAUDE.md", "SKILL.md", ".mcp.json"])
19
+ export const AGENT_CONTROL_DIRECTORIES = Object.freeze([".claude", ".codex"])
20
+ export const INSTRUCTION_DIRECTORY = "skills"
21
+ export const INSTRUCTION_EXTENSIONS = Object.freeze([".md", ".markdown", ".mdc"])
22
+
23
+ export const REMEDY = Object.freeze([
24
+ "Move the guidance to a non-agent filename such as DEVELOPMENT.md.",
25
+ "Untrack the originals (git rm --cached) so they leave the installable tree, and add them to .gitignore.",
26
+ "Keep a recursive release check so they cannot return.",
27
+ ])
28
+
29
+ function basename(path) {
30
+ const slash = path.lastIndexOf("/")
31
+ return slash === -1 ? path : path.slice(slash + 1)
32
+ }
33
+
34
+ function segments(path) {
35
+ return path.split("/")
36
+ }
37
+
38
+ /**
39
+ * Find every agent-control file anywhere in the tree.
40
+ *
41
+ * Matching is case-insensitive on the basename: agents read `agents.md` as
42
+ * readily as `AGENTS.md`, so a lowercase copy is the same injection surface.
43
+ *
44
+ * @param {Array<{ path: string, mode: string, type: string }>} entries
45
+ * @returns {Array<{ path: string, reason: string }>}
46
+ */
47
+ export function findAgentControl(entries) {
48
+ const files = new Set(AGENT_CONTROL_FILES.map((name) => name.toLowerCase()))
49
+ const directories = new Set(AGENT_CONTROL_DIRECTORIES.map((name) => name.toLowerCase()))
50
+ const hits = []
51
+ for (const entry of entries) {
52
+ if (!isBlob(entry)) continue
53
+ const parts = segments(entry.path)
54
+ const name = basename(entry.path).toLowerCase()
55
+ const directory = parts.slice(0, -1).find((part) => directories.has(part.toLowerCase()))
56
+ if (directory) {
57
+ hits.push({ path: entry.path, reason: `inside an agent-control directory (${directory}/)` })
58
+ continue
59
+ }
60
+ if (files.has(name)) {
61
+ hits.push({ path: entry.path, reason: `agent-control file (${basename(entry.path)})` })
62
+ continue
63
+ }
64
+ const inSkills = parts.slice(0, -1).some((part) => part.toLowerCase() === INSTRUCTION_DIRECTORY)
65
+ if (inSkills && INSTRUCTION_EXTENSIONS.some((extension) => name.endsWith(extension))) {
66
+ hits.push({ path: entry.path, reason: `instruction file inside a ${INSTRUCTION_DIRECTORY}/ directory` })
67
+ }
68
+ }
69
+ // Codepoint order, not locale order: the output is read and compared by
70
+ // agents and tests, so it must not depend on the machine's locale data.
71
+ return hits.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
72
+ }
@@ -0,0 +1,307 @@
1
+ // The wordmark, and the one place decoration is allowed.
2
+ //
3
+ // It is drawn in two places: a bare `omakit`, the front door, where the short
4
+ // list under it fits on any screen; and `omakit setup`, a first run already
5
+ // spending seconds fetching the pin. Not on `omakit help`, whose 53 lines
6
+ // scroll it off the top before anyone has read it, and not on `doctor`, which
7
+ // is run to read a report. Never in `submit`, `watch` or `verify` output,
8
+ // because that output gets pasted into issues and read by agents, and a
9
+ // banner there costs a reader lines and costs a submission credibility.
10
+ //
11
+ // One more gate. It draws only when stdout is a terminal, so `omakit | less`
12
+ // and `omakit setup | tee` stay plain text; there is no switch of omakit's own, because a pipe
13
+ // and TERM=dumb are the terminal's way of saying the same thing. NO_COLOR does
14
+ // what it says and no more: the wordmark is still drawn, in the terminal's own
15
+ // foreground, because a person who turned colour off did not ask for a
16
+ // different program. The same words arrive either way; a piped run gets them
17
+ // without the wordmark.
18
+ //
19
+ // The animation is on a budget: the whole scan is MOTION.bannerBudgetMs, about
20
+ // a quarter of a second, so the first line under it is on the screen before a
21
+ // person has finished looking at the wordmark. The first version of this took
22
+ // 1.4 seconds. The schedule below is derived from the budget rather than from
23
+ // taste, so the wordmark can grow a letter without the scan growing a delay.
24
+ //
25
+ // How `oma` and `kit` are told apart, and why it is not by colour.
26
+ //
27
+ // The measured problem: on Omarchy's Matte Black theme every ANSI hue resolves
28
+ // to nearly the same grey, so a wordmark whose two halves differ in tint
29
+ // renders as one flat word, and bold does not rescue it, because a block glyph
30
+ // has no stroke for a bold face to thicken and most terminals no longer
31
+ // brighten bold text. What a monochrome terminal does still draw is ink. So
32
+ // the two halves differ in density: `oma`, the ecosystem's prefix, is drawn in
33
+ // the dark shade (▓), and `kit`, this tool's own name, in the full block (█).
34
+ // The prefix recedes into texture and the name stands solid, on any theme, and
35
+ // under NO_COLOR, where there is no escape sequence at all. The tint is still
36
+ // applied on top when colour is on, cyan on the prefix and the foreground on
37
+ // the name, so a colour theme gets both cues and a monochrome one gets the one
38
+ // it can show.
39
+ //
40
+ // The trade-off is the shade glyph itself: ▓ is a pattern, and a pattern only
41
+ // reads as a letter when adjacent cells tile without a seam. Every monospace
42
+ // font ships it as a tiling glyph, and docs/media/render.py measures the
43
+ // rendered wordmark and refuses to produce a GIF in which the shaded rows do
44
+ // not join.
45
+ //
46
+ // The animation is the tool's own motif rather than an ornament: the same
47
+ // scanner that sweeps the progress line during a baseline run sweeps across the
48
+ // name. Nothing here imitates anyone else's logo, character or product; the
49
+ // letters come from the small font below, so renaming the tool is a change to
50
+ // one string and not a redrawing job.
51
+
52
+ import { code, colourEnabled, DENSITY, motionEnabled, MOTION, rule as floorRule } from "./style.mjs"
53
+ import { effectAvailable, playEffect } from "./effect.mjs"
54
+
55
+ const ESC = "\u001b["
56
+ const RESET = `${ESC}0m`
57
+ // The tints, applied only when colour is on. The head of the scanner is the
58
+ // one bright thing, the typeable role's tint in bold, the same tint the progress
59
+ // line's head moves; the prefix takes that tint too; the name keeps the
60
+ // terminal's foreground.
61
+ const TINT = Object.freeze({
62
+ head: `${ESC}${code("typeable.bold")}m`,
63
+ prefix: `${ESC}${code("typeable")}m`,
64
+ suffix: `${ESC}${code("prose")}m`,
65
+ })
66
+
67
+ // A five-row pixel font, "#" lit and " " blank, one blank column between
68
+ // letters. Only the letters the name needs are defined; adding one is adding one
69
+ // entry of five equal-length strings.
70
+ export const GLYPHS = Object.freeze({
71
+ a: [" ## ", "# #", "####", "# #", "# #"],
72
+ c: [" ###", "# ", "# ", "# ", " ###"],
73
+ // Three wide with bars, not a single stroke: a one-column i is ambiguous next
74
+ // to a k, and the wordmark reads as capitals anyway.
75
+ i: ["###", " # ", " # ", " # ", "###"],
76
+ k: ["# #", "# # ", "## ", "# # ", "# #"],
77
+ m: ["# #", "## ##", "# # #", "# #", "# #"],
78
+ n: ["# #", "## #", "# ##", "# #", "# #"],
79
+ o: [" ## ", "# #", "# #", "# #", " ## "],
80
+ s: [" ###", "# ", " ## ", " #", "### "],
81
+ t: ["###", " # ", " # ", " # ", " # "],
82
+ })
83
+
84
+ export const GLYPH_ROWS = 5
85
+
86
+ /** How many leading letters are the prefix: drawn in the shade, and tinted cyan when colour is on. */
87
+ export const PREFIX_LETTERS = 3
88
+
89
+ /** The two densities: the prefix is texture, the name is solid. */
90
+ export const INK = Object.freeze({ prefix: DENSITY.dark, suffix: DENSITY.full, head: DENSITY.full })
91
+
92
+ /**
93
+ * Lay a word out as five rows plus the column span of each letter.
94
+ * Throws on a letter the font does not have, rather than dropping it silently.
95
+ */
96
+ export function wordmarkLayout(word) {
97
+ const letters = [...String(word).toLowerCase()]
98
+ const missing = letters.filter((letter) => !GLYPHS[letter])
99
+ if (missing.length) {
100
+ throw new Error(`banner: no glyph for ${[...new Set(missing)].join(", ")}; add it to GLYPHS`)
101
+ }
102
+ const rows = []
103
+ for (let row = 0; row < GLYPH_ROWS; row += 1) {
104
+ rows.push(letters.map((letter) => GLYPHS[letter][row]).join(" "))
105
+ }
106
+ const spans = []
107
+ let column = 0
108
+ for (const [index, letter] of letters.entries()) {
109
+ const width = GLYPHS[letter][0].length
110
+ spans.push({ letter, index, from: column, to: column + width - 1 })
111
+ column += width + 1
112
+ }
113
+ return { rows, spans, width: rows[0].length }
114
+ }
115
+
116
+ /** Kept for the simple case: just the five rows. */
117
+ export function wordmarkRows(word) {
118
+ return wordmarkLayout(word).rows
119
+ }
120
+
121
+ /**
122
+ * The whole animation, in milliseconds, stated once in style.mjs beside every
123
+ * other budget. Not a taste parameter: `help` exists to put text on the
124
+ * screen, so the scan has to be over before it is in the way. A quarter of a
125
+ * second is about one glance.
126
+ */
127
+ export const BUDGET_MS = MOTION.bannerBudgetMs
128
+
129
+ // How many columns the head jumps per frame. Two for the reveal keeps the sweep
130
+ // continuous, because the head is two columns wide; three for the return pass
131
+ // is a highlight travelling over letters that are already drawn, where a gap
132
+ // costs nothing.
133
+ const REVEAL_STRIDE = 2
134
+ const SHINE_STRIDE = 3
135
+
136
+ /**
137
+ * Frames are spaced to fit the budget, not the other way round, so a longer
138
+ * name means a quicker step rather than a longer wait.
139
+ *
140
+ * @param {number} width
141
+ * @param {number} [budget]
142
+ */
143
+ export function schedule(width, budget = BUDGET_MS) {
144
+ const stride = REVEAL_STRIDE
145
+ const shineStride = SHINE_STRIDE
146
+ const frames = Math.floor(width / stride) + 1 + Math.ceil(width / shineStride) + 1
147
+ const delay = Math.max(4, Math.floor(budget / frames))
148
+ return { stride, shineStride, delay, frames, total: frames * delay }
149
+ }
150
+
151
+ export function bannerEnabled(stream = process.stdout, env = process.env) {
152
+ return motionEnabled(stream, env)
153
+ }
154
+
155
+ function isPrefix(spans, column) {
156
+ const span = spans.find((entry) => column >= entry.from && column <= entry.to)
157
+ return Boolean(span) && span.index < PREFIX_LETTERS
158
+ }
159
+
160
+ /**
161
+ * One frame of the scan.
162
+ *
163
+ * `band` is where the bright head sits. `revealed` is how far the wordmark has
164
+ * been drawn at all; columns beyond it are blank. The reveal pass moves both
165
+ * together; the shine pass afterwards moves the band across a wordmark that is
166
+ * already complete.
167
+ *
168
+ * With `colour` off no escape is written at all: the head is a full block over
169
+ * the shaded prefix, which is still visible as a change in density, and over
170
+ * the solid name it is simply the name.
171
+ */
172
+ export function frame(layout, band, revealed = band, { colour = true } = {}) {
173
+ const { rows, spans, width } = layout
174
+ return rows.map((row) => {
175
+ let out = ""
176
+ let tint = ""
177
+ for (let column = 0; column < width; column += 1) {
178
+ // Unrevealed columns, and blanks inside revealed ones, are plain spaces.
179
+ // A colour code is only ever emitted for a block that is actually drawn,
180
+ // so a frame carries no escape it does not use.
181
+ const lit = column <= revealed && row[column] === "#"
182
+ if (!lit) {
183
+ if (tint) { out += RESET; tint = "" }
184
+ out += " "
185
+ continue
186
+ }
187
+ const atHead = column >= band - 1 && column <= band
188
+ const prefix = isPrefix(spans, column)
189
+ const wanted = colour ? (atHead ? TINT.head : prefix ? TINT.prefix : TINT.suffix) : ""
190
+ if (tint !== wanted) { out += wanted; tint = wanted }
191
+ out += atHead ? INK.head : prefix ? INK.prefix : INK.suffix
192
+ }
193
+ return tint ? out + RESET : out
194
+ })
195
+ }
196
+
197
+ /**
198
+ * @param {{ word?: string, tagline?: string, stream?: NodeJS.WriteStream,
199
+ * enabled?: boolean, animate?: boolean, shines?: number,
200
+ * effect?: boolean, env?: NodeJS.ProcessEnv }} [options]
201
+ * `effect: true` runs the wordmark through `ttfx` when it is there (see
202
+ * effect.mjs); both callers, the front door and `setup`, pass it.
203
+ */
204
+ export async function banner(options = {}) {
205
+ const stream = options.stream || process.stdout
206
+ const enabled = options.enabled ?? bannerEnabled(stream)
207
+ const colour = options.colour ?? colourEnabled(stream)
208
+ const word = options.word || "omakit"
209
+ const layout = wordmarkLayout(word)
210
+ const { width } = layout
211
+ // The rule and the tagline sit under the wordmark and are still meant to be
212
+ // read, so neither is dim: on a low-contrast theme grey-on-near-black is a
213
+ // decoration nobody can see. The rule takes the prefix tint, the tagline the
214
+ // foreground.
215
+ const c = (name, text) => (colour ? `${ESC}${name}m${text}${RESET}` : text)
216
+ const rule = floorRule((_name, text) => c(code("typeable"), text), { width })
217
+ // The tagline is centred under the wordmark, not set flush left: the rule
218
+ // is exactly as wide as the letters, so a shorter line starting at column
219
+ // 0 reads as slid to the left. The padding is spaces, no escape, so the
220
+ // line is centred under NO_COLOR and in a pipe alike.
221
+ const tagline = options.tagline
222
+ ? " ".repeat(Math.max(0, Math.floor((width - [...String(options.tagline)].length) / 2))) + c(code("prose"), options.tagline)
223
+ : null
224
+
225
+ // Nothing at all when it is not a terminal. There is no plain-text substitute
226
+ // to print: `help` and `setup` already say the name and what it does in words,
227
+ // and a piped run should differ from a watched one only in decoration.
228
+ if (!enabled) return
229
+
230
+ // A five-row animation redrawn with cursor-up needs five rows that stay put.
231
+ // In a terminal with no room the screen scrolls under the animation, the
232
+ // cursor-up lands a line off, and a row from an earlier frame is left stranded
233
+ // above the wordmark. Rather than animate into that, draw it at once.
234
+ const rowsAvailable = Number.isFinite(stream.rows) ? stream.rows : Infinity
235
+ const animate = options.animate !== false && rowsAvailable >= GLYPH_ROWS + 4
236
+
237
+ const { stride, shineStride, delay } = schedule(width, options.budgetMs)
238
+ const paint = (lines) => lines.map((line) => `${ESC}2K${line}`).join("\n")
239
+ // A frame is written without a trailing newline, and the cursor walks back up
240
+ // four rows and to column 0. That is not a detail: a newline written while the
241
+ // cursor is on the last row of the screen scrolls the screen, so a frame that
242
+ // ends in one scrolls once per frame when the prompt happens to sit at the
243
+ // bottom, which it usually does. Twenty-six of those leave the partial frames
244
+ // in the scrollback and a stray row of one of them directly above the
245
+ // wordmark. Ending inside the block instead means the screen scrolls exactly
246
+ // once, for the very first frame, before any cursor-up is issued.
247
+ const redraw = (lines) => stream.write(`${paint(lines)}${ESC}${GLYPH_ROWS - 1}A\r`)
248
+ const finish = (lines) => stream.write(`${paint(lines)}\n`)
249
+
250
+ const draw = (band, revealed, tinted = colour) => frame(layout, band, revealed, { colour: tinted })
251
+
252
+ // The text effect, where asked for and where `ttfx` is there. It draws the
253
+ // plain glyphs, whose density split is in the characters, and leaves the
254
+ // cursor hidden on the line under them; omakit walks back up over the five
255
+ // rows and paints the finished wordmark in its own tints. Absent, the scan
256
+ // below runs exactly as it would have, byte for byte.
257
+ const env = options.env || process.env
258
+ const played = animate && options.effect && effectAvailable(env)
259
+ ? await playEffect(draw(-2, width + 2, false), stream, { env })
260
+ : "absent"
261
+ // The one return pass over the finished wordmark, the shine,
262
+ // on the scan's own schedule. Two looked better and cost twice the budget,
263
+ // and the budget is the point.
264
+ const step = async (band, revealed, delay) => {
265
+ await new Promise((resolve) => setTimeout(resolve, delay))
266
+ redraw(draw(band, revealed))
267
+ }
268
+ const shine = async () => {
269
+ const shines = options.shines ?? 1
270
+ for (let pass = 0; pass < shines; pass += 1) {
271
+ const forward = pass % 2 === 1
272
+ for (let step_ = 0; step_ <= Math.ceil(width / shineStride); step_ += 1) {
273
+ const offset = step_ * shineStride
274
+ await step(forward ? offset : width - offset, width + 2, delay)
275
+ }
276
+ }
277
+ }
278
+
279
+ if (played === "played") {
280
+ // Back up over the effect's plain rows, paint the wordmark in omakit's
281
+ // tints, and end it the way the scan ends: with the shine.
282
+ stream.write(`${ESC}${GLYPH_ROWS}A`)
283
+ redraw(draw(-2, width + 2))
284
+ await shine()
285
+ finish(draw(-2, width + 2))
286
+ stream.write(`${ESC}?25h`)
287
+ } else if (played === "broken") {
288
+ // Something reached the screen and then the effect failed. The cursor is
289
+ // somewhere inside the rows, so the honest thing is to leave what is
290
+ // there, show the cursor, start a fresh line and draw the wordmark once.
291
+ stream.write(`${ESC}?25h\n`)
292
+ finish(draw(-2, width + 2))
293
+ } else if (!animate) {
294
+ finish(draw(-2, width + 2))
295
+ } else {
296
+ // Claim the five rows first, so whatever scrolling has to happen happens
297
+ // here, before a single cursor-up is issued and while the geometry can
298
+ // still shift harmlessly.
299
+ redraw(draw(-2, -2))
300
+ for (let band = 0; band <= width; band += stride) await step(band, band, delay)
301
+ await shine()
302
+ finish(draw(-2, width + 2))
303
+ }
304
+ stream.write(`${rule}\n`)
305
+ if (tagline) stream.write(`${tagline}\n`)
306
+ stream.write("\n")
307
+ }