omakit 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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"
@@ -28,8 +31,11 @@ import { upgrade } from "./upgrade.mjs"
28
31
  import { progress } from "./progress.mjs"
29
32
  import { banner, bannerEnabled } from "./banner.mjs"
30
33
  import { COMMANDS, renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
31
- import { action, colourEnabled, GUTTER, labelled, mark, styler, wrap } from "./style.mjs"
34
+ import { action, colourEnabled, GUTTER, labelled, mark, styler, verdict, wrap } from "./style.mjs"
32
35
  import { omakitCacheDir, withHomeAbbreviated } from "./paths.mjs"
36
+ import { DEFAULTS as WEIGH_DEFAULTS, measureWeigh, planWeigh } from "../weigh/audit.mjs"
37
+ import { confirmationQuestion, renderWeigh, renderPlan } from "../weigh/report.mjs"
38
+ import { askYes } from "../weigh/confirm.mjs"
33
39
 
34
40
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
35
41
 
@@ -51,6 +57,8 @@ const REMEDY = Object.freeze({
51
57
  "github-unavailable": "Wait for GitHub, then run it again. `gh auth login` raises the rate limit if that is what ran out.",
52
58
  "not-found": "Check the issue URL: it has to be an existing issue on the marketplace repository.",
53
59
  "head-unreadable": "Check that the plugin repository is public and its URL is right.",
60
+ "not-confirmed": "Run it again and answer y, or pass --yes when the person whose shell it is has agreed.",
61
+ "interrupted": "shell.json was restored; run it again when the desktop is yours to restart.",
54
62
  })
55
63
 
56
64
  /**
@@ -79,7 +87,7 @@ function option(args, name) {
79
87
  }
80
88
 
81
89
  function positionals(args) {
82
- const valued = new Set(["--profile", "--plugin", "--out", "--category", "--tags", "--notes", "--suggest-tag", "--name", "--count", "--offset"])
90
+ const valued = new Set(["--profile", "--plugin", "--out", "--category", "--tags", "--notes", "--suggest-tag", "--name", "--count", "--offset", "--runs", "--window", "--settle"])
83
91
  return args.filter((value, index) => !value.startsWith("--") && !valued.has(args[index - 1]))
84
92
  }
85
93
 
@@ -273,6 +281,140 @@ async function cmdParity(args) {
273
281
  process.exit(ok ? 0 : 1)
274
282
  }
275
283
 
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
+ /**
327
+ * Every way `weigh` stops without weighing, in one register: the closing
328
+ * word a report would have ended with, negated, then the sentence naming
329
+ * what is missing, then the one thing to do. Exit 2 for a usage error and
330
+ * an unanswered confirmation, 130 for an interrupt, 1 for the rest.
331
+ */
332
+ function notWeighed(code, message, remedy, exit = 1) {
333
+ const c = styler(colourEnabled(process.stderr))
334
+ const lines = verdict("fail", "NOT WEIGHED", message, c)
335
+ if (remedy) lines.push(...action(remedy, c, { indent: 0 }))
336
+ process.stderr.write(`${lines.join("\n")}\n`)
337
+ process.exit(exit)
338
+ }
339
+
340
+ async function cmdWeigh(args) {
341
+ // Every token is checked before anything else: an option weigh does not
342
+ // know, or a second positional, is refused with the accepted list.
343
+ const parsed = checkArgs(args, WEIGH_ARGS)
344
+ if (parsed.offending !== null) notWeighed("usage", `${parsed.reason}. Accepted: ${WEIGH_ACCEPTED}.`, "omakit weigh <plugin-id-or-dir> [--runs N] [--window S] [--settle S] [--yes] [--json] [--out FILE]", 2)
345
+ const json = parsed.options.has("--json")
346
+ const all = parsed.options.has("--all")
347
+ const target = parsed.positionals[0]
348
+ 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
+ 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
+ const integer = (name, fallback, letter, min = 1) => {
351
+ if (!parsed.options.has(name)) return fallback
352
+ const raw = parsed.options.get(name)
353
+ 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)
354
+ return Number(raw)
355
+ }
356
+ const runs = integer("--runs", WEIGH_DEFAULTS.runs, "N")
357
+ const windowSeconds = integer("--window", WEIGH_DEFAULTS.windowSeconds, "S")
358
+ const settleSeconds = integer("--settle", WEIGH_DEFAULTS.settleSeconds, "S", 0)
359
+ let plan
360
+ try {
361
+ plan = planWeigh({ target, all, runs, windowSeconds, settleSeconds, out: parsed.options.get("--out") })
362
+ } catch (error) {
363
+ if (error?.code && typeof error.code === "string") notWeighed(error.code, `${error.message}.`, error.remedy || REMEDY[error.code], error.code === "usage" ? 2 : 1)
364
+ throw error
365
+ }
366
+ // The narration: what was backed up and with which md5, and what was
367
+ // restored. For a person it is part of the report, on stdout; under
368
+ // --json stdout is the document alone, so it goes to stderr.
369
+ const narrate = json ? process.stderr : process.stdout
370
+ const c = styler(colourEnabled(narrate))
371
+ const say = (line) => narrate.write(`${mark(line.state, c)}${wrap(withHomeAbbreviated(line.text), { indent: GUTTER }, c).join("\n").trimStart()}\n`)
372
+ // The confirmation. The plan is printed either way, so the record says
373
+ // what was agreed to; the question is asked only at a terminal on both
374
+ // ends, and --yes is the only other way past it.
375
+ narrate.write(`${renderPlan(plan, { colour: colourEnabled(narrate) }).join("\n")}\n`)
376
+ if (!parsed.options.has("--yes")) {
377
+ const interactive = !json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)
378
+ 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)
379
+ const agreed = await askYes({ question: confirmationQuestion(plan) })
380
+ if (!agreed) notWeighed("not-confirmed", "not confirmed; nothing was changed.", REMEDY["not-confirmed"], 2)
381
+ }
382
+ narrate.write("\n")
383
+ // An interrupt is a request to stop, not a reason to leave the user's
384
+ // shell on a measurement configuration: the signal aborts the run, the
385
+ // measurement's own finally restores shell.json and restarts the shell,
386
+ // and only then does the process exit, 130 as an interrupted program does.
387
+ const controller = new AbortController()
388
+ const interrupt = () => {
389
+ if (!controller.signal.aborted) narrate.write(`\n${mark("advisory", c)}interrupted: restoring shell.json before exiting\n`)
390
+ controller.abort()
391
+ }
392
+ process.on("SIGINT", interrupt)
393
+ process.on("SIGTERM", interrupt)
394
+ const spinner = spinnerFor(args)
395
+ let document
396
+ try {
397
+ document = await measureWeigh(plan, { signal: controller.signal, omakitVersion: VERSION, onPhase: spinner.phase, onLine: (line) => { spinner.done(); say(line) } })
398
+ } catch (error) {
399
+ spinner.done()
400
+ if (error?.code === "interrupted") notWeighed("interrupted", "interrupted before the measurement completed.", REMEDY.interrupted, 130)
401
+ if (error?.code && typeof error.code === "string") notWeighed(error.code, `${error.message}.`, error.remedy || REMEDY[error.code])
402
+ throw error
403
+ } finally {
404
+ process.off("SIGINT", interrupt)
405
+ process.off("SIGTERM", interrupt)
406
+ }
407
+ spinner.done()
408
+ if (json) {
409
+ process.stdout.write(`${JSON.stringify(document, null, 2)}\n`)
410
+ } else {
411
+ process.stdout.write(`\n${renderWeigh(document)}\n`)
412
+ }
413
+ process.exit(0)
414
+ }
415
+
416
+ const VERSION = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8")).version
417
+
276
418
  const [command, ...rest] = process.argv.slice(2)
