omakit 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,20 +48,18 @@ review writing. The rest of the benefit is the submitter's.
48
48
 
49
49
  ## Install
50
50
 
51
- On Omarchy, or any Arch:
52
-
53
- ```bash
54
- yay -S omakit
55
- omakit setup
56
- ```
57
-
58
- Anywhere with Node 22 or newer:
59
-
60
51
  ```bash
61
52
  npm install --global omakit
62
53
  omakit setup
63
54
  ```
64
55
 
56
+ If `omakit` is not found afterwards, npm's global `bin` is not on your PATH
57
+ (measured: a prefix of `~/.local/share/lerd/node-global` whose `bin` no shell
58
+ searched). Run `"$(npm prefix --global)/bin/omakit" setup` once: it prints the
59
+ one line that puts that directory on PATH for the shell in `$SHELL`, and the
60
+ rc file to keep it in; `omakit doctor` reports the same as `omakit.path`.
61
+ Nothing writes to your rc file.
62
+
65
63
  Or read what you run:
66
64
 
67
65
  ```bash
@@ -72,15 +70,16 @@ omakit setup
72
70
 
73
71
  | Needs | Why |
74
72
  | --- | --- |
75
- | Node 22 or newer | the tool is plain ESM with no dependencies and no build step. A stock Omarchy has it through `mise`, along with `git`, `yay`, `gh` and `ttfx` |
73
+ | Node 22 or newer | the tool is plain ESM with no dependencies and no build step. A stock Omarchy has Node and npm through `mise`, along with `git`, `gh` and `ttfx` |
76
74
  | `git` | the pin, and reading a subject's tree at an exact commit |
77
75
  | network, once | `omakit pin`. After that, `submit` and `verify` on a local repository need none at all |
78
76
  | 15 MB on disk | the pinned checkout, in `$XDG_CACHE_HOME/omakit/marketplace`, or `~/.cache/omakit/marketplace` |
79
77
 
80
- Updates come from whichever installer you used: `omarchy-update` or `yay -Syu`
81
- for the package, `npm i -g omakit@latest` for npm, `omakit upgrade` for a
82
- clone. Nothing in omakit updates itself, and `omarchy-mise-install npm:omakit`
83
- would, so it is not the way in.
78
+ `omakit upgrade` updates either install through the installer that made it:
79
+ `npm` for the package, at the exact version the registry names, and a
80
+ fast-forward for a clone. `omakit doctor` says when a newer version is
81
+ published. Nothing in omakit fetches and runs its own replacement, and
82
+ `omarchy-mise-install npm:omakit` would, so it is not the way in.
84
83
 
85
84
  ## Watch
86
85
 
@@ -110,7 +109,7 @@ omakit verify <plugin-repo> # the official security baseline over the local tra
110
109
  omakit parity # the baseline over GitHub versus the local transport, on real listings; writes the evidence
111
110
  omakit doctor # what is installed, what is pinned, and what has moved
112
111
  omakit pin # what setup does for the pin, on its own
113
- omakit upgrade # fast-forwards this checkout of omakit itself
112
+ omakit upgrade # updates omakit through its own installer: npm, or a fast-forward
114
113
  omakit help --agent # the operating instructions, for the agent running this
115
114
  ```
116
115
 
@@ -129,11 +128,12 @@ There is nothing to authenticate. If you have `gh auth login` done, omakit
129
128
  reads that credential for GET requests and stores nothing; a token in
130
129
  `GH_TOKEN` or `GITHUB_TOKEN` reaches it the same way, because `gh` honours
131
130
  those itself. Without either, `watch` and `parity` share GitHub's
132
- 60-requests-an-hour unauthenticated allowance, and `submit` and `verify` on a
133
- local repository do not touch the network at all (a `<url>@<sha>` target is
134
- fetched once, over git, into the cache). omakit reads no environment variable of its own,
135
- and `omakit doctor` names the credential source it found, or that it found
136
- none.
131
+ 60-requests-an-hour unauthenticated allowance; `submit` reads two things
132
+ online, the subject's default-branch HEAD and the marketplace's current
133
+ registry, and `--offline` turns both off; `verify` on a local repository does
134
+ not touch the network at all (a `<url>@<sha>` target is fetched once, over
135
+ git, into the cache). omakit reads no environment variable of its own, and
136
+ `omakit doctor` names the credential source it found, or that it found none.
137
137
 
138
138
  Every colour omakit prints is an ANSI palette index, so your Omarchy theme
139
139
  decides what it looks like, and nothing is said by colour alone. What the
@@ -149,7 +149,11 @@ tags and the exact text of the five checklist items are all read from
149
149
  `.github/ISSUE_TEMPLATE/submit-plugin.yml` in a marketplace checkout pinned to an
150
150
  exact commit. The rendered body is then handed to the marketplace's own
151
151
  `parseCurrentSubmission` from that same commit. If it accepts the body here, it
152
- accepts it there.
152
+ accepts it there. The one exception is data, not rules: the registry and the
153
+ catalog that say which ids and repositories are already listed are read from
154
+ the marketplace's current HEAD when the network is there, because the pin's
155
+ copy is stale within hours (4,201 of 4,293 commits in 30 days touched only
156
+ `registry.json`), and from the pin with `--offline`.
153
157
 
154
158
  The security baseline is the marketplace's own code, imported unmodified and run
155
159
  over a local snapshot with no network. Omakit adds no rule, renames no outcome,
@@ -169,18 +173,20 @@ move on its own. That distinction is now enforced rather than argued.
169
173
  **The tool:**
170
174
 
171
175
  ```bash
172
- omakit upgrade # fast-forwards this checkout of omakit itself
176
+ omakit upgrade # the npm package, or a clone: through its own installer
173
177
  omakit upgrade --dry-run
174
178
  ```
175
179
 
176
- It refuses a dirty tree, a detached HEAD, a remote that is not this repository,
177
- and anything that is not a fast-forward, and it names what to run yourself in
178
- each case. It is not a self-updater of the kind this repository warns other
179
- people about: it fast-forwards a Git checkout you cloned, from the remote you
180
- cloned it from, and it touches nothing else. On a package install it says so and
181
- names the installer's own command: `npm i -g omakit@latest`, or
182
- `sudo pacman -Syu omakit` for the Arch package. `git -C ~/.local/share/omakit pull` still works
183
- and does the same thing.
180
+ It is not a self-updater of the kind this repository warns other people
181
+ about: it never fetches and runs its own replacement. On an npm install it asks
182
+ the registry for the newest version and, if that is newer, runs the `npm` on
183
+ PATH with frozen arguments (`npm install --global --ignore-scripts omakit@<that
184
+ version>`, never `@latest`, never with sudo), and it refuses when the npm on
185
+ PATH is not the one that installed it. On a clone it fast-forwards from the
186
+ remote you cloned it from, and refuses a dirty tree, a detached HEAD, a remote
187
+ that is not this repository, and anything that is not a fast-forward. In every
188
+ refusal it names what to run yourself. `git -C ~/.local/share/omakit pull`
189
+ still works on a clone and does the same thing.
184
190
 
185
191
  **The pin** does not move by itself, ever, and `omakit upgrade` does not move it
