omakit 0.2.0 → 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/package.json +1 -1
- package/tools/marketplace/README.md +3 -0
- package/tools/marketplace/cli.mjs +64 -51
- package/tools/marketplace/completion-check.mjs +224 -0
- package/tools/marketplace/completion.mjs +101 -23
- package/tools/marketplace/doctor.mjs +9 -1
- package/tools/marketplace/options.mjs +72 -0
- package/tools/marketplace/setup.mjs +90 -24
- package/tools/marketplace/upgrade.mjs +31 -5
- package/tools/marketplace/usage.mjs +10 -5
- package/tools/weigh/list.mjs +90 -0
- package/tools/weigh/report.mjs +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omakit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "The safe place to find out: everything knowable about an Omarchy Quattro plugin submission before you post it, on your own machine. Agent-first, read-only against the marketplace, posts nothing, zero dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Maarten Tolhuijs",
|
|
@@ -28,7 +28,9 @@ local commit through the transport seam the marketplace tests itself
|
|
|
28
28
|
| `report.mjs` | Text rendering of submit, watch, doctor and verify for the agent that runs this tool, and the person reading over its shoulder. |
|
|
29
29
|
| `path-hint.mjs` | Is `omakit` reachable as a bare command, and if not, the one line that makes it so for the install that is here: a symlink for a clone, the npm prefix's `bin` on PATH for a package, said for the shell in `$SHELL`. `setup` and `doctor` print it; nothing writes an rc file. |
|
|
30
30
|
| `usage.mjs` | The help text, as data. |
|
|
31
|
+
| `options.mjs` | Every option every command accepts, in one table, and the parser that reads a command line against it before anything runs; `tests/unit/options.test.mjs` holds the help signatures, and through them the completion scripts, to the table. |
|
|
31
32
|
| `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. |
|
|
33
|
+
| `completion-check.mjs` | Whether tab completion actually works: a frozen probe per shell run interactively, asking the loader to load `omakit` the way TAB does; the one marked block `setup` may append to an rc file after a yes, looked for by its marker first; the installed script's version and pin read from its first line; `doctor`'s `omakit.completion`; and the once-a-day stale notice. |
|
|
32
34
|
| `banner.mjs` | The wordmark, on a bare `omakit` and in `setup` only. |
|
|
33
35
|
| `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. |
|
|
34
36
|
| `progress.mjs` | The progress line, on stderr, only when a person is looking. |
|
|
@@ -46,6 +48,7 @@ machine (docs/WEIGH.md):
|
|
|
46
48
|
| `weigh/stats.mjs` | Median, spread, the stats object, and the within-noise comparison. |
|
|
47
49
|
| `weigh/audit.mjs` | `planWeigh()` reads and decides (what runs, how many restarts, the estimate) and writes nothing; `measureWeigh()` restarts, samples and restores in a `finally`; `buildDocument()` turns the samples into the document. |
|
|
48
50
|
| `weigh/contract.mjs` | The JSON contract of docs/WEIGH.md as a validator, run by the unit tests and by the lab over a real document. |
|
|
51
|
+
| `weigh/list.mjs` | `weigh --list`: every installed plugin with its last weighing from the documents under the state directory, read-only, unweighed enabled plugins first. |
|
|
49
52
|
| `weigh/report.mjs` | The confirmation and the report for a person, drawn with `style.mjs`; the README sentence and the evidence path come last. |
|
|
50
53
|
| `weigh/confirm.mjs` | The one question, at a terminal, on stderr. |
|
|
51
54
|
|
|
@@ -26,7 +26,9 @@ import { validationWatch } from "./watch.mjs"
|
|
|
26
26
|
import { renderSubmit, renderWatch, renderDoctor, renderVerify } from "./report.mjs"
|
|
27
27
|
import { consequence } from "./preflight.mjs"
|
|
28
28
|
import { doctor } from "./doctor.mjs"
|
|
29
|
-
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"
|
|
30
32
|
import { upgrade } from "./upgrade.mjs"
|
|
31
33
|
import { progress } from "./progress.mjs"
|
|
32
34
|
import { banner, bannerEnabled } from "./banner.mjs"
|
|
@@ -34,7 +36,8 @@ import { COMMANDS, renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
|
|
|
34
36
|
import { action, colourEnabled, GUTTER, labelled, mark, styler, verdict, wrap } from "./style.mjs"
|
|
35
37
|
import { omakitCacheDir, withHomeAbbreviated } from "./paths.mjs"
|
|
36
38
|
import { DEFAULTS as WEIGH_DEFAULTS, measureWeigh, planWeigh } from "../weigh/audit.mjs"
|
|
37
|
-
import { confirmationQuestion, renderWeigh, renderPlan } from "../weigh/report.mjs"
|
|
39
|
+
import { confirmationQuestion, renderList, renderWeigh, renderPlan } from "../weigh/report.mjs"
|
|
40
|
+
import { listWeighings } from "../weigh/list.mjs"
|
|
38
41
|
import { askYes } from "../weigh/confirm.mjs"
|
|
39
42
|
|
|
40
43
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
|
|
@@ -86,8 +89,9 @@ function option(args, name) {
|
|
|
86
89
|
return index >= 0 ? args[index + 1] : undefined
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
/** The bare arguments, with every valued option's value (options.mjs, one table) left out. */
|
|
89
93
|
function positionals(args) {
|
|
90
|
-
const valued = new Set(
|
|
94
|
+
const valued = new Set(Object.values(ACCEPTED).flatMap((spec) => spec.valued))
|
|
91
95
|
return args.filter((value, index) => !value.startsWith("--") && !valued.has(args[index - 1]))
|
|
92
96
|
}
|
|
93
97
|
|
|
@@ -197,8 +201,16 @@ async function cmdFrontDoor() {
|
|
|
197
201
|
process.stdout.write(renderSummary({ heading: !drew }))
|
|
198
202
|
}
|
|
199
203
|
|
|
200
|
-
async function cmdSetup() {
|
|
201
|
-
|
|
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") })
|
|
202
214
|
process.exit(result.ok ? 0 : 1)
|
|
203
215
|
}
|
|
204
216
|
|
|
@@ -281,48 +293,6 @@ async function cmdParity(args) {
|
|
|
281
293
|
process.exit(ok ? 0 : 1)
|
|
282
294
|
}
|
|
283
295
|
|
|
284
|
-
/**
|
|
285
|
-
* Strict argument checking for one command: every token is a known option
|
|
286
|
-
* (valued or not), the value of a valued option, or a positional up to
|
|
287
|
-
* the allowed count. Anything else is returned as the offending token, so
|
|
288
|
-
* the command refuses before any preflight instead of running with the
|
|
289
|
-
* defaults as if nothing had been passed (measured: `weigh <plugin> -n 1`
|
|
290
|
-
* ran three runs). `--name=value` is accepted for a valued option. Used by
|
|
291
|
-
* weigh; the other commands still read their flags one by one (see
|
|
292
|
-
* CONTRIBUTING.md, "Debts").
|
|
293
|
-
*
|
|
294
|
-
* @param {string[]} args
|
|
295
|
-
* @param {{ valued: string[], flags: string[], positionals: number }} spec
|
|
296
|
-
* @returns {{ offending: string|null, reason: string|null, options: Map<string, string|true>, positionals: string[] }}
|
|
297
|
-
*/
|
|
298
|
-
function checkArgs(args, spec) {
|
|
299
|
-
const options = new Map()
|
|
300
|
-
const positionals = []
|
|
301
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
302
|
-
const token = args[index]
|
|
303
|
-
const [name, inline] = token.startsWith("--") && token.includes("=") ? [token.slice(0, token.indexOf("=")), token.slice(token.indexOf("=") + 1)] : [token, undefined]
|
|
304
|
-
if (spec.valued.includes(name)) {
|
|
305
|
-
const value = inline !== undefined ? inline : args[index + 1]
|
|
306
|
-
if (value === undefined || (inline === undefined && value.startsWith("-"))) return { offending: token, reason: `${name} needs a value`, options, positionals }
|
|
307
|
-
options.set(name, value)
|
|
308
|
-
if (inline === undefined) index += 1
|
|
309
|
-
} else if (spec.flags.includes(token)) {
|
|
310
|
-
options.set(token, true)
|
|
311
|
-
} else if (token.startsWith("-")) {
|
|
312
|
-
return { offending: token, reason: `${token} is not an option this command knows`, options, positionals }
|
|
313
|
-
} else if (positionals.length < spec.positionals) {
|
|
314
|
-
positionals.push(token)
|
|
315
|
-
} else {
|
|
316
|
-
return { offending: token, reason: `${JSON.stringify(token)} is one argument more than the command takes`, options, positionals }
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
return { offending: null, reason: null, options, positionals }
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** What `weigh` accepts, in the words the refusal prints. */
|
|
323
|
-
const WEIGH_ARGS = Object.freeze({ valued: ["--runs", "--window", "--settle", "--out"], flags: ["--all", "--json", "--yes"], positionals: 1 })
|
|
324
|
-
const WEIGH_ACCEPTED = "--runs N, --window S, --settle S, --all, --json, --out FILE, --yes"
|
|
325
|
-
|
|
326
296
|
/**
|
|
327
297
|
* Every way `weigh` stops without weighing, in one register: the closing
|
|
328
298
|
* word a report would have ended with, negated, then the sentence naming
|
|
@@ -340,11 +310,28 @@ function notWeighed(code, message, remedy, exit = 1) {
|
|
|
340
310
|
async function cmdWeigh(args) {
|
|
341
311
|
// Every token is checked before anything else: an option weigh does not
|
|
342
312
|
// know, or a second positional, is refused with the accepted list.
|
|
343
|
-
const parsed = checkArgs(args,
|
|
344
|
-
if (parsed.offending !== null) notWeighed("usage", `${parsed.reason}. Accepted: ${
|
|
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)
|
|
345
315
|
const json = parsed.options.has("--json")
|
|
346
316
|
const all = parsed.options.has("--all")
|
|
347
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
|
+
}
|
|
348
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)
|
|
349
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)
|
|
350
337
|
const integer = (name, fallback, letter, min = 1) => {
|
|
@@ -416,8 +403,34 @@ async function cmdWeigh(args) {
|
|
|
416
403
|
const VERSION = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8")).version
|
|
417
404
|
|
|
418
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
|
+
|
|
419
432
|
if (command === "setup") {
|
|
420
|
-
await cmdSetup()
|
|
433
|
+
await cmdSetup(rest)
|
|
421
434
|
} else if (command === "pin" || command === "marketplace-pin") {
|
|
422
435
|
const c = styler(colourEnabled())
|
|
423
436
|
const spinner = progress()
|
|
@@ -442,7 +455,7 @@ if (command === "setup") {
|
|
|
442
455
|
} else if (command === "watch") {
|
|
443
456
|
await cmdWatch(rest)
|
|
444
457
|
} else if (command === "verify") {
|
|
445
|
-
await cmdVerify(rest
|
|
458
|
+
await cmdVerify(rest)
|
|
446
459
|
} else if (command === "parity") {
|
|
447
460
|
await cmdParity(rest)
|
|
448
461
|
} else if (command === "weigh") {
|
|
@@ -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
|
+
}
|
|
@@ -28,15 +28,36 @@ export function subcommandsOf(commands = COMMANDS) {
|
|
|
28
28
|
const signature = [].concat(command.signature).join(" ")
|
|
29
29
|
const name = signature.match(/^omakit +([a-z][a-z-]*)/)?.[1]
|
|
30
30
|
if (!name) throw new Error(`completion: no subcommand in signature ${JSON.stringify(signature)}`)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
// A flag named on two signature lines (`--json` on weigh's main line and
|
|
32
|
+
// on its `--list` line) is one flag.
|
|
33
|
+
const seen = new Set()
|
|
34
|
+
const flags = [...signature.matchAll(/(--[a-z][a-z-]*)(?: <([^>]+)>)?/g)]
|
|
35
|
+
.filter(([, flag]) => !seen.has(flag) && seen.add(flag))
|
|
36
|
+
.map(([, flag, placeholder]) => ({
|
|
37
|
+
flag,
|
|
38
|
+
value: placeholder ? placeholderKind(placeholder) : null,
|
|
39
|
+
}))
|
|
35
40
|
const sentence = command.lines.join(" ").replace(/`/g, "").split(/(?<=\.)\s/)[0]
|
|
36
|
-
|
|
41
|
+
// `<target>` completes as a directory; `<plugin-id-or-dir>` as the ids
|
|
42
|
+
// the running shell has installed, read at TAB time, with a directory
|
|
43
|
+
// as the fallback.
|
|
44
|
+
const target = /<plugin-id-or-dir>/.test(signature) ? "plugin" : /<target>/.test(signature) ? "directory" : false
|
|
45
|
+
return { name, description: sentence, flags, target }
|
|
37
46
|
})
|
|
38
47
|
}
|
|
39
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The plugin ids a TAB offers for `omakit weigh <TAB>`: what the running
|
|
51
|
+
* shell reports through `omarchy-shell shell listPlugins`, filtered with
|
|
52
|
+
* `jq` at TAB time, enabled ids first and whole bars left out, since a bar
|
|
53
|
+
* cannot be weighed. No node process behind the TAB: a shell that does not
|
|
54
|
+
* answer within a second yields nothing, and the script falls back to a
|
|
55
|
+
* directory. The pipeline is the same in every shell's script and the jq
|
|
56
|
+
* expression is exported so a test can run it through jq.
|
|
57
|
+
*/
|
|
58
|
+
export const PLUGIN_IDS_JQ = "[.[] | select(((.kinds // []) | index(\"bar\")) | not)] | sort_by(.enabled | not) | .[].id"
|
|
59
|
+
export const PLUGIN_IDS_COMMAND = `timeout 1 omarchy-shell shell listPlugins 2>/dev/null | jq -r '${PLUGIN_IDS_JQ}' 2>/dev/null`
|
|
60
|
+
|
|
40
61
|
/** What a valued flag takes, by its placeholder: a controlled value, a file, or free text. */
|
|
41
62
|
function placeholderKind(placeholder) {
|
|
42
63
|
if (placeholder === "c") return "category"
|
|
@@ -50,32 +71,59 @@ function placeholderKind(placeholder) {
|
|
|
50
71
|
* @param {{ contract: { categories: string[], tagLabels: string[] }, pin: string, commands?: typeof COMMANDS }} options
|
|
51
72
|
* @returns {string} the script
|
|
52
73
|
*/
|
|
53
|
-
export function renderCompletion(shell, { contract, pin, commands = COMMANDS }) {
|
|
74
|
+
export function renderCompletion(shell, { contract, pin, version = "unknown", commands = COMMANDS }) {
|
|
54
75
|
if (!COMPLETION_SHELLS.includes(shell)) throw new Error(`completion: no script for ${shell}`)
|
|
55
76
|
const model = {
|
|
56
77
|
subcommands: subcommandsOf(commands),
|
|
57
78
|
categories: [...contract.categories],
|
|
58
79
|
tags: contract.tagLabels.map(tagSlug),
|
|
59
80
|
pin,
|
|
81
|
+
version,
|
|
60
82
|
}
|
|
61
83
|
return { bash, zsh, fish }[shell](model)
|
|
62
84
|
}
|
|
63
85
|
|
|
64
|
-
|
|
86
|
+
/**
|
|
87
|
+
* The first line of every script names the omakit version and the pin it
|
|
88
|
+
* was rendered from, in one line, so the run-time staleness check
|
|
89
|
+
* (completion-check.mjs) reads one line and nothing else. The pin is what
|
|
90
|
+
* decides the categories and tags; the version is what decides the
|
|
91
|
+
* subcommands and flags, and a script from 0.1.9 knows no `weigh`.
|
|
92
|
+
*/
|
|
93
|
+
function header(comment, shell, pin, version) {
|
|
65
94
|
return [
|
|
66
|
-
`${comment} omakit completion for ${shell}
|
|
67
|
-
`${comment}
|
|
68
|
-
`${comment}
|
|
69
|
-
`${comment} again.`,
|
|
95
|
+
`${comment} omakit completion for ${shell}, omakit ${version}, marketplace pin ${pin}.`,
|
|
96
|
+
`${comment} Generated by \`omakit setup\`; the categories and tags below are that pin's`,
|
|
97
|
+
`${comment} submission form and the commands are that version's. Regenerate it by`,
|
|
98
|
+
`${comment} running setup again.`,
|
|
70
99
|
]
|
|
71
100
|
}
|
|
72
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The version and the pin a script names, from its first line (its second
|
|
104
|
+
* for zsh, under `#compdef`), or null when
|
|
105
|
+
* the line is not one omakit wrote (a script from before the version was
|
|
106
|
+
* recorded reads as version null and its pin from the old header).
|
|
107
|
+
*
|
|
108
|
+
* @param {string} text the first two lines are enough
|
|
109
|
+
* @returns {{ shell: string|null, version: string|null, pin: string|null }}
|
|
110
|
+
*/
|
|
111
|
+
export function parseCompletionHeader(text) {
|
|
112
|
+
// zsh's script starts with its `#compdef` line; the header is the next.
|
|
113
|
+
const lines = String(text).split("\n", 2)
|
|
114
|
+
const line = lines[0].startsWith("#compdef") ? lines[1] || "" : lines[0]
|
|
115
|
+
const current = line.match(/^# omakit completion for (\w+), omakit (\S+), marketplace pin ([0-9a-f]{40})\.$/)
|
|
116
|
+
if (current) return { shell: current[1], version: current[2], pin: current[3] }
|
|
117
|
+
const older = line.match(/^#\s*omakit completion for (\w+)\./)
|
|
118
|
+
return { shell: older ? older[1] : null, version: null, pin: null }
|
|
119
|
+
}
|
|
120
|
+
|
|
73
121
|
const single = (text) => `'${String(text).replace(/'/g, "'\\''")}'`
|
|
74
122
|
|
|
75
123
|
// --- bash ---------------------------------------------------------------------
|
|
76
124
|
|
|
77
|
-
function bash({ subcommands, categories, tags, pin }) {
|
|
78
|
-
const lines = [...header("#", "bash", pin), ""]
|
|
125
|
+
function bash({ subcommands, categories, tags, pin, version }) {
|
|
126
|
+
const lines = [...header("#", "bash", pin, version), ""]
|
|
79
127
|
lines.push("_omakit() {")
|
|
80
128
|
lines.push(" local cur prev command")
|
|
81
129
|
lines.push(" cur=${COMP_WORDS[COMP_CWORD]}")
|
|
@@ -109,7 +157,12 @@ function bash({ subcommands, categories, tags, pin }) {
|
|
|
109
157
|
lines.push(" return")
|
|
110
158
|
lines.push(" fi")
|
|
111
159
|
}
|
|
112
|
-
if (sub.target) {
|
|
160
|
+
if (sub.target === "plugin") {
|
|
161
|
+
lines.push(' if ((COMP_CWORD == 2)); then')
|
|
162
|
+
lines.push(' COMPREPLY=($(compgen -W "$(_omakit_plugin_ids)" -- "$cur"))')
|
|
163
|
+
lines.push(' if ((${#COMPREPLY[@]} == 0)); then COMPREPLY=($(compgen -d -- "$cur")); compopt -o filenames 2>/dev/null; fi')
|
|
164
|
+
lines.push(" fi")
|
|
165
|
+
} else if (sub.target) {
|
|
113
166
|
lines.push(' COMPREPLY=($(compgen -d -- "$cur"))')
|
|
114
167
|
lines.push(" compopt -o filenames 2>/dev/null")
|
|
115
168
|
}
|
|
@@ -118,6 +171,13 @@ function bash({ subcommands, categories, tags, pin }) {
|
|
|
118
171
|
lines.push(" esac")
|
|
119
172
|
lines.push("}")
|
|
120
173
|
lines.push("")
|
|
174
|
+
lines.push("# The plugin ids the running shell has installed, enabled first, whole bars")
|
|
175
|
+
lines.push("# left out, read at TAB time; nothing when the shell does not answer in a")
|
|
176
|
+
lines.push("# second, and the caller falls back to a directory.")
|
|
177
|
+
lines.push("_omakit_plugin_ids() {")
|
|
178
|
+
lines.push(` ${PLUGIN_IDS_COMMAND}`)
|
|
179
|
+
lines.push("}")
|
|
180
|
+
lines.push("")
|
|
121
181
|
lines.push("# A controlled value may contain a space, so each match is one line and is")
|
|
122
182
|
lines.push("# escaped on the way out, the way the shell would have to type it.")
|
|
123
183
|
lines.push("_omakit_values() {")
|
|
@@ -142,8 +202,8 @@ function bash({ subcommands, categories, tags, pin }) {
|
|
|
142
202
|
|
|
143
203
|
const zshDescribe = (name, description) => single(`${name}:${description.replace(/:/g, "\\:")}`)
|
|
144
204
|
|
|
145
|
-
function zsh({ subcommands, categories, tags, pin }) {
|
|
146
|
-
const lines = ["#compdef omakit", ...header("#", "zsh", pin), ""]
|
|
205
|
+
function zsh({ subcommands, categories, tags, pin, version }) {
|
|
206
|
+
const lines = ["#compdef omakit", ...header("#", "zsh", pin, version), ""]
|
|
147
207
|
lines.push("_omakit() {")
|
|
148
208
|
lines.push(" local curcontext=\"$curcontext\" state line")
|
|
149
209
|
lines.push(" typeset -A opt_args")
|
|
@@ -169,7 +229,8 @@ function zsh({ subcommands, categories, tags, pin }) {
|
|
|
169
229
|
if (value === "text") return single(`${flag}:text:`)
|
|
170
230
|
return single(flag)
|
|
171
231
|
})
|
|
172
|
-
if (sub.target) specs.push(single("1:
|
|
232
|
+
if (sub.target === "plugin") specs.push(single("1:plugin:_omakit_plugins"))
|
|
233
|
+
else if (sub.target) specs.push(single("1:target:_directories"))
|
|
173
234
|
if (specs.length) lines.push(` _arguments ${specs.join(" ")}`)
|
|
174
235
|
lines.push(" ;;")
|
|
175
236
|
}
|
|
@@ -178,6 +239,14 @@ function zsh({ subcommands, categories, tags, pin }) {
|
|
|
178
239
|
lines.push(" esac")
|
|
179
240
|
lines.push("}")
|
|
180
241
|
lines.push("")
|
|
242
|
+
lines.push("# The plugin ids the running shell has installed, enabled first, whole bars")
|
|
243
|
+
lines.push("# left out, read at TAB time; a directory when the shell does not answer.")
|
|
244
|
+
lines.push("_omakit_plugins() {")
|
|
245
|
+
lines.push(" local -a ids")
|
|
246
|
+
lines.push(` ids=(\${(f)"$(${PLUGIN_IDS_COMMAND})"})`)
|
|
247
|
+
lines.push(" if (( ${#ids} )); then compadd -a ids; else _directories; fi")
|
|
248
|
+
lines.push("}")
|
|
249
|
+
lines.push("")
|
|
181
250
|
lines.push('_omakit "$@"')
|
|
182
251
|
return `${lines.join("\n")}\n`
|
|
183
252
|
}
|
|
@@ -186,14 +255,23 @@ function zsh({ subcommands, categories, tags, pin }) {
|
|
|
186
255
|
|
|
187
256
|
const fishWord = (text) => String(text).replace(/([\\'" ])/g, "\\$1")
|
|
188
257
|
|
|
189
|
-
function fish({ subcommands, categories, tags, pin }) {
|
|
190
|
-
const lines = [...header("#", "fish", pin), ""]
|
|
258
|
+
function fish({ subcommands, categories, tags, pin, version }) {
|
|
259
|
+
const lines = [...header("#", "fish", pin, version), ""]
|
|
191
260
|
lines.push("complete -c omakit -f")
|
|
261
|
+
lines.push("")
|
|
262
|
+
lines.push("# The plugin ids the running shell has installed, enabled first, whole bars")
|
|
263
|
+
lines.push("# left out, read at TAB time; nothing when the shell does not answer, and")
|
|
264
|
+
lines.push("# the directories offered beside them stand.")
|
|
265
|
+
lines.push("function __omakit_plugin_ids")
|
|
266
|
+
lines.push(` ${PLUGIN_IDS_COMMAND}`)
|
|
267
|
+
lines.push("end")
|
|
268
|
+
lines.push("")
|
|
192
269
|
for (const sub of subcommands) {
|
|
193
270
|
lines.push(`complete -c omakit -n __fish_use_subcommand -a ${sub.name} -d ${single(sub.description)}`)
|
|
194
271
|
}
|
|
195
272
|
for (const sub of subcommands) {
|
|
196
273
|
const when = `-n ${single(`__fish_seen_subcommand_from ${sub.name}`)}`
|
|
274
|
+
if (sub.target === "plugin") lines.push(`complete -c omakit ${when} -a '(__omakit_plugin_ids)'`)
|
|
197
275
|
if (sub.target) lines.push(`complete -c omakit ${when} -a '(__fish_complete_directories)'`)
|
|
198
276
|
for (const { flag, value } of sub.flags) {
|
|
199
277
|
const long = `-l ${flag.slice(2)}`
|
|
@@ -243,13 +321,13 @@ export function completionInstall(env = process.env) {
|
|
|
243
321
|
* the completion script at the path `completionInstall` names, and
|
|
244
322
|
* tests/unit/self-containment.test.mjs holds it to that.
|
|
245
323
|
*
|
|
246
|
-
* @param {{ contract: { categories: string[], tagLabels: string[] }, pin: string, env?: NodeJS.ProcessEnv }} options
|
|
247
|
-
* @returns {{ state: "installed"|"updated"|"current"|"unsupported", shell: string|null, display: string|null, note: string|null }}
|
|
324
|
+
* @param {{ contract: { categories: string[], tagLabels: string[] }, pin: string, version?: string, env?: NodeJS.ProcessEnv }} options
|
|
325
|
+
* @returns {{ state: "installed"|"updated"|"current"|"unsupported", shell: string|null, display: string|null, note: string|null, path?: string }}
|
|
248
326
|
*/
|
|
249
|
-
export function installCompletion({ contract, pin, env = process.env }) {
|
|
327
|
+
export function installCompletion({ contract, pin, version = "unknown", env = process.env }) {
|
|
250
328
|
const target = completionInstall(env)
|
|
251
329
|
if (!target) return { state: "unsupported", shell: basename(env.SHELL || "") || null, display: null, note: null }
|
|
252
|
-
const script = renderCompletion(target.shell, { contract, pin })
|
|
330
|
+
const script = renderCompletion(target.shell, { contract, pin, version })
|
|
253
331
|
let existing = null
|
|
254
332
|
try {
|
|
255
333
|
existing = readFileSync(target.path, "utf8")
|
|
@@ -44,6 +44,7 @@ import { LIVE_PATHS } from "./registry.mjs"
|
|
|
44
44
|
import { credential, defaultBranchHead, getJson, UNAUTHENTICATED_LIMIT, GitHubError } from "./github.mjs"
|
|
45
45
|
import { NPM_REGISTRY, registryLatest, upgradeCommand } from "./upgrade.mjs"
|
|
46
46
|
import { pathHint } from "./path-hint.mjs"
|
|
47
|
+
import { completionStatus } from "./completion-check.mjs"
|
|
47
48
|
|
|
48
49
|
/** "git+https://github.com/owner/name.git" in package.json -> "https://github.com/owner/name", or null. */
|
|
49
50
|
function repositoryPage(repository) {
|
|
@@ -52,7 +53,7 @@ function repositoryPage(repository) {
|
|
|
52
53
|
return match ? `https://github.com/${match[1]}/${match[2]}` : null
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
function tool(repoRoot) {
|
|
56
|
+
export function tool(repoRoot) {
|
|
56
57
|
try {
|
|
57
58
|
const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
|
|
58
59
|
return { name: pkg.name, version: pkg.version, engines: pkg.engines?.node || null, repository: repositoryPage(pkg.repository) }
|
|
@@ -224,6 +225,13 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
|
|
|
224
225
|
: `${reach.reason}${reach.where ? ` Keep the line below in ${reach.where}.` : ""}`,
|
|
225
226
|
reach.reachable ? null : reach.line)
|
|
226
227
|
|
|
228
|
+
// Tab completion, as it is and not as it was written: the script, its
|
|
229
|
+
// omakit version and pin against this one's, and a new shell asked
|
|
230
|
+
// whether it loads (completion-check.mjs). Measured before this (M8):
|
|
231
|
+
// setup reported success for a script no shell was ever asked about.
|
|
232
|
+
const completion = completionStatus({ version: self.version, pin: MARKETPLACE_PIN.commit, env })
|
|
233
|
+
add("omakit.completion", completion.state, completion.detail, completion.action, completion.evidence)
|
|
234
|
+
|
|
227
235
|
const node = process.versions.node
|
|
228
236
|
const major = Number(node.split(".")[0])
|
|
229
237
|
add("node", major >= 22 ? "ok" : "problem", `node ${node}${self.engines ? ` (needs ${self.engines})` : ""}`,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Every option every command accepts, in one table, and the one parser
|
|
2
|
+
// that reads a command line against it.
|
|
3
|
+
//
|
|
4
|
+
// The table is the source the help signatures and the completion scripts
|
|
5
|
+
// are held to (tests/unit/options.test.mjs): a flag a command reads must
|
|
6
|
+
// be in its signature, and a flag in a signature must be one the command
|
|
7
|
+
// reads, so `omakit help`, tab completion and the code cannot disagree. A
|
|
8
|
+
// token the command does not know, an option without its value, or one
|
|
9
|
+
// positional more than the command takes is refused before anything runs.
|
|
10
|
+
// Measured before this: `omakit weigh <plugin> -n 1` ran three runs as if
|
|
11
|
+
// nothing had been passed, and the other commands read their flags one by
|
|
12
|
+
// one and ignored the rest.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What each command accepts. `valued` options take the next token (or
|
|
16
|
+
* `--name=value`); `flags` stand alone; `positionals` is how many bare
|
|
17
|
+
* arguments the command takes. `--help` and `-h` are handled before any
|
|
18
|
+
* command runs and are not options of one.
|
|
19
|
+
*/
|
|
20
|
+
export const ACCEPTED = Object.freeze({
|
|
21
|
+
setup: Object.freeze({ valued: [], flags: ["--yes", "--completion"], positionals: 0 }),
|
|
22
|
+
pin: Object.freeze({ valued: [], flags: [], positionals: 0 }),
|
|
23
|
+
submit: Object.freeze({ valued: ["--category", "--tags", "--notes", "--suggest-tag", "--name", "--out"], flags: ["--offline", "--allow-dirty", "--json"], positionals: 1 }),
|
|
24
|
+
watch: Object.freeze({ valued: ["--out"], flags: ["--json"], positionals: 1 }),
|
|
25
|
+
verify: Object.freeze({ valued: ["--out"], flags: ["--allow-dirty", "--json"], positionals: 1 }),
|
|
26
|
+
help: Object.freeze({ valued: [], flags: ["--agent"], positionals: 0 }),
|
|
27
|
+
upgrade: Object.freeze({ valued: [], flags: ["--dry-run"], positionals: 0 }),
|
|
28
|
+
doctor: Object.freeze({ valued: ["--out"], flags: ["--offline", "--json"], positionals: 0 }),
|
|
29
|
+
parity: Object.freeze({ valued: ["--count", "--offset", "--out"], flags: [], positionals: 0 }),
|
|
30
|
+
weigh: Object.freeze({ valued: ["--runs", "--window", "--settle", "--out"], flags: ["--all", "--list", "--json", "--yes"], positionals: 1 }),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/** The accepted options of a command in the words a refusal prints: `--runs N, --all, ...`. */
|
|
34
|
+
export function acceptedWords(name) {
|
|
35
|
+
const spec = ACCEPTED[name]
|
|
36
|
+
const value = (option) => ({ "--out": "FILE", "--runs": "N", "--count": "N", "--offset": "N", "--window": "S", "--settle": "S", "--category": "C", "--tags": "A,B" }[option] || "TEXT")
|
|
37
|
+
return [...spec.valued.map((option) => `${option} ${value(option)}`), ...spec.flags].join(", ")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read a command line against a command's table. Every token is a known
|
|
42
|
+
* option (valued or not), the value of a valued option, or a positional up
|
|
43
|
+
* to the allowed count; the first token that is none of those is returned
|
|
44
|
+
* as `offending` with a reason, so the command refuses before any preflight.
|
|
45
|
+
*
|
|
46
|
+
* @param {string[]} args
|
|
47
|
+
* @param {{ valued: string[], flags: string[], positionals: number }} spec
|
|
48
|
+
* @returns {{ offending: string|null, reason: string|null, options: Map<string, string|true>, positionals: string[] }}
|
|
49
|
+
*/
|
|
50
|
+
export function checkArgs(args, spec) {
|
|
51
|
+
const options = new Map()
|
|
52
|
+
const positionals = []
|
|
53
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
54
|
+
const token = args[index]
|
|
55
|
+
const [name, inline] = token.startsWith("--") && token.includes("=") ? [token.slice(0, token.indexOf("=")), token.slice(token.indexOf("=") + 1)] : [token, undefined]
|
|
56
|
+
if (spec.valued.includes(name)) {
|
|
57
|
+
const value = inline !== undefined ? inline : args[index + 1]
|
|
58
|
+
if (value === undefined || (inline === undefined && value.startsWith("-"))) return { offending: token, reason: `${name} needs a value`, options, positionals }
|
|
59
|
+
options.set(name, value)
|
|
60
|
+
if (inline === undefined) index += 1
|
|
61
|
+
} else if (spec.flags.includes(token)) {
|
|
62
|
+
options.set(token, true)
|
|
63
|
+
} else if (token.startsWith("-")) {
|
|
64
|
+
return { offending: token, reason: `${token} is not an option this command knows`, options, positionals }
|
|
65
|
+
} else if (positionals.length < spec.positionals) {
|
|
66
|
+
positionals.push(token)
|
|
67
|
+
} else {
|
|
68
|
+
return { offending: token, reason: `${JSON.stringify(token)} is one argument more than the command takes`, options, positionals }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { offending: null, reason: null, options, positionals }
|
|
72
|
+
}
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
// and what to try first. It is idempotent, so running it again on a machine that
|
|
6
6
|
// is already set up just confirms that.
|
|
7
7
|
//
|
|
8
|
-
// It writes
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// It writes the pinned checkout, through the same `ensurePin` that `omakit
|
|
9
|
+
// pin` uses, and the completion script where the shell in $SHELL loads it
|
|
10
|
+
// from. It does not create symlinks or install anything, and it edits a shell
|
|
11
|
+
// profile in exactly one case, after an explicit yes: the guarded block that
|
|
12
|
+
// makes completions load, when a new shell has no loader. Everywhere else a
|
|
13
|
+
// step that is the user's to take is printed as the command, and stops.
|
|
12
14
|
|
|
13
15
|
import { execFileSync } from "node:child_process"
|
|
14
16
|
import { banner } from "./banner.mjs"
|
|
@@ -18,9 +20,86 @@ import { progress } from "./progress.mjs"
|
|
|
18
20
|
import { action, colourEnabled, GUTTER, mark, styler, wrap } from "./style.mjs"
|
|
19
21
|
import { TAGLINE } from "./usage.mjs"
|
|
20
22
|
import { installCompletion } from "./completion.mjs"
|
|
23
|
+
import { appendLoaderBlock, completionWorks, loaderBlock, loaderBlockPresent, verifyCompletion } from "./completion-check.mjs"
|
|
21
24
|
import { submissionContract } from "./form.mjs"
|
|
22
25
|
import { pathHint } from "./path-hint.mjs"
|
|
23
26
|
import { withHomeAbbreviated } from "./paths.mjs"
|
|
27
|
+
import { askYes } from "../weigh/confirm.mjs"
|
|
28
|
+
import { tool } from "./doctor.mjs"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Tab completion, end to end: the script written for the shell in $SHELL
|
|
32
|
+
* where that shell loads it from, then a new interactive shell asked
|
|
33
|
+
* whether it can complete `omakit`, the way TAB asks. Measured before this
|
|
34
|
+
* (docs/MEASUREMENTS.md M8): setup reported the script installed and never
|
|
35
|
+
* asked a shell. When the shell has no completion loader, the one guarded
|
|
36
|
+
* block that gives it one is offered, once, and appended only on yes; with
|
|
37
|
+
* `askRc: false` (the step `upgrade` re-runs) it is named and not offered.
|
|
38
|
+
* `▁ ok` is printed only when a new shell shows the spec.
|
|
39
|
+
*
|
|
40
|
+
* @param {{ repoRoot: string, pin: string, version: string, stream?: NodeJS.WriteStream, env?: object,
|
|
41
|
+
* yes?: boolean, askRc?: boolean, input?: NodeJS.ReadStream, verify?: typeof verifyCompletion }} options
|
|
42
|
+
* @returns {Promise<{ state: "ok"|"note"|"unsupported"|"error", shell: string|null, rcAppended: boolean }>}
|
|
43
|
+
*/
|
|
44
|
+
export async function completionStep({ repoRoot, pin, version, stream = process.stdout, env = process.env, yes = false, askRc = true, input = process.stdin, verify = verifyCompletion }) {
|
|
45
|
+
const c = styler(colourEnabled(stream))
|
|
46
|
+
const out = (line = "") => stream.write(`${line}\n`)
|
|
47
|
+
const step = (state, text) => out(`${mark(state, c)}${wrap(withHomeAbbreviated(text, env), { indent: GUTTER }, c).join("\n").trimStart()}`)
|
|
48
|
+
const fix = (text) => { for (const line of action(withHomeAbbreviated(text, env), c)) out(line) }
|
|
49
|
+
let completion
|
|
50
|
+
try {
|
|
51
|
+
const contract = await submissionContract({ repoRoot })
|
|
52
|
+
completion = installCompletion({ contract, pin, version, env })
|
|
53
|
+
} catch (error) {
|
|
54
|
+
step("info", `tab completion was not installed: ${error.message}`)
|
|
55
|
+
return { state: "error", shell: null, rcAppended: false }
|
|
56
|
+
}
|
|
57
|
+
if (completion.state === "unsupported") {
|
|
58
|
+
step("info", completion.shell
|
|
59
|
+
? `tab completion: no script for ${completion.shell}; there is one for bash, zsh and fish.`
|
|
60
|
+
: "tab completion: $SHELL is not set, so no script was installed.")
|
|
61
|
+
return { state: "unsupported", shell: completion.shell, rcAppended: false }
|
|
62
|
+
}
|
|
63
|
+
const what = { installed: "installed", updated: "updated for this omakit and pin", current: "already installed" }[completion.state]
|
|
64
|
+
const where = `${completion.display}${completion.note ? `, ${completion.note}` : ""}`
|
|
65
|
+
let probe = verify(completion.shell, { env })
|
|
66
|
+
let rcAppended = false
|
|
67
|
+
if (!probe.ran) {
|
|
68
|
+
step("advisory", `tab completion for ${completion.shell} ${what} at ${where}, but a new ${completion.shell} could not be asked whether it loads: ${probe.reason}.`)
|
|
69
|
+
return { state: "note", shell: completion.shell, rcAppended }
|
|
70
|
+
}
|
|
71
|
+
if (!probe.loader) {
|
|
72
|
+
const block = loaderBlock(completion.shell, env)
|
|
73
|
+
const missing = completion.shell === "bash"
|
|
74
|
+
? "a new bash has no completion loader: /usr/share/bash-completion/bash_completion is not sourced, so a script under ~/.local/share/bash-completion/ is never read"
|
|
75
|
+
: "a new zsh has not run compinit, so no completion function is ever loaded"
|
|
76
|
+
step("advisory", `tab completion for ${completion.shell} ${what} at ${where}, but ${missing}.`)
|
|
77
|
+
if (block && !loaderBlockPresent(completion.shell, env)) {
|
|
78
|
+
const question = `Add one guarded line to ${block.display} so completions load?`
|
|
79
|
+
const agreed = askRc ? (yes || (Boolean(input.isTTY) && Boolean(stream.isTTY) && await askYes({ input, output: process.stderr, question }))) : false
|
|
80
|
+
if (agreed) {
|
|
81
|
+
const wrote = appendLoaderBlock(completion.shell, env)
|
|
82
|
+
rcAppended = wrote.appended
|
|
83
|
+
step("info", `appended to ${block.display}, marked \`${block.lines[0]}\`; omakit never edits or removes it.`)
|
|
84
|
+
probe = verify(completion.shell, { env })
|
|
85
|
+
} else {
|
|
86
|
+
step("info", `nothing was written. The lines that make completions load, for ${block.display}:`)
|
|
87
|
+
for (const line of block.lines) fix(line)
|
|
88
|
+
return { state: "note", shell: completion.shell, rcAppended }
|
|
89
|
+
}
|
|
90
|
+
} else if (block) {
|
|
91
|
+
step("info", `${block.display} already carries the \`${block.lines[0]}\` block; a new shell still reports no loader, so something later in that file undoes it.`)
|
|
92
|
+
return { state: "note", shell: completion.shell, rcAppended }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (completionWorks(probe)) {
|
|
96
|
+
step("pass", `tab completion for ${completion.shell} ${what} at ${where}; a new ${completion.shell} completes \`omakit\`${probe.spec === "lazy" ? " on the first TAB" : ""}.${rcAppended ? ` Open terminals need a new shell: \`exec ${completion.shell}\`.` : ""}`)
|
|
97
|
+
return { state: "ok", shell: completion.shell, rcAppended }
|
|
98
|
+
}
|
|
99
|
+
step("advisory", `tab completion for ${completion.shell} ${what} at ${where}, but a new ${completion.shell} ${probe.loader ? "does not load it" : "still has no completion loader"}${rcAppended ? " even after the block was appended" : ""}.`)
|
|
100
|
+
fix(`Open a new shell (\`exec ${completion.shell}\`) and run \`omakit doctor\`; it reports the script, the loader and the spec as \`omakit.completion\`.`)
|
|
101
|
+
return { state: "note", shell: completion.shell, rcAppended }
|
|
102
|
+
}
|
|
24
103
|
|
|
25
104
|
function version(command) {
|
|
26
105
|
try {
|
|
@@ -33,11 +112,13 @@ function version(command) {
|
|
|
33
112
|
}
|
|
34
113
|
|
|
35
114
|
/**
|
|
36
|
-
* @param {{ repoRoot: string, entryPoint: string, stream?: NodeJS.WriteStream, env?: object
|
|
115
|
+
* @param {{ repoRoot: string, entryPoint: string, stream?: NodeJS.WriteStream, env?: object, yes?: boolean,
|
|
116
|
+
* input?: NodeJS.ReadStream, verify?: typeof verifyCompletion }} options
|
|
37
117
|
* `env` is where `$HOME` is read from: this is output for a person, so a
|
|
38
|
-
* path under it is printed as `~/...`.
|
|
118
|
+
* path under it is printed as `~/...`. `yes` answers the one question
|
|
119
|
+
* setup can ask (the rc block for completion), for an agent.
|
|
39
120
|
*/
|
|
40
|
-
export async function setup({ repoRoot, entryPoint, stream = process.stdout, env = process.env }) {
|
|
121
|
+
export async function setup({ repoRoot, entryPoint, stream = process.stdout, env = process.env, yes = false, input = process.stdin, verify = verifyCompletion }) {
|
|
41
122
|
const c = styler(colourEnabled(stream))
|
|
42
123
|
const out = (line = "") => stream.write(`${line}\n`)
|
|
43
124
|
// A step is a status line: the mark, then the fact, wrapped under itself.
|
|
@@ -110,23 +191,8 @@ export async function setup({ repoRoot, entryPoint, stream = process.stdout, env
|
|
|
110
191
|
}
|
|
111
192
|
|
|
112
193
|
// Tab completion, installed for the shell in $SHELL where that shell loads
|
|
113
|
-
// it from,
|
|
114
|
-
|
|
115
|
-
// alone otherwise.
|
|
116
|
-
try {
|
|
117
|
-
const contract = await submissionContract({ repoRoot })
|
|
118
|
-
const completion = installCompletion({ contract, pin: identity.commit })
|
|
119
|
-
if (completion.state === "unsupported") {
|
|
120
|
-
step("info", completion.shell
|
|
121
|
-
? `tab completion: no script for ${completion.shell}; there is one for bash, zsh and fish.`
|
|
122
|
-
: "tab completion: $SHELL is not set, so no script was installed.")
|
|
123
|
-
} else {
|
|
124
|
-
const what = { installed: "installed", updated: "updated for this pin", current: "already installed" }[completion.state]
|
|
125
|
-
step("pass", `tab completion for ${completion.shell} ${what} at ${completion.display}${completion.note ? `, ${completion.note}` : ""}.`)
|
|
126
|
-
}
|
|
127
|
-
} catch (error) {
|
|
128
|
-
step("info", `tab completion was not installed: ${error.message}`)
|
|
129
|
-
}
|
|
194
|
+
// it from, and then proven in a new shell (completionStep).
|
|
195
|
+
await completionStep({ repoRoot, pin: identity.commit, version: tool(repoRoot).version, stream, env, yes, askRc: true, input, verify })
|
|
130
196
|
out()
|
|
131
197
|
|
|
132
198
|
out("Try it on a plugin you have checked out:")
|
|
@@ -144,7 +144,29 @@ export function isExpectedRemote(url, expected = REPOSITORY) {
|
|
|
144
144
|
* a way to point the command at somebody else's repository, because nothing
|
|
145
145
|
* on the command line reaches it.
|
|
146
146
|
*/
|
|
147
|
-
|
|
147
|
+
/**
|
|
148
|
+
* After a successful install, the completion step of the omakit that was
|
|
149
|
+
* just installed: it renders the script with its own version and proves it
|
|
150
|
+
* in a new shell, and never asks the rc question (the loader does not
|
|
151
|
+
* change with an upgrade). Run as a child of the new entry point, not in
|
|
152
|
+
* this process, whose code is the old version's. Frozen arguments.
|
|
153
|
+
*/
|
|
154
|
+
export const COMPLETION_REFRESH_ARGS = Object.freeze(["setup", "--completion"])
|
|
155
|
+
|
|
156
|
+
function refreshCompletionWith(root, stream) {
|
|
157
|
+
const entryPoint = join(root, "bin/omakit")
|
|
158
|
+
if (!existsSync(entryPoint)) return { ran: false, reason: `${entryPoint} is not there` }
|
|
159
|
+
try {
|
|
160
|
+
const out = execFileSync(process.execPath, [entryPoint, ...COMPLETION_REFRESH_ARGS], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
|
|
161
|
+
stream.write(out)
|
|
162
|
+
return { ran: true, ok: true }
|
|
163
|
+
} catch (error) {
|
|
164
|
+
stream.write(error.stdout || "")
|
|
165
|
+
return { ran: true, ok: false, reason: String(error.stderr || error.message).trim() }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function upgrade({ repoRoot, stream = process.stdout, dryRun = false, expectedRemote = REPOSITORY, latest = latestOnRegistry, npmRoot = npmGlobalRoot, name = "omakit", refreshCompletion = refreshCompletionWith }) {
|
|
148
170
|
const c = styler(colourEnabled(stream))
|
|
149
171
|
const out = (line = "") => stream.write(`${line}\n`)
|
|
150
172
|
const lines = (list) => { for (const line of list) out(line) }
|
|
@@ -160,7 +182,7 @@ export async function upgrade({ repoRoot, stream = process.stdout, dryRun = fals
|
|
|
160
182
|
if (installKind(repoRoot) === "distro") {
|
|
161
183
|
return refuse("this is a distro package under /usr, so omakit leaves upgrades to the package manager.", upgradeCommand(repoRoot))
|
|
162
184
|
}
|
|
163
|
-
return upgradeNpm({ repoRoot, stream, dryRun, latest, npmRoot, name, refuse, note, ok, out, lines, c })
|
|
185
|
+
return upgradeNpm({ repoRoot, stream, dryRun, latest, npmRoot, name, refuse, note, ok, out, lines, c, refreshCompletion })
|
|
164
186
|
}
|
|
165
187
|
|
|
166
188
|
let remote
|
|
@@ -245,15 +267,17 @@ export async function upgrade({ repoRoot, stream = process.stdout, dryRun = fals
|
|
|
245
267
|
ok(`${before.slice(0, 7)} to ${after.slice(0, 7)} on ${branch}, ${log.length} commit(s)`)
|
|
246
268
|
for (const line of log) out(subject(line))
|
|
247
269
|
out()
|
|
270
|
+
const completion = refreshCompletion(resolve(repoRoot), stream)
|
|
271
|
+
if (completion.ran === false) note(`tab completion was not refreshed: ${completion.reason}. Run \`omakit setup\`.`)
|
|
248
272
|
lines(wrap("The marketplace pin did not move: this updated the tool, not the commit its rules are read from. `omakit doctor` says whether that pin is behind, and docs/UPSTREAM_CONTRACT.md says what moving it involves.", {}, c))
|
|
249
|
-
return { ok: true, changed: true, from: before, to: after, commits: log.length }
|
|
273
|
+
return { ok: true, changed: true, from: before, to: after, commits: log.length, completion }
|
|
250
274
|
}
|
|
251
275
|
|
|
252
276
|
/**
|
|
253
277
|
* The npm route. `latest` and `npmRoot` are injectable for the tests only, the
|
|
254
278
|
* way `expectedRemote` is: nothing on the command line reaches them.
|
|
255
279
|
*/
|
|
256
|
-
async function upgradeNpm({ repoRoot, stream, dryRun, latest, npmRoot, name, refuse, note, ok, out, lines, c }) {
|
|
280
|
+
async function upgradeNpm({ repoRoot, stream, dryRun, latest, npmRoot, name, refuse, note, ok, out, lines, c, refreshCompletion }) {
|
|
257
281
|
const root = resolve(repoRoot)
|
|
258
282
|
const globalRoot = npmRoot()
|
|
259
283
|
if (!globalRoot) {
|
|
@@ -302,6 +326,8 @@ async function upgradeNpm({ repoRoot, stream, dryRun, latest, npmRoot, name, ref
|
|
|
302
326
|
}
|
|
303
327
|
ok(`${current} to ${after}, through the npm that installed it`)
|
|
304
328
|
out()
|
|
329
|
+
const completion = refreshCompletion(root, stream)
|
|
330
|
+
if (completion.ran === false) note(`tab completion was not refreshed: ${completion.reason}. Run \`omakit setup\`.`)
|
|
305
331
|
lines(wrap("The marketplace pin did not move: this updated the tool, not the commit its rules are read from. `omakit doctor` says whether that pin is behind, and docs/UPSTREAM_CONTRACT.md says what moving it involves.", {}, c))
|
|
306
|
-
return { ok: true, changed: true, from: current, to: after }
|
|
332
|
+
return { ok: true, changed: true, from: current, to: after, completion }
|
|
307
333
|
}
|
|
@@ -22,10 +22,13 @@ export const COMPLETION_SHELLS = Object.freeze(["bash", "zsh", "fish"])
|
|
|
22
22
|
|
|
23
23
|
export const COMMANDS = Object.freeze([
|
|
24
24
|
{
|
|
25
|
-
signature: "omakit setup",
|
|
25
|
+
signature: "omakit setup [--yes] [--completion]",
|
|
26
26
|
lines: [
|
|
27
27
|
"First run, in one command: check the environment, fetch the pinned",
|
|
28
|
-
"marketplace checkout, and
|
|
28
|
+
"marketplace checkout, install tab completion and prove it in a new shell,",
|
|
29
|
+
"and say what to try first. Idempotent. When a new shell has no completion",
|
|
30
|
+
"loader it asks once before adding one guarded block to the rc file; --yes",
|
|
31
|
+
"answers for an agent. --completion is that step alone, never the question.",
|
|
29
32
|
],
|
|
30
33
|
},
|
|
31
34
|
{
|
|
@@ -55,7 +58,7 @@ export const COMMANDS = Object.freeze([
|
|
|
55
58
|
],
|
|
56
59
|
},
|
|
57
60
|
{
|
|
58
|
-
signature: "omakit watch <issue-url> [--json]",
|
|
61
|
+
signature: "omakit watch <issue-url> [--json] [--out <file>]",
|
|
59
62
|
lines: [
|
|
60
63
|
"Compare the commit the marketplace validated on a submission issue with",
|
|
61
64
|
"the plugin repository's current default-branch HEAD, and say what makes",
|
|
@@ -86,7 +89,7 @@ export const COMMANDS = Object.freeze([
|
|
|
86
89
|
],
|
|
87
90
|
},
|
|
88
91
|
{
|
|
89
|
-
signature: "omakit doctor [--offline] [--json]",
|
|
92
|
+
signature: "omakit doctor [--offline] [--json] [--out <file>]",
|
|
90
93
|
lines: [
|
|
91
94
|
"What is installed, what is pinned, and what has moved since. Reads and",
|
|
92
95
|
"prints; it installs nothing and never moves the pin.",
|
|
@@ -104,6 +107,7 @@ export const COMMANDS = Object.freeze([
|
|
|
104
107
|
"omakit weigh <plugin-id-or-dir> [--runs <n>] [--window <s>] [--settle <s>]",
|
|
105
108
|
" [--yes] [--json] [--out <file>]",
|
|
106
109
|
"omakit weigh --all",
|
|
110
|
+
"omakit weigh --list [--json]",
|
|
107
111
|
],
|
|
108
112
|
lines: [
|
|
109
113
|
"What a plugin weighs on the shell, measured: the shell is restarted",
|
|
@@ -117,7 +121,8 @@ export const COMMANDS = Object.freeze([
|
|
|
117
121
|
"enabled third-party plugin and is sized for a lab machine, not a working",
|
|
118
122
|
"desktop. Writes the document to --out, by default",
|
|
119
123
|
"$XDG_STATE_HOME/omakit/weigh/<date>.json, and ends with the sentence",
|
|
120
|
-
"for the plugin's README.",
|
|
124
|
+
"for the plugin's README. --list is read-only: every installed plugin",
|
|
125
|
+
"and when it was last weighed, unweighed enabled plugins first.",
|
|
121
126
|
],
|
|
122
127
|
},
|
|
123
128
|
])
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// `omakit weigh --list`: every installed plugin, and when it was last
|
|
2
|
+
// weighed. Read-only: `listPlugins` answering is the only preflight, no
|
|
3
|
+
// shell is restarted, nothing is written, and the documents under
|
|
4
|
+
// `$XDG_STATE_HOME/omakit/weigh/` are read for the last sentence per id.
|
|
5
|
+
|
|
6
|
+
import { readdirSync, readFileSync } from "node:fs"
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
import { run } from "./commands.mjs"
|
|
9
|
+
import { WeighError } from "./audit.mjs"
|
|
10
|
+
import { omakitStateDir } from "../marketplace/paths.mjs"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The last weighing of each plugin id, from the documents in the state
|
|
14
|
+
* directory: the newest `started` wins. A document that cannot be read or
|
|
15
|
+
* parsed is skipped; `timing.json` is not a document.
|
|
16
|
+
*
|
|
17
|
+
* @returns {Map<string, { date: string, started: string, readme: string|null, summary: string, document: string }>}
|
|
18
|
+
*/
|
|
19
|
+
export function lastWeighings(stateDir) {
|
|
20
|
+
const latest = new Map()
|
|
21
|
+
let names = []
|
|
22
|
+
try {
|
|
23
|
+
names = readdirSync(stateDir).filter((name) => name.endsWith(".json") && name !== "timing.json")
|
|
24
|
+
} catch {
|
|
25
|
+
return latest
|
|
26
|
+
}
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
const file = join(stateDir, name)
|
|
29
|
+
let document
|
|
30
|
+
try {
|
|
31
|
+
document = JSON.parse(readFileSync(file, "utf8"))
|
|
32
|
+
} catch {
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (document?.command !== "weigh" || !Array.isArray(document.plugins) || typeof document.started !== "string") continue
|
|
36
|
+
for (const row of document.plugins) {
|
|
37
|
+
const known = latest.get(row.id)
|
|
38
|
+
if (known && known.started >= document.started) continue
|
|
39
|
+
latest.set(row.id, { date: document.started.slice(0, 10), started: document.started, readme: row.readme ?? null, summary: row.verdict?.summary ?? "", document: file })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return latest
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One row per installed plugin, sorted: enabled and not weighed first (by
|
|
47
|
+
* id), then enabled and weighed, oldest weighing first, then disabled.
|
|
48
|
+
*
|
|
49
|
+
* @param {{ env?: NodeJS.ProcessEnv }} [options]
|
|
50
|
+
*/
|
|
51
|
+
export function listWeighings({ env = process.env } = {}) {
|
|
52
|
+
const listed = run("listPlugins", { env })
|
|
53
|
+
if (!listed.ok) throw new WeighError("shell-not-running", "omarchy plugin list did not answer, and the list is what the shell has installed", "omarchy-restart-shell")
|
|
54
|
+
let installed
|
|
55
|
+
try {
|
|
56
|
+
installed = JSON.parse(listed.stdout)
|
|
57
|
+
} catch {
|
|
58
|
+
throw new WeighError("shell-unreadable", "omarchy plugin list did not answer with JSON", "omarchy-restart-shell, then run it again.")
|
|
59
|
+
}
|
|
60
|
+
const catalogRun = run("catalog", { env })
|
|
61
|
+
let catalog = []
|
|
62
|
+
try {
|
|
63
|
+
catalog = catalogRun.ok ? JSON.parse(catalogRun.stdout) : []
|
|
64
|
+
} catch {
|
|
65
|
+
catalog = []
|
|
66
|
+
}
|
|
67
|
+
const sourceDirOf = (id) => catalog.find((entry) => entry.id === id)?.sourceDir || null
|
|
68
|
+
const stateDir = omakitStateDir("weigh", env)
|
|
69
|
+
const latest = lastWeighings(stateDir)
|
|
70
|
+
const rows = installed.map((plugin) => {
|
|
71
|
+
const kinds = plugin.kinds || []
|
|
72
|
+
const last = latest.get(plugin.id) || null
|
|
73
|
+
return {
|
|
74
|
+
id: plugin.id,
|
|
75
|
+
name: plugin.name || plugin.id,
|
|
76
|
+
kinds,
|
|
77
|
+
enabled: plugin.enabled === true,
|
|
78
|
+
firstParty: plugin.firstParty === true,
|
|
79
|
+
sourceDir: sourceDirOf(plugin.id),
|
|
80
|
+
weighable: !kinds.includes("bar"),
|
|
81
|
+
lastWeighed: last ? { date: last.date, readme: last.readme, summary: last.summary, document: last.document } : null,
|
|
82
|
+
enable: plugin.enabled === true ? null : `omarchy plugin enable ${plugin.id}`,
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
const rank = (row) => (row.enabled ? (row.lastWeighed ? 1 : 0) : 2)
|
|
86
|
+
rows.sort((a, b) => rank(a) - rank(b)
|
|
87
|
+
|| (rank(a) === 1 ? a.lastWeighed.date.localeCompare(b.lastWeighed.date) : 0)
|
|
88
|
+
|| a.id.localeCompare(b.id))
|
|
89
|
+
return { stateDir, rows }
|
|
90
|
+
}
|
package/tools/weigh/report.mjs
CHANGED
|
@@ -164,3 +164,33 @@ export function renderWeigh(document, { colour = colourEnabled(), env = process.
|
|
|
164
164
|
}
|
|
165
165
|
return out.join("\n")
|
|
166
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* `omakit weigh --list` for a person: one `░ info` row per installed
|
|
170
|
+
* plugin, in the list's order (enabled and not weighed first), each with
|
|
171
|
+
* its name, its state and its last weighing in words. Every row is
|
|
172
|
+
* information: the list decides nothing, so no row is a pass, a note or a
|
|
173
|
+
* question.
|
|
174
|
+
*/
|
|
175
|
+
export function renderList(list, { colour = colourEnabled(), env = process.env } = {}) {
|
|
176
|
+
const c = styler(colour)
|
|
177
|
+
const out = []
|
|
178
|
+
const { rows } = list
|
|
179
|
+
const weighed = rows.filter((row) => row.lastWeighed).length
|
|
180
|
+
const enabled = rows.filter((row) => row.enabled).length
|
|
181
|
+
out.push(...field("installed", `${rows.length} plugin${rows.length === 1 ? "" : "s"}, ${enabled} enabled, ${weighed} weighed; weighings read from ${withHomeAbbreviated(list.stateDir, env)}`, c))
|
|
182
|
+
out.push("")
|
|
183
|
+
for (const [index, row] of rows.entries()) {
|
|
184
|
+
if (index > 0) out.push("")
|
|
185
|
+
out.push(head("info", row.id, row.kinds.join(", ") || "no kinds", c))
|
|
186
|
+
out.push(...field("name", row.name, c))
|
|
187
|
+
out.push(...field("state", row.enabled ? "enabled" : `disabled; \`${row.enable}\` enables it`, c))
|
|
188
|
+
const last = row.lastWeighed
|
|
189
|
+
out.push(...field("weighed", !row.weighable
|
|
190
|
+
? "not weighed: a whole bar, and replacing the bar is not a weight"
|
|
191
|
+
: last
|
|
192
|
+
? `${last.date}: ${last.readme || last.summary}`
|
|
193
|
+
: "not weighed", c))
|
|
194
|
+
}
|
|
195
|
+
return out.join("\n")
|
|
196
|
+
}
|