omakit 0.1.8 → 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"
32
- import { omakitCacheDir } from "./paths.mjs"
34
+ import { action, colourEnabled, GUTTER, labelled, mark, styler, verdict, wrap } from "./style.mjs"
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()
@@ -284,7 +426,7 @@ if (command === "setup") {
284
426
  // ensurePin narrates: a state line to keep, then a fetch it is about to
285
427
  // start. The fetch is the slow part, so it gets the progress line.
286
428
  if (line.state === "fetching") spinner.phase(line.text)
287
- else process.stdout.write(`${mark(line.state, c)}${wrap(line.text, { indent: GUTTER }, c).join("\n").trimStart()}\n`)
429
+ else process.stdout.write(`${mark(line.state, c)}${wrap(withHomeAbbreviated(line.text), { indent: GUTTER }, c).join("\n").trimStart()}\n`)
288
430
  })
289
431
  } catch (error) {
290
432
  spinner.done()
@@ -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
@@ -13,12 +13,13 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
13
13
  import { basename, dirname, join } from "node:path"
14
14
  import { tagSlug } from "./form.mjs"
15
15
  import { COMMANDS, COMPLETION_SHELLS } from "./usage.mjs"
16
+ import { withHomeAbbreviated } from "./paths.mjs"
16
17
 
17
18
  /**
18
19
  * The completion model, read out of the help data. A subcommand is the word
19
20
  * after `omakit` in its signature; a flag is every `--word` in it, valued when
20
- * a `<placeholder>` follows it; `<target>` in the signature means the first
21
- * positional is a directory.
21
+ * a `<placeholder>` follows it; `<target>` or `<plugin-id-or-dir>` in the
22
+ * signature means the first positional can be a directory.
22
23
  *
23
24
  * @param {ReadonlyArray<{ signature: string|string[], lines: string[] }>} commands
24
25
  */
@@ -32,7 +33,7 @@ export function subcommandsOf(commands = COMMANDS) {
32
33
  value: placeholder ? placeholderKind(placeholder) : null,
33
34
  }))