186
192
  either: a test asserts that its source does not so much as mention the pin or
@@ -188,7 +194,9 @@ the cache. Bumping it changes where the submission contract and the baseline
188
194
  policy are read from, and the procedure in
189
195
  [docs/UPSTREAM_CONTRACT.md](docs/UPSTREAM_CONTRACT.md) ends in re-proving
190
196
  transport parity and committing the evidence. `omakit doctor` tells you when the
191
- pin is behind the marketplace's current branch and then leaves it alone. That the
197
+ pin is behind the marketplace's current branch, names which of the paths omakit
198
+ reads actually changed (about 140 commits a day touch only `registry.json`, so
199
+ "behind" alone would be true of every run), and then leaves it alone. That the
192
200
  pin can go stale unnoticed is the same defect class `omakit watch` reports, so it
193
201
  would be poor form to hide it here.
194
202
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
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, posts nothing, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Maarten Tolhuijs",
@@ -28,7 +28,14 @@ them. The failing checks print the controlled lists, read from the pinned form.
28
28
 
29
29
  Useful flags: `--notes` for the Maintainer notes field, `--suggest-tag` for the
30
30
  optional suggestion, `--name` when the manifest has no name, `--json` for a
31
- machine-readable result, `--offline` to skip the one check that needs the network.
31
+ machine-readable result, `--offline` to skip the one check that needs the network
32
+ and to read the listed ids from the pin instead of the marketplace's current HEAD.
33
+
34
+ Never pass `--offline` to get around a listed id. `identity.available` reads the
35
+ registry from HEAD because the pin's copy is stale within hours; a run that
36
+ passes only against the pin describes a marketplace that no longer exists, and
37
+ the marketplace will refuse the id anyway. If an id or repository is listed at
38
+ HEAD, tell the owner and choose another id.
32
39
 
33
40
  ## Reading the result
34
41
 
@@ -35,6 +35,11 @@ it from the operator's `gh` login (which itself honours `GH_TOKEN` and
35
35
  `GITHUB_TOKEN`); without one it falls back to the public commit feed. Never ask the operator for a token: run
36
36
  `omakit doctor` and read the `github.auth` line, which names the source.
37
37
 
38
+ If the diagnosis ends in a resubmission under a new id, run `omakit submit`
39
+ online: never pass `--offline` to get around a listed id, because that reads
40
+ the listed ids from the pin, which is stale within hours, and the marketplace
41
+ refuses against its current registry.
42
+
38
43
  ## Acting on each verdict
39
44
 
40
45
  **`stale`.** The validated commit is behind the repository. Tell the owner plainly: the marketplace has not
@@ -14,7 +14,7 @@ local commit through the transport seam the marketplace tests itself
14
14
  | `preflight.mjs` | Translates that result into what it will cause on submission, using the pinned policy, and renders the marketplace's own report text with its attestation marker stripped and asserted absent. |
15
15
  | `yaml.mjs` | A deliberately small YAML reader for the pinned issue form. Accepts that subset and throws on anything else. |
16
16
  | `form.mjs` | The submission contract, read from the form and cross-checked against the marketplace's own constants. |
17
- | `registry.mjs` | The plugin-id and repository universe, and the reserved namespace, read from the pinned registry, catalog and catalog builder. |
17
+ | `registry.mjs` | The plugin-id and repository universe: the reserved namespace from the pinned catalog builder, the listed and retired ids and listed repositories from `registry.json` and `site/catalog.json` at the marketplace's current HEAD when the network is there (cached under `$XDG_CACHE_HOME/omakit/registry/<commit>/`, never in the pin) and at the pin with `--offline`; `liveFileUrl()` is the only way to the raw file host, at a 40-character commit, for those two files. |
18
18
  | `tree.mjs` | The installable tree of a subject at one exact commit, from the Git object database. |
19
19
  | `plugin.mjs` | The root files the submission contract needs, and the declared plugin identity. |
20
20
  | `agent-control.mjs` | The recursive agent-control warning, and its remedy. |
@@ -24,6 +24,7 @@ local commit through the transport seam the marketplace tests itself
24
24
  | `github.mjs` | Read-only GitHub access. GET only. The credential is your `gh` login, read through one frozen `gh auth token` call, and is never written anywhere. |
25
25
  | `style.mjs` | The visual system, defined once: the palette, the status vocabulary, the block ramp, the columns, the motion budgets, and the composition helpers every command draws with. `docs/TUI.md` explains it. |
26
26
  | `report.mjs` | Text rendering of submit, watch and doctor for the agent that runs this tool, and the person reading over its shoulder. |
27
+ | `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. |
27
28
  | `usage.mjs` | The help text, as data. |
28
29
  | `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. |
29
30
  | `banner.mjs` | The wordmark, on a bare `omakit` and in `setup` only. |
@@ -57,8 +58,10 @@ the registry recorded so the corpus always contains repositories that are not
57
58
  side rather than the findings themselves. The GitHub side uses whatever
58
59
  credential `github.mjs` resolves (a `gh` login, and only that), read-only.
59
60
 
60
- `parity` and `watch` are the only commands that reach the network, and they do it
61
- with Node's built-in `fetch`, which does not read proxy environment variables by
62
- default. Behind a proxy, run them with `NODE_USE_ENV_PROXY=1`. `submit` and
63
- `verify` need no network at all beyond fetching a reviewer-mode subject, and
64
- `tests/parity/offline.mjs` proves it.
61
+ `parity`, `watch`, `doctor` and `submit` reach the network, with Node's
62
+ built-in `fetch`, which does not read proxy environment variables by default.
63
+ Behind a proxy, run them with `NODE_USE_ENV_PROXY=1`. `submit` reads two things
64
+ online, the subject's default-branch HEAD and the marketplace's current
65
+ registry, and `--offline` turns both off; `verify` needs no network at all
66
+ beyond fetching a reviewer-mode subject, and `tests/parity/offline.mjs` proves
67
+ it.
@@ -4,9 +4,10 @@
4
4
  // checkout, because the two things a person might want "upgraded" here are not
5
5
  // the same thing and only one of them may ever move on its own.
6
6
  //
7
- // The tool version belongs to the installer: Git for a checkout, npm for its
8
- // package, and pacman for the Arch package. A CLI that fetches and executes its
9
- // own replacement is the supply-chain shape this repository warns about.
7
+ // The tool version belongs to the installer: `omakit upgrade` hands it to Git
8
+ // for a checkout and to npm for its package, and names pacman for a distro
9
+ // package. A CLI that fetches and executes its own replacement is the
10
+ // supply-chain shape this repository warns about.
10
11
  //
11
12
  // The pin must not move by itself. Bumping it changes where the submission
12
13
  // contract is read from, and the procedure in docs/UPSTREAM_CONTRACT.md requires
@@ -16,13 +17,22 @@
16
17
  //
17
18
  // That the pin goes stale unnoticed is, of course, exactly the defect class
18
19
  // `omakit watch` exists to report. It would be poor form not to apply it here.
