omakit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/bin/omakit +3 -0
- package/package.json +40 -0
- package/skills/omarchy-plugin-submit/SKILL.md +87 -0
- package/skills/omarchy-plugin-validation-watch/SKILL.md +62 -0
- package/tests/parity/corpus.mjs +62 -0
- package/tests/parity/run.mjs +194 -0
- package/tools/marketplace/README.md +64 -0
- package/tools/marketplace/agent-control.mjs +72 -0
- package/tools/marketplace/banner.mjs +307 -0
- package/tools/marketplace/cli.mjs +283 -0
- package/tools/marketplace/completion.mjs +263 -0
- package/tools/marketplace/doctor.mjs +156 -0
- package/tools/marketplace/effect.mjs +115 -0
- package/tools/marketplace/form.mjs +209 -0
- package/tools/marketplace/github.mjs +206 -0
- package/tools/marketplace/issue.mjs +80 -0
- package/tools/marketplace/local-transport.mjs +162 -0
- package/tools/marketplace/parity-output.mjs +26 -0
- package/tools/marketplace/paths.mjs +10 -0
- package/tools/marketplace/pin.mjs +203 -0
- package/tools/marketplace/plugin.mjs +64 -0
- package/tools/marketplace/preflight.mjs +164 -0
- package/tools/marketplace/progress.mjs +96 -0
- package/tools/marketplace/registry.mjs +216 -0
- package/tools/marketplace/report.mjs +192 -0
- package/tools/marketplace/run-baseline.mjs +72 -0
- package/tools/marketplace/setup.mjs +136 -0
- package/tools/marketplace/style.mjs +443 -0
- package/tools/marketplace/submit.mjs +341 -0
- package/tools/marketplace/tree.mjs +50 -0
- package/tools/marketplace/upgrade.mjs +179 -0
- package/tools/marketplace/usage.mjs +191 -0
- package/tools/marketplace/verify.mjs +70 -0
- package/tools/marketplace/watch.mjs +262 -0
- package/tools/marketplace/yaml.mjs +164 -0
- package/tools/subject/resolve.mjs +124 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The single entry point.
|
|
3
|
+
//
|
|
4
|
+
// omakit pin fetch or verify the pinned marketplace checkout
|
|
5
|
+
// omakit submit <target> ... everything knowable before submitting; prints, never posts
|
|
6
|
+
// omakit watch <issue-url> is this submission's validated commit still current?
|
|
7
|
+
// omakit verify <target> the official baseline over the local transport, verbatim
|
|
8
|
+
// omakit parity [--count n] prove the local transport equals the GitHub transport
|
|
9
|
+
//
|
|
10
|
+
// Nothing here writes to the marketplace. There is no POST, PATCH, PUT or
|
|
11
|
+
// DELETE anywhere in this repository, and `tests/unit/read-only.test.mjs`
|
|
12
|
+
// proves it.
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
15
|
+
import { dirname, resolve } from "node:path"
|
|
16
|
+
import { fileURLToPath } from "node:url"
|
|
17
|
+
import { ensurePin, MARKETPLACE_PIN } from "./pin.mjs"
|
|
18
|
+
import { marketplaceBaselineSection } from "./verify.mjs"
|
|
19
|
+
import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
|
|
20
|
+
import { submitPreflight } from "./submit.mjs"
|
|
21
|
+
import { validationWatch } from "./watch.mjs"
|
|
22
|
+
import { renderSubmit, renderWatch, renderDoctor } from "./report.mjs"
|
|
23
|
+
import { doctor } from "./doctor.mjs"
|
|
24
|
+
import { setup } from "./setup.mjs"
|
|
25
|
+
import { upgrade } from "./upgrade.mjs"
|
|
26
|
+
import { progress } from "./progress.mjs"
|
|
27
|
+
import { banner, bannerEnabled } from "./banner.mjs"
|
|
28
|
+
import { COMMANDS, renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
|
|
29
|
+
import { action, colourEnabled, GUTTER, mark, styler, wrap } from "./style.mjs"
|
|
30
|
+
import { omakitCacheDir } from "./paths.mjs"
|
|
31
|
+
import { parityOutput } from "./parity-output.mjs"
|
|
32
|
+
|
|
33
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The one action for each way a command can stop. A failure state says what
|
|
37
|
+
* happened (the code), what it means (the message the module raised) and the
|
|
38
|
+
* single command that fixes it, in that order, every time. A code with no
|
|
39
|
+
* entry here gets the first two and no arrow, which is the honest rendering of
|
|
40
|
+
* a state nobody has written a fix for yet.
|
|
41
|
+
*/
|
|
42
|
+
const REMEDY = Object.freeze({
|
|
43
|
+
"usage": "omakit help",
|
|
44
|
+
"marketplace-unavailable": "omakit pin",
|
|
45
|
+
"dirty-worktree": "Commit the changes, or pass --allow-dirty to check the tree as it is.",
|
|
46
|
+
"subject-not-found": "Pass a local Git repository path, or <https url>@<40-char sha>.",
|
|
47
|
+
"not-a-git-repository": "Pass a local Git repository path, or <https url>@<40-char sha>.",
|
|
48
|
+
"commit-not-found": "Commit first; the checks read the tree at an exact commit, never the working copy.",
|
|
49
|
+
"network-unavailable": "Connect to the network, then run it again.",
|
|
50
|
+
"github-unavailable": "Wait for GitHub, then run it again. `gh auth login` raises the rate limit if that is what ran out.",
|
|
51
|
+
"not-found": "Check the issue URL: it has to be an existing issue on the marketplace repository.",
|
|
52
|
+
"head-unreadable": "Check that the plugin repository is public and its URL is right.",
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every failure, in one register, on stderr. `usage` errors carry the
|
|
57
|
+
* signature that was expected, so the remedy is the reference and not a
|
|
58
|
+
* restatement of the message.
|
|
59
|
+
*/
|
|
60
|
+
function fail(code, message, exit = 1, remedy = REMEDY[code]) {
|
|
61
|
+
const c = styler(colourEnabled(process.stderr))
|
|
62
|
+
const lines = [`${mark("fail", c)}${c("name", code)}`, ...wrap(message, { indent: GUTTER }, c)]
|
|
63
|
+
if (remedy) lines.push(...action(remedy, c))
|
|
64
|
+
process.stderr.write(`${lines.join("\n")}\n`)
|
|
65
|
+
process.exit(exit)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A thrown error becomes a failure state when it carries a code; anything else is a bug and keeps its stack. */
|
|
69
|
+
function failFrom(error) {
|
|
70
|
+
if (error?.code && typeof error.code === "string") fail(error.code, error.message, error.code === "usage" ? 2 : 1, error.remedy || REMEDY[error.code])
|
|
71
|
+
throw error
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function option(args, name) {
|
|
75
|
+
const index = args.indexOf(name)
|
|
76
|
+
return index >= 0 ? args[index + 1] : undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function positionals(args) {
|
|
80
|
+
const valued = new Set(["--profile", "--plugin", "--out", "--category", "--tags", "--notes", "--suggest-tag", "--name", "--count", "--offset"])
|
|
81
|
+
return args.filter((value, index) => !value.startsWith("--") && !valued.has(args[index - 1]))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function emit(args, text) {
|
|
85
|
+
const out = option(args, "--out")
|
|
86
|
+
if (out) {
|
|
87
|
+
mkdirSync(dirname(resolve(out)), { recursive: true })
|
|
88
|
+
writeFileSync(resolve(out), text.endsWith("\n") ? text : `${text}\n`)
|
|
89
|
+
const c = styler(colourEnabled())
|
|
90
|
+
process.stdout.write(`${mark("pass", c)}wrote ${resolve(out)}\n`)
|
|
91
|
+
} else {
|
|
92
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function cmdSubmit(args) {
|
|
97
|
+
const target = positionals(args)[0]
|
|
98
|
+
if (!target) fail("usage", "submit needs a target: `omakit submit <target> --category <c> --tags <a,b>`", 2)
|
|
99
|
+
const spinner = args.includes("--json") ? { phase: () => {}, done: () => {} } : progress()
|
|
100
|
+
let result
|
|
101
|
+
try {
|
|
102
|
+
result = await submitPreflight({
|
|
103
|
+
repoRoot: ROOT,
|
|
104
|
+
target,
|
|
105
|
+
onPhase: spinner.phase,
|
|
106
|
+
category: option(args, "--category"),
|
|
107
|
+
tags: option(args, "--tags"),
|
|
108
|
+
notes: option(args, "--notes"),
|
|
109
|
+
suggestedTag: option(args, "--suggest-tag"),
|
|
110
|
+
pluginName: option(args, "--name"),
|
|
111
|
+
allowDirty: args.includes("--allow-dirty"),
|
|
112
|
+
offline: args.includes("--offline"),
|
|
113
|
+
})
|
|
114
|
+
} catch (error) {
|
|
115
|
+
spinner.done()
|
|
116
|
+
failFrom(error)
|
|
117
|
+
}
|
|
118
|
+
spinner.done()
|
|
119
|
+
emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderSubmit(result))
|
|
120
|
+
process.exit(result.ready ? 0 : 1)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function cmdWatch(args) {
|
|
124
|
+
const issueUrl = positionals(args)[0]
|
|
125
|
+
if (!issueUrl) fail("usage", "watch needs an issue: `omakit watch <issue-url>`", 2)
|
|
126
|
+
const spinner = args.includes("--json") ? { phase: () => {}, done: () => {} } : progress()
|
|
127
|
+
let result
|
|
128
|
+
try {
|
|
129
|
+
result = await validationWatch({ repoRoot: ROOT, issueUrl, onPhase: spinner.phase })
|
|
130
|
+
} catch (error) {
|
|
131
|
+
spinner.done()
|
|
132
|
+
failFrom(error)
|
|
133
|
+
}
|
|
134
|
+
spinner.done()
|
|
135
|
+
emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderWatch(result))
|
|
136
|
+
process.exit(result.verdict.state === "unknown" ? 2 : 0)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function cmdFrontDoor() {
|
|
140
|
+
// A bare `omakit` is the front door: the wordmark, through `ttfx` when it is
|
|
141
|
+
// there, then the short list, which fits under it on any screen. The banner
|
|
142
|
+
// already says the name and the tagline, so the heading would repeat it.
|
|
143
|
+
const drew = bannerEnabled()
|
|
144
|
+
await banner({ tagline: TAGLINE, effect: true })
|
|
145
|
+
process.stdout.write(renderSummary({ heading: !drew }))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function cmdSetup() {
|
|
149
|
+
const result = await setup({ repoRoot: ROOT, entryPoint: resolve(ROOT, "bin/omakit") })
|
|
150
|
+
process.exit(result.ok ? 0 : 1)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function cmdUpgrade(args) {
|
|
154
|
+
const result = await upgrade({ repoRoot: ROOT, dryRun: args.includes("--dry-run") })
|
|
155
|
+
process.exit(result.ok ? 0 : 1)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function cmdDoctor(args) {
|
|
159
|
+
const spinner = args.includes("--json") ? { phase: () => {}, done: () => {} } : progress()
|
|
160
|
+
const result = await doctor({ repoRoot: ROOT, offline: args.includes("--offline"), onPhase: spinner.phase })
|
|
161
|
+
spinner.done()
|
|
162
|
+
emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderDoctor(result))
|
|
163
|
+
process.exit(result.problems ? 1 : 0)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function cmdVerify(args) {
|
|
167
|
+
const target = positionals(args)[0]
|
|
168
|
+
if (!target) fail("usage", "verify needs a target: `omakit verify <path | https-url@sha>`", 2)
|
|
169
|
+
let subject
|
|
170
|
+
try {
|
|
171
|
+
subject = resolveSubject(target, { cacheRoot: omakitCacheDir(), allowDirty: args.includes("--allow-dirty") })
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error instanceof SubjectError) fail(error.code, error.message, error.code === "usage" ? 2 : 1)
|
|
174
|
+
throw error
|
|
175
|
+
}
|
|
176
|
+
const spinner = progress()
|
|
177
|
+
let section
|
|
178
|
+
try {
|
|
179
|
+
spinner.phase("running the official security baseline over a local snapshot")
|
|
180
|
+
section = await marketplaceBaselineSection({ repoRoot: ROOT, subject })
|
|
181
|
+
} catch (error) {
|
|
182
|
+
spinner.done()
|
|
183
|
+
fail(error?.code === "marketplace-unavailable" ? error.code : "baseline-unavailable", error.message)
|
|
184
|
+
}
|
|
185
|
+
spinner.done()
|
|
186
|
+
emit(args, `${JSON.stringify({
|
|
187
|
+
subject: {
|
|
188
|
+
repository: subject.repository,
|
|
189
|
+
commit: subject.commit,
|
|
190
|
+
cleanTree: { clean: subject.clean, proof: "git-status-porcelain-empty" },
|
|
191
|
+
mode: subject.mode,
|
|
192
|
+
},
|
|
193
|
+
marketplaceBaseline: section,
|
|
194
|
+
}, null, 2)}\n`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function cmdParity(args) {
|
|
198
|
+
const count = option(args, "--count")
|
|
199
|
+
const offset = option(args, "--offset")
|
|
200
|
+
if (count) process.env.PARITY_COUNT = count
|
|
201
|
+
if (offset) process.env.PARITY_OFFSET = offset
|
|
202
|
+
try {
|
|
203
|
+
const out = option(args, "--out")
|
|
204
|
+
process.env.PARITY_OUT = parityOutput({ repoRoot: ROOT, out })
|
|
205
|
+
if (out) process.env.PARITY_OUT_EXPLICIT = "1"
|
|
206
|
+
} catch (error) {
|
|
207
|
+
failFrom(error)
|
|
208
|
+
}
|
|
209
|
+
process.env.OMAKIT_ROOT = ROOT
|
|
210
|
+
try {
|
|
211
|
+
await import("../../tests/parity/run.mjs")
|
|
212
|
+
} catch (error) {
|
|
213
|
+
failFrom(error)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const [command, ...rest] = process.argv.slice(2)
|
|
218
|
+
if (command === "setup") {
|
|
219
|
+
await cmdSetup()
|
|
220
|
+
} else if (command === "pin" || command === "marketplace-pin") {
|
|
221
|
+
const c = styler(colourEnabled())
|
|
222
|
+
const spinner = progress()
|
|
223
|
+
try {
|
|
224
|
+
ensurePin(ROOT, (line) => {
|
|
225
|
+
// ensurePin narrates: a state line to keep, then a fetch it is about to
|
|
226
|
+
// start. The fetch is the slow part, so it gets the progress line.
|
|
227
|
+
if (line.state === "fetching") spinner.phase(line.text)
|
|
228
|
+
else process.stdout.write(`${mark(line.state, c)}${wrap(line.text, { indent: GUTTER }, c).join("\n").trimStart()}\n`)
|
|
229
|
+
})
|
|
230
|
+
} catch (error) {
|
|
231
|
+
spinner.done()
|
|
232
|
+
fail(error?.code || "marketplace-unavailable", error.message, 1, error?.remedy || REMEDY[error?.code])
|
|
233
|
+
}
|
|
234
|
+
spinner.done()
|
|
235
|
+
} else if (command === "submit") {
|
|
236
|
+
await cmdSubmit(rest)
|
|
237
|
+
} else if (command === "upgrade") {
|
|
238
|
+
await cmdUpgrade(rest)
|
|
239
|
+
} else if (command === "doctor") {
|
|
240
|
+
await cmdDoctor(rest)
|
|
241
|
+
} else if (command === "watch") {
|
|
242
|
+
await cmdWatch(rest)
|
|
243
|
+
} else if (command === "verify") {
|
|
244
|
+
await cmdVerify(rest.filter((value, index) => value !== "marketplace" || rest[index - 1] !== "--profile"))
|
|
245
|
+
} else if (command === "parity") {
|
|
246
|
+
await cmdParity(rest)
|
|
247
|
+
} else if (command === "help" || command === "--help" || command === "-h" || command === undefined) {
|
|
248
|
+
if (rest.includes("--agent")) {
|
|
249
|
+
// The skills ship in the npm package, so this works from a global install
|
|
250
|
+
// with no repository checked out.
|
|
251
|
+
const dir = resolve(ROOT, "skills")
|
|
252
|
+
const parts = []
|
|
253
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
254
|
+
const file = entry.isDirectory() ? resolve(dir, entry.name, "SKILL.md") : resolve(dir, entry.name)
|
|
255
|
+
if (!file.endsWith(".md")) continue
|
|
256
|
+
try {
|
|
257
|
+
parts.push(readFileSync(file, "utf8").trim())
|
|
258
|
+
} catch {
|
|
259
|
+
// A skill directory without a SKILL.md is not an error worth failing on.
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
process.stdout.write(`${parts.join("\n\n---\n\n")}\n`)
|
|
263
|
+
} else if (command === undefined) {
|
|
264
|
+
await cmdFrontDoor()
|
|
265
|
+
} else {
|
|
266
|
+
// `omakit help` is the reference and gets all of it, under the name and
|
|
267
|
+
// the tagline as one line of text: 53 lines scroll a wordmark off the top
|
|
268
|
+
// of the screen before anyone has read it, so it gets none.
|
|
269
|
+
process.stdout.write(renderUsage())
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
// The short list on a typo, not 53 lines of reference, in the same register
|
|
273
|
+
// as every other failure: what happened, what it means, what to run.
|
|
274
|
+
const c = styler(colourEnabled(process.stderr))
|
|
275
|
+
process.stderr.write([
|
|
276
|
+
`${mark("fail", c)}${c("name", "unknown command")}`,
|
|
277
|
+
...wrap(`\`${command}\` is not something omakit does. The commands it has are listed below.`, { indent: GUTTER }, c),
|
|
278
|
+
...action("omakit help", c),
|
|
279
|
+
"",
|
|
280
|
+
renderSummary({ colour: colourEnabled(process.stderr), heading: false }),
|
|
281
|
+
].join("\n"))
|
|
282
|
+
process.exit(2)
|
|
283
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Tab completion for bash, zsh and fish, installed by `omakit setup` for the
|
|
2
|
+
// shell it runs from.
|
|
3
|
+
//
|
|
4
|
+
// Everything the script completes is derived, never retyped: the subcommands
|
|
5
|
+
// and their flags from COMMANDS in usage.mjs, so a command added there cannot
|
|
6
|
+
// be missing here, and the categories and tags from the submission form at the
|
|
7
|
+
// marketplace pin, the same source the checks read. Those controlled values are
|
|
8
|
+
// baked into the emitted script rather than fetched on every keypress: node's
|
|
9
|
+
// startup plus a pin read is not something to put behind a TAB. The script says
|
|
10
|
+
// in its own header which pin it came from and how to regenerate it.
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
13
|
+
import { basename, dirname, join } from "node:path"
|
|
14
|
+
import { tagSlug } from "./form.mjs"
|
|
15
|
+
import { COMMANDS, COMPLETION_SHELLS } from "./usage.mjs"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The completion model, read out of the help data. A subcommand is the word
|
|
19
|
+
* after `omakit` in its signature; a flag is every `--word` in it, valued when
|
|
20
|
+
* a `<placeholder>` follows it; `<target>` in the signature means the first
|
|
21
|
+
* positional is a directory.
|
|
22
|
+
*
|
|
23
|
+
* @param {ReadonlyArray<{ signature: string|string[], lines: string[] }>} commands
|
|
24
|
+
*/
|
|
25
|
+
export function subcommandsOf(commands = COMMANDS) {
|
|
26
|
+
return commands.map((command) => {
|
|
27
|
+
const signature = [].concat(command.signature).join(" ")
|
|
28
|
+
const name = signature.match(/^omakit +([a-z][a-z-]*)/)?.[1]
|
|
29
|
+
if (!name) throw new Error(`completion: no subcommand in signature ${JSON.stringify(signature)}`)
|
|
30
|
+
const flags = [...signature.matchAll(/(--[a-z][a-z-]*)(?: <([^>]+)>)?/g)].map(([, flag, placeholder]) => ({
|
|
31
|
+
flag,
|
|
32
|
+
value: placeholder ? placeholderKind(placeholder) : null,
|
|
33
|
+
}))
|
|
34
|
+
const sentence = command.lines.join(" ").replace(/`/g, "").split(/(?<=\.)\s/)[0]
|
|
35
|
+
return { name, description: sentence, flags, target: /<target>/.test(signature) }
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What a valued flag takes, by its placeholder: a controlled value, a file, or free text. */
|
|
40
|
+
function placeholderKind(placeholder) {
|
|
41
|
+
if (placeholder === "c") return "category"
|
|
42
|
+
if (placeholder === "a,b") return "tags"
|
|
43
|
+
if (placeholder === "file") return "file"
|
|
44
|
+
return "text"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {"bash"|"zsh"|"fish"} shell
|
|
49
|
+
* @param {{ contract: { categories: string[], tagLabels: string[] }, pin: string, commands?: typeof COMMANDS }} options
|
|
50
|
+
* @returns {string} the script
|
|
51
|
+
*/
|
|
52
|
+
export function renderCompletion(shell, { contract, pin, commands = COMMANDS }) {
|
|
53
|
+
if (!COMPLETION_SHELLS.includes(shell)) throw new Error(`completion: no script for ${shell}`)
|
|
54
|
+
const model = {
|
|
55
|
+
subcommands: subcommandsOf(commands),
|
|
56
|
+
categories: [...contract.categories],
|
|
57
|
+
tags: contract.tagLabels.map(tagSlug),
|
|
58
|
+
pin,
|
|
59
|
+
}
|
|
60
|
+
return { bash, zsh, fish }[shell](model)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function header(comment, shell, pin) {
|
|
64
|
+
return [
|
|
65
|
+
`${comment} omakit completion for ${shell}. Generated by \`omakit setup\` from`,
|
|
66
|
+
`${comment} marketplace pin ${pin}; the categories and tags`,
|
|
67
|
+
`${comment} below are that pin's submission form. Regenerate it by running setup`,
|
|
68
|
+
`${comment} again.`,
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const single = (text) => `'${String(text).replace(/'/g, "'\\''")}'`
|
|
73
|
+
|
|
74
|
+
// --- bash ---------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function bash({ subcommands, categories, tags, pin }) {
|
|
77
|
+
const lines = [...header("#", "bash", pin), ""]
|
|
78
|
+
lines.push("_omakit() {")
|
|
79
|
+
lines.push(" local cur prev command")
|
|
80
|
+
lines.push(" cur=${COMP_WORDS[COMP_CWORD]}")
|
|
81
|
+
lines.push(" prev=${COMP_WORDS[COMP_CWORD-1]}")
|
|
82
|
+
lines.push(` local commands=${single(subcommands.map((c) => c.name).join(" "))}`)
|
|
83
|
+
lines.push(` local categories=${single(categories.join("\n"))}`)
|
|
84
|
+
lines.push(` local tags=${single(tags.join(" "))}`)
|
|
85
|
+
lines.push(" COMPREPLY=()")
|
|
86
|
+
lines.push(" if ((COMP_CWORD == 1)); then")
|
|
87
|
+
lines.push(' COMPREPLY=($(compgen -W "$commands" -- "$cur"))')
|
|
88
|
+
lines.push(" return")
|
|
89
|
+
lines.push(" fi")
|
|
90
|
+
lines.push(" command=${COMP_WORDS[1]}")
|
|
91
|
+
lines.push(' case "$command" in')
|
|
92
|
+
for (const sub of subcommands) {
|
|
93
|
+
lines.push(` ${sub.name})`)
|
|
94
|
+
const valued = sub.flags.filter((f) => f.value)
|
|
95
|
+
if (valued.length) {
|
|
96
|
+
lines.push(' case "$prev" in')
|
|
97
|
+
for (const { flag, value } of valued) {
|
|
98
|
+
if (value === "category") lines.push(` ${flag}) _omakit_values "$categories" "$cur"; return ;;`)
|
|
99
|
+
else if (value === "tags") lines.push(` ${flag}) _omakit_list "$tags" "$cur"; return ;;`)
|
|
100
|
+
else if (value === "file") lines.push(` ${flag}) COMPREPLY=($(compgen -f -- "$cur")); compopt -o filenames 2>/dev/null; return ;;`)
|
|
101
|
+
else lines.push(` ${flag}) return ;;`)
|
|
102
|
+
}
|
|
103
|
+
lines.push(" esac")
|
|
104
|
+
}
|
|
105
|
+
if (sub.flags.length) {
|
|
106
|
+
lines.push(' if [[ "$cur" == -* ]]; then')
|
|
107
|
+
lines.push(` COMPREPLY=($(compgen -W ${single(sub.flags.map((f) => f.flag).join(" "))} -- "$cur"))`)
|
|
108
|
+
lines.push(" return")
|
|
109
|
+
lines.push(" fi")
|
|
110
|
+
}
|
|
111
|
+
if (sub.target) {
|
|
112
|
+
lines.push(' COMPREPLY=($(compgen -d -- "$cur"))')
|
|
113
|
+
lines.push(" compopt -o filenames 2>/dev/null")
|
|
114
|
+
}
|
|
115
|
+
lines.push(" ;;")
|
|
116
|
+
}
|
|
117
|
+
lines.push(" esac")
|
|
118
|
+
lines.push("}")
|
|
119
|
+
lines.push("")
|
|
120
|
+
lines.push("# A controlled value may contain a space, so each match is one line and is")
|
|
121
|
+
lines.push("# escaped on the way out, the way the shell would have to type it.")
|
|
122
|
+
lines.push("_omakit_values() {")
|
|
123
|
+
lines.push(" local IFS=$'\\n' i")
|
|
124
|
+
lines.push(' COMPREPLY=($(compgen -W "$1" -- "${2//\\\\ / }"))')
|
|
125
|
+
lines.push(' for i in "${!COMPREPLY[@]}"; do COMPREPLY[i]=$(printf \'%q\' "${COMPREPLY[i]}"); done')
|
|
126
|
+
lines.push("}")
|
|
127
|
+
lines.push("")
|
|
128
|
+
lines.push("# A comma-separated list: complete the segment after the last comma and keep")
|
|
129
|
+
lines.push("# what came before it.")
|
|
130
|
+
lines.push("_omakit_list() {")
|
|
131
|
+
lines.push(' local prefix="" part="$2"')
|
|
132
|
+
lines.push(' if [[ "$2" == *,* ]]; then prefix="${2%,*},"; part="${2##*,}"; fi')
|
|
133
|
+
lines.push(' COMPREPLY=($(compgen -P "$prefix" -W "$1" -- "$part"))')
|
|
134
|
+
lines.push("}")
|
|
135
|
+
lines.push("")
|
|
136
|
+
lines.push("complete -F _omakit omakit")
|
|
137
|
+
return `${lines.join("\n")}\n`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- zsh ----------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
const zshDescribe = (name, description) => single(`${name}:${description.replace(/:/g, "\\:")}`)
|
|
143
|
+
|
|
144
|
+
function zsh({ subcommands, categories, tags, pin }) {
|
|
145
|
+
const lines = ["#compdef omakit", ...header("#", "zsh", pin), ""]
|
|
146
|
+
lines.push("_omakit() {")
|
|
147
|
+
lines.push(" local curcontext=\"$curcontext\" state line")
|
|
148
|
+
lines.push(" typeset -A opt_args")
|
|
149
|
+
lines.push(" local -a commands categories tags shells")
|
|
150
|
+
lines.push(" commands=(")
|
|
151
|
+
for (const sub of subcommands) lines.push(` ${zshDescribe(sub.name, sub.description)}`)
|
|
152
|
+
lines.push(" )")
|
|
153
|
+
lines.push(` categories=(${categories.map(single).join(" ")})`)
|
|
154
|
+
lines.push(` tags=(${tags.map(single).join(" ")})`)
|
|
155
|
+
lines.push(" _arguments -C '1:command:->command' '*::arguments:->arguments'")
|
|
156
|
+
lines.push(' case "$state" in')
|
|
157
|
+
lines.push(" command)")
|
|
158
|
+
lines.push(" _describe -t commands 'omakit command' commands")
|
|
159
|
+
lines.push(" ;;")
|
|
160
|
+
lines.push(" arguments)")
|
|
161
|
+
lines.push(' case "$line[1]" in')
|
|
162
|
+
for (const sub of subcommands) {
|
|
163
|
+
lines.push(` ${sub.name})`)
|
|
164
|
+
const specs = sub.flags.map(({ flag, value }) => {
|
|
165
|
+
if (value === "category") return single(`${flag}:category:{compadd -a categories}`)
|
|
166
|
+
if (value === "tags") return single(`${flag}:tags:{_values -s , tag $tags}`)
|
|
167
|
+
if (value === "file") return single(`${flag}:file:_files`)
|
|
168
|
+
if (value === "text") return single(`${flag}:text:`)
|
|
169
|
+
return single(flag)
|
|
170
|
+
})
|
|
171
|
+
if (sub.target) specs.push(single("1:target:_directories"))
|
|
172
|
+
if (specs.length) lines.push(` _arguments ${specs.join(" ")}`)
|
|
173
|
+
lines.push(" ;;")
|
|
174
|
+
}
|
|
175
|
+
lines.push(" esac")
|
|
176
|
+
lines.push(" ;;")
|
|
177
|
+
lines.push(" esac")
|
|
178
|
+
lines.push("}")
|
|
179
|
+
lines.push("")
|
|
180
|
+
lines.push('_omakit "$@"')
|
|
181
|
+
return `${lines.join("\n")}\n`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- fish ---------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
const fishWord = (text) => String(text).replace(/([\\'" ])/g, "\\$1")
|
|
187
|
+
|
|
188
|
+
function fish({ subcommands, categories, tags, pin }) {
|
|
189
|
+
const lines = [...header("#", "fish", pin), ""]
|
|
190
|
+
lines.push("complete -c omakit -f")
|
|
191
|
+
for (const sub of subcommands) {
|
|
192
|
+
lines.push(`complete -c omakit -n __fish_use_subcommand -a ${sub.name} -d ${single(sub.description)}`)
|
|
193
|
+
}
|
|
194
|
+
for (const sub of subcommands) {
|
|
195
|
+
const when = `-n ${single(`__fish_seen_subcommand_from ${sub.name}`)}`
|
|
196
|
+
if (sub.target) lines.push(`complete -c omakit ${when} -a '(__fish_complete_directories)'`)
|
|
197
|
+
for (const { flag, value } of sub.flags) {
|
|
198
|
+
const long = `-l ${flag.slice(2)}`
|
|
199
|
+
if (value === "category") lines.push(`complete -c omakit ${when} ${long} -x -a ${single(categories.map(fishWord).join(" "))}`)
|
|
200
|
+
else if (value === "tags") lines.push(`complete -c omakit ${when} ${long} -x -a ${single(tags.join(" "))}`)
|
|
201
|
+
else if (value === "file") lines.push(`complete -c omakit ${when} ${long} -r`)
|
|
202
|
+
else if (value === "text") lines.push(`complete -c omakit ${when} ${long} -x`)
|
|
203
|
+
else lines.push(`complete -c omakit ${when} ${long}`)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return `${lines.join("\n")}\n`
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- where a shell loads it from --------------------------------------------
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Where the user's shell would load the script from, so `omakit setup` can say
|
|
213
|
+
* how to install it and only when it is not there. bash-completion reads the
|
|
214
|
+
* XDG data directory; fish reads its config directory; zsh has no user
|
|
215
|
+
* directory of its own, and `~/.zfunc` is the convention, added to `fpath`.
|
|
216
|
+
*
|
|
217
|
+
* @param {NodeJS.ProcessEnv} env
|
|
218
|
+
* @returns {{ shell: string, path: string, display: string, note: string|null } | null}
|
|
219
|
+
*/
|
|
220
|
+
export function completionInstall(env = process.env) {
|
|
221
|
+
const shell = basename(env.SHELL || "")
|
|
222
|
+
const home = env.HOME || ""
|
|
223
|
+
if (!home || !COMPLETION_SHELLS.includes(shell)) return null
|
|
224
|
+
const tilde = (dir) => (dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir)
|
|
225
|
+
if (shell === "bash") {
|
|
226
|
+
const dir = join(env.XDG_DATA_HOME || join(home, ".local/share"), "bash-completion/completions")
|
|
227
|
+
return { shell, path: join(dir, "omakit"), display: `${tilde(dir)}/omakit`, note: null }
|
|
228
|
+
}
|
|
229
|
+
if (shell === "fish") {
|
|
230
|
+
const dir = join(env.XDG_CONFIG_HOME || join(home, ".config"), "fish/completions")
|
|
231
|
+
return { shell, path: join(dir, "omakit.fish"), display: `${tilde(dir)}/omakit.fish`, note: null }
|
|
232
|
+
}
|
|
233
|
+
const dir = join(env.ZDOTDIR || home, ".zfunc")
|
|
234
|
+
return { shell, path: join(dir, "_omakit"), display: `${tilde(dir)}/_omakit`, note: "with `fpath+=~/.zfunc` before `compinit` in your .zshrc" }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Install the script where the shell in $SHELL loads it from, so a person
|
|
239
|
+
* never has to know the path: `omakit setup` calls this. Idempotent: a script
|
|
240
|
+
* that is already there and names the current pin is left alone; a missing
|
|
241
|
+
* one, or one from another pin, is written. The only file this writes is
|
|
242
|
+
* the completion script at the path `completionInstall` names, and
|
|
243
|
+
* tests/unit/self-containment.test.mjs holds it to that.
|
|
244
|
+
*
|
|
245
|
+
* @param {{ contract: { categories: string[], tagLabels: string[] }, pin: string, env?: NodeJS.ProcessEnv }} options
|
|
246
|
+
* @returns {{ state: "installed"|"updated"|"current"|"unsupported", shell: string|null, display: string|null, note: string|null }}
|
|
247
|
+
*/
|
|
248
|
+
export function installCompletion({ contract, pin, env = process.env }) {
|
|
249
|
+
const target = completionInstall(env)
|
|
250
|
+
if (!target) return { state: "unsupported", shell: basename(env.SHELL || "") || null, display: null, note: null }
|
|
251
|
+
const script = renderCompletion(target.shell, { contract, pin })
|
|
252
|
+
let existing = null
|
|
253
|
+
try {
|
|
254
|
+
existing = readFileSync(target.path, "utf8")
|
|
255
|
+
} catch {
|
|
256
|
+
existing = null
|
|
257
|
+
}
|
|
258
|
+
if (existing === script) return { state: "current", ...target }
|
|
259
|
+
mkdirSync(dirname(target.path), { recursive: true })
|
|
260
|
+
const completionFile = target.path
|
|
261
|
+
writeFileSync(completionFile, script)
|
|
262
|
+
return { state: existing === null ? "installed" : "updated", ...target }
|
|
263
|
+
}
|