34
35
  const sentence = command.lines.join(" ").replace(/`/g, "").split(/(?<=\.)\s/)[0]
35
- return { name, description: sentence, flags, target: /<target>/.test(signature) }
36
+ return { name, description: sentence, flags, target: /<target>|<plugin-id-or-dir>/.test(signature) }
36
37
  })
37
38
  }
38
39
 
@@ -221,7 +222,7 @@ export function completionInstall(env = process.env) {
221
222
  const shell = basename(env.SHELL || "")
222
223
  const home = env.HOME || ""
223
224
  if (!home || !COMPLETION_SHELLS.includes(shell)) return null
224
- const tilde = (dir) => (dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir)
225
+ const tilde = (dir) => withHomeAbbreviated(dir, env)
225
226
  if (shell === "bash") {
226
227
  const dir = join(env.XDG_DATA_HOME || join(home, ".local/share"), "bash-completion/completions")
227
228
  return { shell, path: join(dir, "omakit"), display: `${tilde(dir)}/omakit`, note: null }
@@ -42,7 +42,7 @@ import { join } from "node:path"
42
42
  import { MARKETPLACE_PIN, PIN_PATHS, marketplacePinDir, pinDiskUsage, pinIsSparse, requirePin } from "./pin.mjs"
43
43
  import { LIVE_PATHS } from "./registry.mjs"
44
44
  import { credential, defaultBranchHead, getJson, UNAUTHENTICATED_LIMIT, GitHubError } from "./github.mjs"
45
- import { latestOnRegistry, upgradeCommand } from "./upgrade.mjs"
45
+ import { NPM_REGISTRY, registryLatest, upgradeCommand } from "./upgrade.mjs"
46
46
  import { pathHint } from "./path-hint.mjs"
47
47
 
48
48
  /** "git+https://github.com/owner/name.git" in package.json -> "https://github.com/owner/name", or null. */
@@ -172,13 +172,13 @@ export function pinFreshness(identity, head, changedPaths = null, { issues = nul
172
172
 
173
173
  /**
174
174
  * @param {{ repoRoot: string, offline?: boolean, env?: object, npmPrefix?: () => string|null,
175
- * resolveHead?: typeof defaultBranchHead, latest?: typeof latestOnRegistry }} options
175
+ * resolveHead?: typeof defaultBranchHead, latest?: typeof registryLatest }} options
176
176
  * `env` and `npmPrefix` are injectable for tests of the PATH check;
177
177
  * `resolveHead` and `latest` for tests of the two checks that read the
178
178
  * network, whose defaults are the tool's one HEAD resolver and its one
179
179
  * registry read.
180
180
  */
181
- export async function doctor({ repoRoot, offline = false, onPhase, env = process.env, npmPrefix, resolveHead = defaultBranchHead, latest: latestVersion = latestOnRegistry }) {
181
+ export async function doctor({ repoRoot, offline = false, onPhase, env = process.env, npmPrefix, resolveHead = defaultBranchHead, latest: latestVersion = registryLatest }) {
182
182
  // Optional: told what is being read while the network answers. Never
183
183
  // affects the result.
184
184
  const phase = onPhase || (() => {})
@@ -192,7 +192,27 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
192
192
  })
193
193
 
194
194
  const self = tool(repoRoot)
195
- add("omakit.version", "info", `${self.name} ${self.version}`)
195
+ // What is installed and what is published, one check: the two facts are
196
+ // one question, "is this the current omakit", and were two lines before.
197
+ // Offline, the first fact alone, as information. The evidence carries both
198
+ // and where the second came from.
199
+ const versionCheck = (state, detail, action = null, latest = null, source = null) =>
200
+ add("omakit.version", state, detail, action, { installed: self.version, latest, source })
201
+ if (offline) {
202
+ versionCheck("info", `${self.version}; the newest published version is not checked (--offline)`)
203
+ } else {
204
+ phase("asking the npm registry for the newest published version")
205
+ const published = await latestVersion(self.name)
206
+ if (published.version) {
207
+ const current = published.version === self.version
208
+ versionCheck(current ? "ok" : "advice",
209
+ current ? `${self.version}, the newest published version` : `${self.version}; ${published.version} is published`,
210
+ current ? null : `run \`${upgradeCommand(repoRoot, self.name)}\``,
211
+ published.version, NPM_REGISTRY)
212
+ } else {
213
+ versionCheck("unknown", `${self.version}; could not read the npm registry (${published.error?.code || "error"})`)
214
+ }
215
+ }
196
216
 
197
217
  // Reachable as a bare command, or the one line that makes it so for this
198
218
  // install (path-hint.mjs). Measured: an npm prefix whose bin is not on PATH
@@ -241,16 +261,6 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
241
261
  { pinCommit: identity.commit, marketplaceHead: null, branch: null })
242
262
  }
243
263
 
244
- phase("asking the npm registry for the newest published version")
245
- const latest = await latestVersion(self.name)
246
- if (latest) {
247
- const current = latest === self.version
248
- add("omakit.latest", current ? "ok" : "advice",
249
- current ? `${latest} is the newest published version` : `${latest} is published, this is ${self.version}`,
250
- current ? null : upgradeCommand(repoRoot, self.name))
251
- } else {
252
- add("omakit.latest", "unknown", "the npm registry did not answer, or this version is unpublished")
253
- }
254
264
  }
255
265
 
256
266
  // Where the credential comes from, said out loud. Borrowing someone's `gh`
@@ -2,7 +2,7 @@
2
2
  // it so, for the install that is actually here.
3
3
  //
4
4
  // Measured: after `npm install --global omakit` on a machine whose npm prefix
5
- // was ~/.local/share/lerd/node-global, the command was missing, because that
5
+ // was a directory under ~/.local/share, the command was missing, because that
6
6
  // prefix's `bin` was not on PATH. `omakit setup` then printed the `ln -s` hint