20
+ //
21
+ // And "behind" is not the same as "behind in something omakit reads". Measured
22
+ // on 2026-09-13 (docs/MEASUREMENTS.md M7): 4,201 of the marketplace's 4,293
23
+ // commits in 30 days touched only registry.json, which omakit now reads live,
24
+ // while nothing under scripts/ or .github/ISSUE_TEMPLATE/ changed since the
25
+ // pin. A doctor that only said "behind" would say it about every run. So it
26
+ // compares each path in PIN_PATHS between the pin and HEAD, by tree or blob
27
+ // id, and names the ones that moved.
19
28
 
20
29
  import { execFileSync } from "node:child_process"
21
30
  import { readFileSync } from "node:fs"
22
31
  import { join } from "node:path"
23
- import { MARKETPLACE_PIN, marketplacePinDir, pinDiskUsage, pinIsSparse, requirePin } from "./pin.mjs"
32
+ import { MARKETPLACE_PIN, PIN_PATHS, marketplacePinDir, pinDiskUsage, pinIsSparse, requirePin } from "./pin.mjs"
24
33
  import { credential, defaultBranchHead, getJson, UNAUTHENTICATED_LIMIT, GitHubError } from "./github.mjs"
25
- import { upgradeCommand } from "./upgrade.mjs"
34
+ import { latestOnRegistry, upgradeCommand } from "./upgrade.mjs"
35
+ import { pathHint } from "./path-hint.mjs"
26
36
 
