omakit 0.1.2 → 0.1.4

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
@@ -2,6 +2,8 @@
2
2
  <img src="docs/media/banner.gif" alt="omakit" width="440">
3
3
  </p>
4
4
 
5
+ [![Built for Omarchy](https://raw.githubusercontent.com/tcballard/omarchy-badges/85f859029e236e784e7b05ada6dbe73506d07a91/badges/v1/built-for-omarchy.svg)](https://github.com/tcballard/omarchy-badges)
6
+
5
7
  **Everything knowable about an Omarchy Quattro plugin submission, checked
6
8
  before you post it:** the tree, the manifest, the form, the commit, and the
7
9
  marketplace's own security baseline with its outcome reported as it is. A
@@ -53,6 +55,13 @@ npm install --global omakit
53
55
  omakit setup
54
56
  ```
55
57
 
58
+ If `omakit` is not found afterwards, npm's global `bin` is not on your PATH
59
+ (measured: a prefix of `~/.local/share/lerd/node-global` whose `bin` no shell
60
+ searched). Run `"$(npm prefix --global)/bin/omakit" setup` once: it prints the
61
+ one line that puts that directory on PATH for the shell in `$SHELL`, and the
62
+ rc file to keep it in; `omakit doctor` reports the same as `omakit.path`.
63
+ Nothing writes to your rc file.
64
+
56
65
  Or read what you run:
57
66
 
58
67
  ```bash
@@ -68,9 +77,10 @@ omakit setup
68
77
  | network, once | `omakit pin`. After that, `submit` and `verify` on a local repository need none at all |
69
78
  | 15 MB on disk | the pinned checkout, in `$XDG_CACHE_HOME/omakit/marketplace`, or `~/.cache/omakit/marketplace` |
70
79
 
71
- Updates come from whichever installer you used: `npm i -g omakit@latest` for
72
- the package, `omakit upgrade` for a clone; `omakit doctor` says when a newer
73
- version is published. Nothing in omakit updates itself, and
80
+ `omakit upgrade` updates either install through the installer that made it:
81
+ `npm` for the package, at the exact version the registry names, and a
82
+ fast-forward for a clone. `omakit doctor` says when a newer version is
83
+ published. Nothing in omakit fetches and runs its own replacement, and
74
84
  `omarchy-mise-install npm:omakit` would, so it is not the way in.
75
85
 
76
86
  ## Watch
@@ -101,7 +111,7 @@ omakit verify <plugin-repo> # the official security baseline over the local tra
101
111
  omakit parity # the baseline over GitHub versus the local transport, on real listings; writes the evidence
102
112
  omakit doctor # what is installed, what is pinned, and what has moved
103
113
  omakit pin # what setup does for the pin, on its own
104
- omakit upgrade # fast-forwards this checkout of omakit itself
114
+ omakit upgrade # updates omakit through its own installer: npm, or a fast-forward
105
115
  omakit help --agent # the operating instructions, for the agent running this
106
116
  ```
107
117
 
@@ -120,11 +130,12 @@ There is nothing to authenticate. If you have `gh auth login` done, omakit
120
130
  reads that credential for GET requests and stores nothing; a token in
121
131
  `GH_TOKEN` or `GITHUB_TOKEN` reaches it the same way, because `gh` honours
122
132
  those itself. Without either, `watch` and `parity` share GitHub's
123
- 60-requests-an-hour unauthenticated allowance, and `submit` and `verify` on a
124
- local repository do not touch the network at all (a `<url>@<sha>` target is
125
- fetched once, over git, into the cache). omakit reads no environment variable of its own,
126
- and `omakit doctor` names the credential source it found, or that it found
127
- none.
133
+ 60-requests-an-hour unauthenticated allowance; `submit` reads two things
134
+ online, the subject's default-branch HEAD and the marketplace's current
135
+ registry, and `--offline` turns both off; `verify` on a local repository does
136
+ not touch the network at all (a `<url>@<sha>` target is fetched once, over
137
+ git, into the cache). omakit reads no environment variable of its own, and
138
+ `omakit doctor` names the credential source it found, or that it found none.
128
139
 
129
140
  Every colour omakit prints is an ANSI palette index, so your Omarchy theme
130
141
  decides what it looks like, and nothing is said by colour alone. What the
@@ -140,7 +151,11 @@ tags and the exact text of the five checklist items are all read from
140
151
  `.github/ISSUE_TEMPLATE/submit-plugin.yml` in a marketplace checkout pinned to an
141
152
  exact commit. The rendered body is then handed to the marketplace's own
142
153
  `parseCurrentSubmission` from that same commit. If it accepts the body here, it
143
- accepts it there.
154
+ accepts it there. The one exception is data, not rules: the registry and the
155
+ catalog that say which ids and repositories are already listed are read from
156
+ the marketplace's current HEAD when the network is there, because the pin's
157
+ copy is stale within hours (4,201 of 4,293 commits in 30 days touched only
158
+ `registry.json`), and from the pin with `--offline`.
144
159
 
145
160
  The security baseline is the marketplace's own code, imported unmodified and run
146
161
  over a local snapshot with no network. Omakit adds no rule, renames no outcome,
@@ -160,17 +175,20 @@ move on its own. That distinction is now enforced rather than argued.
160
175
  **The tool:**
161
176
 
162
177
  ```bash
163
- omakit upgrade # fast-forwards this checkout of omakit itself
178
+ omakit upgrade # the npm package, or a clone: through its own installer
164
179
  omakit upgrade --dry-run
165
180
  ```
166
181
 
167
- It refuses a dirty tree, a detached HEAD, a remote that is not this repository,
168
- and anything that is not a fast-forward, and it names what to run yourself in
169
- each case. It is not a self-updater of the kind this repository warns other
170
- people about: it fast-forwards a Git checkout you cloned, from the remote you
171
- cloned it from, and it touches nothing else. On a package install it says so and
172
- names the installer's own command, `npm i -g omakit@latest`. `git -C ~/.local/share/omakit pull` still works
173
- and does the same thing.
182
+ It is not a self-updater of the kind this repository warns other people
183
+ about: it never fetches and runs its own replacement. On an npm install it asks
184
+ the registry for the newest version and, if that is newer, runs the `npm` on
185
+ PATH with frozen arguments (`npm install --global --ignore-scripts omakit@<that
186
+ version>`, never `@latest`, never with sudo), and it refuses when the npm on
187
+ PATH is not the one that installed it. On a clone it fast-forwards from the
188
+ remote you cloned it from, and refuses a dirty tree, a detached HEAD, a remote
189
+ that is not this repository, and anything that is not a fast-forward. In every
190
+ refusal it names what to run yourself. `git -C ~/.local/share/omakit pull`
191
+ still works on a clone and does the same thing.
174
192
 
175
193
  **The pin** does not move by itself, ever, and `omakit upgrade` does not move it
176
194
  either: a test asserts that its source does not so much as mention the pin or
@@ -178,7 +196,9 @@ the cache. Bumping it changes where the submission contract and the baseline
178
196
  policy are read from, and the procedure in
179
197
  [docs/UPSTREAM_CONTRACT.md](docs/UPSTREAM_CONTRACT.md) ends in re-proving
180
198
  transport parity and committing the evidence. `omakit doctor` tells you when the
181
- pin is behind the marketplace's current branch and then leaves it alone. That the
199
+ pin is behind the marketplace's current branch, names which of the paths omakit
200
+ reads actually changed (about 140 commits a day touch only `registry.json`, so
201
+ "behind" alone would be true of every run), and then leaves it alone. That the
182
202
  pin can go stale unnoticed is the same defect class `omakit watch` reports, so it
183
203
  would be poor form to hide it here.
184
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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",
@@ -24,16 +24,30 @@ comes from `origin`. You supply the category and the tags, because nobody else
24
24
  can: they are an editorial choice about where the plugin belongs.
25
25
 
26
26
  If you do not know which category and tags are allowed, run the command without
27
- them. The failing checks print the controlled lists, read from the pinned form.
27
+ them: that is a usage error (exit 2) whose message lists the controlled values,
28
+ read from the pinned form, before any check runs. With `--json` the same lists
29
+ come back under `usage`.
28
30
 
29
31
  Useful flags: `--notes` for the Maintainer notes field, `--suggest-tag` for the
30
32
  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.
33
+ machine-readable result, `--offline` to skip the one check that needs the network
34
+ and to read the listed ids from the pin instead of the marketplace's current HEAD.
35
+
36
+ Never pass `--offline` to get around a listed id. `identity.available` reads the
37
+ registry from HEAD because the pin's copy is stale within hours; a run that
38
+ passes only against the pin describes a marketplace that no longer exists, and
39
+ the marketplace will refuse the id anyway. If an id or repository is listed at
40
+ HEAD, tell the owner and choose another id.
32
41
 
33
42
  ## Reading the result
34
43
 
35
44
  Exit code 0 means every blocking check passed and the output contains the issue
36
- title and body. Exit code 1 means it refused, and no body was produced.
45
+ title and body. Exit code 1 means it refused, and no body was produced. Exit
46
+ code 2 is a usage error: nothing was checked.
47
+
48
+ A check drawn as `▒ ?` did not run because one it depends on failed; its detail
49
+ names that check. It is not a failure of its own, and the closing refusal lists
50
+ root causes only. Fix those.
37
51
 
38
52
  Each check names a source. `[marketplace-pin]` is the marketplace's own rule, read
39
53
  from a pinned checkout. `[omakit]` is this tool's own check, derived from public
@@ -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.
@@ -17,7 +17,8 @@ import { fileURLToPath } from "node:url"
17
17
  import { ensurePin, MARKETPLACE_PIN } from "./pin.mjs"
18
18
  import { marketplaceBaselineSection } from "./verify.mjs"
19
19
  import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
20
- import { submitPreflight } from "./submit.mjs"
20
+ import { missingSubmitFlags, submitPreflight } from "./submit.mjs"
21
+ import { submissionContract } from "./form.mjs"
21
22
  import { validationWatch } from "./watch.mjs"
22
23
  import { renderSubmit, renderWatch, renderDoctor } from "./report.mjs"
23
24
  import { doctor } from "./doctor.mjs"
@@ -26,7 +27,7 @@ import { upgrade } from "./upgrade.mjs"
26
27
  import { progress } from "./progress.mjs"
27
28
  import { banner, bannerEnabled } from "./banner.mjs"
28
29
  import { COMMANDS, renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
29
- import { action, colourEnabled, GUTTER, mark, styler, wrap } from "./style.mjs"
30
+ import { action, colourEnabled, GUTTER, labelled, mark, styler, wrap } from "./style.mjs"
30
31
  import { omakitCacheDir } from "./paths.mjs"
31
32
  import { parityOutput } from "./parity-output.mjs"
32
33
 
@@ -55,11 +56,12 @@ const REMEDY = Object.freeze({
55
56
  /**
56
57
  * Every failure, in one register, on stderr. `usage` errors carry the
57
58
  * signature that was expected, so the remedy is the reference and not a
58
- * restatement of the message.
59
+ * restatement of the message. `body` is extra labelled lines between the
60
+ * message and the arrow, for a usage error that has values to list.
59
61
  */
60
- function fail(code, message, exit = 1, remedy = REMEDY[code]) {
62
+ function fail(code, message, exit = 1, remedy = REMEDY[code], body = () => []) {
61
63
  const c = styler(colourEnabled(process.stderr))
62
- const lines = [`${mark("fail", c)}${c("name", code)}`, ...wrap(message, { indent: GUTTER }, c)]
64
+ const lines = [`${mark("fail", c)}${c("name", code)}`, ...wrap(message, { indent: GUTTER }, c), ...body(c)]
63
65
  if (remedy) lines.push(...action(remedy, c))
64
66
  process.stderr.write(`${lines.join("\n")}\n`)
65
67
  process.exit(exit)
@@ -96,6 +98,28 @@ function emit(args, text) {
96
98
  async function cmdSubmit(args) {
97
99
  const target = positionals(args)[0]
98
100
  if (!target) fail("usage", "submit needs a target: `omakit submit <target> --category <c> --tags <a,b>`", 2)
101
+ // A missing category or tag list is a usage error, decided before any check
102
+ // runs and before the spinner starts: nothing can be rendered without them,
103
+ // and the values the form accepts are the answer, read from the pin.
104
+ let usage = null
105
+ try {
106
+ usage = missingSubmitFlags(await submissionContract({ repoRoot: ROOT }), { category: option(args, "--category"), tags: option(args, "--tags") })
107
+ } catch (error) {
108
+ failFrom(error)
109
+ }
110
+ if (usage) {
111
+ if (args.includes("--json")) {
112
+ process.stdout.write(`${JSON.stringify({ usage }, null, 2)}\n`)
113
+ process.exit(2)
114
+ }
115
+ const flags = usage.missing.join(" and ")
116
+ fail("usage", `submit needs ${flags}: ${usage.missing.length === 1 ? "it is" : "they are"} an editorial choice nobody else can make, from the pinned form's own lists.`, 2,
117
+ `omakit submit ${target} --category <c> --tags <a,b>`,
118
+ (c) => [
119
+ ...labelled("categories", usage.categories.join(", "), c),
120
+ ...labelled(`tags, 1 to ${usage.maximumTags}`, usage.tags.join(", "), c),
121
+ ])
122
+ }
99
123
  const spinner = args.includes("--json") ? { phase: () => {}, done: () => {} } : progress()
100
124
  let result
101
125
  try {
@@ -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
+ }