7
7
  // written for a clone, which points at a bin/omakit that npm did not lay out
8
8
  // where the hint assumes. An npm install needs the npm prefix's `bin` on PATH,
@@ -8,3 +8,34 @@ export function omakitCacheDir(name = "", env = process.env) {
8
8
  : join(env.HOME || homedir(), ".cache")
9
9
  return join(base, "omakit", name)
10
10
  }
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
+
25
+ /**
26
+ * Text for a person, with every path under the home directory written the
27
+ * way a shell would take it: `~/.cache/omakit/marketplace`. Only `$HOME`
28
+ * counts, because `~` is what the shell expands to `$HOME` and nothing else;
29
+ * with it unset, or for a path outside it, the text is returned as it came.
30
+ * Human output only: `--json` keeps every path absolute, so this is applied
31
+ * where text is rendered, never where a result is built.
32
+ */
33
+ export function withHomeAbbreviated(text, env = process.env) {
34
+ const home = String(env.HOME || "").replace(/\/+$/, "")
35
+ if (!home || !home.startsWith("/")) return String(text)
36
+ // The home directory where a path starts: at the start of the text or after
37
+ // the characters a path follows in prose or a command, and followed by a
38
+ // separator or the end, so /tmp/home/me/x and /home/meh/x are left alone.
39
+ const literal = home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
40
+ return String(text).replace(new RegExp(`(^|[\\s"'(=:])${literal}(?=/|$|[\\s"'),:])`, "g"), "$1~")
41
+ }
@@ -20,6 +20,7 @@
20
20
  import {
21
21
  action, colourEnabled, COLUMNS, continuation, field, GUTTER, labelled, mark, section, STEP, styler, verdict, width, wrap,
22
22
  } from "./style.mjs"
23
+ import { withHomeAbbreviated } from "./paths.mjs"
23
24
 
24
25
  const body = " ".repeat(GUTTER)
25
26
 
