omakit 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,15 +25,21 @@ See [docs/INSTALL.md](docs/INSTALL.md) for the clone route, PATH, requirements a
25
25
  | --- | --- |
26
26
  | [`omakit setup`](docs/COMMANDS.md) | The environment, the pin, tab completion, and what to try first. |
27
27
  | [`omakit submit <plugin-repo>`](docs/SUBMIT.md) | Every check, the issue title and body; asks for a category and tags at a terminal. |
28
- | [`omakit watch <issue-url>`](docs/VALIDATION_WATCH.md) | The commit the marketplace validated, against the plugin's current HEAD. |
28
+ | [`omakit watch [<issue-url>]`](docs/VALIDATION_WATCH.md) | Pick your marketplace issues, list them, or check all with `--all`. |
29
29
  | [`omakit verify <plugin-repo>`](docs/COMMANDS.md) | The official security baseline over the local transport; `--json` for the document. |
30
30
  | [`omakit parity`](docs/COMMANDS.md) | The baseline over GitHub versus the local transport, on real listings; writes the evidence. |
31
+ | [`omakit audit [<plugin>]`](docs/AUDIT.md) | Installed third-party commits against the exact commits the marketplace validated. |
31
32
  | [`omakit weigh <plugin>`](docs/WEIGH.md) | What a plugin weighs on the shell, measured by restarting it without and with the plugin; asks first. |
32
33
  | [`omakit doctor`](docs/COMMANDS.md) | What is installed, what is pinned, and what has moved. |
33
34
  | [`omakit pin`](docs/COMMANDS.md) | What setup does for the pin, on its own. |
34
35
  | [`omakit upgrade`](docs/COMMANDS.md) | Updates omakit through its own installer: npm, or a fast-forward. |
35
36
  | [`omakit help --agent`](docs/COMMANDS.md) | The operating instructions, for the agent running this. |
36
37
 
