omakit 0.1.7 → 0.1.9
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 +15 -6
- package/package.json +1 -1
- package/skills/omarchy-plugin-submit/SKILL.md +3 -1
- package/tests/parity/run.mjs +145 -113
- package/tools/marketplace/README.md +17 -2
- package/tools/marketplace/cli.mjs +35 -20
- package/tools/marketplace/completion.mjs +2 -1
- package/tools/marketplace/doctor.mjs +89 -36
- package/tools/marketplace/path-hint.mjs +1 -1
- package/tools/marketplace/paths.mjs +18 -0
- package/tools/marketplace/report.mjs +22 -9
- package/tools/marketplace/setup.mjs +7 -4
- package/tools/marketplace/style.mjs +13 -3
- package/tools/marketplace/submit.mjs +17 -5
- package/tools/marketplace/upgrade.mjs +22 -4
- package/tools/marketplace/usage.mjs +5 -4
- package/tools/marketplace/watch.mjs +21 -11
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<img src="docs/media/banner.gif" alt="omakit" width="440">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
-
[](https://github.com/tcballard/omarchy-badges)
|
|
6
6
|
|
|
7
7
|
**Everything knowable about an Omarchy Quattro plugin submission, checked
|
|
8
8
|
before you post it:** the tree, the manifest, the form, the commit, and the
|
|
@@ -56,7 +56,7 @@ omakit setup
|
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
If `omakit` is not found afterwards, npm's global `bin` is not on your PATH
|
|
59
|
-
(measured:
|
|
59
|
+
(measured: an npm global prefix under `~/.local/share` whose `bin` no shell
|
|
60
60
|
searched). Run `"$(npm prefix --global)/bin/omakit" setup` once: it prints the
|
|
61
61
|
one line that puts that directory on PATH for the shell in `$SHELL`, and the
|
|
62
62
|
rc file to keep it in; `omakit doctor` reports the same as `omakit.path`.
|
|
@@ -192,6 +192,11 @@ the marketplace's current HEAD when the network is there, because the pin's
|
|
|
192
192
|
copy is stale within hours (4,201 of 4,293 commits in 30 days touched only
|
|
193
193
|
`registry.json`), and from the pin with `--offline`.
|
|
194
194
|
|
|
195
|
+
This is why the tool is Node: the marketplace's scanner, form parser and
|
|
196
|
+
catalog builder are Node modules, and omakit runs them verbatim from the
|
|
197
|
+
pinned commit instead of reimplementing their rules, where a different
|
|
198
|
+
language would mean a copy that can drift.
|
|
199
|
+
|
|
195
200
|
The security baseline is the marketplace's own code, imported unmodified and run
|
|
196
201
|
over a local snapshot with no network. Omakit adds no rule, renames no outcome,
|
|
197
202
|
and never restates the result as a safety claim: the baseline does no data-flow
|
|
@@ -231,16 +236,20 @@ the cache. Bumping it changes where the submission contract and the baseline
|
|
|
231
236
|
policy are read from, and the procedure in
|
|
232
237
|
[docs/UPSTREAM_CONTRACT.md](docs/UPSTREAM_CONTRACT.md) ends in re-proving
|
|
233
238
|
transport parity and committing the evidence. `omakit doctor` tells you when the
|
|
234
|
-
pin is behind
|
|
235
|
-
|
|
236
|
-
|
|
239
|
+
pin is behind in something omakit reads from it, names which paths changed,
|
|
240
|
+
and then leaves it alone: `registry.json` and `site/catalog.json` moving is
|
|
241
|
+
fine, because those are read live from HEAD (about 140 commits a day touch
|
|
242
|
+
only `registry.json`, so "behind" alone would be true of every run); the
|
|
243
|
+
marketplace's code or forms moving is a note, and what you can do about it
|
|
244
|
+
is run `omakit upgrade`, since a newer omakit may already carry the new pin,
|
|
245
|
+
and otherwise open an issue naming the paths. That the
|
|
237
246
|
pin can go stale unnoticed is the same defect class `omakit watch` reports, so it
|
|
238
247
|
would be poor form to hide it here.
|
|
239
248
|
|
|
240
249
|
## Evidence, not claims
|
|
241
250
|
|
|
242
251
|
```bash
|
|
243
|
-
npm test #
|
|
252
|
+
npm test # node --test, no dependencies; green from `git archive` too
|
|
244
253
|
```
|
|
245
254
|
|
|
246
255
|
| Claim | Proof |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omakit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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",
|
|
@@ -57,7 +57,9 @@ omakit verify <path-to-the-plugin-repo> --json
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
`verify` without `--json` prints a report for a person; `--json` is the
|
|
60
|
-
document, unchanged between releases.
|
|
60
|
+
document, unchanged between releases. It needs no network once the pin
|
|
61
|
+
exists; a reviewer-mode target (`<https url>@<40-char sha>`) is fetched once,
|
|
62
|
+
read-only, into omakit's own cache.
|
|
61
63
|
|
|
62
64
|
Useful flags: `--notes` for the Maintainer notes field, `--suggest-tag` for the
|
|
63
65
|
optional suggestion, `--name` when the manifest has no name, `--json` for a
|
package/tests/parity/run.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { createHash } from "node:crypto"
|
|
|
14
14
|
import { execFileSync } from "node:child_process"
|
|
15
15
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
16
16
|
import { join, resolve } from "node:path"
|
|
17
|
+
import { pathToFileURL } from "node:url"
|
|
17
18
|
import { runBaseline } from "../../tools/marketplace/run-baseline.mjs"
|
|
18
19
|
import { requirePin } from "../../tools/marketplace/pin.mjs"
|
|
19
20
|
import { parityCorpus, strataSizes } from "./corpus.mjs"
|
|
@@ -22,14 +23,7 @@ import { token } from "../../tools/marketplace/github.mjs"
|
|
|
22
23
|
import { omakitCacheDir } from "../../tools/marketplace/paths.mjs"
|
|
23
24
|
import { parityOutput } from "../../tools/marketplace/parity-output.mjs"
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
const pinDir = requirePin(repoRoot).dir
|
|
27
|
-
const cacheDir = omakitCacheDir("parity")
|
|
28
|
-
const output = parityOutput({ repoRoot, out: process.env.PARITY_OUT || null })
|
|
29
|
-
const count = Number(process.env.PARITY_COUNT || 30)
|
|
30
|
-
const offset = Number(process.env.PARITY_OFFSET || 0)
|
|
31
|
-
|
|
32
|
-
function shallowClone(repoUrl, commit) {
|
|
26
|
+
function shallowClone(cacheDir, repoUrl, commit) {
|
|
33
27
|
const dir = join(cacheDir, subjectSlug(repoUrl))
|
|
34
28
|
mkdirSync(dir, { recursive: true })
|
|
35
29
|
const run = (args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
|
|
@@ -71,124 +65,162 @@ function pinCommit(dir) {
|
|
|
71
65
|
}
|
|
72
66
|
}
|
|
73
67
|
|
|
74
|
-
|
|
75
|
-
const rows = []
|
|
76
|
-
const ruleTotals = {}
|
|
77
|
-
const capabilityTotals = {}
|
|
78
|
-
let mismatches = 0
|
|
79
|
-
let failures = 0
|
|
80
|
-
|
|
81
|
-
for (const target of corpus) {
|
|
82
|
-
const row = {
|
|
83
|
-
repo: target.repo,
|
|
84
|
-
commit: target.commit,
|
|
85
|
-
type: target.type,
|
|
86
|
-
registryOutcome: target.registryOutcome,
|
|
87
|
-
registryOutcomeAtSameCommit: target.registryOutcomeCommit === target.commit,
|
|
88
|
-
}
|
|
68
|
+
function generatorIdentity(repoRoot) {
|
|
89
69
|
try {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
70
|
+
return { omakitCommit: execFileSync("git", ["-C", repoRoot, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() }
|
|
71
|
+
} catch {
|
|
72
|
+
const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
|
|
73
|
+
return { omakitPackage: `${pkg.name}@${pkg.version}` }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The parity run. `cli.mjs` calls this with its own root and the flags it
|
|
79
|
+
* parsed; the adapter at the bottom of this file calls it from the command
|
|
80
|
+
* line. Everything the run needs arrives as an argument: omakit reads no
|
|
81
|
+
* environment variable of its own, and this handoff used to be four
|
|
82
|
+
* PARITY_* variables and an OMAKIT_ROOT, which was the one OMAKIT_* name in
|
|
83
|
+
* the tree and needed an exemption in the test that forbids them.
|
|
84
|
+
*
|
|
85
|
+
* @param {{ repoRoot: string, count?: number, offset?: number, out?: string|null, log?: (line: string) => void }} options
|
|
86
|
+
* `out` is an explicit evidence file; without it the evidence lands in
|
|
87
|
+
* `docs/evidence/parity/` under a dated name that never overwrites.
|
|
88
|
+
* @returns {Promise<{ summary: object, outFile: string, ok: boolean }>}
|
|
89
|
+
*/
|
|
90
|
+
export async function runParity({ repoRoot, count = 30, offset = 0, out = null, log = console.log }) {
|
|
91
|
+
const root = resolve(repoRoot)
|
|
92
|
+
const pinDir = requirePin(root).dir
|
|
93
|
+
const cacheDir = omakitCacheDir("parity")
|
|
94
|
+
// Resolved before any fetch, so a packaged read-only install refuses here.
|
|
95
|
+
const output = parityOutput({ repoRoot: root, out })
|
|
96
|
+
const corpus = parityCorpus(pinDir, count, offset)
|
|
97
|
+
const rows = []
|
|
98
|
+
const ruleTotals = {}
|
|
99
|
+
const capabilityTotals = {}
|
|
100
|
+
let mismatches = 0
|
|
101
|
+
let failures = 0
|
|
102
|
+
|
|
103
|
+
for (const target of corpus) {
|
|
104
|
+
const row = {
|
|
105
|
+
repo: target.repo,
|
|
106
|
+
commit: target.commit,
|
|
107
|
+
type: target.type,
|
|
108
|
+
registryOutcome: target.registryOutcome,
|
|
109
|
+
registryOutcomeAtSameCommit: target.registryOutcomeCommit === target.commit,
|
|
109
110
|
}
|
|
110
111
|
try {
|
|
111
|
-
const
|
|
112
|
-
|
|
112
|
+
const dir = shallowClone(cacheDir, target.repo, target.commit)
|
|
113
|
+
const local = await runBaseline({
|
|
114
|
+
repoRoot: root,
|
|
113
115
|
repoUrl: target.repo,
|
|
114
116
|
commitSha: target.commit,
|
|
115
|
-
transport: "
|
|
116
|
-
|
|
117
|
+
transport: "local",
|
|
118
|
+
repoDir: dir,
|
|
117
119
|
})
|
|
118
|
-
row.
|
|
119
|
-
row.
|
|
120
|
-
row.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
120
|
+
row.localOutcome = local.result.outcome
|
|
121
|
+
row.requests = local.adapter.requests
|
|
122
|
+
row.localDigest = digest(local.result)
|
|
123
|
+
// Rule and capability ids are counted in the summary, unattributed; they are
|
|
124
|
+
// deliberately not recorded against this repository.
|
|
125
|
+
for (const finding of local.result.findings || []) {
|
|
126
|
+
const id = finding.ruleId || finding.id
|
|
127
|
+
if (id) ruleTotals[id] = (ruleTotals[id] || 0) + 1
|
|
128
|
+
}
|
|
129
|
+
for (const capability of local.result.capabilities || []) {
|
|
130
|
+
if (capability.id) capabilityTotals[capability.id] = (capabilityTotals[capability.id] || 0) + 1
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const github = await runBaseline({
|
|
134
|
+
repoRoot: root,
|
|
135
|
+
repoUrl: target.repo,
|
|
136
|
+
commitSha: target.commit,
|
|
137
|
+
transport: "github",
|
|
138
|
+
token: token() ?? undefined,
|
|
139
|
+
})
|
|
140
|
+
row.githubOutcome = github.result.outcome
|
|
141
|
+
row.githubDigest = digest(github.result)
|
|
142
|
+
row.identical = row.localDigest === row.githubDigest
|
|
143
|
+
if (!row.identical) {
|
|
144
|
+
mismatches += 1
|
|
145
|
+
// A mismatch is the one case worth describing, and it is described as a
|
|
146
|
+
// difference between two transports of the same code, not as a finding
|
|
147
|
+
// about the plugin: which keys differ, never their contents.
|
|
148
|
+
const localPayload = JSON.parse(comparable(local.result))
|
|
149
|
+
const githubPayload = JSON.parse(comparable(github.result))
|
|
150
|
+
row.differingKeys = Object.keys(localPayload).filter(
|
|
151
|
+
(key) => JSON.stringify(localPayload[key]) !== JSON.stringify(githubPayload[key]),
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
row.githubError = (error.code || "error") + ": " + error.message
|
|
156
|
+
failures += 1
|
|
131
157
|
}
|
|
132
158
|
} catch (error) {
|
|
133
|
-
row.
|
|
159
|
+
row.localError = (error.code || "error") + ": " + error.message
|
|
134
160
|
failures += 1
|
|
135
161
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
162
|
+
rows.push(row)
|
|
163
|
+
log(
|
|
164
|
+
[
|
|
165
|
+
row.identical === true ? "ok " : row.identical === false ? "DIFF" : "?? ",
|
|
166
|
+
target.repo.replace("https://github.com/", "").padEnd(44),
|
|
167
|
+
"local=" + (row.localOutcome || row.localError || "-"),
|
|
168
|
+
"github=" + (row.githubOutcome || row.githubError || "-"),
|
|
169
|
+
"registry=" + (row.registryOutcome || "-"),
|
|
170
|
+
].join(" "),
|
|
171
|
+
)
|
|
139
172
|
}
|
|
140
|
-
rows.push(row)
|
|
141
|
-
console.log(
|
|
142
|
-
[
|
|
143
|
-
row.identical === true ? "ok " : row.identical === false ? "DIFF" : "?? ",
|
|
144
|
-
target.repo.replace("https://github.com/", "").padEnd(44),
|
|
145
|
-
"local=" + (row.localOutcome || row.localError || "-"),
|
|
146
|
-
"github=" + (row.githubOutcome || row.githubError || "-"),
|
|
147
|
-
"registry=" + (row.registryOutcome || "-"),
|
|
148
|
-
].join(" "),
|
|
149
|
-
)
|
|
150
|
-
}
|
|
151
173
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
174
|
+
const summary = {
|
|
175
|
+
generator: generatorIdentity(root),
|
|
176
|
+
pinnedMarketplaceCommit: pinCommit(pinDir),
|
|
177
|
+
corpusSize: corpus.length,
|
|
178
|
+
requestedCount: count,
|
|
179
|
+
offset,
|
|
180
|
+
strata: strataSizes(count),
|
|
181
|
+
outcomes: rows.reduce((acc, row) => {
|
|
182
|
+
const key = row.localOutcome || "error"
|
|
183
|
+
acc[key] = (acc[key] || 0) + 1
|
|
184
|
+
return acc
|
|
185
|
+
}, {}),
|
|
186
|
+
identical: rows.filter((row) => row.identical === true).length,
|
|
187
|
+
mismatches,
|
|
188
|
+
failures,
|
|
189
|
+
// Unattributed aggregates. No repository name is attached to any rule.
|
|
190
|
+
ruleTotals,
|
|
191
|
+
capabilityTotals,
|
|
192
|
+
publication: {
|
|
193
|
+
perRepositoryDetail: "omitted",
|
|
194
|
+
reason: "Findings about a specific third-party plugin are not published. Equality is evidenced by the sha256 digests below, which anyone can recompute from the repository, the commit and the pinned marketplace commit.",
|
|
195
|
+
digest: "sha256 of {outcome, disposition, blocksApproval, findings, capabilities}, first 32 hex characters",
|
|
196
|
+
},
|
|
197
|
+
rows,
|
|
158
198
|
}
|
|
199
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
200
|
+
const outputIsFile = Boolean(out)
|
|
201
|
+
const outputDir = outputIsFile ? resolve(output, "..") : output
|
|
202
|
+
mkdirSync(outputDir, { recursive: true })
|
|
203
|
+
let outFile = outputIsFile ? output : join(outputDir, `${date}-local-vs-github.json`)
|
|
204
|
+
for (let n = 2; !outputIsFile && existsSync(outFile); n += 1) outFile = join(outputDir, `${date}-local-vs-github-${n}.json`)
|
|
205
|
+
writeFileSync(outFile, JSON.stringify(summary, null, 2) + "\n")
|
|
206
|
+
log("\nidentical " + summary.identical + "/" + corpus.length + ", mismatches " + mismatches + ", failures " + failures + " -> " + outFile)
|
|
207
|
+
return { summary, outFile, ok: mismatches === 0 && failures === 0 }
|
|
159
208
|
}
|
|
160
209
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
ruleTotals,
|
|
178
|
-
capabilityTotals,
|
|
179
|
-
publication: {
|
|
180
|
-
perRepositoryDetail: "omitted",
|
|
181
|
-
reason: "Findings about a specific third-party plugin are not published. Equality is evidenced by the sha256 digests below, which anyone can recompute from the repository, the commit and the pinned marketplace commit.",
|
|
182
|
-
digest: "sha256 of {outcome, disposition, blocksApproval, findings, capabilities}, first 32 hex characters",
|
|
183
|
-
},
|
|
184
|
-
rows,
|
|
210
|
+
// The command-line adapter: `node tests/parity/run.mjs [--count n] [--offset n]
|
|
211
|
+
// [--out file]` from the repository root. Thin on purpose; `omakit parity`
|
|
212
|
+
// is the same call with cli.mjs's own root.
|
|
213
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
214
|
+
const args = process.argv.slice(2)
|
|
215
|
+
const flag = (name) => {
|
|
216
|
+
const index = args.indexOf(name)
|
|
217
|
+
return index >= 0 ? args[index + 1] : undefined
|
|
218
|
+
}
|
|
219
|
+
const { ok } = await runParity({
|
|
220
|
+
repoRoot: process.cwd(),
|
|
221
|
+
count: Number(flag("--count") || 30),
|
|
222
|
+
offset: Number(flag("--offset") || 0),
|
|
223
|
+
out: flag("--out") || null,
|
|
224
|
+
})
|
|
225
|
+
process.exit(ok ? 0 : 1)
|
|
185
226
|
}
|
|
186
|
-
const date = new Date().toISOString().slice(0, 10)
|
|
187
|
-
const outputIsFile = process.env.PARITY_OUT_EXPLICIT === "1"
|
|
188
|
-
const outputDir = outputIsFile ? resolve(output, "..") : output
|
|
189
|
-
mkdirSync(outputDir, { recursive: true })
|
|
190
|
-
let outFile = outputIsFile ? output : join(outputDir, `${date}-local-vs-github.json`)
|
|
191
|
-
for (let n = 2; !outputIsFile && existsSync(outFile); n += 1) outFile = join(outputDir, `${date}-local-vs-github-${n}.json`)
|
|
192
|
-
writeFileSync(outFile, JSON.stringify(summary, null, 2) + "\n")
|
|
193
|
-
console.log("\nidentical " + summary.identical + "/" + corpus.length + ", mismatches " + mismatches + ", failures " + failures + " -> " + outFile)
|
|
194
|
-
process.exit(mismatches === 0 && failures === 0 ? 0 : 1)
|
|
@@ -19,11 +19,12 @@ local commit through the transport seam the marketplace tests itself
|
|
|
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. |
|
|
21
21
|
| `issue.mjs` | Renders the issue the way the form would, then has the marketplace's own parser judge it. |
|
|
22
|
-
| `submit.mjs` | Assembles every check with its measured reason, and withholds the body when a blocking check fails. Three outcomes: `ready` (the body), `refused` (a blocking check failed) and `listed` (the plugin is already listed by its own repository: `identity.available` passes with the listing's record, the five body checks are omitted rather than drawn as waiting, no body exists on purpose, and `listing` carries the listed commit against the local one and the form to use for a newer commit). Decides the category and tags after the registry: a listed plugin, own or taken, is asked for neither; an unlisted one without them is asked through `ask.mjs` at a terminal, and is a usage error otherwise. Ends with `reproduce`, the command line that repeats the run without asking. |
|
|
22
|
+
| `submit.mjs` | Assembles every check with its measured reason, and withholds the body when a blocking check fails. Three outcomes: `ready` (the body), `refused` (a blocking check failed) and `listed` (the plugin is already listed by its own repository: `identity.available` passes with the listing's record, the five body checks are omitted rather than drawn as waiting, no body exists on purpose, and `listing` carries the listed commit against the local one and the form to use for a newer commit). Decides the category and tags after the registry: a listed plugin, own or taken, is asked for neither; an unlisted one without them is asked through `ask.mjs` at a terminal, and is a usage error otherwise. Ends with `reproduce`, the command line that repeats the run without asking. Under `--offline` the validation-commit check is `skipped`, not passed: verdict `skipped`, listed under `skipped` and not `unknown`, never blocking, and the READY line says "1 check skipped (--offline)". |
|
|
23
23
|
| `ask.mjs` | The two questions `submit` asks a person at a terminal, and only there: category and tags, numbered from the pinned form, with the marketplace's own presentation for the manifest's kinds (read from the pinned catalog builder) as the default where it is on the list. Prompts on stderr, nothing persisted. |
|
|
24
|
+
| `doctor.mjs` | What is installed, what is pinned, and what has moved. `omakit.version` is one check for one question, is this the current omakit: `ok` with "0.1.8, the newest published version", a `note` with "0.1.8; 0.1.9 is published" and the upgrade command, `unknown` with the registry's failure code, `info` offline; its evidence carries `{ installed, latest, source }`. `pin.freshness` compares each path in `PIN_PATHS` between the pin and the marketplace's HEAD by tree or blob id and splits what moved by the list `registry.mjs` reads live from: `registry.json` and `site/catalog.json` moving is `ok`, because those are read from HEAD anyway; anything else moving (`scripts/`, the two forms) is a `note` naming the paths, with an action a user can take, `omakit upgrade` and then an issue at the repository named in package.json. The maintainer's pin procedure stays in `docs/UPSTREAM_CONTRACT.md` and is never printed. HEAD unreadable is `unknown`. `--json` carries `changedPaths`, `readLive` and `pinned` under the check's evidence. |
|
|
24
25
|
| `watch.mjs` | The validation watch: validated commit versus current default-branch HEAD, and the one action that refreshes it. |
|
|
25
26
|
| `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. |
|
|
26
|
-
| `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. |
|
|
27
|
+
| `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. Six states: `▁ ok`, `█ FAIL`, `▓ note`, `░ info`, `▒ ?` for a check that could not be made, and `▔ skip` for a check a flag said not to make, the floor's ink at the ceiling so it is never read as a pass. `docs/TUI.md` explains it. |
|
|
27
28
|
| `report.mjs` | Text rendering of submit, watch, doctor and verify for the agent that runs this tool, and the person reading over its shoulder. |
|
|
28
29
|
| `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. |
|
|
29
30
|
| `usage.mjs` | The help text, as data. |
|
|
@@ -31,6 +32,7 @@ local commit through the transport seam the marketplace tests itself
|
|
|
31
32
|
| `banner.mjs` | The wordmark, on a bare `omakit` and in `setup` only. |
|
|
32
33
|
| `effect.mjs` | The one text effect: the wordmark through `ttfx` where it is drawn, with frozen arguments, a hard budget, no colour of its own, and nothing at all when `ttfx` is not there. |
|
|
33
34
|
| `progress.mjs` | The progress line, on stderr, only when a person is looking. |
|
|
35
|
+
| `paths.mjs` | Omakit's cache directory, following XDG, and `withHomeAbbreviated()`: a path under `$HOME` written as `~/...` for a person, applied where doctor, setup and pin render text and never where a result is built, so `--json` keeps every path absolute. |
|
|
34
36
|
| `cli.mjs` | The one entry point behind `bin/omakit`, and the one register every failure is reported in. `submit` exits on the outcome: 1 for `refused`, 0 for `ready` and `listed`. |
|
|
35
37
|
|
|
36
38
|
```text
|
|
@@ -60,6 +62,19 @@ the registry recorded so the corpus always contains repositories that are not
|
|
|
60
62
|
side rather than the findings themselves. The GitHub side uses whatever
|
|
61
63
|
credential `github.mjs` resolves (a `gh` login, and only that), read-only.
|
|
62
64
|
|
|
65
|
+
Two rules about the output, stated as rules because each is an exception to
|
|
66
|
+
a wider one. Everything omakit writes itself stays within 80 columns; text
|
|
67
|
+
that will be posted verbatim, the marketplace's own baseline report and the
|
|
68
|
+
issue body rendered from the pinned form, is never wrapped and may exceed 80,
|
|
69
|
+
because a wrapped body would not be the body. And stdout is the whole result;
|
|
70
|
+
stderr carries interactive decoration, the progress line from `progress.mjs`
|
|
71
|
+
and the chooser from `ask.mjs`, only when stderr is a TTY, so a piped stderr
|
|
72
|
+
is empty on success, and failures go to stderr always. `tests/unit/cli.test.mjs`
|
|
73
|
+
holds both: the width test names the two verbatim regions by their heading
|
|
74
|
+
and holds every other line to 80, the three-way test asserts the empty pipe,
|
|
75
|
+
and a pseudo-terminal test asserts that a terminal's stderr gets the progress
|
|
76
|
+
sequences and nothing else.
|
|
77
|
+
|
|
63
78
|
`parity`, `watch`, `doctor` and `submit` reach the network, with Node's
|
|
64
79
|
built-in `fetch`, which does not read proxy environment variables by default.
|
|
65
80
|
Behind a proxy, run them with `NODE_USE_ENV_PROXY=1`. `submit` reads two things
|
|
@@ -29,8 +29,7 @@ import { progress } from "./progress.mjs"
|
|
|
29
29
|
import { banner, bannerEnabled } from "./banner.mjs"
|
|
30
30
|
import { COMMANDS, renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
|
|
31
31
|
import { action, colourEnabled, GUTTER, labelled, mark, styler, wrap } from "./style.mjs"
|
|
32
|
-
import { omakitCacheDir } from "./paths.mjs"
|
|
33
|
-
import { parityOutput } from "./parity-output.mjs"
|
|
32
|
+
import { omakitCacheDir, withHomeAbbreviated } from "./paths.mjs"
|
|
34
33
|
|
|
35
34
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
|
|
36
35
|
|
|
@@ -84,6 +83,18 @@ function positionals(args) {
|
|
|
84
83
|
return args.filter((value, index) => !value.startsWith("--") && !valued.has(args[index - 1]))
|
|
85
84
|
}
|
|
86
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The progress line for a command, or nothing under --json: a machine
|
|
88
|
+
* reading the document on stdout gets no decoration on stderr either.
|
|
89
|
+
* Measured on 0.1.6: submit, watch and doctor silenced it and verify did
|
|
90
|
+
* not, so `verify --json` at a terminal drew a progress line the others
|
|
91
|
+
* never drew.
|
|
92
|
+
*/
|
|
93
|
+
const SILENT = Object.freeze({ phase: () => {}, done: () => {} })
|
|
94
|
+
function spinnerFor(args) {
|
|
95
|
+
return args.includes("--json") ? SILENT : progress()
|
|
96
|
+
}
|
|
97
|
+
|
|
87
98
|
function emit(args, text) {
|
|
88
99
|
const out = option(args, "--out")
|
|
89
100
|
if (out) {
|
|
@@ -100,7 +111,7 @@ async function cmdSubmit(args) {
|
|
|
100
111
|
const target = positionals(args)[0]
|
|
101
112
|
if (!target) fail("usage", "submit needs a target: `omakit submit <target> --category <c> --tags <a,b>`", 2)
|
|
102
113
|
const json = args.includes("--json")
|
|
103
|
-
const spinner =
|
|
114
|
+
const spinner = spinnerFor(args)
|
|
104
115
|
// A missing --category or --tags on an unlisted plugin is asked for, once
|
|
105
116
|
// each, when a person is at a terminal on both ends and no machine is
|
|
106
117
|
// reading the result. Anything else, a pipe, an agent, --json, gets the
|
|
@@ -156,7 +167,7 @@ async function cmdSubmit(args) {
|
|
|
156
167
|
async function cmdWatch(args) {
|
|
157
168
|
const issueUrl = positionals(args)[0]
|
|
158
169
|
if (!issueUrl) fail("usage", "watch needs an issue: `omakit watch <issue-url>`", 2)
|
|
159
|
-
const spinner = args
|
|
170
|
+
const spinner = spinnerFor(args)
|
|
160
171
|
let result
|
|
161
172
|
try {
|
|
162
173
|
result = await validationWatch({ repoRoot: ROOT, issueUrl, onPhase: spinner.phase })
|
|
@@ -189,7 +200,7 @@ async function cmdUpgrade(args) {
|
|
|
189
200
|
}
|
|
190
201
|
|
|
191
202
|
async function cmdDoctor(args) {
|
|
192
|
-
const spinner = args
|
|
203
|
+
const spinner = spinnerFor(args)
|
|
193
204
|
const result = await doctor({ repoRoot: ROOT, offline: args.includes("--offline"), onPhase: spinner.phase })
|
|
194
205
|
spinner.done()
|
|
195
206
|
emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderDoctor(result))
|
|
@@ -206,7 +217,7 @@ async function cmdVerify(args) {
|
|
|
206
217
|
if (error instanceof SubjectError) fail(error.code, error.message, error.code === "usage" ? 2 : 1)
|
|
207
218
|
throw error
|
|
208
219
|
}
|
|
209
|
-
const spinner =
|
|
220
|
+
const spinner = spinnerFor(args)
|
|
210
221
|
let section
|
|
211
222
|
try {
|
|
212
223
|
spinner.phase("running the official security baseline over a local snapshot")
|
|
@@ -239,23 +250,27 @@ async function cmdVerify(args) {
|
|
|
239
250
|
}
|
|
240
251
|
|
|
241
252
|
async function cmdParity(args) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
253
|
+
// The runner takes everything as arguments. Before 0.1.8 this handed over
|
|
254
|
+
// four PARITY_* variables and an OMAKIT_ROOT through the process
|
|
255
|
+
// environment, and OMAKIT_ROOT was the one OMAKIT_* name in the tree.
|
|
256
|
+
let ok = false
|
|
246
257
|
try {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
// Imported here, not at the top: the runner ships with the package, but
|
|
259
|
+
// no other command needs it, and a copy of bin/ and tools/ alone runs
|
|
260
|
+
// everything else.
|
|
261
|
+
const { runParity } = await import("../../tests/parity/run.mjs")
|
|
262
|
+
const count = option(args, "--count")
|
|
263
|
+
const offset = option(args, "--offset")
|
|
264
|
+
;({ ok } = await runParity({
|
|
265
|
+
repoRoot: ROOT,
|
|
266
|
+
count: count ? Number(count) : undefined,
|
|
267
|
+
offset: offset ? Number(offset) : undefined,
|
|
268
|
+
out: option(args, "--out") || null,
|
|
269
|
+
}))
|
|
256
270
|
} catch (error) {
|
|
257
271
|
failFrom(error)
|
|
258
272
|
}
|
|
273
|
+
process.exit(ok ? 0 : 1)
|
|
259
274
|
}
|
|
260
275
|
|
|
261
276
|
const [command, ...rest] = process.argv.slice(2)
|
|
@@ -269,7 +284,7 @@ if (command === "setup") {
|
|
|
269
284
|
// ensurePin narrates: a state line to keep, then a fetch it is about to
|
|
270
285
|
// start. The fetch is the slow part, so it gets the progress line.
|
|
271
286
|
if (line.state === "fetching") spinner.phase(line.text)
|
|
272
|
-
else process.stdout.write(`${mark(line.state, c)}${wrap(line.text, { indent: GUTTER }, c).join("\n").trimStart()}\n`)
|
|
287
|
+
else process.stdout.write(`${mark(line.state, c)}${wrap(withHomeAbbreviated(line.text), { indent: GUTTER }, c).join("\n").trimStart()}\n`)
|
|
273
288
|
})
|
|
274
289
|
} catch (error) {
|
|
275
290
|
spinner.done()
|
|
@@ -13,6 +13,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
|
13
13
|
import { basename, dirname, join } from "node:path"
|
|
14
14
|
import { tagSlug } from "./form.mjs"
|
|
15
15
|
import { COMMANDS, COMPLETION_SHELLS } from "./usage.mjs"
|
|
16
|
+
import { withHomeAbbreviated } from "./paths.mjs"
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* The completion model, read out of the help data. A subcommand is the word
|
|
@@ -221,7 +222,7 @@ export function completionInstall(env = process.env) {
|
|
|
221
222
|
const shell = basename(env.SHELL || "")
|
|
222
223
|
const home = env.HOME || ""
|
|
223
224
|
if (!home || !COMPLETION_SHELLS.includes(shell)) return null
|
|
224
|
-
const tilde = (dir) => (dir
|
|
225
|
+
const tilde = (dir) => withHomeAbbreviated(dir, env)
|
|
225
226
|
if (shell === "bash") {
|
|
226
227
|
const dir = join(env.XDG_DATA_HOME || join(home, ".local/share"), "bash-completion/completions")
|
|
227
228
|
return { shell, path: join(dir, "omakit"), display: `${tilde(dir)}/omakit`, note: null }
|
|
@@ -13,33 +13,51 @@
|
|
|
13
13
|
// contract is read from, and the procedure in docs/UPSTREAM_CONTRACT.md requires
|
|
14
14
|
// re-proving transport parity and committing the evidence afterwards. An
|
|
15
15
|
// `upgrade` that quietly advanced the pin would break the one guarantee this
|
|
16
|
-
// tool sells. So doctor reports that the pin is behind and
|
|
16
|
+
// tool sells. So doctor reports that the pin is behind, and that is all it
|
|
17
|
+
// does: the procedure is the maintainer's, it lives in that document and in
|
|
18
|
+
// this comment, and it is never printed, because the person running doctor
|
|
19
|
+
// is a user of a package that does not even ship docs/. What a user can do
|
|
20
|
+
// is run `omakit upgrade`, since a newer omakit may already carry the new
|
|
21
|
+
// pin, and otherwise open an issue naming the paths that moved.
|
|
17
22
|
//
|
|
18
23
|
// That the pin goes stale unnoticed is, of course, exactly the defect class
|
|
19
24
|
// `omakit watch` exists to report. It would be poor form not to apply it here.
|
|
20
25
|
//
|
|
21
26
|
// And "behind" is not the same as "behind in something omakit reads". Measured
|
|
22
27
|
// 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
|
|
28
|
+
// commits in 30 days touched only registry.json, which omakit reads live,
|
|
24
29
|
// while nothing under scripts/ or .github/ISSUE_TEMPLATE/ changed since the
|
|
25
30
|
// pin. A doctor that only said "behind" would say it about every run. So it
|
|
26
31
|
// compares each path in PIN_PATHS between the pin and HEAD, by tree or blob
|
|
27
|
-
// id, and
|
|
32
|
+
// id, and splits the ones that moved by the same list registry.mjs reads
|
|
33
|
+
// live from: the two data files, which a moved HEAD cannot make stale, and
|
|
34
|
+
// everything else, which only a new pin can carry. Measured on 0.1.7: with
|
|
35
|
+
// only registry.json and site/catalog.json moved, doctor said `note` and
|
|
36
|
+
// pointed a user at docs/UPSTREAM_CONTRACT.md, though the pin was behind in
|
|
37
|
+
// nothing the tool uses.
|
|
28
38
|
|
|
29
39
|
import { execFileSync } from "node:child_process"
|
|
30
40
|
import { readFileSync } from "node:fs"
|
|
31
41
|
import { join } from "node:path"
|
|
32
42
|
import { MARKETPLACE_PIN, PIN_PATHS, marketplacePinDir, pinDiskUsage, pinIsSparse, requirePin } from "./pin.mjs"
|
|
43
|
+
import { LIVE_PATHS } from "./registry.mjs"
|
|
33
44
|
import { credential, defaultBranchHead, getJson, UNAUTHENTICATED_LIMIT, GitHubError } from "./github.mjs"
|
|
34
|
-
import {
|
|
45
|
+
import { NPM_REGISTRY, registryLatest, upgradeCommand } from "./upgrade.mjs"
|
|
35
46
|
import { pathHint } from "./path-hint.mjs"
|
|
36
47
|
|
|
48
|
+
/** "git+https://github.com/owner/name.git" in package.json -> "https://github.com/owner/name", or null. */
|
|
49
|
+
function repositoryPage(repository) {
|
|
50
|
+
const url = typeof repository === "string" ? repository : repository?.url
|
|
51
|
+
const match = String(url || "").match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/)
|
|
52
|
+
return match ? `https://github.com/${match[1]}/${match[2]}` : null
|
|
53
|
+
}
|
|
54
|
+
|
|
37
55
|
function tool(repoRoot) {
|
|
38
56
|
try {
|
|
39
57
|
const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
|
|
40
|
-
return { name: pkg.name, version: pkg.version, engines: pkg.engines?.node || null }
|
|
58
|
+
return { name: pkg.name, version: pkg.version, engines: pkg.engines?.node || null, repository: repositoryPage(pkg.repository) }
|
|
41
59
|
} catch {
|
|
42
|
-
return { name: "omakit", version: "unknown", engines: null }
|
|
60
|
+
return { name: "omakit", version: "unknown", engines: null, repository: null }
|
|
43
61
|
}
|
|
44
62
|
}
|
|
45
63
|
|
|
@@ -102,40 +120,65 @@ export async function changedPinPaths({ pinDir, headCommit, pinCommit = MARKETPL
|
|
|
102
120
|
return changed
|
|
103
121
|
}
|
|
104
122
|
|
|
123
|
+
/** A PIN_PATHS pattern as a path: "/site/catalog.json" -> "site/catalog.json". */
|
|
124
|
+
const asPath = (pattern) => pattern.replace(/^\/|\/$/g, "")
|
|
125
|
+
|
|
126
|
+
/** "a", "a and b", "a, b and c". */
|
|
127
|
+
function list(items) {
|
|
128
|
+
return items.length < 3 ? items.join(" and ") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`
|
|
129
|
+
}
|
|
130
|
+
|
|
105
131
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
132
|
+
* The pin against the marketplace's HEAD, for a user. `changedPaths` is the
|
|
133
|
+
* answer from changedPinPaths(), split by LIVE_PATHS into `readLive`, the
|
|
134
|
+
* data files a moved HEAD cannot make stale because registry.mjs reads them
|
|
135
|
+
* from HEAD, and `pinned`, everything only a new pin can carry. Only the
|
|
136
|
+
* second set is worth a note; the action for it is a user's, not the
|
|
137
|
+
* maintainer's. Null when the comparison was not made, and then the detail
|
|
138
|
+
* says only that HEAD moved. Full commits in the evidence; short ones in
|
|
139
|
+
* the detail. `issues` is where a user reports a pinned path that moved,
|
|
140
|
+
* read from package.json by the caller.
|
|
109
141
|
*/
|
|
110
|
-
export function pinFreshness(identity, head, changedPaths = null) {
|
|
142
|
+
export function pinFreshness(identity, head, changedPaths = null, { issues = null } = {}) {
|
|
111
143
|
const current = head.commit === identity.commit
|
|
112
144
|
const branch = head.branch || "default"
|
|
145
|
+
const changed = current ? [] : changedPaths || []
|
|
146
|
+
const readLive = changed.filter((pattern) => LIVE_PATHS.includes(asPath(pattern)))
|
|
147
|
+
const pinned = changed.filter((pattern) => !LIVE_PATHS.includes(asPath(pattern)))
|
|
148
|
+
const where = `pin ${identity.commit.slice(0, 7)}; marketplace ${branch} at ${head.commit.slice(0, 7)}`
|
|
113
149
|
const moved = changedPaths === null
|
|
114
|
-
? ""
|
|
115
|
-
:
|
|
116
|
-
? `; changed since the pin: ${
|
|
117
|
-
:
|
|
150
|
+
? "; the paths omakit reads were not compared"
|
|
151
|
+
: pinned.length
|
|
152
|
+
? `; changed since the pin: ${pinned.join(", ")}${readLive.length ? ` (${list(readLive.map(asPath))} moved too, and ${readLive.length === 1 ? "that is" : "those are"} read live)` : ""}`
|
|
153
|
+
: readLive.length
|
|
154
|
+
? `; only ${list(readLive.map(asPath))} moved, and ${readLive.length === 1 ? "that is" : "those are"} read live`
|
|
155
|
+
: "; nothing omakit reads moved"
|
|
156
|
+
const stale = !current && (changedPaths === null || pinned.length > 0)
|
|
118
157
|
return {
|
|
119
158
|
id: "pin.freshness",
|
|
120
|
-
state:
|
|
121
|
-
detail: current
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
159
|
+
state: stale ? "advice" : "ok",
|
|
160
|
+
detail: current ? `the pin is the marketplace's current ${branch}-branch HEAD` : `${where}${moved}`,
|
|
161
|
+
action: stale
|
|
162
|
+
? `A newer omakit may already carry the new pin: run \`omakit upgrade\`. If it does not, open an issue${issues ? ` at ${issues}/issues` : ""} naming the paths above.`
|
|
163
|
+
: null,
|
|
125
164
|
evidence: {
|
|
126
165
|
pinCommit: identity.commit,
|
|
127
166
|
marketplaceHead: head.commit,
|
|
128
167
|
branch,
|
|
129
|
-
...(changedPaths === null ? {} : { changedPaths:
|
|
168
|
+
...(changedPaths === null ? {} : { changedPaths: changed, readLive, pinned }),
|
|
130
169
|
},
|
|
131
170
|
}
|
|
132
171
|
}
|
|
133
172
|
|
|
134
173
|
/**
|
|
135
|
-
* @param {{ repoRoot: string, offline?: boolean, env?: object, npmPrefix?: () => string|null
|
|
136
|
-
*
|
|
174
|
+
* @param {{ repoRoot: string, offline?: boolean, env?: object, npmPrefix?: () => string|null,
|
|
175
|
+
* resolveHead?: typeof defaultBranchHead, latest?: typeof registryLatest }} options
|
|
176
|
+
* `env` and `npmPrefix` are injectable for tests of the PATH check;
|
|
177
|
+
* `resolveHead` and `latest` for tests of the two checks that read the
|
|
178
|
+
* network, whose defaults are the tool's one HEAD resolver and its one
|
|
179
|
+
* registry read.
|
|
137
180
|
*/
|
|
138
|
-
export async function doctor({ repoRoot, offline = false, onPhase, env = process.env, npmPrefix }) {
|
|
181
|
+
export async function doctor({ repoRoot, offline = false, onPhase, env = process.env, npmPrefix, resolveHead = defaultBranchHead, latest: latestVersion = registryLatest }) {
|
|
139
182
|
// Optional: told what is being read while the network answers. Never
|
|
140
183
|
// affects the result.
|
|
141
184
|
const phase = onPhase || (() => {})
|
|
@@ -149,7 +192,27 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
|
|
|
149
192
|
})
|
|
150
193
|
|
|
151
194
|
const self = tool(repoRoot)
|
|
152
|
-
|
|
195
|
+
// What is installed and what is published, one check: the two facts are
|
|
196
|
+
// one question, "is this the current omakit", and were two lines before.
|
|
197
|
+
// Offline, the first fact alone, as information. The evidence carries both
|
|
198
|
+
// and where the second came from.
|
|
199
|
+
const versionCheck = (state, detail, action = null, latest = null, source = null) =>
|
|
200
|
+
add("omakit.version", state, detail, action, { installed: self.version, latest, source })
|
|
201
|
+
if (offline) {
|
|
202
|
+
versionCheck("info", `${self.version}; the newest published version is not checked (--offline)`)
|
|
203
|
+
} else {
|
|
204
|
+
phase("asking the npm registry for the newest published version")
|
|
205
|
+
const published = await latestVersion(self.name)
|
|
206
|
+
if (published.version) {
|
|
207
|
+
const current = published.version === self.version
|
|
208
|
+
versionCheck(current ? "ok" : "advice",
|
|
209
|
+
current ? `${self.version}, the newest published version` : `${self.version}; ${published.version} is published`,
|
|
210
|
+
current ? null : `run \`${upgradeCommand(repoRoot, self.name)}\``,
|
|
211
|
+
published.version, NPM_REGISTRY)
|
|
212
|
+
} else {
|
|
213
|
+
versionCheck("unknown", `${self.version}; could not read the npm registry (${published.error?.code || "error"})`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
153
216
|
|
|
154
217
|
// Reachable as a bare command, or the one line that makes it so for this
|
|
155
218
|
// install (path-hint.mjs). Measured: an npm prefix whose bin is not on PATH
|
|
@@ -185,29 +248,19 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
|
|
|
185
248
|
if (!offline && identity) {
|
|
186
249
|
phase("reading the marketplace's current default-branch HEAD")
|
|
187
250
|
try {
|
|
188
|
-
const head = await
|
|
251
|
+
const head = await resolveHead(MARKETPLACE_PIN.repository)
|
|
189
252
|
let changedPaths = []
|
|
190
253
|
if (head.commit !== identity.commit) {
|
|
191
254
|
phase("comparing each path omakit reads between the pin and HEAD")
|
|
192
255
|
changedPaths = await changedPinPaths({ pinDir: dir, headCommit: head.commit, pinCommit: identity.commit })
|
|
193
256
|
}
|
|
194
|
-
checks.push(pinFreshness(identity, head, changedPaths))
|
|
257
|
+
checks.push(pinFreshness(identity, head, changedPaths, { issues: self.repository }))
|
|
195
258
|
} catch (error) {
|
|
196
259
|
add("pin.freshness", "unknown", `could not read the marketplace's HEAD (${error.code || "error"})`,
|
|
197
260
|
error.code === "network-unavailable" ? "Connect to the network, or pass --offline to skip the two checks that need it." : null,
|
|
198
261
|
{ pinCommit: identity.commit, marketplaceHead: null, branch: null })
|
|
199
262
|
}
|
|
200
263
|
|
|
201
|
-
phase("asking the npm registry for the newest published version")
|
|
202
|
-
const latest = await latestOnRegistry(self.name)
|
|
203
|
-
if (latest) {
|
|
204
|
-
const current = latest === self.version
|
|
205
|
-
add("omakit.latest", current ? "ok" : "advice",
|
|
206
|
-
current ? `${latest} is the newest published version` : `${latest} is published, this is ${self.version}`,
|
|
207
|
-
current ? null : upgradeCommand(repoRoot, self.name))
|
|
208
|
-
} else {
|
|
209
|
-
add("omakit.latest", "unknown", "the npm registry did not answer, or this version is unpublished")
|
|
210
|
-
}
|
|
211
264
|
}
|
|
212
265
|
|
|
213
266
|
// Where the credential comes from, said out loud. Borrowing someone's `gh`
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// it so, for the install that is actually here.
|
|
3
3
|
//
|
|
4
4
|
// Measured: after `npm install --global omakit` on a machine whose npm prefix
|
|
5
|
-
// was ~/.local/share
|
|
5
|
+
// was a directory under ~/.local/share, the command was missing, because that
|
|
6
6
|
// prefix's `bin` was not on PATH. `omakit setup` then printed the `ln -s` hint
|
|
7
7
|
// written for a clone, which points at a bin/omakit that npm did not lay out
|
|
8
8
|
// where the hint assumes. An npm install needs the npm prefix's `bin` on PATH,
|
|
@@ -8,3 +8,21 @@ export function omakitCacheDir(name = "", env = process.env) {
|
|
|
8
8
|
: join(env.HOME || homedir(), ".cache")
|
|
9
9
|
return join(base, "omakit", name)
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Text for a person, with every path under the home directory written the
|
|
14
|
+
* way a shell would take it: `~/.cache/omakit/marketplace`. Only `$HOME`
|
|
15
|
+
* counts, because `~` is what the shell expands to `$HOME` and nothing else;
|
|
16
|
+
* with it unset, or for a path outside it, the text is returned as it came.
|
|
17
|
+
* Human output only: `--json` keeps every path absolute, so this is applied
|
|
18
|
+
* where text is rendered, never where a result is built.
|
|
19
|
+
*/
|
|
20
|
+
export function withHomeAbbreviated(text, env = process.env) {
|
|
21
|
+
const home = String(env.HOME || "").replace(/\/+$/, "")
|
|
22
|
+
if (!home || !home.startsWith("/")) return String(text)
|
|
23
|
+
// The home directory where a path starts: at the start of the text or after
|
|
24
|
+
// the characters a path follows in prose or a command, and followed by a
|
|
25
|
+
// separator or the end, so /tmp/home/me/x and /home/meh/x are left alone.
|
|
26
|
+
const literal = home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
27
|
+
return String(text).replace(new RegExp(`(^|[\\s"'(=:])${literal}(?=/|$|[\\s"'),:])`, "g"), "$1~")
|
|
28
|
+
}
|
|
@@ -20,13 +20,15 @@
|
|
|
20
20
|
import {
|
|
21
21
|
action, colourEnabled, COLUMNS, continuation, field, GUTTER, labelled, mark, section, STEP, styler, verdict, width, wrap,
|
|
22
22
|
} from "./style.mjs"
|
|
23
|
+
import { withHomeAbbreviated } from "./paths.mjs"
|
|
23
24
|
|
|
24
25
|
const body = " ".repeat(GUTTER)
|
|
25
26
|
|
|
26
|
-
/** The state a check renders in: an advisory failure is a note, not a FAIL,
|
|
27
|
+
/** The state a check renders in: an advisory failure is a note, not a FAIL, a check that waited on another is a question, and a check a flag skipped says so. */
|
|
27
28
|
function stateOf(check) {
|
|
28
29
|
if (check.verdict === "pass") return "pass"
|
|
29
30
|
if (check.verdict === "unknown") return "unknown"
|
|
31
|
+
if (check.verdict === "skipped") return "skipped"
|
|
30
32
|
return check.severity === "advisory" ? "advisory" : "fail"
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -101,10 +103,10 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
|
|
|
101
103
|
out.push(...field("marketplace", `${result.pin.commit}, baseline ${result.pin.baselineVersion}, ${result.pin.enforcementMode}`, c))
|
|
102
104
|
out.push("")
|
|
103
105
|
|
|
104
|
-
// Passing checks run together, and so
|
|
105
|
-
//
|
|
106
|
-
// collapsed where two blocks meet.
|
|
107
|
-
const quiet = (state) => state === "pass" || state === "unknown"
|
|
106
|
+
// Passing checks run together, and so do a check that waited on another
|
|
107
|
+
// and one a flag skipped: all are two quiet lines. Anything else gets a
|
|
108
|
+
// blank line on each side, collapsed where two blocks meet.
|
|
109
|
+
const quiet = (state) => state === "pass" || state === "unknown" || state === "skipped"
|
|
108
110
|
let previous = "pass"
|
|
109
111
|
for (const [index, check] of result.checks.entries()) {
|
|
110
112
|
const state = stateOf(check)
|
|
@@ -168,7 +170,12 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
|
|
|
168
170
|
const validation = result.validationCommit.defaultBranchHead
|
|
169
171
|
? `${result.validationCommit.local} is the ${result.validationCommit.branch || "default"}-branch HEAD, so it is the commit the marketplace will validate.`
|
|
170
172
|
: `${result.validationCommit.local} is the local commit; the marketplace validates the default-branch HEAD it resolves when the issue is opened.`
|
|
171
|
-
|
|
173
|
+
// A skipped check is said on the READY line itself, so the one line an
|
|
174
|
+
// agent quotes does not read as "everything passed" when one check never
|
|
175
|
+
// ran. The count is the whole `skipped` list; today only --offline skips.
|
|
176
|
+
const skipped = (result.skipped || []).length
|
|
177
|
+
const skippedNote = skipped ? ` ${skipped === 1 ? "1 check" : `${skipped} checks`} skipped (--offline).` : ""
|
|
178
|
+
out.push(...verdict("pass", "READY", `every blocking check passed.${skippedNote} ${validation}`, c))
|
|
172
179
|
out.push("")
|
|
173
180
|
out.push(...section("issue title", c))
|
|
174
181
|
out.push(c("heading", result.issue.title))
|
|
@@ -311,7 +318,13 @@ export function renderVerify(document, { colour = colourEnabled(), blockingRules
|
|
|
311
318
|
|
|
312
319
|
const DOCTOR_STATE = { ok: "pass", advice: "advisory", problem: "fail", info: "info", unknown: "unknown" }
|
|
313
320
|
|
|
314
|
-
|
|
321
|
+
/**
|
|
322
|
+
* @param {{ colour?: boolean, env?: object }} [options] `env` is where `$HOME`
|
|
323
|
+
* is read from: a path under it is printed as `~/...` for a person, the
|
|
324
|
+
* way a shell takes it, while the result itself, and so `--json`, keeps
|
|
325
|
+
* every path absolute.
|
|
326
|
+
*/
|
|
327
|
+
export function renderDoctor(result, { colour = colourEnabled(), env = process.env } = {}) {
|
|
315
328
|
const c = styler(colour)
|
|
316
329
|
const out = []
|
|
317
330
|
let previous = false
|
|
@@ -320,8 +333,8 @@ export function renderDoctor(result, { colour = colourEnabled() } = {}) {
|
|
|
320
333
|
const loud = state === "fail" || state === "advisory"
|
|
321
334
|
if (index > 0 && (loud || previous)) out.push("")
|
|
322
335
|
out.push(`${mark(state, c)}${c("name", check.id)}`)
|
|
323
|
-
out.push(...wrap(check.detail, { indent: GUTTER }, c))
|
|
324
|
-
if (check.action) out.push(...action(check.action, c))
|
|
336
|
+
out.push(...wrap(withHomeAbbreviated(check.detail, env), { indent: GUTTER }, c))
|
|
337
|
+
if (check.action) out.push(...action(withHomeAbbreviated(check.action, env), c))
|
|
325
338
|
previous = loud
|
|
326
339
|
}
|
|
327
340
|
out.push("")
|
|
@@ -20,6 +20,7 @@ import { TAGLINE } from "./usage.mjs"
|
|
|
20
20
|
import { installCompletion } from "./completion.mjs"
|
|
21
21
|
import { submissionContract } from "./form.mjs"
|
|
22
22
|
import { pathHint } from "./path-hint.mjs"
|
|
23
|
+
import { withHomeAbbreviated } from "./paths.mjs"
|
|
23
24
|
|
|
24
25
|
function version(command) {
|
|
25
26
|
try {
|
|
@@ -32,16 +33,18 @@ function version(command) {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
|
-
* @param {{ repoRoot: string, entryPoint: string, stream?: NodeJS.WriteStream }} options
|
|
36
|
+
* @param {{ repoRoot: string, entryPoint: string, stream?: NodeJS.WriteStream, env?: object }} options
|
|
37
|
+
* `env` is where `$HOME` is read from: this is output for a person, so a
|
|
38
|
+
* path under it is printed as `~/...`.
|
|
36
39
|
*/
|
|
37
|
-
export async function setup({ repoRoot, entryPoint, stream = process.stdout }) {
|
|
40
|
+
export async function setup({ repoRoot, entryPoint, stream = process.stdout, env = process.env }) {
|
|
38
41
|
const c = styler(colourEnabled(stream))
|
|
39
42
|
const out = (line = "") => stream.write(`${line}\n`)
|
|
40
43
|
// A step is a status line: the mark, then the fact, wrapped under itself.
|
|
41
|
-
const step = (state, text) => out(`${mark(state, c)}${wrap(text, { indent: GUTTER }, c).join("\n").trimStart()}`)
|
|
44
|
+
const step = (state, text) => out(`${mark(state, c)}${wrap(withHomeAbbreviated(text, env), { indent: GUTTER }, c).join("\n").trimStart()}`)
|
|
42
45
|
// The one action under a step sits in the step's body; under a sentence it
|
|
43
46
|
// sits where the sentence does.
|
|
44
|
-
const fix = (text, indent = GUTTER) => { for (const line of action(text, c, { indent })) out(line) }
|
|
47
|
+
const fix = (text, indent = GUTTER) => { for (const line of action(withHomeAbbreviated(text, env), c, { indent })) out(line) }
|
|
45
48
|
|
|
46
49
|
// The one place the wordmark runs through `ttfx` (effect.mjs): a first run
|
|
47
50
|
// already spending seconds fetching the pin.
|
|
@@ -162,6 +162,7 @@ export const DENSITY = Object.freeze({
|
|
|
162
162
|
medium: "▒", // ▒ unknown: neither here nor there
|
|
163
163
|
light: "░", // ░ information: present, weightless
|
|
164
164
|
floor: "▁", // ▁ settled: a pass, a rule, the track the scanner runs on
|
|
165
|
+
ceiling: "▔", // ▔ skipped: the floor's own ink, never landed, because a flag said not to look
|
|
165
166
|
})
|
|
166
167
|
|
|
167
168
|
/** The glyph that starts the one line that fixes things. */
|
|
@@ -170,15 +171,23 @@ export const ARROW = "→"
|
|
|
170
171
|
// --- the status vocabulary ---------------------------------------------------
|
|
171
172
|
|
|
172
173
|
/**
|
|
173
|
-
*
|
|
174
|
+
* Six states, and only six. Each has one glyph, one word and one tint, and
|
|
174
175
|
* every command prints them through `mark()` below. `pass` and `fail` are
|
|
175
176
|
* verdicts; `advisory` is a failure that does not block; `info` is a fact with
|
|
176
|
-
* no verdict; `unknown` is a check that could not be made
|
|
177
|
+
* no verdict; `unknown` is a check that could not be made; `skipped` is a
|
|
178
|
+
* check that was not made because a flag said not to (`--offline`).
|
|
177
179
|
*
|
|
178
180
|
* The glyph is the density ramp read as severity: the more ink, the more it
|
|
179
181
|
* matters. The word is the same thing in letters, and its case carries it too:
|
|
180
182
|
* FAIL is the only upper-case mark, so it is the one the eye lands on in a
|
|
181
183
|
* column of lower-case ones, with or without colour.
|
|
184
|
+
*
|
|
185
|
+
* `skipped` and `unknown` are one family: neither has a verdict, both wear the
|
|
186
|
+
* same tint, and only the glyph and the word tell them apart. Measured on
|
|
187
|
+
* 0.1.6: `--offline` drew the skipped validation-commit check as `▁ ok`, and
|
|
188
|
+
* `--json` said `"verdict": "pass"`, for a comparison that never happened. The
|
|
189
|
+
* skipped mark is the floor's ink at the ceiling: the same weight as a pass,
|
|
190
|
+
* visibly not landed on one, on a theme that shows no hue.
|
|
182
191
|
*/
|
|
183
192
|
export const STATUS = Object.freeze({
|
|
184
193
|
pass: Object.freeze({ glyph: DENSITY.floor, word: "ok", tint: "pass" }),
|
|
@@ -186,6 +195,7 @@ export const STATUS = Object.freeze({
|
|
|
186
195
|
advisory: Object.freeze({ glyph: DENSITY.dark, word: "note", tint: "advisory" }),
|
|
187
196
|
info: Object.freeze({ glyph: DENSITY.light, word: "info", tint: "info" }),
|
|
188
197
|
unknown: Object.freeze({ glyph: DENSITY.medium, word: "?", tint: "unknown" }),
|
|
198
|
+
skipped: Object.freeze({ glyph: DENSITY.ceiling, word: "skip", tint: "unknown" }),
|
|
189
199
|
})
|
|
190
200
|
|
|
191
201
|
/** The width of the widest mark, "█ FAIL"; every mark is padded to it so the names beside them align. */
|
|
@@ -360,7 +370,7 @@ export function wrap(text, { indent = 0, width: total = COLUMNS, first = indent
|
|
|
360
370
|
* after it starts at GUTTER. This is the only place a status is turned into
|
|
361
371
|
* characters.
|
|
362
372
|
*
|
|
363
|
-
* @param {"pass"|"fail"|"advisory"|"info"|"unknown"} state
|
|
373
|
+
* @param {"pass"|"fail"|"advisory"|"info"|"unknown"|"skipped"} state
|
|
364
374
|
* @param {(name: string, text: string) => string} c
|
|
365
375
|
*/
|
|
366
376
|
export function mark(state, c) {
|
|
@@ -56,7 +56,7 @@ export class SubmitError extends Error {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* A check has
|
|
59
|
+
* A check has four verdicts. `pass` and `fail` are its own. `unknown` is
|
|
60
60
|
* for a check that could not run because one it depends on failed: it is
|
|
61
61
|
* rendered as a question, its detail names what it waited on, and it counts
|
|
62
62
|
* in neither `blocking` nor `advisory`, so a refusal lists root causes only.
|
|
@@ -65,19 +65,28 @@ export class SubmitError extends Error {
|
|
|
65
65
|
* checklist and official-parser each failed for want of a body nobody could
|
|
66
66
|
* render yet, and the closing refusal listed them with "not rendered" where
|
|
67
67
|
* a remedy goes.
|
|
68
|
+
*
|
|
69
|
+
* `skipped` is for a check that was not made because a flag said not to,
|
|
70
|
+
* independent of `waitedOn`: it never blocks, it counts in `skipped` and
|
|
71
|
+
* not in `unknown`, and its detail says which flag. Measured on 0.1.6:
|
|
72
|
+
* `--offline` handed `verdict: true` to the validation-commit check, so
|
|
73
|
+
* `--json` said `"verdict": "pass"` and the report drew `▁ ok` for a
|
|
74
|
+
* comparison that never happened, and an agent reading `checks` rather than
|
|
75
|
+
* the prose could tell the owner every check passed.
|
|
68
76
|
*/
|
|
69
77
|
function check(id, fields) {
|
|
70
78
|
const waitedOn = (fields.waitedOn || []).filter(Boolean)
|
|
79
|
+
const skipped = fields.skipped === true
|
|
71
80
|
return {
|
|
72
81
|
id,
|
|
73
82
|
source: fields.source,
|
|
74
83
|
why: fields.why,
|
|
75
84
|
severity: fields.severity || "blocking",
|
|
76
|
-
verdict: waitedOn.length ? "unknown" : fields.verdict ? "pass" : "fail",
|
|
77
|
-
detail: waitedOn.length ? `not checked: it needs ${waitedOn.join(" and ")} to pass first` : fields.detail || "",
|
|
85
|
+
verdict: skipped ? "skipped" : waitedOn.length ? "unknown" : fields.verdict ? "pass" : "fail",
|
|
86
|
+
detail: !skipped && waitedOn.length ? `not checked: it needs ${waitedOn.join(" and ")} to pass first` : fields.detail || "",
|
|
78
87
|
paths: fields.paths || [],
|
|
79
88
|
// One arrow, or one per cause in the order they should be read.
|
|
80
|
-
remedy: waitedOn.length ? null : Array.isArray(fields.remedy) ? (fields.remedy.length ? fields.remedy : null) : fields.remedy || null,
|
|
89
|
+
remedy: skipped || waitedOn.length ? null : Array.isArray(fields.remedy) ? (fields.remedy.length ? fields.remedy : null) : fields.remedy || null,
|
|
81
90
|
}
|
|
82
91
|
}
|
|
83
92
|
|
|
@@ -405,7 +414,8 @@ export async function submitPreflight(options) {
|
|
|
405
414
|
source: "omakit",
|
|
406
415
|
why: "The marketplace validates the default-branch HEAD it resolves when the issue is opened or edited, not the commit checked here. 73% of the 464 submissions parked in the author's court have a HEAD ahead of their validated commit, so a preflight against a commit that is not the pushed HEAD describes a tree nobody will review. Not a marketplace rule; an Omakit refusal to report on the wrong tree.",
|
|
407
416
|
severity: options.offline ? "advisory" : "blocking",
|
|
408
|
-
|
|
417
|
+
skipped: options.offline === true,
|
|
418
|
+
verdict: validationMatches === true,
|
|
409
419
|
detail: options.offline
|
|
410
420
|
? `not checked (--offline). Local commit ${subject.commit}.`
|
|
411
421
|
: head
|
|
@@ -454,6 +464,7 @@ export async function submitPreflight(options) {
|
|
|
454
464
|
const blocking = checks.filter((entry) => entry.severity === "blocking" && entry.verdict === "fail")
|
|
455
465
|
const advisory = checks.filter((entry) => entry.severity === "advisory" && entry.verdict === "fail")
|
|
456
466
|
const unknown = checks.filter((entry) => entry.verdict === "unknown")
|
|
467
|
+
const skipped = checks.filter((entry) => entry.verdict === "skipped")
|
|
457
468
|
// Three outcomes. `refused`: a blocking check failed and no body exists.
|
|
458
469
|
// `listed`: nothing failed and the plugin is already listed by this
|
|
459
470
|
// repository, so there is no body either, and nothing is wrong. `ready`:
|
|
@@ -506,6 +517,7 @@ export async function submitPreflight(options) {
|
|
|
506
517
|
blocking: blocking.map((entry) => entry.id),
|
|
507
518
|
advisory: advisory.map((entry) => entry.id),
|
|
508
519
|
unknown: unknown.map((entry) => entry.id),
|
|
520
|
+
skipped: skipped.map((entry) => entry.id),
|
|
509
521
|
issue: ready ? issue : null,
|
|
510
522
|
baseline: preflight.invoked
|
|
511
523
|
? {
|
|
@@ -59,11 +59,29 @@ export const NPM_UPGRADE_ARGS = Object.freeze(["install", "--global", "--ignore-
|
|
|
59
59
|
|
|
60
60
|
/** The newest published version, or null when the registry did not answer. */
|
|
61
61
|
export async function latestOnRegistry(name) {
|
|
62
|
+
return (await registryLatest(name)).version
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The registry the newest version is read from, named in doctor's evidence. */
|
|
66
|
+
export const NPM_REGISTRY = "https://registry.npmjs.org"
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The newest published version, with the reason when there is none: the
|
|
70
|
+
* failure code from the one GET call site (network-unavailable,
|
|
71
|
+
* github-unavailable's npm sibling, not-found for an unpublished name), or
|
|
72
|
+
* `unpublished` when the registry answered without a version. `doctor` prints
|
|
73
|
+
* the code; `upgrade` only needs the version.
|
|
74
|
+
*
|
|
75
|
+
* @returns {Promise<{ version: string|null, error: { code: string, message: string }|null }>}
|
|
76
|
+
*/
|
|
77
|
+
export async function registryLatest(name) {
|
|
62
78
|
try {
|
|
63
|
-
const meta = await getJson(
|
|
64
|
-
return meta?.version
|
|
65
|
-
|
|
66
|
-
|
|
79
|
+
const meta = await getJson(`${NPM_REGISTRY}/${encodeURIComponent(name)}/latest`)
|
|
80
|
+
return meta?.version
|
|
81
|
+
? { version: meta.version, error: null }
|
|
82
|
+
: { version: null, error: { code: "unpublished", message: "the registry answered without a version" } }
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return { version: null, error: { code: error?.code || "error", message: String(error?.message || error) } }
|
|
67
85
|
}
|
|
68
86
|
}
|
|
69
87
|
|
|
@@ -118,10 +118,11 @@ export const TARGET_NOTE = "<target> is a local Git repository path, or <https u
|
|
|
118
118
|
*/
|
|
119
119
|
export const AUTHENTICATION = Object.freeze([
|
|
120
120
|
"Read-only, and optional. omakit uses your `gh` login if you have one, and",
|
|
121
|
-
"otherwise goes unauthenticated. `verify` needs no network
|
|
122
|
-
"
|
|
123
|
-
`
|
|
124
|
-
|
|
121
|
+
"otherwise goes unauthenticated. `verify` needs no network once the pin",
|
|
122
|
+
"exists, except to fetch a reviewer-mode <https url>@<sha> target, once;",
|
|
123
|
+
"`submit` reads two things online and `--offline` turns both off; `watch` and",
|
|
124
|
+
`\`parity\` are capped at ${UNAUTHENTICATED_LIMIT} requests an hour without a login. omakit never`,
|
|
125
|
+
"writes a credential anywhere.",
|
|
125
126
|
])
|
|
126
127
|
|
|
127
128
|
/**
|
|
@@ -106,12 +106,17 @@ export function validationCommentCommit(comments) {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
|
-
* @param {{ repoRoot: string, issueUrl: string
|
|
109
|
+
* @param {{ repoRoot: string, issueUrl: string, onPhase?: (name: string) => void,
|
|
110
|
+
* github?: { issue?: typeof issue, issueComments?: typeof issueComments, defaultBranchHead?: typeof defaultBranchHead } }} options
|
|
111
|
+
* `github` is injectable for tests, so the whole path from an issue body to
|
|
112
|
+
* a verdict can be run on data that never left the machine; the default is
|
|
113
|
+
* the read-only GitHub access in `github.mjs`.
|
|
110
114
|
*/
|
|
111
|
-
export async function validationWatch({ repoRoot, issueUrl, onPhase }) {
|
|
115
|
+
export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {} }) {
|
|
112
116
|
// Optional: told the name of the step about to run, so a terminal can say
|
|
113
117
|
// what is happening while the network answers. Never affects the result.
|
|
114
118
|
const phase = onPhase || (() => {})
|
|
119
|
+
const read = { issue, issueComments, defaultBranchHead, ...github }
|
|
115
120
|
const { dir: pinDir } = requirePin(repoRoot)
|
|
116
121
|
const target = parseIssueUrl(issueUrl)
|
|
117
122
|
if (`${target.owner}/${target.repository}`.toLowerCase() !== MARKETPLACE_SLUG) {
|
|
@@ -124,14 +129,14 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase }) {
|
|
|
124
129
|
const record = await loadRecord(pinDir)
|
|
125
130
|
|
|
126
131
|
phase(`reading issue #${target.number}`)
|
|
127
|
-
const subject = await issue(target.owner, target.repository, target.number)
|
|
132
|
+
const subject = await read.issue(target.owner, target.repository, target.number)
|
|
128
133
|
phase(`reading the comments on issue #${target.number}`)
|
|
129
|
-
const comments = await issueComments(target.owner, target.repository, target.number)
|
|
134
|
+
const comments = await read.issueComments(target.owner, target.repository, target.number)
|
|
130
135
|
|
|
131
|
-
const
|
|
132
|
-
const repositoryUrl =
|
|
133
|
-
const repositoryError =
|
|
134
|
-
const issueKind =
|
|
136
|
+
const repository = await repositoryFor(pinDir, subject)
|
|
137
|
+
const repositoryUrl = repository.url
|
|
138
|
+
const repositoryError = repository.error
|
|
139
|
+
const issueKind = repository.kind
|
|
135
140
|
|
|
136
141
|
let validated = null
|
|
137
142
|
let baselineError = null
|
|
@@ -164,7 +169,7 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase }) {
|
|
|
164
169
|
if (repositoryUrl) {
|
|
165
170
|
phase("reading the plugin repository's default-branch HEAD")
|
|
166
171
|
try {
|
|
167
|
-
head = await defaultBranchHead(repositoryUrl)
|
|
172
|
+
head = await read.defaultBranchHead(repositoryUrl)
|
|
168
173
|
} catch (error) {
|
|
169
174
|
headError = { code: error.code || "head-unreadable", message: error.message }
|
|
170
175
|
}
|
|
@@ -202,11 +207,16 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase }) {
|
|
|
202
207
|
baselineError,
|
|
203
208
|
head,
|
|
204
209
|
headError,
|
|
205
|
-
verdict: validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview }),
|
|
210
|
+
verdict: validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl }),
|
|
206
211
|
}
|
|
207
212
|
}
|
|
208
213
|
|
|
209
|
-
|
|
214
|
+
// `repositoryUrl` has no default on purpose. Measured on 0.1.6: the command
|
|
215
|
+
// computed it and then did not pass it, and the parameter defaulted to the
|
|
216
|
+
// truthy string "unknown", so an issue whose body named no repository was
|
|
217
|
+
// reported as a HEAD that could not be read, and the branch below that names
|
|
218
|
+
// the real cause was reachable from the unit test alone.
|
|
219
|
+
export function validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl }) {
|
|
210
220
|
if (baselineError) {
|
|
211
221
|
return {
|
|
212
222
|
state: "unknown",
|