omakit 0.1.2 → 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 +37 -19
- package/package.json +1 -1
- package/skills/omarchy-plugin-submit/SKILL.md +8 -1
- package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -0
- package/tools/marketplace/README.md +9 -6
- package/tools/marketplace/doctor.mjs +98 -19
- package/tools/marketplace/path-hint.mjs +53 -0
- package/tools/marketplace/registry.mjs +180 -11
- package/tools/marketplace/setup.mjs +9 -10
- package/tools/marketplace/submit.mjs +19 -7
- package/tools/marketplace/upgrade.mjs +123 -13
- package/tools/marketplace/usage.mjs +7 -6
package/README.md
CHANGED
|
@@ -53,6 +53,13 @@ npm install --global omakit
|
|
|
53
53
|
omakit setup
|
|
54
54
|
```
|
|
55
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
|
+
|
|
56
63
|
Or read what you run:
|
|
57
64
|
|
|
58
65
|
```bash
|
|
@@ -68,9 +75,10 @@ omakit setup
|
|
|
68
75
|
| network, once | `omakit pin`. After that, `submit` and `verify` on a local repository need none at all |
|
|
69
76
|
| 15 MB on disk | the pinned checkout, in `$XDG_CACHE_HOME/omakit/marketplace`, or `~/.cache/omakit/marketplace` |
|
|
70
77
|
|
|
71
|
-
|
|
72
|
-
the package,
|
|
73
|
-
|
|
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
|
|
74
82
|
`omarchy-mise-install npm:omakit` would, so it is not the way in.
|
|
75
83
|
|
|
76
84
|
## Watch
|
|
@@ -101,7 +109,7 @@ omakit verify <plugin-repo> # the official security baseline over the local tra
|
|
|
101
109
|
omakit parity # the baseline over GitHub versus the local transport, on real listings; writes the evidence
|
|
102
110
|
omakit doctor # what is installed, what is pinned, and what has moved
|
|
103
111
|
omakit pin # what setup does for the pin, on its own
|
|
104
|
-
omakit upgrade #
|
|
112
|
+
omakit upgrade # updates omakit through its own installer: npm, or a fast-forward
|
|
105
113
|
omakit help --agent # the operating instructions, for the agent running this
|
|
106
114
|
```
|
|
107
115
|
|
|
@@ -120,11 +128,12 @@ There is nothing to authenticate. If you have `gh auth login` done, omakit
|
|
|
120
128
|
reads that credential for GET requests and stores nothing; a token in
|
|
121
129
|
`GH_TOKEN` or `GITHUB_TOKEN` reaches it the same way, because `gh` honours
|
|
122
130
|
those itself. Without either, `watch` and `parity` share GitHub's
|
|
123
|
-
60-requests-an-hour unauthenticated allowance
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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.
|
|
128
137
|
|
|
129
138
|
Every colour omakit prints is an ANSI palette index, so your Omarchy theme
|
|
130
139
|
decides what it looks like, and nothing is said by colour alone. What the
|
|
@@ -140,7 +149,11 @@ tags and the exact text of the five checklist items are all read from
|
|
|
140
149
|
`.github/ISSUE_TEMPLATE/submit-plugin.yml` in a marketplace checkout pinned to an
|
|
141
150
|
exact commit. The rendered body is then handed to the marketplace's own
|
|
142
151
|
`parseCurrentSubmission` from that same commit. If it accepts the body here, it
|
|
143
|
-
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`.
|
|
144
157
|
|
|
145
158
|
The security baseline is the marketplace's own code, imported unmodified and run
|
|
146
159
|
over a local snapshot with no network. Omakit adds no rule, renames no outcome,
|
|
@@ -160,17 +173,20 @@ move on its own. That distinction is now enforced rather than argued.
|
|
|
160
173
|
**The tool:**
|
|
161
174
|
|
|
162
175
|
```bash
|
|
163
|
-
omakit upgrade #
|
|
176
|
+
omakit upgrade # the npm package, or a clone: through its own installer
|
|
164
177
|
omakit upgrade --dry-run
|
|
165
178
|
```
|
|
166
179
|
|
|
167
|
-
It
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
and
|
|
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.
|
|
174
190
|
|
|
175
191
|
**The pin** does not move by itself, ever, and `omakit upgrade` does not move it
|
|
176
192
|
either: a test asserts that its source does not so much as mention the pin or
|
|
@@ -178,7 +194,9 @@ the cache. Bumping it changes where the submission contract and the baseline
|
|
|
178
194
|
policy are read from, and the procedure in
|
|
179
195
|
[docs/UPSTREAM_CONTRACT.md](docs/UPSTREAM_CONTRACT.md) ends in re-proving
|
|
180
196
|
transport parity and committing the evidence. `omakit doctor` tells you when the
|
|
181
|
-
pin is behind the marketplace's current branch
|
|
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
|
|
182
200
|
pin can go stale unnoticed is the same defect class `omakit watch` reports, so it
|
|
183
201
|
would be poor form to hide it here.
|
|
184
202
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omakit",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
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 `
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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:
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
/**
|
|
54
|
-
|
|
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 ${
|
|
61
|
-
: `the pin is ${identity.commit.slice(0, 7)}; the marketplace's ${
|
|
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
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
*
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
20
|
-
//
|
|
21
|
-
// it
|
|
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:
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
"
|
|
76
|
-
"
|
|
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. `
|
|
114
|
-
|
|
115
|
-
|
|
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
|
/**
|