38
+ Normal terminal use also checks for a newer npm release at most once daily
39
+ and shows the upgrade command. It installs nothing automatically; scripts
40
+ and JSON stay quiet. `DISABLE_UPDATE_NOTIFIER=1` disables the notice, and
41
+ `omakit doctor` checks explicitly. [Update behaviour](docs/INSTALL.md#updating).
42
+
37
43
  ### `submit`
38
44
 
39
45
  ```bash
@@ -50,10 +56,15 @@ Read more: [docs/SUBMIT.md](docs/SUBMIT.md).
50
56
 
51
57
  ```bash
52
58
  omakit watch <submission-issue-url>
59
+ omakit watch --all
60
+ omakit watch --list
61
+ omakit watch # choose one or several issues at a terminal
53
62
  ```
54
63
 
55
64
  It decides whether the marketplace validated the plugin's current commit; [73% of parked submissions have a HEAD the marketplace never saw](docs/MEASUREMENTS.md).
56
65
 
66
+ Account-wide discovery uses your signed-in `gh` account and reads your open marketplace issues. `--user <login>` reads another public account. Batch output includes baseline results, labels and the latest human discussion; `current` compares commits and does not imply approval or publication. Each command takes one snapshot and posts nothing.
67
+
57
68
  ![omakit watch reporting that a validated commit has fallen behind](docs/media/watch.gif)
58
69
 
59
70
  Read more: [docs/VALIDATION_WATCH.md](docs/VALIDATION_WATCH.md).
@@ -95,6 +106,7 @@ Committed evidence records a digest of each side rather than the results themsel
95
106
  | [docs/INSTALL.md](docs/INSTALL.md) | install details, PATH, requirements, upgrading, and what Socket reports and why |
96
107
  | [docs/HOW.md](docs/HOW.md) | what omakit is doing, why it uses Node, the baseline and check labels |
97
108
  | [docs/COMMANDS.md](docs/COMMANDS.md) | command details, authentication and network behaviour |
109
+ | [docs/AUDIT.md](docs/AUDIT.md) | installed plugin drift against marketplace-validated commits, with JSON origins |
98
110
  | [docs/SUBMIT.md](docs/SUBMIT.md) | every check and what it decides |
99
111
  | [docs/WEIGH.md](docs/WEIGH.md) | what `weigh` measures, the noise floor, the `shell.json` mutation and its restore, and the JSON contract |
100
112
  | [docs/VALIDATION_WATCH.md](docs/VALIDATION_WATCH.md) | the validation watch: what the marketplace validated, and what moves it |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
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 against the marketplace, posts nothing, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Maarten Tolhuijs",
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: omarchy-plugin-audit
3
+ description: Compare installed Omarchy Quattro plugin commits with the exact commits the marketplace validated. Use before touching an installed third-party plugin, before enabling or updating one, or when asked whether installed plugins match marketplace review. Read-only.
4
+ ---
5
+
6
+ # Audit installed plugin commits
7
+
8
+ Run `omakit audit` before touching an installed third-party plugin. Run
9
+ `omakit audit <plugin-id-or-dir>` when only one installed plugin is in scope.
10
+ The command reads the shell's installed list, the marketplace catalog and local
11
+ Git facts. It changes nothing.
12
+
13
+ Read the primary state first:
14
+
15
+ - `validated` means installed HEAD is one of the commits the marketplace
16
+ records as validated.
17
+ - `ahead` means a validated commit is an ancestor, but HEAD contains later
18
+ commits the marketplace did not validate.
19
+ - `diverged` means installed history does not descend from a validated commit,
20
+ or its origin is not the listed repository. A validated object missing from
21
+ local history is diverged, with the clone's shallow status stated.
22
+ - `unverified` means the listing records no validated commit. Its status is stated.
23
+ - `unlisted` means neither plugin id nor origin identifies a listing.
24
+ - `unknown` means Git or the source directory could not answer. Keep the error.
25
+
26
+ Then report every stacking flag: `modified`, `disabled`, and `upstream moved`.
27
+ Do not collapse a flag into the primary state. First-party plugins are counted
28
+ and excluded because they ship with the shell.
29
+
30
+ Use `--json` when another tool needs the document. Every figure carries its
31
+ origin. Use `--offline` when the live catalog must not be read; the header will
32
+ name the pin. `--drift` is a view filter, not a different measurement.
33
+
34
+ An `ahead` or `diverged` report prints the exact checkout command for the
35
+ validated commit and names the marketplace form for validating a newer one.
36
+ Never run that checkout command without the person's explicit request. Never
37
+ replace it with `omarchy plugin update`; update moves to mutable HEAD and does
38
+ not establish marketplace validation.
39
+
40
+ `AUDITED`, exit 0, means every audited row is validated, or there was nothing
41
+ third-party to audit. `DRIFT`, exit 1, means drift or an unknown answer.
42
+ Exit 2 means the invocation was refused. `NOT AUDITED` because the shell or
43
+ catalog did not answer is a
44
+ result to report, not a reason to substitute a directory scan.
@@ -27,6 +27,8 @@ push came after a review comment had also commented: engaged, and stuck.
27
27
 
28
28
  ```bash
29
29
  omakit watch https://github.com/omacom/omarchy-plugin-marketplace/issues/<number>
30
+ omakit watch --all --json
31
+ omakit watch --list --json
30
32
  ```
31
33
 
32
34
  If `omakit` is not installed: `npm install --global omakit` (Omarchy ships Node
@@ -35,6 +37,10 @@ the install, `"$(npm prefix --global)/bin/omakit" setup` prints the one line
35
37
  that puts npm's bin on PATH. `omakit upgrade` keeps it current through npm.
36
38
  Add `--json` for a machine-readable result.
37
39
 
40
+ Use `--all --json` to discover and check every open marketplace issue authored by the signed-in `gh` account. `--list --json` discovers issue URLs without reading every plugin; use an individual URL for focused follow-up. `--user <login>` selects an explicit public author. A bare `omakit watch` opens a numbered picker only at a terminal; an agent must pass a URL, `--all` or `--list` and never wait on keyboard input.
41
+
42
+ Inspect every batch row, including read errors and unknown results; a successful row cannot stand in for a failed one. `current` only says the commit matches HEAD. Read the baseline outcome, labels and latest discussion to understand outstanding work; never equate it with approval, publication or restored catalog verification. A run is a single read-only snapshot, not a background monitor.
43
+
38
44
  Read-only. It does not comment, label, or edit anything, and it cannot. It reads
39
45
  the default branch through the REST API when a credential is available, taking
40
46
  it from the operator's `gh` login (which itself honours `GH_TOKEN` and
@@ -0,0 +1,223 @@
1
+ // Compare installed third-party checkouts with commits recorded by the live
2
+ // marketplace catalog. Nothing here fetches or changes a checkout.
3
+
4
+ import { existsSync, readFileSync } from "node:fs"
5
+ import { resolve } from "node:path"
6
+ import { newerCommitChoice } from "../marketplace/form.mjs"
7
+ import { liveRegistry, sameRepository } from "../marketplace/registry.mjs"
8
+ import { installedPlugins } from "../weigh/list.mjs"
9
+ import { ancestorOf, commitsAfter, hasCommit, isShallow, readCheckout } from "./git.mjs"
10
+
11
+ export class AuditError extends Error {
12
+ constructor(code, message, remedy = null) {
13
+ super(message)
14
+ this.name = "AuditError"
15
+ this.code = code
16
+ this.remedy = remedy
17
+ }
18
+ }
19
+
20
+ const text = (value) => (typeof value === "string" && value ? value : null)
21
+ const lower = (value) => text(value)?.toLowerCase() || null
22
+ const figure = (value, origin) => (value === null || value === undefined ? null : { value, origin })
23
+ const catalogFigure = (listing, name) => figure(text(listing?.[name]), `site/catalog.json ${name}`)
24
+ const short = (value) => value.slice(0, 8)
25
+ const ROW_ORDER = ["diverged", "ahead", "modified", "unverified", "unlisted", "unknown", "validated"]
26
+ const rowRank = (row) => ROW_ORDER.indexOf(["diverged", "ahead"].includes(row.state) ? row.state : row.flags.includes("modified") ? "modified" : row.state)
27
+
28
+ function targetId(target) {
29
+ if (!target || !existsSync(target)) return target || null
30
+ try {
31
+ return text(JSON.parse(readFileSync(resolve(target, "manifest.json"), "utf8"))?.id)
32
+ } catch {
33
+ return null
34
+ }
35
+ }
36
+
37
+ function chooseListing(plugin, checkout, listings) {
38
+ const byId = listings.find((entry) => entry?.id === plugin.id) || null
39
+ const byOrigin = listings.find((entry) => sameRepository(entry?.repo, checkout.repository)) || null
40
+ if (byId && byOrigin && byId !== byOrigin) {
41
+ return { listing: null, conflict: `manifest id matches ${byId.id}, but origin matches ${byOrigin.id}; neither listing was used` }
42
+ }
43
+ return { listing: byId || byOrigin, conflict: null }
44
+ }
45
+
46
+ function validatedFigures(listing) {
47
+ const figures = []
48
+ const add = (prefix) => {
49
+ const commit = lower(listing?.[`${prefix}ValidatedCommit`])
50
+ if (!commit || figures.some((entry) => entry.commit.value === commit)) return
51
+ figures.push({
52
+ kind: prefix,
53
+ commit: figure(commit, `site/catalog.json ${prefix}ValidatedCommit`),
54
+ date: catalogFigure(listing, `${prefix}ValidatedAt`),
55
+ branch: catalogFigure(listing, prefix === "listing" ? "listingValidatedBranch" : "upstreamObservedBranch"),
56
+ })
57
+ }
58
+ add("upstream")
59
+ add("listing")
60
+ return figures
61
+ }
62
+
63
+ function rowFact(plugin, checkout, listing, conflict, git, env) {
64
+ const flags = []
65
+ if (checkout.status) flags.push("modified")
66
+ if (plugin.enabled !== true) flags.push("disabled")
67
+ const validated = validatedFigures(listing)
68
+ const observed = lower(listing?.upstreamObservedCommit)
69
+ if (observed && observed !== checkout.commit && !validated.some((entry) => entry.commit.value === observed)) flags.push("upstream moved")
70
+
71
+ const common = {
72
+ id: plugin.id,
73
+ flags,
74
+ installed: {
75
+ commit: figure(checkout.commit, "git rev-parse HEAD"),
76
+ enabled: figure(plugin.enabled === true, "omarchy plugin list --json enabled"),
77
+ modified: figure(Boolean(checkout.status), "git status --porcelain"),
78
+ repository: figure(checkout.repository, "git remote get-url origin"),
79
+ },
80
+ validated,
81
+ upstream: {
82
+ observedCommit: catalogFigure(listing, "upstreamObservedCommit"),
83
+ observedBranch: catalogFigure(listing, "upstreamObservedBranch"),
84
+ checkedAt: catalogFigure(listing, "upstreamCheckedAt"),
85
+ checkStatus: catalogFigure(listing, "upstreamCheckStatus"),
86
+ },
87
+ sourceDir: plugin.sourceDir,
88
+ listing: listing ? { id: listing.id, repository: listing.repo } : null,
89
+ }
90
+
91
+ if (!listing) return { ...common, state: "unlisted", fact: conflict || "manifest id and origin match no marketplace listing", aheadBy: null, matchedValidated: null }
92
+ if (validated.some((entry) => entry.commit.value === checkout.commit)) {
93
+ const matched = validated.find((entry) => entry.commit.value === checkout.commit)
94
+ return { ...common, state: "validated", fact: `validated ${short(matched.commit.value)} on ${matched.date?.value || "an unrecorded date"}`, aheadBy: null, matchedValidated: matched }
95
+ }
96
+ if (!validated.length) {
97
+ return { ...common, state: "unverified", fact: `listed with verificationStatus ${listing.verificationStatus || "unrecorded"}, and no validated commit`, aheadBy: null, matchedValidated: null }
98
+ }
99
+ const missing = []
100
+ for (const candidate of validated) {
101
+ if (!git.hasCommit(plugin.sourceDir, candidate.commit.value, { env })) {
102
+ missing.push(candidate)
103
+ continue
104
+ }
105
+ if (git.ancestor(plugin.sourceDir, candidate.commit.value, { env })) {
106
+ const count = git.count(plugin.sourceDir, candidate.commit.value, { env })
107
+ return { ...common, state: "ahead", fact: `${count} commit${count === 1 ? "" : "s"} ahead of validated ${short(candidate.commit.value)} (${candidate.date?.value || "unrecorded date"}); HEAD ${short(checkout.commit)}`, aheadBy: figure(count, `git rev-list --count ${candidate.commit.value}..HEAD`), matchedValidated: candidate }
108
+ }
109
+ }
110
+ if (missing.length) {
111
+ const shallow = figure(git.shallow(plugin.sourceDir, { env }), "git rev-parse --is-shallow-repository")
112
+ return { ...common, state: "diverged", fact: `validated commit not in local history; shallow clone: ${shallow.value}`, shallow, aheadBy: null, matchedValidated: missing[0] }
113
+ }
114
+ const moved = sameRepository(checkout.repository, listing.repo)
115
+ return { ...common, state: "diverged", fact: moved ? "installed commit does not descend from a validated commit" : `origin does not match ${listing.repo}`, aheadBy: null, matchedValidated: validated[0] }
116
+ }
117
+
118
+ /**
119
+ * Audit the installed third-party plugin set, or one target from that set.
120
+ * All external readers are injectable so tests establish every state without
121
+ * touching the person's shell or plugin directories.
122
+ */
123
+ export async function auditInstalled(options = {}) {
124
+ const env = options.env || process.env
125
+ const readers = {
126
+ installed: options.installed || ((args) => installedPlugins(args)),
127
+ registry: options.registry || ((args) => liveRegistry(args)),
128
+ checkout: options.checkout || ((dir, args) => readCheckout(dir, args)),
129
+ hasCommit: options.hasCommit || ((dir, sha, args) => hasCommit(dir, sha, args)),
130
+ shallow: options.shallow || ((dir, args) => isShallow(dir, args)),
131
+ ancestor: options.ancestor || ((dir, sha, args) => ancestorOf(dir, sha, args)),
132
+ count: options.count || ((dir, sha, args) => commitsAfter(dir, sha, args)),
133
+ route: options.route || ((args) => newerCommitChoice(args)),
134
+ }
135
+ let installed
136
+ try {
137
+ installed = readers.installed({ env })
138
+ } catch (error) {
139
+ throw new AuditError(error?.code || "shell-not-running", error?.message || String(error))
140
+ }
141
+ let live
142
+ try {
143
+ live = await readers.registry({ repoRoot: options.repoRoot, offline: options.offline })
144
+ } catch (error) {
145
+ throw new AuditError(error?.code || "marketplace-unavailable", error?.message || String(error), error?.remedy || "omakit pin")
146
+ }
147
+ const listings = Array.isArray(live.catalog?.plugins) ? live.catalog.plugins : null
148
+ if (!listings) throw new AuditError("catalog-unreadable", "the marketplace catalog has no plugins array", "omakit pin")
149
+
150
+ const wanted = targetId(options.target)
151
+ let selected = installed
152
+ if (options.target) {
153
+ const path = existsSync(options.target) ? resolve(options.target) : null
154
+ selected = installed.filter((plugin) => plugin.id === wanted || (path && plugin.sourceDir && resolve(plugin.sourceDir) === path))
155
+ if (!selected.length) throw new AuditError("plugin-not-installed", `${JSON.stringify(options.target)} is not an installed plugin`, "omakit audit")
156
+ }
157
+
158
+ let firstPartyCount = 0
159
+ const rows = []
160
+ for (const plugin of selected) {
161
+ const idListing = listings.find((entry) => entry?.id === plugin.id)
162
+ if (plugin.firstParty === true || idListing?.sourceType === "builtin") {
163
+ firstPartyCount += 1
164
+ continue
165
+ }
166
+ if (!plugin.sourceDir) {
167
+ rows.push({ id: plugin.id, state: "unknown", flags: plugin.enabled === true ? [] : ["disabled"], installed: { enabled: figure(plugin.enabled === true, "omarchy plugin list --json enabled") }, validated: [], upstream: {}, sourceDir: null, listing: idListing ? { id: idListing.id, repository: idListing.repo } : null, fact: "omarchy-plugin-catalog records no source directory", error: "source directory unavailable", aheadBy: null, matchedValidated: null })
168
+ continue
169
+ }
170
+ let checkout
171
+ try {
172
+ checkout = readers.checkout(plugin.sourceDir, { env })
173
+ } catch (error) {
174
+ rows.push({ id: plugin.id, state: "unknown", flags: plugin.enabled === true ? [] : ["disabled"], installed: { enabled: figure(plugin.enabled === true, "omarchy plugin list --json enabled") }, validated: [], upstream: {}, sourceDir: plugin.sourceDir, listing: idListing ? { id: idListing.id, repository: idListing.repo } : null, fact: `git failed: ${error.message}`, error: error.message, aheadBy: null, matchedValidated: null })
175
+ continue
176
+ }
177
+ try {
178
+ const { listing, conflict } = chooseListing(plugin, checkout, listings)
179
+ if (listing?.sourceType === "builtin") {
180
+ firstPartyCount += 1
181
+ continue
182
+ }
183
+ rows.push(rowFact(plugin, checkout, listing, conflict, readers, env))
184
+ } catch (error) {
185
+ rows.push({ id: plugin.id, state: "unknown", flags: [checkout.status ? "modified" : null, plugin.enabled === true ? null : "disabled"].filter(Boolean), installed: { commit: figure(checkout.commit, "git rev-parse HEAD"), enabled: figure(plugin.enabled === true, "omarchy plugin list --json enabled"), modified: figure(Boolean(checkout.status), "git status --porcelain"), repository: figure(checkout.repository, "git remote get-url origin") }, validated: validatedFigures(idListing), upstream: {}, sourceDir: plugin.sourceDir, listing: idListing ? { id: idListing.id, repository: idListing.repo } : null, fact: `git failed: ${error.message}`, error: error.message, aheadBy: null, matchedValidated: null })
186
+ }
187
+ }
188
+
189
+ rows.sort((a, b) => rowRank(a) - rowRank(b))
190
+ const needsAction = rows.some((row) => row.state === "ahead" || row.state === "diverged")
191
+ let updateRoute = null
192
+ let updateRouteError = null
193
+ if (needsAction) {
194
+ try {
195
+ updateRoute = await readers.route({ repoRoot: options.repoRoot })
196
+ } catch (error) {
197
+ updateRouteError = error?.message || String(error)
198
+ }
199
+ }
200
+ const drift = rows.filter((row) => row.state !== "validated")
201
+ return {
202
+ command: "audit",
203
+ catalog: {
204
+ source: live.source,
205
+ commit: figure(live.commit, live.source === "head" ? "marketplace default-branch HEAD" : "marketplace pin"),
206
+ readAt: figure(live.fetchedAt, live.source === "head" ? "liveRegistry fetchedAt" : "--offline or liveRegistry fallback"),
207
+ offline: options.offline === true,
208
+ reason: live.reason,
209
+ },
210
+ counts: {
211
+ installed: figure(installed.length, "omarchy plugin list --json length"),
212
+ selected: figure(selected.length, options.target ? "target selection" : "omarchy plugin list --json length"),
213
+ firstPartyExcluded: figure(firstPartyCount, "sourceType builtin or omarchy plugin list --json firstParty"),
214
+ audited: figure(rows.length, "audited row count"),
215
+ validated: figure(rows.length - drift.length, "rows whose state is validated"),
216
+ drift: figure(drift.length, "rows whose state is not validated"),
217
+ },
218
+ rows: options.drift ? drift : rows,
219
+ updateRoute,
220
+ updateRouteError,
221
+ ok: drift.length === 0,
222
+ }
223
+ }
@@ -0,0 +1,63 @@
1
+ // The local Git facts used by `omakit audit`. Every invocation is read-only,
2
+ // has a fixed verb and argument shape, and is passed to Git without a shell.
3
+
4
+ import { spawnSync } from "node:child_process"
5
+
6
+ function runGit(sourceDir, args, env = process.env) {
7
+ const result = spawnSync("git", ["-C", sourceDir, ...args], {
8
+ encoding: "utf8",
9
+ env: { ...env, GIT_NO_LAZY_FETCH: "1" },
10
+ stdio: ["ignore", "pipe", "pipe"],
11
+ })
12
+ if (result.status !== 0 || result.error) {
13
+ const detail = (result.stderr || result.error?.message || `git exited ${result.status}`).trim()
14
+ throw new Error(`${args.join(" ")}: ${detail}`)
15
+ }
16
+ return result.stdout.trim()
17
+ }
18
+
19
+ /** Read HEAD, tree state and origin from one checkout. */
20
+ export function readCheckout(sourceDir, { env = process.env } = {}) {
21
+ return {
22
+ commit: runGit(sourceDir, ["rev-parse", "HEAD"], env).toLowerCase(),
23
+ status: runGit(sourceDir, ["status", "--porcelain"], env),
24
+ repository: runGit(sourceDir, ["remote", "get-url", "origin"], env),
25
+ }
26
+ }
27
+
28
+ /** A missing object is a local-history fact, not a failed ancestry check. */
29
+ export function hasCommit(sourceDir, commit, { env = process.env } = {}) {
30
+ const result = spawnSync("git", ["-C", sourceDir, "cat-file", "-e", commit], {
31
+ encoding: "utf8",
32
+ env: { ...env, GIT_NO_LAZY_FETCH: "1" },
33
+ stdio: ["ignore", "pipe", "pipe"],
34
+ })
35
+ if (result.error || result.status === null) throw new Error(result.error?.message || "git could not run")
36
+ return result.status === 0
37
+ }
38
+
39
+ export function isShallow(sourceDir, { env = process.env } = {}) {
40
+ const value = runGit(sourceDir, ["rev-parse", "--is-shallow-repository"], env)
41
+ if (!["true", "false"].includes(value)) throw new Error(`rev-parse --is-shallow-repository returned ${JSON.stringify(value)}`)
42
+ return value === "true"
43
+ }
44
+
45
+ /** Is a recorded commit an ancestor of the running checkout? */
46
+ export function ancestorOf(sourceDir, commit, { env = process.env } = {}) {
47
+ const result = spawnSync("git", ["-C", sourceDir, "merge-base", "--is-ancestor", commit, "HEAD"], {
48
+ encoding: "utf8",
49
+ env: { ...env, GIT_NO_LAZY_FETCH: "1" },
50
+ stdio: ["ignore", "pipe", "pipe"],
51
+ })
52
+ if (result.status === 0) return true
53
+ if (result.status === 1) return false
54
+ const detail = (result.stderr || result.error?.message || `git exited ${result.status}`).trim()
55
+ throw new Error(`merge-base --is-ancestor ${commit} HEAD: ${detail}`)
56
+ }
57
+
58
+ /** Count commits after one recorded ancestor. */
59
+ export function commitsAfter(sourceDir, commit, { env = process.env } = {}) {
60
+ const value = runGit(sourceDir, ["rev-list", "--count", `${commit}..HEAD`], env)
61
+ if (!/^\d+$/.test(value)) throw new Error(`rev-list --count returned ${JSON.stringify(value)}`)
62
+ return Number(value)
63
+ }
@@ -0,0 +1,56 @@
1
+ import { action, AUDIT_VERDICTS, colourEnabled, field, GUTTER, mark, styler, verdict, wrap } from "../marketplace/style.mjs"
2
+
3
+ const short = (value) => value ? String(value).slice(0, 8) : "unrecorded"
4
+ const flagText = (flags) => flags.length ? `; ${flags.join(", ")}` : ""
5
+ const markFor = (row) => {
6
+ if (row.flags.includes("modified")) return "advisory"
7
+ if (row.state === "validated") return "pass"
8
+ if (["ahead", "diverged", "unverified"].includes(row.state)) return "advisory"
9
+ if (row.state === "unlisted") return "info"
10
+ if (row.flags.includes("disabled")) return "info"
11
+ return "unknown"
12
+ }
13
+
14
+ function catalogText(catalog) {
15
+ if (catalog.source === "head") return `live HEAD ${short(catalog.commit.value)} at ${catalog.readAt?.value || "an unrecorded time"}`
16
+ return `pin ${short(catalog.commit.value)}${catalog.offline ? " (offline)" : ""}`
17
+ }
18
+
19
+ /** A terminal report made only from the shared style vocabulary. */
20
+ export function renderAudit(document, { colour = colourEnabled() } = {}) {
21
+ const c = styler(colour)
22
+ const out = []
23
+ out.push(...field("catalog", catalogText(document.catalog), c))
24
+ out.push(...field("installed", `${document.counts.installed.value}`, c))
25
+ out.push(...field("first-party", `${document.counts.firstPartyExcluded.value} left out`, c))
26
+ out.push(...field("audited", `${document.counts.audited.value}`, c))
27
+ out.push("")
28
+ for (const row of document.rows) {
29
+ const installed = row.installed?.commit?.value
30
+ const validated = row.matchedValidated?.commit?.value
31
+ const date = row.matchedValidated?.date?.value
32
+ const detail = ["validated", "ahead"].includes(row.state)
33
+ ? `${row.fact}${flagText(row.flags)}`
34
+ : `${row.state}: ${row.fact}${installed ? `; HEAD ${short(installed)}` : ""}${validated ? `; validated ${short(validated)}${date ? ` on ${date}` : ""}` : ""}${flagText(row.flags)}`
35
+ out.push(`${mark(markFor(row), c)}${c("name", row.id)}`)
36
+ out.push(...wrap(detail, { indent: GUTTER }, c))
37
+ if ((row.state === "ahead" || row.state === "diverged") && validated) {
38
+ out.push(...action(`git -C ${JSON.stringify(row.sourceDir)} checkout ${validated}`, c))
39
+ }
40
+ out.push("")
41
+ }
42
+ const total = document.counts.audited.value
43
+ const good = document.counts.validated.value
44
+ const drift = document.counts.drift.value
45
+ if (document.updateRoute) {
46
+ out.push(...action(`To validate a newer commit: ${document.updateRoute.url}`, c))
47
+ out.push(...wrap(`${document.updateRoute.name}; choose ${JSON.stringify(document.updateRoute.choice)}.`, { indent: GUTTER }, c))
48
+ out.push("")
49
+ }
50
+ if (document.updateRouteError) {
51
+ out.push(...wrap(`Verification route unavailable: ${document.updateRouteError}; run omakit pin.`, { indent: GUTTER }, c))
52
+ out.push("")
53
+ }
54
+ out.push(...verdict(document.ok ? "pass" : "fail", document.ok ? AUDIT_VERDICTS.validated : AUDIT_VERDICTS.drift, `${good} of ${total} run a commit the marketplace validated; ${drift} run one it never saw.`, c))
55
+ return out.join("\n")
56
+ }
@@ -26,6 +26,7 @@ local commit through the transport seam the marketplace tests itself
26
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. |
27
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. |
28
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. |
29
+ | `update-check.mjs` | A passive npm-release notice during normal terminal use, throttled to one check per day with a one-second network budget. Stores only installed/latest version metadata and time under XDG_STATE_HOME/omakit. Offline, JSON, pipes, help and CI skip it; DISABLE_UPDATE_NOTIFIER disables it. Version precedence and the registry GET are shared with doctor/upgrade. |
29
30
  | `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. |
30
31
  | `usage.mjs` | The help text, as data. |
31
32
  | `options.mjs` | Every option every command accepts, in one table, and the parser that reads a command line against it before anything runs; `tests/unit/options.test.mjs` holds the help signatures, and through them the completion scripts, to the table. |
@@ -99,3 +100,5 @@ online, the subject's default-branch HEAD and the marketplace's current
99
100
  registry, and `--offline` turns both off; `verify` needs no network at all
100
101
  beyond fetching a reviewer-mode subject, and `tests/parity/offline.mjs` proves
101
102
  it.
103
+
104
+ `watch --list` discovers the signed-in account's open marketplace issues through `github.mjs`; `--user` bypasses the account lookup. `watch --all` runs the existing single-issue watch with four workers and shared repository HEAD promises, preserving each read failure. A bare terminal invocation uses `ask.mjs` to choose one or several issues. `report.mjs` composes the list and batch views; all options remain in `options.mjs` and `usage.mjs`, which also generate completion. The batch JSON retains complete single-issue reports; `current` compares commits and never substitutes for review or publication.
@@ -17,6 +17,7 @@
17
17
  import { createInterface } from "node:readline"
18
18
  import { colourEnabled, STEP, action, styler, wrap } from "./style.mjs"
19
19
  import { resolveCategory, resolveTags } from "./form.mjs"
20
+ import { watchIssueTitle } from "./watch.mjs"
20
21
 
21
22
  export class AskError extends Error {
22
23
  constructor(message) {
@@ -49,11 +50,13 @@ function reader(input) {
49
50
  }
50
51
 
51
52
  /** Ask one question until an answer resolves; `parse` returns { ok, value } or { ok: false, reason }. */
52
- async function question(lines, output, c, { name, heading, options, defaultIndexes, parse }) {
53
+ async function question(lines, output, c, { name, heading, options, defaultIndexes, parse, wrapOptions = false, eofRemedy }) {
53
54
  const step = " ".repeat(STEP)
54
55
  output.write(`${wrap(heading, {}, c).join("\n")}\n`)
55
56
  for (const [index, option] of options.entries()) {
56
- output.write(`${step}${c("typeable", String(index + 1).padStart(2))} ${option}\n`)
57
+ output.write(wrapOptions
58
+ ? `${wrap(`${String(index + 1).padStart(2)} ${option}`, { indent: STEP }, c).join("\n")}\n`
59
+ : `${step}${c("typeable", String(index + 1).padStart(2))} ${option}\n`)
57
60
  }
58
61
  const fallback = defaultIndexes.length ? defaultIndexes.map((index) => index + 1).join(",") : null
59
62
  const prompt = `${action(fallback ? `${name} [${fallback}]:` : `${name}:`, c, { indent: 0 })[0]} `
@@ -62,7 +65,7 @@ async function question(lines, output, c, { name, heading, options, defaultIndex
62
65
  const raw = await lines.next()
63
66
  if (raw === null) {
64
67
  output.write("\n")
65
- throw new AskError(`stdin ended before the ${name} was answered; pass --${name} on the command line`)
68
+ throw new AskError(`stdin ended before the ${name} was answered; ${eofRemedy || `pass --${name} on the command line`}`)
66
69
  }
67
70
  const text = raw.trim() || (fallback ?? "")
68
71
  const parsed = parse(text)
@@ -138,3 +141,30 @@ export async function askChoices({ contract, defaults, missing, input = process.
138
141
  }
139
142
  return answers
140
143
  }
144
+
145
+ /** Select one or several discovered issues; nothing is written or opened. */
146
+ export async function askWatchIssues({ issues, input = process.stdin, output = process.stderr, colour = colourEnabled(output) }) {
147
+ const c = styler(colour)
148
+ const lines = reader(input)
149
+ try {
150
+ return await question(lines, output, c, {
151
+ name: "issues",
152
+ heading: "Choose list numbers, comma-separated; all checks every issue, q cancels.",
153
+ options: issues.map((issue) => `#${issue.number} ${watchIssueTitle(issue.title)}`),
154
+ defaultIndexes: [],
155
+ wrapOptions: true,
156
+ eofRemedy: "pass an issue URL, --all or --list on the command line",
157
+ parse: (text) => {
158
+ if (text.toLowerCase() === "q") return { ok: true, value: [] }
159
+ if (text.toLowerCase() === "all") return { ok: true, value: issues }
160
+ const indexes = text.split(",").map((value) => value.trim())
161
+ if (indexes.some((value) => !/^\d+$/.test(value) || Number(value) < 1 || Number(value) > issues.length)) {
162
+ return { ok: false, reason: `Answer with list numbers from 1 to ${issues.length}, all, or q.` }
163
+ }
164
+ return { ok: true, value: [...new Set(indexes.map(Number))].map((index) => issues[index - 1]) }
165
+ },
166
+ })
167
+ } finally {
168
+ lines.close()
169
+ }
170
+ }
@@ -21,24 +21,27 @@ import { ensurePin, MARKETPLACE_PIN, requirePin } from "./pin.mjs"
21
21
  import { marketplaceBaselineSection } from "./verify.mjs"
22
22
  import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
23
23
  import { submitPreflight } from "./submit.mjs"
24
- import { askChoices } from "./ask.mjs"
25
- import { validationWatch } from "./watch.mjs"
26
- import { renderSubmit, renderWatch, renderDoctor, renderVerify } from "./report.mjs"
24
+ import { askChoices, askWatchIssues } from "./ask.mjs"
25
+ import { validationWatch, discoverWatchIssues, validationWatchAll } from "./watch.mjs"
26
+ import { renderSubmit, renderWatch, renderWatchList, renderWatchAll, renderDoctor, renderVerify } from "./report.mjs"
27
27
  import { consequence } from "./preflight.mjs"
28
28
  import { doctor } from "./doctor.mjs"
29
29
  import { completionStep, setup } from "./setup.mjs"
30
30
  import { staleCompletionNotice } from "./completion-check.mjs"
31
31
  import { ACCEPTED, acceptedWords, checkArgs } from "./options.mjs"
32
32
  import { upgrade } from "./upgrade.mjs"
33
+ import { updateCheckEnabled, updateNotice } from "./update-check.mjs"
33
34
  import { progress } from "./progress.mjs"
34
35
  import { banner, bannerEnabled } from "./banner.mjs"
35
36
  import { COMMANDS, renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
36
- import { action, colourEnabled, GUTTER, labelled, mark, styler, verdict, wrap } from "./style.mjs"
37
+ import { action, AUDIT_VERDICTS, colourEnabled, GUTTER, labelled, mark, styler, verdict, wrap } from "./style.mjs"
37
38
  import { omakitCacheDir, withHomeAbbreviated } from "./paths.mjs"
38
39
  import { DEFAULTS as WEIGH_DEFAULTS, measureWeigh, planWeigh } from "../weigh/audit.mjs"
39
40
  import { confirmationQuestion, renderList, renderWeigh, renderPlan } from "../weigh/report.mjs"
40
41
  import { listWeighings } from "../weigh/list.mjs"
41
42
  import { askYes } from "../weigh/confirm.mjs"
43
+ import { auditInstalled } from "../audit/audit.mjs"
44
+ import { renderAudit } from "../audit/report.mjs"
42
45
 
43
46
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
44
47
 
@@ -60,6 +63,7 @@ const REMEDY = Object.freeze({
60
63
  "github-unavailable": "Wait for GitHub, then run it again. `gh auth login` raises the rate limit if that is what ran out.",
61
64
  "not-found": "Check the issue URL: it has to be an existing issue on the marketplace repository.",
62
65
  "head-unreadable": "Check that the plugin repository is public and its URL is right.",
66
+ "login-required": "gh auth login",
63
67
  "not-confirmed": "Run it again and answer y, or pass --yes when the person whose shell it is has agreed.",
64
68
  "interrupted": "shell.json was restored; run it again when the desktop is yours to restart.",
65
69
  })
@@ -178,18 +182,41 @@ async function cmdSubmit(args) {
178
182
 
179
183
  async function cmdWatch(args) {
180
184
  const issueUrl = positionals(args)[0]
181
- if (!issueUrl) fail("usage", "watch needs an issue: `omakit watch <issue-url>`", 2)
185
+ const all = args.includes("--all")
186
+ const list = args.includes("--list")
187
+ const user = option(args, "--user")
188
+ const interactive = !args.includes("--json") && !option(args, "--out") && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)
189
+ if ((all && list) || (issueUrl && (all || list || user !== undefined))) {
190
+ fail("usage", "Use an issue URL, --all, or --list; --user belongs to account-wide discovery.", 2)
191
+ }
192
+ if (!issueUrl && !all && !list && !interactive) {
193
+ fail("usage", "watch needs an issue or a mode: `omakit watch <issue-url>`, `omakit watch --all`, or `omakit watch --list`. A terminal can pick issues with `omakit watch`.", 2)
194
+ }
182
195
  const spinner = spinnerFor(args)
183
196
  let result
184
197
  try {
185
- result = await validationWatch({ repoRoot: ROOT, issueUrl, onPhase: spinner.phase })
198
+ if (issueUrl) {
199
+ result = await validationWatch({ repoRoot: ROOT, issueUrl, onPhase: spinner.phase })
200
+ } else {
201
+ const discovery = await discoverWatchIssues({ user, onPhase: spinner.phase })
202
+ if (list) {
203
+ result = discovery
204
+ } else {
205
+ if (!all && discovery.issues.length) {
206
+ spinner.done()
207
+ discovery.issues = await askWatchIssues({ issues: discovery.issues })
208
+ }
209
+ result = await validationWatchAll({ repoRoot: ROOT, discovery, onPhase: spinner.phase })
210
+ }
211
+ }
186
212
  } catch (error) {
187
213
  spinner.done()
188
214
  failFrom(error)
189
215
  }
190
216
  spinner.done()
191
- emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderWatch(result))
192
- process.exit(result.verdict.state === "unknown" ? 2 : 0)
217
+ const render = result.mode === "list" ? renderWatchList : result.mode === "all" ? renderWatchAll : renderWatch
218
+ emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : render(result))
219
+ process.exit(result.verdict?.state === "unknown" || result.summary?.unknown > 0 ? 2 : 0)
193
220
  }
194
221
 
195
222
  async function cmdFrontDoor() {
@@ -293,6 +320,40 @@ async function cmdParity(args) {
293
320
  process.exit(ok ? 0 : 1)
294
321
  }
295
322
 
323
+ function notAudited(message, remedy = null, exit = 1) {
324
+ const c = styler(colourEnabled(process.stderr))
325
+ const lines = verdict("fail", AUDIT_VERDICTS.unavailable, message, c)
326
+ if (remedy) lines.push(...action(remedy, c, { indent: 0 }))
327
+ process.stderr.write(`${lines.join("\n")}\n`)
328
+ process.exit(exit)
329
+ }
330
+
331
+ async function cmdAudit(args) {
332
+ const parsed = checkArgs(args, ACCEPTED.audit)
333
+ if (parsed.offending !== null) notAudited(`${parsed.reason}. Accepted: ${acceptedWords("audit")}.`, "omakit audit [<plugin-id-or-dir>] [--drift] [--json] [--out FILE] [--offline]", 2)
334
+ let document
335
+ try {
336
+ document = await auditInstalled({
337
+ repoRoot: ROOT,
338
+ target: parsed.positionals[0],
339
+ drift: parsed.options.has("--drift"),
340
+ offline: parsed.options.has("--offline"),
341
+ })
342
+ } catch (error) {
343
+ if (error?.code && typeof error.code === "string") notAudited(`${error.message}.`, error.remedy || REMEDY[error.code])
344
+ throw error
345
+ }
346
+ const json = `${JSON.stringify(document, null, 2)}\n`
347
+ const out = parsed.options.get("--out")
348
+ if (out) {
349
+ mkdirSync(dirname(resolve(out)), { recursive: true })
350
+ writeFileSync(resolve(out), json)
351
+ }
352
+ if (parsed.options.has("--json")) process.stdout.write(json)
353
+ else process.stdout.write(`${renderAudit(document)}\n`)
354
+ process.exit(document.ok ? 0 : 1)
355
+ }
356
+
296
357
  /**
297
358
  * Every way `weigh` stops without weighing, in one register: the closing
298
359
  * word a report would have ended with, negated, then the sentence naming
@@ -424,6 +485,11 @@ const [command, ...rest] = process.argv.slice(2)
424
485
  // completion script names another omakit, so `omakit we<TAB>` may not know
425
486
  // `weigh`. One stat and one short read; never under a pipe, whose stderr
426
487
  // stays empty on success, and never for setup, which is the fix.
488
+ if (updateCheckEnabled({ command, args: rest, stdinTTY: process.stdin.isTTY, stdoutTTY: process.stdout.isTTY, stderrTTY: process.stderr.isTTY })) {
489
+ const notice = await updateNotice({ repoRoot: ROOT, version: VERSION })
490
+ if (notice) process.stderr.write(`${wrap(notice, {}, styler(colourEnabled(process.stderr))).join("\n")}\n`)
491
+ }
492
+
427
493
  if (command !== "setup" && process.stderr.isTTY) {
428
494
  const notice = staleCompletionNotice({ version: VERSION })
429
495
  if (notice) process.stderr.write(`${styler(colourEnabled(process.stderr))("label", notice)}\n`)
@@ -458,6 +524,8 @@ if (command === "setup") {
458
524
  await cmdVerify(rest)
459
525
  } else if (command === "parity") {
460
526
  await cmdParity(rest)
527
+ } else if (command === "audit") {
528
+ await cmdAudit(rest)
461
529
  } else if (command === "weigh") {
462
530
  await cmdWeigh(rest)
463
531
  } else if (command === "help" || command === "--help" || command === "-h" || command === undefined) {
@@ -47,7 +47,8 @@ export function subcommandsOf(commands = COMMANDS) {
47
47
  }
48
48
 
49
49
  /**
50
- * The plugin ids a TAB offers for `omakit weigh <TAB>`: what the running
50
+ * The plugin ids a TAB offers for `omakit weigh <TAB>` and `omakit audit <TAB>`:
51
+ * what the running
51
52
  * shell reports through `omarchy-shell shell listPlugins`, filtered with
52
53
  * `jq` at TAB time, enabled ids first and whole bars left out, since a bar
53
54
  * cannot be weighed. No node process behind the TAB: a shell that does not
@@ -42,7 +42,7 @@ import { join } from "node:path"
42
42
  import { MARKETPLACE_PIN, PIN_PATHS, marketplacePinDir, pinDiskUsage, pinIsSparse, requirePin } from "./pin.mjs"
43
43
  import { LIVE_PATHS } from "./registry.mjs"
44
44
  import { credential, defaultBranchHead, getJson, UNAUTHENTICATED_LIMIT, GitHubError } from "./github.mjs"
45
- import { NPM_REGISTRY, registryLatest, upgradeCommand } from "./upgrade.mjs"
45
+ import { compareVersions, NPM_REGISTRY, registryLatest, upgradeCommand } from "./upgrade.mjs"
46
46
  import { pathHint } from "./path-hint.mjs"
47
47
  import { completionStatus } from "./completion-check.mjs"
48
48
 
@@ -205,11 +205,17 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
205
205
  phase("asking the npm registry for the newest published version")
206
206
  const published = await latestVersion(self.name)
207
207
  if (published.version) {
208
- const current = published.version === self.version
209
- versionCheck(current ? "ok" : "advice",
210
- current ? `${self.version}, the newest published version` : `${self.version}; ${published.version} is published`,
211
- current ? null : `run \`${upgradeCommand(repoRoot, self.name)}\``,
212
- published.version, NPM_REGISTRY)
208
+ const comparison = compareVersions(published.version, self.version)
209
+ if (comparison === null) {
210
+ versionCheck("unknown", `${self.version}; the installed or published version is invalid`)
211
+ } else {
212
+ versionCheck(comparison > 0 ? "advice" : "ok",
213
+ comparison === 0 ? `${self.version}, the newest published version`
214
+ : comparison < 0 ? `${self.version}; ahead of the newest published version ${published.version}`
215
+ : `${self.version}; ${published.version} is published`,
216
+ comparison > 0 ? `run \`${upgradeCommand(repoRoot, self.name)}\`` : null,
217
+ published.version, NPM_REGISTRY)
218
+ }
213
219
  } else {
214
220
  versionCheck("unknown", `${self.version}; could not read the npm registry (${published.error?.code || "error"})`)
215
221
  }
@@ -23,7 +23,7 @@ import { readFileSync } from "node:fs"
23
23
  import { join } from "node:path"
24
24
  import { pathToFileURL } from "node:url"
25
25
  import { parseYaml } from "./yaml.mjs"
26
- import { requirePin } from "./pin.mjs"
26
+ import { MARKETPLACE_PIN, requirePin } from "./pin.mjs"
27
27
 
28
28
  export const SUBMIT_FORM_PATH = ".github/ISSUE_TEMPLATE/submit-plugin.yml"
29
29
  export const OFFICIAL_SUBMISSION_MODULE = "scripts/submission.mjs"
@@ -147,7 +147,7 @@ export async function submissionContract(options = {}) {
147
147
  * pick something the form no longer offers.
148
148
  *
149
149
  * @param {{ repoRoot?: string, pinDir?: string }} [options]
150
- * @returns {Promise<{ formPath: string, name: string, choice: string }>}
150
+ * @returns {Promise<{ formPath: string, name: string, choice: string, url: string }>}
151
151
  */
152
152
  export async function newerCommitChoice(options = {}) {
153
153
  const pinDir = options.pinDir || requirePin(options.repoRoot).dir
@@ -164,7 +164,9 @@ export async function newerCommitChoice(options = {}) {
164
164
  }
165
165
  const name = typeof form.name === "string" ? form.name.trim() : ""
166
166
  if (!name) throw new ContractError("form-shape-changed", `${VERIFY_FORM_PATH} has no name`)
167
- return { formPath: VERIFY_FORM_PATH, name, choice }
167
+ const url = new URL(`${MARKETPLACE_PIN.repository.replace(/\/$/, "")}/issues/new`)
168
+ url.searchParams.set("template", VERIFY_FORM_PATH.split("/").at(-1))
169
+ return { formPath: VERIFY_FORM_PATH, name, choice, url: url.href }
168
170
  }
169
171
 
170
172
  /**
@@ -109,7 +109,7 @@ export function token() {
109
109
  /** The one host the borrowed gh credential may be sent to. */
110
110
  export const CREDENTIAL_HOST = "api.github.com"
111
111
 
112
- async function get(url, { accept } = {}) {
112
+ async function get(url, { accept, signal } = {}) {
113
113
  const { host } = new URL(url)
114
114
  // The credential is GitHub's and goes to GitHub's API and nowhere else.
115
115
  // Measured before this held: `omakit upgrade` and `doctor` sent the gh
@@ -125,7 +125,7 @@ async function get(url, { accept } = {}) {
125
125
  if (auth) headers.authorization = `Bearer ${auth}`
126
126
  let response
127
127
  try {
128
- response = await fetch(url, { method: "GET", headers, redirect: "follow" })
128
+ response = await fetch(url, { method: "GET", headers, redirect: "follow", ...(signal ? { signal } : {}) })
129
129
  } catch (error) {
130
130
  // Node reports every transport failure as "fetch failed" with the real
131
131
  // reason in `cause`. A person needs the reason, and the CLI keys its
@@ -150,8 +150,8 @@ async function get(url, { accept } = {}) {
150
150
  return response
151
151
  }
152
152
 
153
- export async function getJson(url) {
154
- return (await get(url)).json()
153
+ export async function getJson(url, options) {
154
+ return (await get(url, options)).json()
155
155
  }
156
156
 
157
157
  export async function getText(url, accept) {
@@ -171,17 +171,38 @@ export async function issue(owner, repository, number) {
171
171
  return getJson(`https://api.github.com/repos/${owner}/${repository}/issues/${number}`)
172
172
  }
173
173
 
174
- export async function issueComments(owner, repository, number, maxPages = 10) {
174
+ /** Resolve the account whose credential gh lent us; never persist it. */
175
+ export async function authenticatedUser() {
176
+ if (!token()) throw new GitHubError("login-required", "Account-wide watch needs your GitHub login. Run `gh auth login`, or pass --user <login> to read a public account.")
177
+ const user = await getJson("https://api.github.com/user")
178
+ if (!user?.login) throw new GitHubError("github-unavailable", "GitHub did not return the signed-in account's login")
179
+ return user.login
180
+ }
181
+
182
+ /** Repository issues by their creator. PRs are excluded; pagination never silently truncates. */
183
+ export async function repositoryIssues(owner, repository, creator, { readJson = getJson, maxPages = 100 } = {}) {
184
+ const all = []
185
+ for (let page = 1; page <= maxPages; page += 1) {
186
+ const query = new URLSearchParams({ creator, state: "open", sort: "updated", direction: "desc", per_page: "100", page: String(page) })
187
+ const batch = await readJson(`https://api.github.com/repos/${owner}/${repository}/issues?${query}`)
188
+ if (!Array.isArray(batch)) throw new GitHubError("github-unavailable", "GitHub did not return an issue list")
189
+ all.push(...batch.filter((item) => !item.pull_request))
190
+ if (batch.length < 100) return all
191
+ }
192
+ throw new GitHubError("issue-list-incomplete", `The issue list exceeded ${maxPages} pages; no complete account-wide result is available`)
193
+ }
194
+
195
+ export async function issueComments(owner, repository, number, maxPages = 10, readJson = getJson) {
175
196
  const all = []
176
197
  for (let page = 1; page <= maxPages; page += 1) {
177
- const batch = await getJson(
198
+ const batch = await readJson(
178
199
  `https://api.github.com/repos/${owner}/${repository}/issues/${number}/comments?per_page=100&page=${page}`,
179
200
  )
180
- if (!Array.isArray(batch) || !batch.length) break
201
+ if (!Array.isArray(batch)) throw new GitHubError("github-unavailable", "GitHub did not return issue comments")
181
202
  all.push(...batch)
182
- if (batch.length < 100) break
203
+ if (batch.length < 100) return all
183
204
  }
184
- return all
205
+ throw new GitHubError("comments-incomplete", `Issue #${number} exceeded ${maxPages} comment pages; its latest baseline cannot be determined`)
185
206
  }
186
207
 
187
208
  /**
@@ -21,12 +21,13 @@ export const ACCEPTED = Object.freeze({
21
21
  setup: Object.freeze({ valued: [], flags: ["--yes", "--completion"], positionals: 0 }),
22
22
  pin: Object.freeze({ valued: [], flags: [], positionals: 0 }),
23
23
  submit: Object.freeze({ valued: ["--category", "--tags", "--notes", "--suggest-tag", "--name", "--out"], flags: ["--offline", "--allow-dirty", "--json"], positionals: 1 }),
24
- watch: Object.freeze({ valued: ["--out"], flags: ["--json"], positionals: 1 }),
24
+ watch: Object.freeze({ valued: ["--out", "--user"], flags: ["--json", "--all", "--list"], positionals: 1 }),
25
25
  verify: Object.freeze({ valued: ["--out"], flags: ["--allow-dirty", "--json"], positionals: 1 }),
26
26
  help: Object.freeze({ valued: [], flags: ["--agent"], positionals: 0 }),
27
27
  upgrade: Object.freeze({ valued: [], flags: ["--dry-run"], positionals: 0 }),
28
28
  doctor: Object.freeze({ valued: ["--out"], flags: ["--offline", "--json"], positionals: 0 }),
29
29
  parity: Object.freeze({ valued: ["--count", "--offset", "--out"], flags: [], positionals: 0 }),
30
+ audit: Object.freeze({ valued: ["--out"], flags: ["--drift", "--json", "--offline"], positionals: 1 }),
30
31
  weigh: Object.freeze({ valued: ["--runs", "--window", "--settle", "--out"], flags: ["--all", "--list", "--json", "--yes"], positionals: 1 }),
31
32
  })
32
33
 
@@ -21,6 +21,7 @@ import {
21
21
  action, colourEnabled, COLUMNS, continuation, field, GUTTER, labelled, mark, section, STEP, styler, verdict, width, wrap,
22
22
  } from "./style.mjs"
23
23
  import { withHomeAbbreviated } from "./paths.mjs"
24
+ import { watchIssueTitle } from "./watch.mjs"
24
25
 
25
26
  const body = " ".repeat(GUTTER)
26
27
 
@@ -244,6 +245,50 @@ export function renderWatch(result, { colour = colourEnabled() } = {}) {
244
245
  return out.join("\n")
245
246
  }
246
247
 
248
+ /** Discovery is cheap: issue metadata and repeatable commands, no plugin HEAD reads. */
249
+ export function renderWatchList(result, { colour = colourEnabled() } = {}) {
250
+ const c = styler(colour)
251
+ const out = [...field("account", result.account, c), ...field("issues", `${result.issues.length} open marketplace issue(s)`, c), ""]
252
+ for (const issue of result.issues) {
253
+ out.push(...wrap(`#${issue.number} ${watchIssueTitle(issue.title)}`, {}, c))
254
+ out.push(...field("state", `${issue.state}${issue.labels.length ? `; labels ${issue.labels.map(watchIssueTitle).join(", ")}` : ""}`, c))
255
+ out.push(...action(`omakit watch ${issue.url}`, c, { indent: 0 }), "")
256
+ }
257
+ if (!result.issues.length) out.push(...wrap("No open marketplace issues found for this account.", {}, c))
258
+ else out.push(...action(`omakit watch --all --user ${result.account}`, c, { indent: 0 }))
259
+ return out.join("\n")
260
+ }
261
+
262
+ /** Compact batch report; exact commits and full discussion remain in JSON. */
263
+ export function renderWatchAll(result, { colour = colourEnabled() } = {}) {
264
+ const c = styler(colour)
265
+ const out = [...field("account", result.account, c), ...field("issues", `${result.summary.total} checked; ${result.summary.current} current, ${result.summary.stale} stale, ${result.summary.unknown} unknown`, c), ""]
266
+ for (const row of result.issues) {
267
+ const state = row.report?.verdict.state || "unknown"
268
+ const style = { current: "pass", stale: "fail", unknown: "unknown" }[state]
269
+ out.push(...verdict(style, state.toUpperCase(), `#${row.issue.number} ${watchIssueTitle(row.report?.read.title || row.issue.title)}`, c))
270
+ out.push(...field("issue", row.issue.url, c, { wrapValue: false }))
271
+ if (row.error) {
272
+ out.push(...field("read error", watchIssueTitle(`${row.error.code}: ${row.error.message}`), c))
273
+ } else {
274
+ const report = row.report
275
+ out.push(...field("baseline", report.validated ? `${report.validated.outcome}; ${report.validated.findings.length} finding(s); capabilities ${report.validated.capabilities.join(", ") || "none"}` : "no complete baseline", c))
276
+ out.push(...field("state", `${report.read.state}; labels ${report.read.labels.map(watchIssueTitle).join(", ") || "none"}`, c))
277
+ if (state !== "current") out.push(...field("comparison", report.verdict.summary, c))
278
+ if (report.discussion) {
279
+ const text = watchIssueTitle(report.discussion.body).replace(/\s+/g, " ").trim()
280
+ out.push(...field("discussion", text.length > 240 ? `${text.slice(0, 240)}…` : text, c))
281
+ if (report.discussion.url) out.push(...field("source", report.discussion.url, c, { wrapValue: false }))
282
+ }
283
+ if (report.verdict.action) out.push(...action(report.verdict.action, c, { indent: 0 }))
284
+ }
285
+ out.push("")
286
+ }
287
+ if (!result.issues.length) out.push(...wrap("No issues selected for checking.", {}, c))
288
+ out.push(...wrap("Current means the validated commit matches HEAD. It does not mean review, approval or publication is complete. Read-only; this run posts nothing.", {}, c))
289
+ return out.join("\n")
290
+ }
291
+
247
292
  /** A list of paths at fault, the way a failing check prints them: a tinted dash, the path in the placeholder tint. */
248
293
  function pathLines(paths, c, dash = "fail") {
249
294
  return paths.flatMap((path) => wrap(`- ${path}`, { indent: GUTTER + STEP, first: GUTTER })
@@ -198,6 +198,12 @@ export const STATUS = Object.freeze({
198
198
  skipped: Object.freeze({ glyph: DENSITY.ceiling, word: "skip", tint: "unknown" }),
199
199
  })
200
200
 
201
+ export const AUDIT_VERDICTS = Object.freeze({
202
+ validated: "AUDITED",
203
+ drift: "DRIFT",
204
+ unavailable: "NOT AUDITED",
205
+ })
206
+
201
207
  /** The width of the widest mark, "█ FAIL"; every mark is padded to it so the names beside them align. */
202
208
  export const MARK_WIDTH = Math.max(...Object.values(STATUS).map((s) => `${s.glyph} ${s.word}`.length))
203
209
 
@@ -0,0 +1,48 @@
1
+ // A passive npm-version notice. Only metadata and a throttle stamp are stored;
2
+ // nothing is installed or executed. Explicit doctor checks bypass this throttle.
3
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
4
+ import { dirname, join } from "node:path"
5
+ import { homedir } from "node:os"
6
+ import { compareVersions, registryLatest, upgradeCommand } from "./upgrade.mjs"
7
+
8
+ export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
9
+ export const UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
10
+ export const UPDATE_CHECK_TIMEOUT_MS = 1000
11
+
12
+ export function updateCheckEnabled({ command, args = [], stdinTTY, stdoutTTY, stderrTTY, env = process.env }) {
13
+ return Boolean(stdinTTY && stdoutTTY && stderrTTY)
14
+ && !env.DISABLE_UPDATE_NOTIFIER && !env.CI
15
+ && !args.some((arg) => arg === "--offline" || arg === "--json" || arg === "--agent" || arg === "--out" || arg.startsWith("--out="))
16
+ && (command === undefined || ["watch", "submit", "verify", "audit", "weigh"].includes(command))
17
+ && !args.includes("--help") && !args.includes("-h")
18
+ }
19
+
20
+ /** At most one registry read and one notice per successful day (failure retries after an hour). */
21
+ export async function updateNotice({ repoRoot, version, name = "omakit", env = process.env, now = Date.now(), latest = registryLatest, timeoutMs = UPDATE_CHECK_TIMEOUT_MS }) {
22
+ if (compareVersions(version, version) === null) return null
23
+ const updateFile = join(env.XDG_STATE_HOME || join(homedir(), ".local/state"), "omakit", "update-check.json")
24
+ try {
25
+ const cached = JSON.parse(readFileSync(updateFile, "utf8"))
26
+ const age = now - cached.checkedAt
27
+ const validMetadata = cached.latest === null || compareVersions(cached.latest, cached.latest) !== null
28
+ if (cached.installed === version && cached.name === name && validMetadata && Number.isFinite(age) && age >= 0
29
+ && age < (cached.latest ? UPDATE_CHECK_INTERVAL_MS : UPDATE_RETRY_INTERVAL_MS)) return null
30
+ } catch { /* Missing or malformed state triggers a fresh bounded read. */ }
31
+ const controller = new AbortController()
32
+ let timer
33
+ let published
34
+ try {
35
+ published = await Promise.race([
36
+ Promise.resolve().then(() => latest(name, { signal: controller.signal })).catch(() => ({ version: null })),
37
+ new Promise((resolve) => { timer = setTimeout(() => { controller.abort(); resolve({ version: null }) }, timeoutMs) }),
38
+ ])
39
+ } finally { clearTimeout(timer) }
40
+ const newest = compareVersions(published?.version, published?.version) === null ? null : published.version
41
+ try {
42
+ mkdirSync(dirname(updateFile), { recursive: true })
43
+ writeFileSync(updateFile, `${JSON.stringify({ name, installed: version, latest: newest, checkedAt: now })}\n`, { mode: 0o600 })
44
+ } catch { /* An unwritable state directory must not break the user's command. */ }
45
+ return newest && compareVersions(newest, version) > 0
46
+ ? `Omakit ${newest} is available (installed ${version}). Run \`${upgradeCommand(repoRoot, name)}\`.`
47
+ : null
48
+ }
@@ -74,9 +74,12 @@ export const NPM_REGISTRY = "https://registry.npmjs.org"
74
74
  *
75
75
  * @returns {Promise<{ version: string|null, error: { code: string, message: string }|null }>}
76
76
  */
77
- export async function registryLatest(name) {
77
+ export async function registryLatest(name, { signal } = {}) {
78
78
  try {
79
- const meta = await getJson(`${NPM_REGISTRY}/${encodeURIComponent(name)}/latest`)
79
+ const meta = await getJson(`${NPM_REGISTRY}/${encodeURIComponent(name)}/latest`, { signal })
80
+ if (meta?.version && compareVersions(meta.version, meta.version) === null) {
81
+ return { version: null, error: { code: "invalid-version", message: "the registry did not return a valid semantic version" } }
82
+ }
80
83
  return meta?.version
81
84
  ? { version: meta.version, error: null }
82
85
  : { version: null, error: { code: "unpublished", message: "the registry answered without a version" } }
@@ -85,6 +88,33 @@ export async function registryLatest(name) {
85
88
  }
86
89
  }
87
90
 
91
+ /** Semantic precedence, including prereleases; invalid versions are never update targets. */
92
+ export function compareVersions(left, right) {
93
+ const parse = (value) => {
94
+ const match = String(value || "").match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/)
95
+ if (!match) return null
96
+ const pre = match[4]?.split(".") || []
97
+ if (pre.some((part) => /^\d+$/.test(part) && part.length > 1 && part.startsWith("0"))) return null
98
+ return { core: match.slice(1, 4).map(BigInt), pre }
99
+ }
100
+ const a = parse(left), b = parse(right)
101
+ if (!a || !b) return null
102
+ for (let index = 0; index < 3; index += 1) {
103
+ if (a.core[index] !== b.core[index]) return a.core[index] > b.core[index] ? 1 : -1
104
+ }
105
+ if (!a.pre.length || !b.pre.length) return a.pre.length === b.pre.length ? 0 : a.pre.length ? -1 : 1
106
+ for (let index = 0; index < Math.max(a.pre.length, b.pre.length); index += 1) {
107
+ const x = a.pre[index], y = b.pre[index]
108
+ if (x === undefined || y === undefined) return x === y ? 0 : x === undefined ? -1 : 1
109
+ if (x === y) continue
110
+ const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y)
111
+ if (xn && yn) return BigInt(x) > BigInt(y) ? 1 : -1
112
+ if (xn !== yn) return xn ? -1 : 1
113
+ return x > y ? 1 : -1
114
+ }
115
+ return 0
116
+ }
117
+
88
118
  function installedVersion(repoRoot) {
89
119
  try {
90
120
  return JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version || null
@@ -300,8 +330,10 @@ async function upgradeNpm({ repoRoot, stream, dryRun, latest, npmRoot, name, ref
300
330
  if (!newest) {
301
331
  return refuse("the npm registry did not answer, so there is nothing to compare against.", "Connect to the network, then run `omakit upgrade` again.")
302
332
  }
303
- if (newest === current) {
304
- ok(`already current at ${current}, the newest published version`)
333
+ const compared = compareVersions(newest, current)
334
+ if (compared === null) return refuse("the installed or published version is invalid, so an exact newer release cannot be selected.")
335
+ if (compared <= 0) {
336
+ ok(compared === 0 ? `already current at ${current}, the newest published version` : `${current} is ahead of the newest published version ${newest}; no downgrade applied`)
305
337
  out()
306
338
  lines(wrap("The marketplace pin is a separate thing and is never touched here. `omakit doctor` says whether it is behind.", {}, c))
307
339
  return { ok: true, changed: false, version: current }
@@ -39,6 +39,17 @@ export const COMMANDS = Object.freeze([
39
39
  `Read-only, exact commit ${MARKETPLACE_PIN.commit}.`,
40
40
  ],
41
41
  },
42
+ {
43
+ signature: [
44
+ "omakit audit <plugin-id-or-dir> [--drift] [--json] [--out <file>] [--offline]",
45
+ "omakit audit [--drift] [--json] [--out <file>] [--offline]",
46
+ ],
47
+ lines: [
48
+ "Compare every installed third-party plugin's running commit with the",
49
+ "exact commits the marketplace records as validated. Read-only. --drift",
50
+ "shows only rows that are not validated; --offline reads the pin.",
51
+ ],
52
+ },
42
53
  {
43
54
  signature: [
44
55
  "omakit submit <target> --category <c> --tags <a,b> [--notes <text>]",
@@ -58,11 +69,18 @@ export const COMMANDS = Object.freeze([
58
69
  ],
59
70
  },
60
71
  {
61
- signature: "omakit watch <issue-url> [--json] [--out <file>]",
72
+ signature: [
73
+ "omakit watch <issue-url> [--json] [--out <file>]",
74
+ "omakit watch [--all | --list] [--user <login>] [--json] [--out <file>]",
75
+ ],
62
76
  lines: [
63
77
  "Compare the commit the marketplace validated on a submission issue with",
64
78
  "the plugin repository's current default-branch HEAD, and say what makes",
65
79
  "it validate a newer one. Read-only.",
80
+ "--all checks your open marketplace issues; --list lists them first.",
81
+ "Without a URL or either flag, a terminal asks which issues to check.",
82
+ "Uses your gh account, or --user to read another public account. JSON",
83
+ "and pipes never prompt: use --all or --list. Each run is one snapshot.",
66
84
  ],
67
85
  },
68
86
  {
@@ -93,6 +111,8 @@ export const COMMANDS = Object.freeze([
93
111
  lines: [
94
112
  "What is installed, what is pinned, and what has moved since. Reads and",
95
113
  "prints; it installs nothing and never moves the pin.",
114
+ "Checks the newest npm release explicitly. Normal terminal use also",
115
+ "checks at most once daily; DISABLE_UPDATE_NOTIFIER=1 disables notices.",
96
116
  ],
97
117
  },
98
118
  {
@@ -29,7 +29,7 @@
29
29
  import { join } from "node:path"
30
30
  import { pathToFileURL } from "node:url"
31
31
  import { MARKETPLACE_PIN, requirePin } from "./pin.mjs"
32
- import { defaultBranchHead, issue, issueComments, parseIssueUrl, token, GitHubError } from "./github.mjs"
32
+ import { authenticatedUser, repositoryIssues, defaultBranchHead, issue, issueComments, parseIssueUrl, token, GitHubError } from "./github.mjs"
33
33
 
34
34
  export class WatchError extends Error {
35
35
  constructor(code, message) {
@@ -41,6 +41,70 @@ export class WatchError extends Error {
41
41
 
42
42
  const MARKETPLACE_SLUG = MARKETPLACE_PIN.repository.replace(/^https:\/\/github\.com\//, "").toLowerCase()
43
43
 
44
+ /** Text from an issue remains data, including at a terminal. JSON retains the original title. */
45
+ export function watchIssueTitle(value) {
46
+ return String(value || "").replace(/[\p{Cc}\p{Cf}]/gu, " ")
47
+ }
48
+
49
+ /** Discover the account's open marketplace issues, without reading every plugin. */
50
+ export async function discoverWatchIssues({ user, github = {}, onPhase = () => {} } = {}) {
51
+ const read = { authenticatedUser, repositoryIssues, ...github }
52
+ if (user !== undefined && !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(user)) {
53
+ throw new WatchError("usage", "--user needs a GitHub login, not a URL or search query")
54
+ }
55
+ onPhase("reading the GitHub account")
56
+ const account = user || await read.authenticatedUser()
57
+ const [owner, repository] = MARKETPLACE_SLUG.split("/")
58
+ onPhase(`reading open marketplace issues for ${account}`)
59
+ const subjects = await read.repositoryIssues(owner, repository, account)
60
+ // Check the response as well as the server-side creator filter. Never turn
61
+ // another author's issue, or a PR, into an account-wide watch target.
62
+ const seen = new Set()
63
+ const issues = subjects.filter((subject) => {
64
+ if (subject.pull_request || subject.state !== "open" || subject.user?.login?.toLowerCase() !== account.toLowerCase()) return false
65
+ if (!Number.isSafeInteger(subject.number) || subject.number < 1 || seen.has(subject.number)) return false
66
+ seen.add(subject.number)
67
+ return true
68
+ }).map((subject) => ({
69
+ number: subject.number,
70
+ url: `${MARKETPLACE_PIN.repository}/issues/${subject.number}`,
71
+ title: subject.title,
72
+ state: subject.state,
73
+ labels: (subject.labels || []).map((label) => typeof label === "string" ? label : label?.name).filter(Boolean),
74
+ updatedAt: subject.updated_at || null,
75
+ }))
76
+ return { mode: "list", account, marketplace: MARKETPLACE_PIN.repository, issues }
77
+ }
78
+
79
+ /** A batch keeps independent read failures visible and shares repository HEAD reads. */
80
+ export async function validationWatchAll({ repoRoot, discovery, github = {}, onPhase = () => {} }) {
81
+ if (discovery.issues.length) requirePin(repoRoot)
82
+ const heads = new Map()
83
+ const readHead = github.defaultBranchHead || defaultBranchHead
84
+ const shared = { ...github, defaultBranchHead: (url) => {
85
+ if (!heads.has(url)) heads.set(url, Promise.resolve().then(() => readHead(url)))
86
+ return heads.get(url)
87
+ } }
88
+ const results = new Array(discovery.issues.length)
89
+ let next = 0
90
+ async function worker() {
91
+ while (next < discovery.issues.length) {
92
+ const index = next++
93
+ const target = discovery.issues[index]
94
+ onPhase(`checking issue #${target.number} (${index + 1}/${discovery.issues.length})`)
95
+ try {
96
+ results[index] = { issue: target, report: await validationWatch({ repoRoot, issueUrl: target.url, github: shared }), error: null }
97
+ } catch (error) {
98
+ results[index] = { issue: target, report: null, error: { code: error.code || "watch-unavailable", message: error.message } }
99
+ }
100
+ }
101
+ }
102
+ await Promise.all(Array.from({ length: Math.min(4, discovery.issues.length) }, worker))
103
+ const summary = { total: results.length, current: 0, stale: 0, unknown: 0 }
104
+ for (const result of results) summary[result.report?.verdict.state || "unknown"] += 1
105
+ return { mode: "all", account: discovery.account, marketplace: discovery.marketplace, summary, issues: results }
106
+ }
107
+
44
108
  // The one action that re-runs validation, in the register the marketplace itself
45
109
  // uses in its own failure feedback.
46
110
  export const REFRESH_ACTION =
@@ -207,6 +271,12 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
207
271
  baselineError,
208
272
  head,
209
273
  headError,
274
+ discussion: maintainerComments.length ? {
275
+ body: maintainerComments.at(-1).body || "",
276
+ url: maintainerComments.at(-1).html_url || null,
277
+ createdAt: maintainerComments.at(-1).created_at || null,
278
+ authorAssociation: maintainerComments.at(-1).author_association || null,
279
+ } : null,
210
280
  verdict: validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl }),
211
281
  }
212
282
  }
@@ -43,12 +43,13 @@ export function lastWeighings(stateDir) {
43
43
  }
44
44
 
45
45
  /**
46
- * One row per installed plugin, sorted: enabled and not weighed first (by
47
- * id), then enabled and weighed, oldest weighing first, then disabled.
46
+ * The installed-plugin list joined to the manifest catalog by id. This is the
47
+ * one join used by both `weigh --list` and `audit`, so a source directory has
48
+ * one authority throughout the tool.
48
49
  *
49
50
  * @param {{ env?: NodeJS.ProcessEnv }} [options]
50
51
  */
51
- export function listWeighings({ env = process.env } = {}) {
52
+ export function installedPlugins({ env = process.env } = {}) {
52
53
  const listed = run("listPlugins", { env })
53
54
  if (!listed.ok) throw new WeighError("shell-not-running", "omarchy plugin list did not answer, and the list is what the shell has installed", "omarchy-restart-shell")
54
55
  let installed
@@ -57,6 +58,7 @@ export function listWeighings({ env = process.env } = {}) {
57
58
  } catch {
58
59
  throw new WeighError("shell-unreadable", "omarchy plugin list did not answer with JSON", "omarchy-restart-shell, then run it again.")
59
60
  }
61
+ if (!Array.isArray(installed)) throw new WeighError("shell-unreadable", "omarchy plugin list did not answer with a JSON array", "omarchy-restart-shell, then run it again.")
60
62
  const catalogRun = run("catalog", { env })
61
63
  let catalog = []
62
64
  try {
@@ -65,6 +67,17 @@ export function listWeighings({ env = process.env } = {}) {
65
67
  catalog = []
66
68
  }
67
69
  const sourceDirOf = (id) => catalog.find((entry) => entry.id === id)?.sourceDir || null
70
+ return installed.map((plugin) => ({ ...plugin, sourceDir: sourceDirOf(plugin.id) }))
71
+ }
72
+
73
+ /**
74
+ * One row per installed plugin, sorted: enabled and not weighed first (by
75
+ * id), then enabled and weighed, oldest weighing first, then disabled.
76
+ *
77
+ * @param {{ env?: NodeJS.ProcessEnv }} [options]
78
+ */
79
+ export function listWeighings({ env = process.env } = {}) {
80
+ const installed = installedPlugins({ env })
68
81
  const stateDir = omakitStateDir("weigh", env)
69
82
  const latest = lastWeighings(stateDir)
70
83
  const rows = installed.map((plugin) => {
@@ -76,7 +89,7 @@ export function listWeighings({ env = process.env } = {}) {
76
89
  kinds,
77
90
  enabled: plugin.enabled === true,
78
91
  firstParty: plugin.firstParty === true,
79
- sourceDir: sourceDirOf(plugin.id),
92
+ sourceDir: plugin.sourceDir,
80
93
  weighable: !kinds.includes("bar"),
81
94
  lastWeighed: last ? { date: last.date, readme: last.readme, summary: last.summary, document: last.document } : null,
82
95
  enable: plugin.enabled === true ? null : `omarchy plugin enable ${plugin.id}`,