27
37
  function tool(repoRoot) {
28
38
  try {
@@ -41,37 +51,91 @@ function version(command, args = ["--version"]) {
41
51
  }
42
52
  }
43
53
 
44
- async function latestOnRegistry(name) {
45
- try {
46
- const meta = await getJson(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`)
47
- return meta?.version || null
48
- } catch {
49
- return null
54
+ /** The object id a path has at a commit in the pinned checkout: a tree id for a directory, a blob id for a file. Local; a blob-filtered clone still has every tree. */
55
+ function pinObjectId(pinDir, commit, path) {
56
+ return execFileSync("git", ["-C", pinDir, "rev-parse", `${commit}:${path}`], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
57
+ }
58
+
59
+ /**
60
+ * Which of PIN_PATHS differ between the pin and `headCommit`. The pin side is
61
+ * read from the local checkout; the HEAD side walks the git-trees API from the
62
+ * exact commit, one read per tree on the way (three for PIN_PATHS as it
63
+ * stands), so a directory compares by its tree id and a file by its blob id,
64
+ * which is what "the blob at HEAD versus the blob at the pin" means for a
65
+ * directory that omakit reads whole. A path missing at HEAD counts as changed.
66
+ *
67
+ * `fetchJson` is injectable for tests; the default is the one GET call site.
68
+ */
69
+ export async function changedPinPaths({ pinDir, headCommit, pinCommit = MARKETPLACE_PIN.commit, fetchJson = getJson }) {
70
+ const match = MARKETPLACE_PIN.repository.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/)
71
+ const trees = new Map()
72
+ const tree = async (sha) => {
73
+ if (!trees.has(sha)) {
74
+ const read = await fetchJson(`https://api.github.com/repos/${match[1]}/${match[2]}/git/trees/${sha}`)
75
+ if (!Array.isArray(read?.tree)) throw new GitHubError("head-unreadable", `the tree ${sha} at the marketplace's HEAD did not read as a tree`)
76
+ trees.set(sha, read.tree)
77
+ }
78
+ return trees.get(sha)
79
+ }
80
+ const headObjectId = async (path) => {
81
+ const segments = path.split("/")
82
+ let entries = await tree(headCommit)
83
+ let id = null
84
+ for (const [index, segment] of segments.entries()) {
85
+ const entry = entries.find((candidate) => candidate.path === segment)
86
+ if (!entry) return null
87
+ id = entry.sha
88
+ // Descend only on the way to a deeper segment: the path's own tree id
89
+ // is the comparison, and its contents need no read.
90
+ if (index < segments.length - 1) {
91
+ if (entry.type !== "tree") return null
92
+ entries = await tree(entry.sha)
93
+ }
94
+ }
95
+ return id
96
+ }
97
+ const changed = []
98
+ for (const pattern of PIN_PATHS) {
99
+ const path = pattern.replace(/^\/|\/$/g, "")
100
+ if (pinObjectId(pinDir, pinCommit, path) !== await headObjectId(path)) changed.push(pattern)
50
101
  }
102
+ return changed
51
103
  }
52
104
 
53
- /** Full pin evidence for machines; human output deliberately keeps short hashes. */
54
- export function pinFreshness(identity, head) {
105
+ /**
106
+ * Full pin evidence for machines; human output deliberately keeps short
107
+ * hashes. `changedPaths` is the answer from changedPinPaths(); null when the
108
+ * comparison was not made, and then the detail says only that HEAD moved.
109
+ */
110
+ export function pinFreshness(identity, head, changedPaths = null) {
55
111
  const current = head.commit === identity.commit
112
+ const branch = head.branch || "default"
113
+ const moved = changedPaths === null
114
+ ? ""
115
+ : changedPaths.length
116
+ ? `; changed since the pin: ${changedPaths.join(", ")}`
117
+ : "; every path omakit reads is unchanged since the pin"
56
118
  return {
57
119
  id: "pin.freshness",
58
120
  state: current ? "ok" : "advice",
59
121
  detail: current
60
- ? `the pin is the marketplace's current ${head.branch || "default"}-branch HEAD`
61
- : `the pin is ${identity.commit.slice(0, 7)}; the marketplace's ${head.branch || "default"} branch is now at ${head.commit.slice(0, 7)}`,
122
+ ? `the pin is the marketplace's current ${branch}-branch HEAD`
123
+ : `the pin is ${identity.commit.slice(0, 7)}; the marketplace's ${branch} branch is now at ${head.commit.slice(0, 7)}${moved}`,
62
124
  action: current ? null : "Bumping the pin is a deliberate change: docs/UPSTREAM_CONTRACT.md has the procedure, which ends in re-proving parity and committing its evidence. Nothing here does it for you.",
63
125
  evidence: {
64
126
  pinCommit: identity.commit,
65
127
  marketplaceHead: head.commit,
66
- branch: head.branch || "default",
128
+ branch,
129
+ ...(changedPaths === null ? {} : { changedPaths: current ? [] : changedPaths }),
67
130
  },
68
131
  }
69
132
  }
70
133
 
71
134
  /**
72
- * @param {{ repoRoot: string, offline?: boolean }} options
135
+ * @param {{ repoRoot: string, offline?: boolean, env?: object, npmPrefix?: () => string|null }} options
136
+ * `env` and `npmPrefix` are injectable for tests of the PATH check.
73
137
  */
74
- export async function doctor({ repoRoot, offline = false, onPhase }) {
138
+ export async function doctor({ repoRoot, offline = false, onPhase, env = process.env, npmPrefix }) {
75
139
  // Optional: told what is being read while the network answers. Never
76
140
  // affects the result.
77
141
  const phase = onPhase || (() => {})
@@ -87,6 +151,16 @@ export async function doctor({ repoRoot, offline = false, onPhase }) {
87
151
  const self = tool(repoRoot)
88
152
  add("omakit.version", "info", `${self.name} ${self.version}`)
89
153
 
154
+ // Reachable as a bare command, or the one line that makes it so for this
155
+ // install (path-hint.mjs). Measured: an npm prefix whose bin is not on PATH
156
+ // installs a command nobody can run, and nothing said so.
157
+ const reach = pathHint({ repoRoot, entryPoint: join(repoRoot, "bin/omakit"), env, ...(npmPrefix ? { npmPrefix } : {}) })
158
+ add("omakit.path", reach.reachable ? "ok" : "advice",
159
+ reach.reachable
160
+ ? `\`omakit\` is reachable as a command from PATH (${reach.kind} install)`
161
+ : `${reach.reason}${reach.where ? ` Keep the line below in ${reach.where}.` : ""}`,
162
+ reach.reachable ? null : reach.line)
163
+
90
164
  const node = process.versions.node
91
165
  const major = Number(node.split(".")[0])
92
166
  add("node", major >= 22 ? "ok" : "problem", `node ${node}${self.engines ? ` (needs ${self.engines})` : ""}`,
@@ -112,7 +186,12 @@ export async function doctor({ repoRoot, offline = false, onPhase }) {
112
186
  phase("reading the marketplace's current default-branch HEAD")
113
187
  try {
114
188
  const head = await defaultBranchHead(MARKETPLACE_PIN.repository)
115
- checks.push(pinFreshness(identity, head))
189
+ let changedPaths = []
190
+ if (head.commit !== identity.commit) {
191
+ phase("comparing each path omakit reads between the pin and HEAD")
192
+ changedPaths = await changedPinPaths({ pinDir: dir, headCommit: head.commit, pinCommit: identity.commit })
193
+ }
194
+ checks.push(pinFreshness(identity, head, changedPaths))
116
195
  } catch (error) {
117
196
  add("pin.freshness", "unknown", `could not read the marketplace's HEAD (${error.code || "error"})`,
118
197
  error.code === "network-unavailable" ? "Connect to the network, or pass --offline to skip the two checks that need it." : null,
@@ -0,0 +1,53 @@
1
+ // Is `omakit` reachable as a bare command, and if not, the one line that makes
2
+ // it so, for the install that is actually here.
3
+ //
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
6
+ // prefix's `bin` was not on PATH. `omakit setup` then printed the `ln -s` hint
7
+ // written for a clone, which points at a bin/omakit that npm did not lay out
8
+ // where the hint assumes. An npm install needs the npm prefix's `bin` on PATH,
9
+ // and the way to say that depends on the shell in $SHELL. Nothing here writes
10
+ // to an rc file: the line is printed, and adding it is the user's.
11
+
12
+ import { existsSync } from "node:fs"
13
+ import { basename, delimiter, join } from "node:path"
14
+ import { installKind, npmGlobalPrefix } from "./upgrade.mjs"
15
+
16
+ /** Is `name` reachable as a bare command, without asking a shell? */
17
+ export function onPath(name = "omakit", env = process.env) {
18
+ return (env.PATH || "").split(delimiter).some((dir) => dir && existsSync(join(dir, name)))
19
+ }
20
+
21
+ /** The rc file each supported shell reads at start, for the person to add the line to. */
22
+ const RC = Object.freeze({ bash: "~/.bashrc", zsh: "~/.zshrc" })
23
+
24
+ /**
25
+ * @param {{ repoRoot: string, entryPoint: string, env?: object, npmPrefix?: () => string|null }} options
26
+ * `npmPrefix` is injectable for tests; the default asks the npm on PATH.
27
+ * @returns {{ kind: "git"|"npm"|"distro", reachable: boolean, reason: string|null,
28
+ * line: string|null, where: string|null }}
29
+ * `line` is the command that puts `omakit` on PATH; `where` is the rc file
30
+ * to keep it in, or null when the shell keeps it itself (fish) or is not
31
+ * one of the three with a script.
32
+ */
33
+ export function pathHint({ repoRoot, entryPoint, env = process.env, npmPrefix = npmGlobalPrefix }) {
34
+ const kind = installKind(repoRoot)
35
+ if (onPath("omakit", env)) return { kind, reachable: true, reason: null, line: null, where: null }
36
+ if (kind !== "npm") {
37
+ return { kind, reachable: false, reason: "`omakit` is not on your PATH yet.", line: `ln -s ${entryPoint} ~/.local/bin/omakit`, where: null }
38
+ }
39
+ const prefix = npmPrefix()
40
+ if (!prefix) {
41
+ return { kind, reachable: false, reason: "`omakit` is not on your PATH yet, and no `npm` is on PATH to ask where it was installed.", line: null, where: null }
42
+ }
43
+ const bin = join(prefix, "bin")
44
+ const shell = basename(env.SHELL || "")
45
+ const line = shell === "fish" ? `fish_add_path ${bin}` : `export PATH="${bin}:$PATH"`
46
+ return {
47
+ kind,
48
+ reachable: false,
49
+ reason: `\`omakit\` is not on your PATH yet: npm installed it under ${bin}, which your shell does not search.`,
50
+ line,
51
+ where: RC[shell] || null,
52
+ }
53
+ }
@@ -1,8 +1,7 @@
1
- // The plugin-id and repository universe, read from the pinned marketplace
2
- // checkout. Like the submission contract, nothing here is hardcoded: the
3
- // reserved namespace is read out of the marketplace's own catalog builder, the
4
- // listed ids out of the published catalog and the registry sources, and the
5
- // retired ids out of `registry.json`.
1
+ // The plugin-id and repository universe. Like the submission contract, nothing
2
+ // here is hardcoded: the reserved namespace is read out of the marketplace's
3
+ // own catalog builder, the listed ids out of the published catalog and the
4
+ // registry sources, and the retired ids out of `registry.json`.
6
5
  //
7
6
  // Measured reason these three checks exist (docs/MEASUREMENTS.md M2): the
8
7
  // marketplace refuses a submission whose id is already listed
@@ -13,15 +12,31 @@
13
12
  // median submission-to-publication time rose sevenfold between 2026-W33 and
14
13
  // 2026-W36, and it is knowable before submitting from data the marketplace
15
14
  // publishes.
15
+ //
16
+ // Two kinds of file, two sources (docs/MEASUREMENTS.md M7). The catalog
17
+ // builder is code, and code is only ever read from the pin: it moves a few
18
+ // times a month and must never be fetched and executed unreviewed. The
19
+ // registry and the catalog are data, and the pin's copy of them is stale
20
+ // within hours: registry.json changed in 4,201 of the marketplace's 4,293
21
+ // commits in the 30 days to 2026-09-13, about 140 a day. So those two files
22
+ // are read from the marketplace's current default-branch HEAD when the network
23
+ // is there, at the exact commit `defaultBranchHead()` resolved so they cannot
24
+ // disagree with each other, and from the pin when it is not or when the caller
25
+ // asks for --offline. Every result says which, with the commit.
16
26
 
17
- import { readFileSync } from "node:fs"
18
- import { join } from "node:path"
19
- import { requirePin } from "./pin.mjs"
27
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
28
+ import { dirname, join } from "node:path"
29
+ import { MARKETPLACE_PIN, requirePin } from "./pin.mjs"
30
+ import { defaultBranchHead, getJson } from "./github.mjs"
31
+ import { omakitCacheDir } from "./paths.mjs"
20
32
 
21
33
  export const CATALOG_PATH = "site/catalog.json"
22
34
  export const REGISTRY_PATH = "registry.json"
23
35
  export const CATALOG_BUILDER_PATH = "scripts/build-catalog.mjs"
24
36
 
37
+ /** The only two marketplace files ever read from HEAD. Everything else comes from the pin. */
38
+ export const LIVE_PATHS = Object.freeze([REGISTRY_PATH, CATALOG_PATH])
39
+
25
40
  export class RegistryError extends Error {
26
41
  constructor(code, message) {
27
42
  super(message)
@@ -65,14 +80,168 @@ function repositorySlug(value) {
65
80
  }
66
81
 
67
82
  /**
68
- * @param {{ repoRoot?: string, pinDir?: string }} [options]
83
+ * The one URL shape a live registry file is read from: one of LIVE_PATHS at
84
+ * one explicit 40-character commit on the marketplace's raw file host. Never
85
+ * a branch name, so the two files always come from the same commit and the
86
+ * commit named in the output is the one they came from; never a path outside
87
+ * LIVE_PATHS, so nothing executable can arrive this way.
88
+ */
89
+ export function liveFileUrl(commit, path) {
90
+ if (!/^[0-9a-f]{40}$/.test(String(commit))) {
91
+ throw new RegistryError("usage", `a live registry file is read at a 40-character commit, not "${commit}"`)
92
+ }
93
+ if (!LIVE_PATHS.includes(path)) {
94
+ throw new RegistryError("usage", `${path} is never read from HEAD; only ${LIVE_PATHS.join(" and ")} are`)
95
+ }
96
+ const raw = MARKETPLACE_PIN.repository.replace(/^https:\/\/github\.com\//, "https://raw.githubusercontent.com/")
97
+ return `${raw}/${commit}/${path}`
98
+ }
99
+
100
+ /** Where the live files for one commit are kept: beside the pin, never inside it. */
101
+ export function liveCacheDir(commit, cacheRoot = omakitCacheDir("registry")) {
102
+ return join(cacheRoot, commit)
103
+ }
104
+
105
+ /** A cached commit is one whose meta.json, written last, names every file present. */
106
+ function readCached(liveCache) {
107
+ try {
108
+ const meta = JSON.parse(readFileSync(join(liveCache, "meta.json"), "utf8"))
109
+ if (typeof meta.fetchedAt !== "string" || JSON.stringify(meta.paths) !== JSON.stringify(LIVE_PATHS)) return null
110
+ const files = {}
111
+ for (const path of LIVE_PATHS) files[path] = JSON.parse(readFileSync(join(liveCache, path), "utf8"))
112
+ return { fetchedAt: meta.fetchedAt, files }
113
+ } catch {
114
+ return null
115
+ }
116
+ }
117
+
118
+ /**
119
+ * One directory per commit, the files first and meta.json last, so a run that
120
+ * dies mid-write leaves a directory readCached() does not accept. Older
121
+ * commits are removed: at about 140 registry commits a day, keeping every one
122
+ * would grow the cache by the measured 13 MB per run.
123
+ */
124
+ function writeCached(cacheRoot, commit, fetchedAt, files) {
125
+ const liveCache = liveCacheDir(commit, cacheRoot)
126
+ rmSync(liveCache, { recursive: true, force: true })
127
+ for (const path of LIVE_PATHS) {
128
+ mkdirSync(dirname(join(liveCache, path)), { recursive: true })
129
+ writeFileSync(join(liveCache, path), JSON.stringify(files[path]))
130
+ }
131
+ writeFileSync(join(liveCache, "meta.json"), `${JSON.stringify({ commit, fetchedAt, paths: LIVE_PATHS }, null, 2)}\n`)
132
+ for (const entry of readdirSync(cacheRoot, { withFileTypes: true })) {
133
+ if (entry.isDirectory() && entry.name !== commit) rmSync(join(cacheRoot, entry.name), { recursive: true, force: true })
134
+ }
135
+ }
136
+
137
+ /**
138
+ * The registry and the catalog, from the marketplace's current default-branch
139
+ * HEAD when the network is there and from the pin when it is not. Never
140
+ * throws for a network reason: a HEAD that cannot be read falls back to the pin
141
+ * and says so in `reason`. The pinned checkout is never written to; a fetched
142
+ * pair is cached under `cacheRoot/<commit>/`.
143
+ *
144
+ * `resolveHead`, `fetchJson`, `cacheRoot` and `now` are injectable for tests;
145
+ * the defaults are the tool's one HEAD resolver and its one GET call site.
146
+ *
147
+ * @param {{ repoRoot?: string, pinDir?: string, offline?: boolean,
148
+ * resolveHead?: (url: string) => Promise<{ commit: string }>,
149
+ * fetchJson?: (url: string) => Promise<object>, cacheRoot?: string,
150
+ * now?: () => string }} [options]
151
+ * @returns {Promise<{ source: "head"|"pin", commit: string, fetchedAt: string|null,
152
+ * reason: string|null, fallback: { what: string, code: string, message: string }|null,
153
+ * registry: object, catalog: object }>}
154
+ * `reason` is the whole story, for `--json`; `fallback` is its parts, for
155
+ * the one line a person reads (registrySourceDetail).
156
+ */
157
+ export async function liveRegistry(options = {}) {
158
+ const pinDir = options.pinDir || requirePin(options.repoRoot).dir
159
+ const resolveHead = options.resolveHead || defaultBranchHead
160
+ const fetchJson = options.fetchJson || getJson
161
+ const cacheRoot = options.cacheRoot || omakitCacheDir("registry")
162
+ const now = options.now || (() => new Date().toISOString())
163
+ const fromPin = (reason, fallback = null) => ({
164
+ source: "pin",
165
+ commit: MARKETPLACE_PIN.commit,
166
+ fetchedAt: null,
167
+ reason,
168
+ fallback,
169
+ registry: readJson(pinDir, REGISTRY_PATH),
170
+ catalog: readJson(pinDir, CATALOG_PATH),
171
+ })
172
+ const failed = (what, error) => {
173
+ const code = error?.code || "error"
174
+ const message = String(error?.message || error)
175
+ return fromPin(`${what} (${code}): ${message}`, { what, code, message })
176
+ }
177
+ if (options.offline) return fromPin("--offline")
178
+
179
+ let commit
180
+ try {
181
+ commit = String((await resolveHead(MARKETPLACE_PIN.repository)).commit).toLowerCase()
182
+ } catch (error) {
183
+ return failed("HEAD unreadable", error)
184
+ }
185
+ if (commit === MARKETPLACE_PIN.commit) {
186
+ // HEAD is the pin, so the pin's files are HEAD's files: nothing to fetch.
187
+ return { ...fromPin(null), source: "head", fetchedAt: now() }
188
+ }
189
+
190
+ const cached = readCached(liveCacheDir(commit, cacheRoot))
191
+ if (cached) {
192
+ return { source: "head", commit, fetchedAt: cached.fetchedAt, reason: null, fallback: null, registry: cached.files[REGISTRY_PATH], catalog: cached.files[CATALOG_PATH] }
193
+ }
194
+
195
+ const files = {}
196
+ try {
197
+ for (const path of LIVE_PATHS) files[path] = await fetchJson(liveFileUrl(commit, path))
198
+ } catch (error) {
199
+ return failed(`registry at ${commit} unreadable`, error)
200
+ }
201
+ const fetchedAt = now()
202
+ try {
203
+ mkdirSync(cacheRoot, { recursive: true })
204
+ writeCached(cacheRoot, commit, fetchedAt, files)
205
+ } catch {
206
+ // A cache that cannot be written costs the next run a refetch, nothing else.
207
+ }
208
+ return { source: "head", commit, fetchedAt, reason: null, fallback: null, registry: files[REGISTRY_PATH], catalog: files[CATALOG_PATH] }
209
+ }
210
+
211
+ /**
212
+ * How a check names where its registry data came from. Short hash for the
213
+ * pin, which the docs name that way; the full commit for HEAD, which nothing
214
+ * else names.
215
+ *
216
+ * A fallback is one clause, not the whole story: the transport error's own
217
+ * parenthetical and its "while reading <path>" tail are dropped and the
218
+ * failure code takes their place, so the person reads
219
+ * "registry at the pin 38060f89; HEAD unreadable: github.com did not answer
220
+ * (network-unavailable)" on one line. Measured before this: the full text
221
+ * nested three sets of parentheses and wrapped to three lines at 80 columns.
222
+ * `--json` keeps the whole text under `registry.reason`.
223
+ */
224
+ export function registrySourceDetail(live) {
225
+ if (live.source === "head") return `registry at ${live.commit}, read ${live.fetchedAt}`
226
+ const pin = `registry at the pin ${live.commit.slice(0, 8)}`
227
+ if (!live.fallback) return live.reason === "--offline" ? `${pin} (offline)` : pin
228
+ const { what, code, message } = live.fallback
229
+ const clause = message.replace(/ while reading .*$/, "").replace(/\s*\([^()]*\)\s*$/, "").trim()
230
+ const short = what.replace(/^registry at ([0-9a-f]{40}) unreadable$/, (_, sha) => `HEAD ${sha.slice(0, 7)} unreadable`)
231
+ return `${pin}; ${short}: ${clause} (${code})`
232
+ }
233
+
234
+ /**
235
+ * @param {{ repoRoot?: string, pinDir?: string, registry?: object, catalog?: object }} [options]
236
+ * `registry` and `catalog` are the parsed files from liveRegistry(); without
237
+ * them the pin's copies are read.
69
238
  * @returns {{ reservedPrefix: string, listedIds: Set<string>, retiredIds: Set<string>,
70
239
  * listedRepositories: Set<string>, counts: object }}
71
240
  */
72
241
  export function idUniverse(options = {}) {
73
242
  const pinDir = options.pinDir || requirePin(options.repoRoot).dir
74
- const catalog = readJson(pinDir, CATALOG_PATH)
75
- const registry = readJson(pinDir, REGISTRY_PATH)
243
+ const catalog = options.catalog || readJson(pinDir, CATALOG_PATH)
244
+ const registry = options.registry || readJson(pinDir, REGISTRY_PATH)
76
245
 
77
246
  const listedIds = new Set()
78
247
  for (const plugin of Array.isArray(catalog.plugins) ? catalog.plugins : []) {
@@ -11,8 +11,6 @@
11
11
  // and stops, which is the same contract every other command here keeps.
12
12
 
13
13
  import { execFileSync } from "node:child_process"
14
- import { existsSync } from "node:fs"
15
- import { delimiter, join } from "node:path"
16
14
  import { banner } from "./banner.mjs"
17
15
  import { credential, UNAUTHENTICATED_LIMIT } from "./github.mjs"
18
16
  import { ensurePin, marketplacePinDir, pinDiskUsage } from "./pin.mjs"
@@ -21,6 +19,7 @@ import { action, colourEnabled, GUTTER, mark, styler, wrap } from "./style.mjs"
21
19
  import { TAGLINE } from "./usage.mjs"
22
20
  import { installCompletion } from "./completion.mjs"
23
21
  import { submissionContract } from "./form.mjs"
22
+ import { pathHint } from "./path-hint.mjs"
24
23
 
25
24
  function version(command) {
26
25
  try {
@@ -32,11 +31,6 @@ function version(command) {
32
31
  }
33
32
  }
34
33
 
35
- /** Is `omakit` reachable as a bare command, without asking a shell? */
36
- export function onPath(name = "omakit", env = process.env) {
37
- return (env.PATH || "").split(delimiter).some((dir) => dir && existsSync(join(dir, name)))
38
- }
39
-
40
34
  /**
41
35
  * @param {{ repoRoot: string, entryPoint: string, stream?: NodeJS.WriteStream }} options
42
36
  */
@@ -101,9 +95,14 @@ export async function setup({ repoRoot, entryPoint, stream = process.stdout }) {
101
95
  for (const line of wrap("Every rule omakit checks is read from that checkout, at that exact commit. It never moves on its own. `omakit doctor` says when it is behind.", {}, c)) out(line)
102
96
  out()
103
97
 
104
- if (!onPath()) {
105
- step("info", "`omakit` is not on your PATH yet. This puts it there:")
106
- fix(`ln -s ${entryPoint} ~/.local/bin/omakit`)
98
+ // The hint is for the install that is here: a symlink for a clone, the npm
99
+ // prefix's bin on PATH for a package, in the shell in $SHELL (path-hint.mjs).
100
+ const reach = pathHint({ repoRoot, entryPoint })
101
+ if (!reach.reachable) {
102
+ step("info", reach.line
103
+ ? `${reach.reason} This puts it there${reach.where ? `; keep it in ${reach.where}` : ""}:`
104
+ : reach.reason)
105
+ if (reach.line) fix(reach.line)
107
106
  out()
108
107
  }
109
108
 
@@ -14,7 +14,7 @@
14
14
  import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
15
15
  import { requirePin } from "./pin.mjs"
16
16
  import { submissionContract, resolveCategory, resolveTags } from "./form.mjs"
17
- import { idUniverse, checkIdentity, baselineFigures, figure } from "./registry.mjs"
17
+ import { idUniverse, checkIdentity, baselineFigures, figure, liveRegistry, registrySourceDetail } from "./registry.mjs"
18
18
  import { readTree } from "./tree.mjs"
19
19
  import { inspectTree } from "./plugin.mjs"
20
20
  import { findAgentControl, REMEDY as AGENT_CONTROL_REMEDY } from "./agent-control.mjs"
@@ -48,7 +48,10 @@ function check(id, fields) {
48
48
  /**
49
49
  * @param {{ repoRoot: string, target: string, category?: string, tags?: string|string[],
50
50
  * notes?: string, suggestedTag?: string, pluginName?: string,
51
- * allowDirty?: boolean, offline?: boolean }} options
51
+ * allowDirty?: boolean, offline?: boolean,
52
+ * readRegistry?: typeof liveRegistry }} options
53
+ * `readRegistry` is injectable for tests; the default reads the marketplace's
54
+ * current HEAD, or the pin with `offline`.
52
55
  */
53
56
  export async function submitPreflight(options) {
54
57
  const { repoRoot } = options
@@ -60,8 +63,11 @@ export async function submitPreflight(options) {
60
63
  const { identity: pinIdentity } = requirePin(repoRoot)
61
64
  phase("reading the submission contract from the pin")
62
65
  const contract = await submissionContract({ repoRoot })
63
- phase("reading the listed and retired plugin ids")
64
- const universe = idUniverse({ repoRoot })
66
+ phase(options.offline ? "reading the listed and retired plugin ids from the pin" : "reading the marketplace's current registry")
67
+ const live = await (options.readRegistry || liveRegistry)({ repoRoot, offline: options.offline === true })
68
+ const universe = idUniverse({ repoRoot, registry: live.registry, catalog: live.catalog })
69
+ // The documented figures are the pin's by design: they are cited in prose
70
+ // that a test holds to the pin, so they never move between two runs.
65
71
  const figures = baselineFigures({ repoRoot })
66
72
 
67
73
  phase("resolving the subject commit")
@@ -145,11 +151,11 @@ export async function submitPreflight(options) {
145
151
  const identity = checkIdentity(universe, { id: tree.pluginId, repositoryUrl: subject.repository.url })
146
152
  checks.push(check("identity.available", {
147
153
  source: "marketplace-pin",
148
- why: `The marketplace refuses \`plugin-id-listed\`, \`plugin-id-retired\`, \`reserved-plugin-id\` and \`submission-repository-listed\`. Checked here against ${universe.counts.listedIds} listed ids, ${universe.counts.retiredIds} retired ids and ${universe.counts.listedRepositories} listed repositories read from the pinned registry and catalog.`,
154
+ why: `The marketplace refuses \`plugin-id-listed\`, \`plugin-id-retired\`, \`reserved-plugin-id\` and \`submission-repository-listed\`. Checked here against ${figure(universe.counts.listedIds)} listed ids, ${figure(universe.counts.retiredIds)} retired ids and ${figure(universe.counts.listedRepositories)} listed repositories from the registry and catalog at the commit the detail names, and the reserved namespace from the pinned catalog builder. The registry is read from the marketplace's current HEAD when the network is there because the pin's copy is stale within hours: registry.json changed in 4,201 of the marketplace's 4,293 commits in the 30 days to 2026-09-13, about 140 a day (docs/MEASUREMENTS.md M7). Code and the form are only ever read from the pin.`,
149
155
  verdict: identity.ok,
150
- detail: identity.ok
156
+ detail: `${identity.ok
151
157
  ? `id "${tree.pluginId}" is unused, outside the reserved ${universe.reservedPrefix}* namespace, and the repository is not listed`
152
- : identity.problems.map((problem) => `${problem.code}: ${problem.detail}`).join("; "),
158
+ : identity.problems.map((problem) => `${problem.code}: ${problem.detail}`).join("; ")}; ${registrySourceDetail(live)}`,
153
159
  remedy: identity.ok ? null : "Choose an unused plugin id outside the reserved namespace.",
154
160
  }))
155
161
 
@@ -313,6 +319,12 @@ export async function submitPreflight(options) {
313
319
  commit: subject.commit,
314
320
  cleanTree: subject.clean,
315
321
  },
322
+ registry: {
323
+ source: live.source,
324
+ commit: live.commit,
325
+ fetchedAt: live.fetchedAt,
326
+ reason: live.reason,
327
+ },
316
328
  validationCommit: {
317
329
  local: subject.commit,
318
330
  defaultBranchHead: head?.commit || null,
@@ -16,13 +16,16 @@
16
16
  // against.
17
17
  //
18
18
  // It is not a self-updater in the sense this repository warns other people
19
- // about. It does not fetch and execute arbitrary code: it fast-forwards a Git
20
- // checkout the user cloned themselves, from the remote they cloned it from, and
21
- // it refuses if any of that is not true.
19
+ // about. It does not fetch and execute arbitrary code: it hands the update to
20
+ // the installer that put the tool here. A Git checkout is fast-forwarded from
21
+ // the remote it was cloned from; an npm install is reinstalled by the `npm` on
22
+ // PATH, with frozen arguments, at the exact version the registry named, and
23
+ // only when that version is newer. It refuses if any of that is not true.
22
24
 
23
25
  import { execFileSync } from "node:child_process"
24
- import { existsSync } from "node:fs"
26
+ import { existsSync, readFileSync } from "node:fs"
25
27
  import { join, resolve, sep } from "node:path"
28
+ import { getJson } from "./github.mjs"
26
29
  import { progress } from "./progress.mjs"
27
30
  import { action, colourEnabled, GUTTER, mark, styler, verdict, wrap } from "./style.mjs"
28
31
 
@@ -40,11 +43,64 @@ export function installKind(repoRoot) {
40
43
  export function upgradeCommand(repoRoot, name = "omakit") {
41
44
  return {
42
45
  git: "omakit upgrade",
43
- npm: `npm i -g ${name}@latest`,
46
+ npm: "omakit upgrade",
44
47
  distro: `sudo pacman -Syu ${name}`,
45
48
  }[installKind(repoRoot)]
46
49
  }
47
50
 
51
+ /**
52
+ * The frozen shape of the one `npm` invocation this tool makes. The package
53
+ * spec appended to it is `<name>@<version>` with the version the registry just
54
+ * reported, never `latest`, so what is printed is what is run. No sudo, no
55
+ * script execution (`--ignore-scripts`), and tests/unit/read-only.test.mjs
56
+ * asserts that npm is spawned nowhere else and with nothing else.
57
+ */
58
+ export const NPM_UPGRADE_ARGS = Object.freeze(["install", "--global", "--ignore-scripts", "--no-fund", "--no-audit"])
59
+
60
+ /** The newest published version, or null when the registry did not answer. */
61
+ export async function latestOnRegistry(name) {
62
+ try {
63
+ const meta = await getJson(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`)
64
+ return meta?.version || null
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ function installedVersion(repoRoot) {
71
+ try {
72
+ return JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version || null
73
+ } catch {
74
+ return null
75
+ }
76
+ }
77
+
78
+ /** Where the `npm` on PATH installs global packages, or null when there is no npm. */
79
+ function npmGlobalRoot() {
80
+ try {
81
+ return resolve(execFileSync("npm", ["root", "--global"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim())
82
+ } catch {
83
+ return null
84
+ }
85
+ }
86
+
87
+ /**
88
+ * The frozen shape of the one other question this tool asks npm: where its
89
+ * global prefix is, whose `bin` is where `npm install --global` put the
90
+ * `omakit` command. Read-only; tests/unit/read-only.test.mjs holds npm to
91
+ * this shape, `root --global` and the install above, and to this file.
92
+ */
93
+ export const NPM_PREFIX_ARGS = Object.freeze(["prefix", "--global"])
94
+
95
+ /** The `npm` on PATH's global prefix, or null when there is no npm. */
96
+ export function npmGlobalPrefix() {
97
+ try {
98
+ return resolve(execFileSync("npm", [...NPM_PREFIX_ARGS], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim())
99
+ } catch {
100
+ return null
101
+ }
102
+ }
103
+
48
104
  function git(dir, args) {
49
105
  return execFileSync("git", ["-C", dir, ...args], {
50
106
  encoding: "utf8",
@@ -70,7 +126,7 @@ export function isExpectedRemote(url, expected = REPOSITORY) {
70
126
  * a way to point the command at somebody else's repository, because nothing
71
127
  * on the command line reaches it.
72
128
  */
73
- export async function upgrade({ repoRoot, stream = process.stdout, dryRun = false, expectedRemote = REPOSITORY }) {
129
+ export async function upgrade({ repoRoot, stream = process.stdout, dryRun = false, expectedRemote = REPOSITORY, latest = latestOnRegistry, npmRoot = npmGlobalRoot, name = "omakit" }) {
74
130
  const c = styler(colourEnabled(stream))
75
131
  const out = (line = "") => stream.write(`${line}\n`)
76
132
  const lines = (list) => { for (const line of list) out(line) }
@@ -83,13 +139,10 @@ export async function upgrade({ repoRoot, stream = process.stdout, dryRun = fals
83
139
  const ok = (text) => out(`${mark("pass", c)}${wrap(text, { indent: GUTTER }, c).join("\n").trimStart()}`)
84
140
 
85
141
  if (!existsSync(join(repoRoot, ".git"))) {
86
- const kind = installKind(repoRoot)
87
- return refuse(
88
- kind === "distro"
89
- ? "this is a distro package under /usr, so omakit leaves upgrades to pacman."
90
- : "this is an npm package install, so omakit leaves upgrades to npm.",
91
- upgradeCommand(repoRoot),
92
- )
142
+ if (installKind(repoRoot) === "distro") {
143
+ return refuse("this is a distro package under /usr, so omakit leaves upgrades to the package manager.", upgradeCommand(repoRoot))
144
+ }
145
+ return upgradeNpm({ repoRoot, stream, dryRun, latest, npmRoot, name, refuse, note, ok, out, lines, c })
93
146
  }
94
147
 
95
148
  let remote
@@ -177,3 +230,60 @@ export async function upgrade({ repoRoot, stream = process.stdout, dryRun = fals
177
230
  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))
178
231
  return { ok: true, changed: true, from: before, to: after, commits: log.length }
179
232
  }
233
+
234
+ /**
235
+ * The npm route. `latest` and `npmRoot` are injectable for the tests only, the
236
+ * way `expectedRemote` is: nothing on the command line reaches them.
237
+ */
238
+ async function upgradeNpm({ repoRoot, stream, dryRun, latest, npmRoot, name, refuse, note, ok, out, lines, c }) {
239
+ const root = resolve(repoRoot)
240
+ const globalRoot = npmRoot()
241
+ if (!globalRoot) {
242
+ return refuse("this is an npm package install, but no `npm` is on PATH to update it with.", `npm install --global ${name}@latest`)
243
+ }
244
+ if (root !== join(globalRoot, name)) {
245
+ return refuse(
246
+ `this omakit is installed at ${root}, but the npm on PATH installs global packages under ${globalRoot}. Updating with a different npm would leave this one where it is.`,
247
+ `npm install --global ${name}@latest`,
248
+ )
249
+ }
250
+ const current = installedVersion(root)
251
+ const spinner = progress({ stream: stream === process.stdout ? process.stderr : stream })
252
+ spinner.phase("asking the npm registry for the newest published version")
253
+ const newest = await latest(name)
254
+ spinner.done()
255
+ if (!newest) {
256
+ return refuse("the npm registry did not answer, so there is nothing to compare against.", "Connect to the network, then run `omakit upgrade` again.")
257
+ }
258
+ if (newest === current) {
259
+ ok(`already current at ${current}, the newest published version`)
260
+ out()
261
+ lines(wrap("The marketplace pin is a separate thing and is never touched here. `omakit doctor` says whether it is behind.", {}, c))
262
+ return { ok: true, changed: false, version: current }
263
+ }
264
+ const spec = `${name}@${newest}`
265
+ if (dryRun) {
266
+ note(`${newest} is published, this is ${current}; not applied (--dry-run)`)
267
+ out(`${" ".repeat(GUTTER)}${c("prose", `npm ${[...NPM_UPGRADE_ARGS, spec].join(" ")}`)}`)
268
+ out()
269
+ lines(action("omakit upgrade", c, { indent: 0 }))
270
+ return { ok: true, changed: false, version: current, available: newest }
271
+ }
272
+ spinner.phase(`npm install --global ${spec}`)
273
+ try {
274
+ execFileSync("npm", [...NPM_UPGRADE_ARGS, spec], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
275
+ } catch (error) {
276
+ spinner.done()
277
+ const reason = String(error?.stderr || "").trim().split("\n").filter((line) => /^npm (?:error|ERR!)/.test(line)).pop() || "npm install failed"
278
+ return refuse(`npm could not install ${spec}: ${reason.replace(/^npm (?:error|ERR!)\s*/, "")}`, `npm ${[...NPM_UPGRADE_ARGS, spec].join(" ")}`)
279
+ }
280
+ spinner.done()
281
+ const after = installedVersion(root)
282
+ if (after !== newest) {
283
+ return refuse(`npm finished, but ${root} reports ${after || "no version"} rather than ${newest}.`, `npm ${[...NPM_UPGRADE_ARGS, spec].join(" ")}`)
284
+ }
285
+ ok(`${current} to ${after}, through the npm that installed it`)
286
+ out()
287
+ 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))
288
+ return { ok: true, changed: true, from: current, to: after }
289
+ }
@@ -72,9 +72,9 @@ export const COMMANDS = Object.freeze([
72
72
  {
73
73
  signature: "omakit upgrade [--dry-run]",
74
74
  lines: [
75
- "Fast-forward this checkout of omakit itself. Refuses a dirty tree, an",
76
- "unexpected remote and anything that is not a fast-forward. Never moves",
77
- "the marketplace pin.",
75
+ "Update omakit through the installer that made it: npm, at the exact",
76
+ "version the registry names, or a fast-forward of a clone. Refuses",
77
+ "anything else, and never moves the marketplace pin.",
78
78
  ],
79
79
  },
80
80
  {
@@ -110,9 +110,10 @@ export const TARGET_NOTE = "<target> is a local Git repository path, or <https u
110
110
  */
111
111
  export const AUTHENTICATION = Object.freeze([
112
112
  "Read-only, and optional. omakit uses your `gh` login if you have one, and",
113
- "otherwise goes unauthenticated. `submit` and `verify` need no network at",
114
- `all; \`watch\` and \`parity\` are capped at ${UNAUTHENTICATED_LIMIT} requests an hour without a`,
115
- "login. omakit never writes a credential anywhere.",
113
+ "otherwise goes unauthenticated. `verify` needs no network; `submit` reads",
114
+ "two things online and `--offline` turns both off; `watch` and `parity` are",
115
+ `capped at ${UNAUTHENTICATED_LIMIT} requests an hour without a login. omakit never writes a`,
116
+ "credential anywhere.",
116
117
  ])
117
118
 
118
119
  /**