@@ -34,9 +35,10 @@ function stateOf(check) {
34
35
  /**
35
36
  * A check's head line: the mark, the id in bold, and the source in brackets
36
37
  * pushed to the right edge, so the sources form a column of their own and the
37
- * 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.
38
40
  */
39
- function head(state, id, source, c) {
41
+ export function head(state, id, source, c) {
40
42
  const left = `${mark(state, c)}${c("name", id)}`
41
43
  const tag = c("punctuation", `[${source}]`)
42
44
  const gap = Math.max(2, COLUMNS - width(left) - width(tag))
@@ -317,7 +319,13 @@ export function renderVerify(document, { colour = colourEnabled(), blockingRules
317
319
 
318
320
  const DOCTOR_STATE = { ok: "pass", advice: "advisory", problem: "fail", info: "info", unknown: "unknown" }
319
321
 
320
- export function renderDoctor(result, { colour = colourEnabled() } = {}) {
322
+ /**
323
+ * @param {{ colour?: boolean, env?: object }} [options] `env` is where `$HOME`
324
+ * is read from: a path under it is printed as `~/...` for a person, the
325
+ * way a shell takes it, while the result itself, and so `--json`, keeps
326
+ * every path absolute.
327
+ */
328
+ export function renderDoctor(result, { colour = colourEnabled(), env = process.env } = {}) {
321
329
  const c = styler(colour)
322
330
  const out = []
323
331
  let previous = false
@@ -326,8 +334,8 @@ export function renderDoctor(result, { colour = colourEnabled() } = {}) {
326
334
  const loud = state === "fail" || state === "advisory"
327
335
  if (index > 0 && (loud || previous)) out.push("")
328
336
  out.push(`${mark(state, c)}${c("name", check.id)}`)
329
- out.push(...wrap(check.detail, { indent: GUTTER }, c))
330
- if (check.action) out.push(...action(check.action, c))
337
+ out.push(...wrap(withHomeAbbreviated(check.detail, env), { indent: GUTTER }, c))
338
+ if (check.action) out.push(...action(withHomeAbbreviated(check.action, env), c))
331
339
  previous = loud
332
340
  }
333
341
  out.push("")
@@ -20,6 +20,7 @@ import { TAGLINE } from "./usage.mjs"
20
20
  import { installCompletion } from "./completion.mjs"
21
21
  import { submissionContract } from "./form.mjs"
22
22
  import { pathHint } from "./path-hint.mjs"
23
+ import { withHomeAbbreviated } from "./paths.mjs"
23
24
 
24
25
  function version(command) {
25
26
  try {
@@ -32,16 +33,18 @@ function version(command) {
32
33
  }
33
34
 
34
35
  /**
35
- * @param {{ repoRoot: string, entryPoint: string, stream?: NodeJS.WriteStream }} options
36
+ * @param {{ repoRoot: string, entryPoint: string, stream?: NodeJS.WriteStream, env?: object }} options
37
+ * `env` is where `$HOME` is read from: this is output for a person, so a
38
+ * path under it is printed as `~/...`.
36
39
  */
37
- export async function setup({ repoRoot, entryPoint, stream = process.stdout }) {
40
+ export async function setup({ repoRoot, entryPoint, stream = process.stdout, env = process.env }) {
38
41
  const c = styler(colourEnabled(stream))
39
42
  const out = (line = "") => stream.write(`${line}\n`)
40
43
  // A step is a status line: the mark, then the fact, wrapped under itself.
41
- const step = (state, text) => out(`${mark(state, c)}${wrap(text, { indent: GUTTER }, c).join("\n").trimStart()}`)
44
+ const step = (state, text) => out(`${mark(state, c)}${wrap(withHomeAbbreviated(text, env), { indent: GUTTER }, c).join("\n").trimStart()}`)
42
45
  // The one action under a step sits in the step's body; under a sentence it
43
46
  // sits where the sentence does.
44
- const fix = (text, indent = GUTTER) => { for (const line of action(text, c, { indent })) out(line) }
47
+ const fix = (text, indent = GUTTER) => { for (const line of action(withHomeAbbreviated(text, env), c, { indent })) out(line) }
45
48
 
46
49
  // The one place the wordmark runs through `ttfx` (effect.mjs): a first run
47
50
  // already spending seconds fetching the pin.
@@ -59,11 +59,29 @@ export const NPM_UPGRADE_ARGS = Object.freeze(["install", "--global", "--ignore-
59
59
 
60
60
  /** The newest published version, or null when the registry did not answer. */
61
61
  export async function latestOnRegistry(name) {
62
+ return (await registryLatest(name)).version
63
+ }
64
+
65
+ /** The registry the newest version is read from, named in doctor's evidence. */
66
+ export const NPM_REGISTRY = "https://registry.npmjs.org"
67
+
68
+ /**
69
+ * The newest published version, with the reason when there is none: the
70
+ * failure code from the one GET call site (network-unavailable,
71
+ * github-unavailable's npm sibling, not-found for an unpublished name), or
72
+ * `unpublished` when the registry answered without a version. `doctor` prints
73
+ * the code; `upgrade` only needs the version.
74
+ *
75
+ * @returns {Promise<{ version: string|null, error: { code: string, message: string }|null }>}
76
+ */
77
+ export async function registryLatest(name) {
62
78
  try {
63
- const meta = await getJson(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`)
64
- return meta?.version || null
65
- } catch {
66
- return null
79
+ const meta = await getJson(`${NPM_REGISTRY}/${encodeURIComponent(name)}/latest`)
80
+ return meta?.version
81
+ ? { version: meta.version, error: null }
82
+ : { version: null, error: { code: "unpublished", message: "the registry answered without a version" } }
83
+ } catch (error) {
84
+ return { version: null, error: { code: error?.code || "error", message: String(error?.message || error) } }
67
85
  }
68
86
  }
69
87
 
@@ -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