277
419
  if (command === "setup") {
278
420
  await cmdSetup()
@@ -303,6 +445,8 @@ if (command === "setup") {
303
445
  await cmdVerify(rest.filter((value, index) => value !== "marketplace" || rest[index - 1] !== "--profile"))
304
446
  } else if (command === "parity") {
305
447
  await cmdParity(rest)
448
+ } else if (command === "weigh") {
449
+ await cmdWeigh(rest)
306
450
  } else if (command === "help" || command === "--help" || command === "-h" || command === undefined) {
307
451
  if (rest.includes("--agent")) {
308
452
  // The skills ship in the npm package, so this works from a global install
@@ -18,8 +18,8 @@ import { withHomeAbbreviated } from "./paths.mjs"
18
18
  /**
19
19
  * The completion model, read out of the help data. A subcommand is the word
20
20
  * after `omakit` in its signature; a flag is every `--word` in it, valued when
21
- * a `<placeholder>` follows it; `<target>` in the signature means the first
22
- * positional is a directory.
21
+ * a `<placeholder>` follows it; `<target>` or `<plugin-id-or-dir>` in the
22
+ * signature means the first positional can be a directory.
23
23
  *
24
24
  * @param {ReadonlyArray<{ signature: string|string[], lines: string[] }>} commands
25
25
  */
@@ -33,7 +33,7 @@ export function subcommandsOf(commands = COMMANDS) {
33
33
  value: placeholder ? placeholderKind(placeholder) : null,
34
34
  }))
35
35
  const sentence = command.lines.join(" ").replace(/`/g, "").split(/(?<=\.)\s/)[0]
36
- return { name, description: sentence, flags, target: /<target>/.test(signature) }
36
+ return { name, description: sentence, flags, target: /<target>|<plugin-id-or-dir>/.test(signature) }
37
37
  })
38
38
  }
39
39
 
@@ -9,6 +9,19 @@ export function omakitCacheDir(name = "", env = process.env) {
9
9
  return join(base, "omakit", name)
10
10
  }
11
11
 
12
+ /**
13
+ * Omakit's user-writable state, following XDG with the usual ~/.local/state
14
+ * fallback: where `omakit weigh` keeps its documents and its per-restart
15
+ * timing. State, not cache, because a measurement is not something to
16
+ * fetch again.
17
+ */
18
+ export function omakitStateDir(name = "", env = process.env) {
19
+ const base = env.XDG_STATE_HOME
20
+ ? resolve(env.XDG_STATE_HOME)
21
+ : join(env.HOME || homedir(), ".local/state")
22
+ return join(base, "omakit", name)
23
+ }
24
+
12
25
  /**
13
26
  * Text for a person, with every path under the home directory written the
14
27
  * way a shell would take it: `~/.cache/omakit/marketplace`. Only `$HOME`
@@ -35,9 +35,10 @@ function stateOf(check) {
35
35
  /**
36
36
  * A check's head line: the mark, the id in bold, and the source in brackets
37
37
  * pushed to the right edge, so the sources form a column of their own and the
38
- * ids form another.
38
+ * ids form another. Exported for the weigh report, whose rows are plugins
39
+ * with their kinds where a check has its source.
39
40
  */
40
- function head(state, id, source, c) {
41
+ export function head(state, id, source, c) {
41
42
  const left = `${mark(state, c)}${c("name", id)}`
42
43
  const tag = c("punctuation", `[${source}]`)
43
44
  const gap = Math.max(2, COLUMNS - width(left) - width(tag))
@@ -99,6 +99,27 @@ export const COMMANDS = Object.freeze([
99
99
  "listed repositories; a packaged install requires --out for evidence.",
100
100
  ],
101
101
  },
102
+ {
103
+ signature: [
104
+ "omakit weigh <plugin-id-or-dir> [--runs <n>] [--window <s>] [--settle <s>]",
105
+ " [--yes] [--json] [--out <file>]",
106
+ "omakit weigh --all",
107
+ ],
108
+ lines: [
109
+ "What a plugin weighs on the shell, measured: the shell is restarted",
110
+ "without it and with it, several runs, and the difference is the weight,",
111
+ "with the baseline's own spread as the noise floor; memory is printed as",
112
+ "the shell's own startup variance, CPU and child processes as the weight.",
113
+ "The one command that changes your machine: it edits shell.json for the",
114
+ "duration, backs it up first, restores it on every exit path, and asks",
115
+ "before the first restart (--yes answers for you): about a minute per",
116
+ "restart, six restarts for one plugin at three runs. --all weighs every",
117
+ "enabled third-party plugin and is sized for a lab machine, not a working",
118
+ "desktop. Writes the document to --out, by default",
119
+ "$XDG_STATE_HOME/omakit/weigh/<date>.json, and ends with the sentence",
120
+ "for the plugin's README.",
121
+ ],
122
+ },
102
123
  ])
103
124
 
104
125
  // A command sits one STEP in from the heading; what it does sits one STEP in