omakit 0.1.9 → 0.2.1
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/README.md +44 -217
- package/package.json +2 -2
- package/skills/omarchy-plugin-check/SKILL.md +102 -0
- package/skills/omarchy-plugin-submit/SKILL.md +4 -0
- package/skills/omarchy-plugin-weigh/SKILL.md +128 -0
- package/tools/marketplace/README.md +18 -1
- package/tools/marketplace/cli.mjs +165 -8
- package/tools/marketplace/completion-check.mjs +224 -0
- package/tools/marketplace/completion.mjs +103 -25
- package/tools/marketplace/doctor.mjs +9 -1
- package/tools/marketplace/options.mjs +72 -0
- package/tools/marketplace/paths.mjs +13 -0
- package/tools/marketplace/report.mjs +3 -2
- package/tools/marketplace/setup.mjs +90 -24
- package/tools/marketplace/upgrade.mjs +31 -5
- package/tools/marketplace/usage.mjs +30 -4
- package/tools/weigh/audit.mjs +666 -0
- package/tools/weigh/commands.mjs +69 -0
- package/tools/weigh/config.mjs +131 -0
- package/tools/weigh/confirm.mjs +32 -0
- package/tools/weigh/contract.mjs +167 -0
- package/tools/weigh/list.mjs +90 -0
- package/tools/weigh/proc.mjs +150 -0
- package/tools/weigh/report.mjs +196 -0
- package/tools/weigh/stats.mjs +63 -0
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
// omakit watch <issue-url> is this submission's validated commit still current?
|
|
7
7
|
// omakit verify <target> the official baseline over the local transport, verbatim
|
|
8
8
|
// omakit parity [--count n] prove the local transport equals the GitHub transport
|
|
9
|
+
// omakit weigh <plugin> | --all what a plugin weighs on the shell, measured by restarting it
|
|
9
10
|
//
|
|
10
11
|
// Nothing here writes to the marketplace. There is no POST, PATCH, PUT or
|
|
11
12
|
// DELETE anywhere in this repository, and `tests/unit/read-only.test.mjs`
|
|
12
|
-
// proves it.
|
|
13
|
+
// proves it. `weigh` is the one command that changes the user's own machine,
|
|
14
|
+
// their shell and its configuration for the duration of a measurement, and
|
|
15
|
+
// it confirms first; docs/WEIGH.md says what it writes and how it restores.
|
|
13
16
|
|
|
14
17
|
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
15
18
|
import { dirname, resolve } from "node:path"
|
|
@@ -23,13 +26,19 @@ import { validationWatch } from "./watch.mjs"
|
|
|
23
26
|
import { renderSubmit, renderWatch, renderDoctor, renderVerify } from "./report.mjs"
|
|
24
27
|
import { consequence } from "./preflight.mjs"
|
|
25
28
|
import { doctor } from "./doctor.mjs"
|
|
26
|
-
import { setup } from "./setup.mjs"
|
|
29
|
+
import { completionStep, setup } from "./setup.mjs"
|
|
30
|
+
import { staleCompletionNotice } from "./completion-check.mjs"
|
|
31
|
+
import { ACCEPTED, acceptedWords, checkArgs } from "./options.mjs"
|
|
27
32
|
import { upgrade } from "./upgrade.mjs"
|
|
28
33
|
import { progress } from "./progress.mjs"
|
|
29
34
|
import { banner, bannerEnabled } from "./banner.mjs"
|
|
30
35
|
import { COMMANDS, renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
|
|
31
|
-
import { action, colourEnabled, GUTTER, labelled, mark, styler, wrap } from "./style.mjs"
|
|
36
|
+
import { action, colourEnabled, GUTTER, labelled, mark, styler, verdict, wrap } from "./style.mjs"
|
|
32
37
|
import { omakitCacheDir, withHomeAbbreviated } from "./paths.mjs"
|
|
38
|
+
import { DEFAULTS as WEIGH_DEFAULTS, measureWeigh, planWeigh } from "../weigh/audit.mjs"
|
|
39
|
+
import { confirmationQuestion, renderList, renderWeigh, renderPlan } from "../weigh/report.mjs"
|
|
40
|
+
import { listWeighings } from "../weigh/list.mjs"
|
|
41
|
+
import { askYes } from "../weigh/confirm.mjs"
|
|
33
42
|
|
|
34
43
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
|
|
35
44
|
|
|
@@ -51,6 +60,8 @@ const REMEDY = Object.freeze({
|
|
|
51
60
|
"github-unavailable": "Wait for GitHub, then run it again. `gh auth login` raises the rate limit if that is what ran out.",
|
|
52
61
|
"not-found": "Check the issue URL: it has to be an existing issue on the marketplace repository.",
|
|
53
62
|
"head-unreadable": "Check that the plugin repository is public and its URL is right.",
|
|
63
|
+
"not-confirmed": "Run it again and answer y, or pass --yes when the person whose shell it is has agreed.",
|
|
64
|
+
"interrupted": "shell.json was restored; run it again when the desktop is yours to restart.",
|
|
54
65
|
})
|
|
55
66
|
|
|
56
67
|
/**
|
|
@@ -78,8 +89,9 @@ function option(args, name) {
|
|
|
78
89
|
return index >= 0 ? args[index + 1] : undefined
|
|
79
90
|
}
|
|
80
91
|
|
|
92
|
+
/** The bare arguments, with every valued option's value (options.mjs, one table) left out. */
|
|
81
93
|
function positionals(args) {
|
|
82
|
-
const valued = new Set(
|
|
94
|
+
const valued = new Set(Object.values(ACCEPTED).flatMap((spec) => spec.valued))
|
|
83
95
|
return args.filter((value, index) => !value.startsWith("--") && !valued.has(args[index - 1]))
|
|
84
96
|
}
|
|
85
97
|
|
|
@@ -189,8 +201,16 @@ async function cmdFrontDoor() {
|
|
|
189
201
|
process.stdout.write(renderSummary({ heading: !drew }))
|
|
190
202
|
}
|
|
191
203
|
|
|
192
|
-
async function cmdSetup() {
|
|
193
|
-
|
|
204
|
+
async function cmdSetup(args) {
|
|
205
|
+
// `--completion` is the one step on its own: write the script and prove
|
|
206
|
+
// it in a new shell, never the rc question. `upgrade` runs it through the
|
|
207
|
+
// freshly installed omakit, so the script carries the new version.
|
|
208
|
+
if (args.includes("--completion")) {
|
|
209
|
+
const identity = requirePin(ROOT).identity
|
|
210
|
+
const result = await completionStep({ repoRoot: ROOT, pin: identity.commit, version: VERSION, askRc: false })
|
|
211
|
+
process.exit(result.state === "ok" ? 0 : 1)
|
|
212
|
+
}
|
|
213
|
+
const result = await setup({ repoRoot: ROOT, entryPoint: resolve(ROOT, "bin/omakit"), yes: args.includes("--yes") })
|
|
194
214
|
process.exit(result.ok ? 0 : 1)
|
|
195
215
|
}
|
|
196
216
|
|
|
@@ -273,9 +293,144 @@ async function cmdParity(args) {
|
|
|
273
293
|
process.exit(ok ? 0 : 1)
|
|
274
294
|
}
|
|
275
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Every way `weigh` stops without weighing, in one register: the closing
|
|
298
|
+
* word a report would have ended with, negated, then the sentence naming
|
|
299
|
+
* what is missing, then the one thing to do. Exit 2 for a usage error and
|
|
300
|
+
* an unanswered confirmation, 130 for an interrupt, 1 for the rest.
|
|
301
|
+
*/
|
|
302
|
+
function notWeighed(code, message, remedy, exit = 1) {
|
|
303
|
+
const c = styler(colourEnabled(process.stderr))
|
|
304
|
+
const lines = verdict("fail", "NOT WEIGHED", message, c)
|
|
305
|
+
if (remedy) lines.push(...action(remedy, c, { indent: 0 }))
|
|
306
|
+
process.stderr.write(`${lines.join("\n")}\n`)
|
|
307
|
+
process.exit(exit)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function cmdWeigh(args) {
|
|
311
|
+
// Every token is checked before anything else: an option weigh does not
|
|
312
|
+
// know, or a second positional, is refused with the accepted list.
|
|
313
|
+
const parsed = checkArgs(args, ACCEPTED.weigh)
|
|
314
|
+
if (parsed.offending !== null) notWeighed("usage", `${parsed.reason}. Accepted: ${acceptedWords("weigh")}.`, "omakit weigh <plugin-id-or-dir> [--runs N] [--window S] [--settle S] [--yes] [--json] [--out FILE], or omakit weigh --list", 2)
|
|
315
|
+
const json = parsed.options.has("--json")
|
|
316
|
+
const all = parsed.options.has("--all")
|
|
317
|
+
const target = parsed.positionals[0]
|
|
318
|
+
// --list reads and prints: every installed plugin and its last weighing.
|
|
319
|
+
// No preflight beyond listPlugins answering, no confirmation, no restart.
|
|
320
|
+
if (parsed.options.has("--list")) {
|
|
321
|
+
for (const other of ["--all", "--yes", "--runs", "--window", "--settle", "--out"]) {
|
|
322
|
+
if (parsed.options.has(other)) notWeighed("usage", `--list only lists, so ${other} has nothing to apply to.`, "omakit weigh --list [--json]", 2)
|
|
323
|
+
}
|
|
324
|
+
if (target) notWeighed("usage", `--list lists every installed plugin, so ${JSON.stringify(target)} is one argument more than it takes.`, "omakit weigh --list [--json]", 2)
|
|
325
|
+
let list
|
|
326
|
+
try {
|
|
327
|
+
list = listWeighings()
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (error?.code && typeof error.code === "string") notWeighed(error.code, `${error.message}.`, error.remedy || REMEDY[error.code])
|
|
330
|
+
throw error
|
|
331
|
+
}
|
|
332
|
+
process.stdout.write(json ? `${JSON.stringify(list.rows, null, 2)}\n` : `${renderList(list)}\n`)
|
|
333
|
+
process.exit(0)
|
|
334
|
+
}
|
|
335
|
+
if (!target && !all) notWeighed("usage", "weigh needs a plugin: `omakit weigh <plugin-id-or-dir>`, or `omakit weigh --all` for every enabled third-party plugin.", "omakit weigh <plugin-id-or-dir>", 2)
|
|
336
|
+
if (target && all) notWeighed("usage", `--all weighs every enabled third-party plugin, so ${JSON.stringify(target)} is one argument more than it takes.`, "omakit weigh --all, or omakit weigh <plugin-id-or-dir>", 2)
|
|
337
|
+
const integer = (name, fallback, letter, min = 1) => {
|
|
338
|
+
if (!parsed.options.has(name)) return fallback
|
|
339
|
+
const raw = parsed.options.get(name)
|
|
340
|
+
if (!/^\d+$/.test(raw) || Number(raw) < min) notWeighed("usage", `${name} needs an integer of at least ${min}, not ${JSON.stringify(raw)}.`, `omakit weigh <plugin-id-or-dir> ${name} ${letter}`, 2)
|
|
341
|
+
return Number(raw)
|
|
342
|
+
}
|
|
343
|
+
const runs = integer("--runs", WEIGH_DEFAULTS.runs, "N")
|
|
344
|
+
const windowSeconds = integer("--window", WEIGH_DEFAULTS.windowSeconds, "S")
|
|
345
|
+
const settleSeconds = integer("--settle", WEIGH_DEFAULTS.settleSeconds, "S", 0)
|
|
346
|
+
let plan
|
|
347
|
+
try {
|
|
348
|
+
plan = planWeigh({ target, all, runs, windowSeconds, settleSeconds, out: parsed.options.get("--out") })
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (error?.code && typeof error.code === "string") notWeighed(error.code, `${error.message}.`, error.remedy || REMEDY[error.code], error.code === "usage" ? 2 : 1)
|
|
351
|
+
throw error
|
|
352
|
+
}
|
|
353
|
+
// The narration: what was backed up and with which md5, and what was
|
|
354
|
+
// restored. For a person it is part of the report, on stdout; under
|
|
355
|
+
// --json stdout is the document alone, so it goes to stderr.
|
|
356
|
+
const narrate = json ? process.stderr : process.stdout
|
|
357
|
+
const c = styler(colourEnabled(narrate))
|
|
358
|
+
const say = (line) => narrate.write(`${mark(line.state, c)}${wrap(withHomeAbbreviated(line.text), { indent: GUTTER }, c).join("\n").trimStart()}\n`)
|
|
359
|
+
// The confirmation. The plan is printed either way, so the record says
|
|
360
|
+
// what was agreed to; the question is asked only at a terminal on both
|
|
361
|
+
// ends, and --yes is the only other way past it.
|
|
362
|
+
narrate.write(`${renderPlan(plan, { colour: colourEnabled(narrate) }).join("\n")}\n`)
|
|
363
|
+
if (!parsed.options.has("--yes")) {
|
|
364
|
+
const interactive = !json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)
|
|
365
|
+
if (!interactive) notWeighed("not-confirmed", `this restarts the shell ${plan.restarts} times and edits shell.json for the duration; a pipe, an agent or --json cannot answer for the person whose shell it is.`, REMEDY["not-confirmed"], 2)
|
|
366
|
+
const agreed = await askYes({ question: confirmationQuestion(plan) })
|
|
367
|
+
if (!agreed) notWeighed("not-confirmed", "not confirmed; nothing was changed.", REMEDY["not-confirmed"], 2)
|
|
368
|
+
}
|
|
369
|
+
narrate.write("\n")
|
|
370
|
+
// An interrupt is a request to stop, not a reason to leave the user's
|
|
371
|
+
// shell on a measurement configuration: the signal aborts the run, the
|
|
372
|
+
// measurement's own finally restores shell.json and restarts the shell,
|
|
373
|
+
// and only then does the process exit, 130 as an interrupted program does.
|
|
374
|
+
const controller = new AbortController()
|
|
375
|
+
const interrupt = () => {
|
|
376
|
+
if (!controller.signal.aborted) narrate.write(`\n${mark("advisory", c)}interrupted: restoring shell.json before exiting\n`)
|
|
377
|
+
controller.abort()
|
|
378
|
+
}
|
|
379
|
+
process.on("SIGINT", interrupt)
|
|
380
|
+
process.on("SIGTERM", interrupt)
|
|
381
|
+
const spinner = spinnerFor(args)
|
|
382
|
+
let document
|
|
383
|
+
try {
|
|
384
|
+
document = await measureWeigh(plan, { signal: controller.signal, omakitVersion: VERSION, onPhase: spinner.phase, onLine: (line) => { spinner.done(); say(line) } })
|
|
385
|
+
} catch (error) {
|
|
386
|
+
spinner.done()
|
|
387
|
+
if (error?.code === "interrupted") notWeighed("interrupted", "interrupted before the measurement completed.", REMEDY.interrupted, 130)
|
|
388
|
+
if (error?.code && typeof error.code === "string") notWeighed(error.code, `${error.message}.`, error.remedy || REMEDY[error.code])
|
|
389
|
+
throw error
|
|
390
|
+
} finally {
|
|
391
|
+
process.off("SIGINT", interrupt)
|
|
392
|
+
process.off("SIGTERM", interrupt)
|
|
393
|
+
}
|
|
394
|
+
spinner.done()
|
|
395
|
+
if (json) {
|
|
396
|
+
process.stdout.write(`${JSON.stringify(document, null, 2)}\n`)
|
|
397
|
+
} else {
|
|
398
|
+
process.stdout.write(`\n${renderWeigh(document)}\n`)
|
|
399
|
+
}
|
|
400
|
+
process.exit(0)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const VERSION = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8")).version
|
|
404
|
+
|
|
276
405
|
const [command, ...rest] = process.argv.slice(2)
|
|
406
|
+
|
|
407
|
+
// Every token checked against the command's table before anything runs
|
|
408
|
+
// (options.mjs): an option the command does not know, an option without its
|
|
409
|
+
// value, or one positional too many is a usage error naming the token and
|
|
410
|
+
// the accepted list. `weigh` reports it in its own register, below.
|
|
411
|
+
{
|
|
412
|
+
const name = command === "marketplace-pin" ? "pin" : command
|
|
413
|
+
const table = name === "weigh" ? null : ACCEPTED[name] || ((command === "--help" || command === "-h") ? ACCEPTED.pin : null)
|
|
414
|
+
if (table) {
|
|
415
|
+
const parsed = checkArgs(rest, table)
|
|
416
|
+
if (parsed.offending !== null) {
|
|
417
|
+
const accepted = acceptedWords(name)
|
|
418
|
+
fail("usage", `${parsed.reason}.${accepted ? ` Accepted: ${accepted}.` : ` \`omakit ${name}\` takes no options.`}`, 2, "omakit help")
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Once a day, one dim line on stderr, only at a terminal: the installed
|
|
424
|
+
// completion script names another omakit, so `omakit we<TAB>` may not know
|
|
425
|
+
// `weigh`. One stat and one short read; never under a pipe, whose stderr
|
|
426
|
+
// stays empty on success, and never for setup, which is the fix.
|
|
427
|
+
if (command !== "setup" && process.stderr.isTTY) {
|
|
428
|
+
const notice = staleCompletionNotice({ version: VERSION })
|
|
429
|
+
if (notice) process.stderr.write(`${styler(colourEnabled(process.stderr))("label", notice)}\n`)
|
|
430
|
+
}
|
|
431
|
+
|
|
277
432
|
if (command === "setup") {
|
|
278
|
-
await cmdSetup()
|
|
433
|
+
await cmdSetup(rest)
|
|
279
434
|
} else if (command === "pin" || command === "marketplace-pin") {
|
|
280
435
|
const c = styler(colourEnabled())
|
|
281
436
|
const spinner = progress()
|
|
@@ -300,9 +455,11 @@ if (command === "setup") {
|
|
|
300
455
|
} else if (command === "watch") {
|
|
301
456
|
await cmdWatch(rest)
|
|
302
457
|
} else if (command === "verify") {
|
|
303
|
-
await cmdVerify(rest
|
|
458
|
+
await cmdVerify(rest)
|
|
304
459
|
} else if (command === "parity") {
|
|
305
460
|
await cmdParity(rest)
|
|
461
|
+
} else if (command === "weigh") {
|
|
462
|
+
await cmdWeigh(rest)
|
|
306
463
|
} else if (command === "help" || command === "--help" || command === "-h" || command === undefined) {
|
|
307
464
|
if (rest.includes("--agent")) {
|
|
308
465
|
// The skills ship in the npm package, so this works from a global install
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Does tab completion actually work, and if not, what is missing.
|
|
2
|
+
//
|
|
3
|
+
// Measured on 15 September 2026 against an installed Omarchy (docs/
|
|
4
|
+
// MEASUREMENTS.md, M8): `omakit setup` wrote the script and reported it
|
|
5
|
+
// installed, and whether a new shell could complete anything was never
|
|
6
|
+
// checked. On a stock Omarchy it can, because `default/bash/shell` has
|
|
7
|
+
// sourced bash-completion since v1.2.0, but `complete -p omakit` in a fresh
|
|
8
|
+
// shell says "no completion specification" all the same, because
|
|
9
|
+
// bash-completion loads the user directory's script on the first TAB and
|
|
10
|
+
// not before. So the probe here does what TAB does: an interactive shell,
|
|
11
|
+
// the loader asked to load `omakit`, and then `complete -p`. Where the
|
|
12
|
+
// loader is missing (a `~/.bashrc` that no longer sources Omarchy's rc, a
|
|
13
|
+
// zsh without `compinit`), setup says so and asks once before it adds one
|
|
14
|
+
// guarded, marked block to the rc file; that block is the only thing omakit
|
|
15
|
+
// ever writes to an rc file, only after a yes, and it is never edited or
|
|
16
|
+
// removed by omakit.
|
|
17
|
+
//
|
|
18
|
+
// The probes are frozen argument lists (tests/unit/read-only.test.mjs asserts
|
|
19
|
+
// every spawn in this file uses them), run through the shell by name, so a
|
|
20
|
+
// test puts a stub shell first on PATH.
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from "node:child_process"
|
|
23
|
+
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs"
|
|
24
|
+
import { basename, dirname, join } from "node:path"
|
|
25
|
+
import { completionInstall, parseCompletionHeader } from "./completion.mjs"
|
|
26
|
+
import { omakitStateDir } from "./paths.mjs"
|
|
27
|
+
|
|
28
|
+
/** Milliseconds an interactive shell may take to answer a probe; a hung rc file is reported, not waited for. */
|
|
29
|
+
export const PROBE_TIMEOUT_MS = 10_000
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The probe per shell. Each prints `loader=yes|no` and `spec=eager|lazy|none`:
|
|
33
|
+
* whether the completion machinery is present in a new interactive shell,
|
|
34
|
+
* and whether `omakit` has a completion spec, before or after the loader
|
|
35
|
+
* has been asked for it the way TAB asks. fish has no loader to miss and
|
|
36
|
+
* autoloads from its completions directory on `complete -C`.
|
|
37
|
+
*/
|
|
38
|
+
export const PROBES = Object.freeze({
|
|
39
|
+
bash: Object.freeze(["-ic", [
|
|
40
|
+
"declare -F _init_completion >/dev/null 2>&1 && echo loader=yes || echo loader=no",
|
|
41
|
+
"if complete -p omakit >/dev/null 2>&1; then echo spec=eager",
|
|
42
|
+
"else _comp_load omakit >/dev/null 2>&1 || __load_completion omakit >/dev/null 2>&1 || _completion_loader omakit >/dev/null 2>&1",
|
|
43
|
+
"if complete -p omakit >/dev/null 2>&1; then echo spec=lazy; else echo spec=none; fi; fi",
|
|
44
|
+
].join("; ")]),
|
|
45
|
+
zsh: Object.freeze(["-ic", [
|
|
46
|
+
"(( $+functions[compdef] )) && echo loader=yes || echo loader=no",
|
|
47
|
+
"if [[ -n ${_comps[omakit]-} ]]; then echo spec=eager",
|
|
48
|
+
"elif (( $+functions[compdef] )) && whence -w _omakit >/dev/null 2>&1; then echo spec=lazy",
|
|
49
|
+
"else echo spec=none; fi",
|
|
50
|
+
].join("; ")]),
|
|
51
|
+
fish: Object.freeze(["-c", [
|
|
52
|
+
"echo loader=yes",
|
|
53
|
+
"complete -C 'omakit ' >/dev/null 2>&1",
|
|
54
|
+
"if complete -c omakit | string match -q '*omakit*'; echo spec=lazy; else; echo spec=none; end",
|
|
55
|
+
].join("; ")]),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Ask a new interactive shell whether completion works.
|
|
60
|
+
*
|
|
61
|
+
* @param {"bash"|"zsh"|"fish"} shell
|
|
62
|
+
* @param {{ env?: NodeJS.ProcessEnv, timeoutMs?: number }} [options]
|
|
63
|
+
* @returns {{ ran: boolean, loader: boolean, spec: "eager"|"lazy"|"none", reason: string|null }}
|
|
64
|
+
*/
|
|
65
|
+
export function verifyCompletion(shell, { env = process.env, timeoutMs = PROBE_TIMEOUT_MS } = {}) {
|
|
66
|
+
const probe = PROBES[shell]
|
|
67
|
+
if (!probe) return { ran: false, loader: false, spec: "none", reason: `no probe for ${shell}` }
|
|
68
|
+
const result = spawnSync(shell, [...probe], { encoding: "utf8", env, timeout: timeoutMs, stdio: ["ignore", "pipe", "ignore"] })
|
|
69
|
+
if (result.error) {
|
|
70
|
+
return { ran: false, loader: false, spec: "none", reason: result.error.code === "ENOENT" ? `${shell} is not on PATH` : result.error.code === "ETIMEDOUT" ? `${shell} did not answer within ${timeoutMs / 1000} s` : String(result.error.message) }
|
|
71
|
+
}
|
|
72
|
+
const out = result.stdout || ""
|
|
73
|
+
const loader = /^loader=yes$/m.test(out)
|
|
74
|
+
const spec = out.match(/^spec=(eager|lazy|none)$/m)?.[1] || "none"
|
|
75
|
+
return { ran: true, loader, spec, reason: null }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The marker every block starts with; its presence is what makes a second run append nothing. */
|
|
79
|
+
export const RC_MARKER = "# omakit completion"
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The guarded lines that make completion load, per shell, and the rc file
|
|
83
|
+
* they belong in: bash sources the system bash-completion if it is there;
|
|
84
|
+
* zsh puts `~/.zfunc` on fpath and runs compinit. fish needs nothing.
|
|
85
|
+
*
|
|
86
|
+
* @returns {{ file: string, display: string, lines: string[] } | null}
|
|
87
|
+
*/
|
|
88
|
+
export function loaderBlock(shell, env = process.env) {
|
|
89
|
+
const home = env.HOME || ""
|
|
90
|
+
if (shell === "bash") {
|
|
91
|
+
return {
|
|
92
|
+
file: join(home, ".bashrc"),
|
|
93
|
+
display: "~/.bashrc",
|
|
94
|
+
lines: [RC_MARKER, "[[ -r /usr/share/bash-completion/bash_completion ]] && source /usr/share/bash-completion/bash_completion"],
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (shell === "zsh") {
|
|
98
|
+
const dir = env.ZDOTDIR || home
|
|
99
|
+
return {
|
|
100
|
+
file: join(dir, ".zshrc"),
|
|
101
|
+
display: env.ZDOTDIR ? `${env.ZDOTDIR}/.zshrc` : "~/.zshrc",
|
|
102
|
+
lines: [RC_MARKER, "fpath+=~/.zfunc", "autoload -Uz compinit && compinit"],
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Is the marked block already in the rc file. */
|
|
109
|
+
export function loaderBlockPresent(shell, env = process.env) {
|
|
110
|
+
const block = loaderBlock(shell, env)
|
|
111
|
+
if (!block) return false
|
|
112
|
+
try {
|
|
113
|
+
return readFileSync(block.file, "utf8").split("\n").some((line) => line.trim() === RC_MARKER)
|
|
114
|
+
} catch {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Append the marked block to the rc file, once. The only write omakit ever
|
|
121
|
+
* makes to an rc file, and only after `setup` was answered yes: the marker
|
|
122
|
+
* is looked for first, and a file that has it is left exactly as it is.
|
|
123
|
+
*
|
|
124
|
+
* @returns {{ appended: boolean, file: string }}
|
|
125
|
+
*/
|
|
126
|
+
export function appendLoaderBlock(shell, env = process.env) {
|
|
127
|
+
const block = loaderBlock(shell, env)
|
|
128
|
+
if (!block) throw new Error(`completion: no loader block for ${shell}`)
|
|
129
|
+
if (loaderBlockPresent(shell, env)) return { appended: false, file: block.file }
|
|
130
|
+
let existing = ""
|
|
131
|
+
try {
|
|
132
|
+
existing = readFileSync(block.file, "utf8")
|
|
133
|
+
} catch {
|
|
134
|
+
existing = ""
|
|
135
|
+
}
|
|
136
|
+
mkdirSync(dirname(block.file), { recursive: true })
|
|
137
|
+
const rcFile = block.file
|
|
138
|
+
appendFileSync(rcFile, `${existing && !existing.endsWith("\n") ? "\n" : ""}${existing ? "\n" : ""}${block.lines.join("\n")}\n`)
|
|
139
|
+
return { appended: true, file: block.file }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The installed script's header, read cheaply: one stat, one read of the
|
|
144
|
+
* first 200 bytes. Null when there is no script for the shell in $SHELL.
|
|
145
|
+
*
|
|
146
|
+
* @returns {{ path: string, shell: string, version: string|null, pin: string|null } | null}
|
|
147
|
+
*/
|
|
148
|
+
export function installedCompletion(env = process.env) {
|
|
149
|
+
const target = completionInstall(env)
|
|
150
|
+
if (!target) return null
|
|
151
|
+
let head = ""
|
|
152
|
+
try {
|
|
153
|
+
statSync(target.path)
|
|
154
|
+
const fd = openSync(target.path, "r")
|
|
155
|
+
try {
|
|
156
|
+
const buffer = Buffer.alloc(200)
|
|
157
|
+
const read = readSync(fd, buffer, 0, 200, 0)
|
|
158
|
+
head = buffer.toString("utf8", 0, read)
|
|
159
|
+
} finally {
|
|
160
|
+
closeSync(fd)
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
return { path: target.path, shell: target.shell, ...parseCompletionHeader(head) }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Everything `omakit doctor` says under `omakit.completion`: the script,
|
|
170
|
+
* its version and pin against this omakit's, the loader, and the spec in a
|
|
171
|
+
* new shell.
|
|
172
|
+
*
|
|
173
|
+
* @param {{ version: string, pin: string, env?: NodeJS.ProcessEnv }} options
|
|
174
|
+
*/
|
|
175
|
+
export function completionStatus({ version, pin, env = process.env }) {
|
|
176
|
+
const shell = basename(env.SHELL || "")
|
|
177
|
+
const target = completionInstall(env)
|
|
178
|
+
if (!target) return { state: "info", shell: shell || null, detail: shell ? `no completion script for ${shell}; there is one for bash, zsh and fish` : "$SHELL is not set, so no completion script is installed", action: null, evidence: { shell: shell || null } }
|
|
179
|
+
const installed = installedCompletion(env)
|
|
180
|
+
const evidence = { shell: target.shell, path: target.path, present: Boolean(installed), version: installed?.version ?? null, pin: installed?.pin ?? null, loader: null, spec: null }
|
|
181
|
+
if (!installed) return { state: "advice", shell: target.shell, detail: `no completion script at ${target.display}`, action: "omakit setup", evidence }
|
|
182
|
+
const probe = verifyCompletion(target.shell, { env })
|
|
183
|
+
evidence.loader = probe.ran ? probe.loader : null
|
|
184
|
+
evidence.spec = probe.ran ? probe.spec : null
|
|
185
|
+
const identity = `${target.display}, omakit ${installed.version || "unknown"}, pin ${installed.pin ? installed.pin.slice(0, 7) : "unknown"}`
|
|
186
|
+
if (installed.version !== version || installed.pin !== pin) {
|
|
187
|
+
return { state: "advice", shell: target.shell, detail: `${identity}; this omakit is ${version} at pin ${pin.slice(0, 7)}, so the script is stale`, action: "omakit setup", evidence }
|
|
188
|
+
}
|
|
189
|
+
if (!probe.ran) return { state: "unknown", shell: target.shell, detail: `${identity}; a new ${target.shell} could not be asked: ${probe.reason}`, action: null, evidence }
|
|
190
|
+
if (!probe.loader) return { state: "advice", shell: target.shell, detail: `${identity}; a new ${target.shell} has no completion loader, so the script is never read`, action: "omakit setup", evidence }
|
|
191
|
+
if (probe.spec === "none") return { state: "advice", shell: target.shell, detail: `${identity}; the loader is there but a new ${target.shell} does not load the script`, action: "omakit setup", evidence }
|
|
192
|
+
return { state: "ok", shell: target.shell, detail: `${identity}; loader active, \`complete -p omakit\` seen in a new ${target.shell}${probe.spec === "lazy" ? " after the loader was asked, the way TAB asks" : ""}`, action: null, evidence }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* At startup, once a day: is the installed script from another omakit. One
|
|
197
|
+
* stat and one short read, no pin read, and a stamp file so the line is
|
|
198
|
+
* printed once per day and not once per command.
|
|
199
|
+
*
|
|
200
|
+
* @param {{ version: string, env?: NodeJS.ProcessEnv, today?: string }} options
|
|
201
|
+
* @returns {string|null} the line to print, or null
|
|
202
|
+
*/
|
|
203
|
+
export function staleCompletionNotice({ version, env = process.env, today = new Date().toISOString().slice(0, 10) }) {
|
|
204
|
+
const installed = installedCompletion(env)
|
|
205
|
+
if (!installed || installed.version === version) return null
|
|
206
|
+
const stampFile = join(omakitStateDir("", env), "completion-noticed")
|
|
207
|
+
try {
|
|
208
|
+
if (readFileSync(stampFile, "utf8").trim() === today) return null
|
|
209
|
+
} catch {
|
|
210
|
+
// No stamp yet: the first notice.
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
mkdirSync(dirname(stampFile), { recursive: true })
|
|
214
|
+
writeFileSync(stampFile, `${today}\n`)
|
|
215
|
+
} catch {
|
|
216
|
+
// A stamp that cannot be written costs one line a command, nothing else.
|
|
217
|
+
}
|
|
218
|
+
return `tab completion is from ${installed.version || "an older omakit"}; run omakit setup`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Whether a probe result means completion works: a spec seen, eagerly or after the loader was asked. */
|
|
222
|
+
export function completionWorks(probe) {
|
|
223
|
+
return probe.ran && probe.loader && probe.spec !== "none"
|
|
224
|
+
}
|