omakit 0.2.1 → 0.3.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
@@ -28,6 +28,7 @@ See [docs/INSTALL.md](docs/INSTALL.md) for the clone route, PATH, requirements a
28
28
  | [`omakit watch <issue-url>`](docs/VALIDATION_WATCH.md) | The commit the marketplace validated, against the plugin's current HEAD. |
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. |
@@ -95,6 +96,7 @@ Committed evidence records a digest of each side rather than the results themsel
95
96
  | [docs/INSTALL.md](docs/INSTALL.md) | install details, PATH, requirements, upgrading, and what Socket reports and why |
96
97
  | [docs/HOW.md](docs/HOW.md) | what omakit is doing, why it uses Node, the baseline and check labels |
97
98
  | [docs/COMMANDS.md](docs/COMMANDS.md) | command details, authentication and network behaviour |
99
+ | [docs/AUDIT.md](docs/AUDIT.md) | installed plugin drift against marketplace-validated commits, with JSON origins |
98
100
  | [docs/SUBMIT.md](docs/SUBMIT.md) | every check and what it decides |
99
101
  | [docs/WEIGH.md](docs/WEIGH.md) | what `weigh` measures, the noise floor, the `shell.json` mutation and its restore, and the JSON contract |
100
102
  | [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.1",
3
+ "version": "0.3.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.
@@ -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
+ }
@@ -33,12 +33,14 @@ import { upgrade } from "./upgrade.mjs"
33
33
  import { progress } from "./progress.mjs"
34
34
  import { banner, bannerEnabled } from "./banner.mjs"
35
35
  import { COMMANDS, renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
36
- import { action, colourEnabled, GUTTER, labelled, mark, styler, verdict, wrap } from "./style.mjs"
36
+ import { action, AUDIT_VERDICTS, colourEnabled, GUTTER, labelled, mark, styler, verdict, wrap } from "./style.mjs"
37
37
  import { omakitCacheDir, withHomeAbbreviated } from "./paths.mjs"
38
38
  import { DEFAULTS as WEIGH_DEFAULTS, measureWeigh, planWeigh } from "../weigh/audit.mjs"
39
39
  import { confirmationQuestion, renderList, renderWeigh, renderPlan } from "../weigh/report.mjs"
40
40
  import { listWeighings } from "../weigh/list.mjs"
41
41
  import { askYes } from "../weigh/confirm.mjs"
42
+ import { auditInstalled } from "../audit/audit.mjs"
43
+ import { renderAudit } from "../audit/report.mjs"
42
44
 
43
45
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
44
46
 
@@ -293,6 +295,40 @@ async function cmdParity(args) {
293
295
  process.exit(ok ? 0 : 1)
294
296
  }
295
297
 
298
+ function notAudited(message, remedy = null, exit = 1) {
299
+ const c = styler(colourEnabled(process.stderr))
300
+ const lines = verdict("fail", AUDIT_VERDICTS.unavailable, message, c)
301
+ if (remedy) lines.push(...action(remedy, c, { indent: 0 }))
302
+ process.stderr.write(`${lines.join("\n")}\n`)
303
+ process.exit(exit)
304
+ }
305
+
306
+ async function cmdAudit(args) {
307
+ const parsed = checkArgs(args, ACCEPTED.audit)
308
+ if (parsed.offending !== null) notAudited(`${parsed.reason}. Accepted: ${acceptedWords("audit")}.`, "omakit audit [<plugin-id-or-dir>] [--drift] [--json] [--out FILE] [--offline]", 2)
309
+ let document
310
+ try {
311
+ document = await auditInstalled({
312
+ repoRoot: ROOT,
313
+ target: parsed.positionals[0],
314
+ drift: parsed.options.has("--drift"),
315
+ offline: parsed.options.has("--offline"),
316
+ })
317
+ } catch (error) {
318
+ if (error?.code && typeof error.code === "string") notAudited(`${error.message}.`, error.remedy || REMEDY[error.code])
319
+ throw error
320
+ }
321
+ const json = `${JSON.stringify(document, null, 2)}\n`
322
+ const out = parsed.options.get("--out")
323
+ if (out) {
324
+ mkdirSync(dirname(resolve(out)), { recursive: true })
325
+ writeFileSync(resolve(out), json)
326
+ }
327
+ if (parsed.options.has("--json")) process.stdout.write(json)
328
+ else process.stdout.write(`${renderAudit(document)}\n`)
329
+ process.exit(document.ok ? 0 : 1)
330
+ }
331
+
296
332
  /**
297
333
  * Every way `weigh` stops without weighing, in one register: the closing
298
334
  * word a report would have ended with, negated, then the sentence naming
@@ -458,6 +494,8 @@ if (command === "setup") {
458
494
  await cmdVerify(rest)
459
495
  } else if (command === "parity") {
460
496
  await cmdParity(rest)
497
+ } else if (command === "audit") {
498
+ await cmdAudit(rest)
461
499
  } else if (command === "weigh") {
462
500
  await cmdWeigh(rest)
463
501
  } 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
@@ -56,7 +57,15 @@ export function subcommandsOf(commands = COMMANDS) {
56
57
  * expression is exported so a test can run it through jq.
57
58
  */
58
59
  export const PLUGIN_IDS_JQ = "[.[] | select(((.kinds // []) | index(\"bar\")) | not)] | sort_by(.enabled | not) | .[].id"
59
- export const PLUGIN_IDS_COMMAND = `timeout 1 omarchy-shell shell listPlugins 2>/dev/null | jq -r '${PLUGIN_IDS_JQ}' 2>/dev/null`
60
+ /**
61
+ * The pipeline, in the shell's own syntax. `timeout 1` bounds the wait
62
+ * where coreutils has it; a system without `timeout` (measured: the macOS
63
+ * runner in CI, where the TAB fell back to directories) runs the command
64
+ * unbounded rather than never, since `omarchy-shell` itself gives up on
65
+ * its IPC timeout.
66
+ */
67
+ export const PLUGIN_IDS_COMMAND = `omarchy-shell shell listPlugins 2>/dev/null | jq -r '${PLUGIN_IDS_JQ}' 2>/dev/null`
68
+ export const PLUGIN_IDS_TIMED = `timeout 1 ${PLUGIN_IDS_COMMAND}`
60
69
 
61
70
  /** What a valued flag takes, by its placeholder: a controlled value, a file, or free text. */
62
71
  function placeholderKind(placeholder) {
@@ -175,7 +184,7 @@ function bash({ subcommands, categories, tags, pin, version }) {
175
184
  lines.push("# left out, read at TAB time; nothing when the shell does not answer in a")
176
185
  lines.push("# second, and the caller falls back to a directory.")
177
186
  lines.push("_omakit_plugin_ids() {")
178
- lines.push(` ${PLUGIN_IDS_COMMAND}`)
187
+ lines.push(` if command -v timeout >/dev/null 2>&1; then ${PLUGIN_IDS_TIMED}; else ${PLUGIN_IDS_COMMAND}; fi`)
179
188
  lines.push("}")
180
189
  lines.push("")
181
190
  lines.push("# A controlled value may contain a space, so each match is one line and is")
@@ -243,7 +252,7 @@ function zsh({ subcommands, categories, tags, pin, version }) {
243
252
  lines.push("# left out, read at TAB time; a directory when the shell does not answer.")
244
253
  lines.push("_omakit_plugins() {")
245
254
  lines.push(" local -a ids")
246
- lines.push(` ids=(\${(f)"$(${PLUGIN_IDS_COMMAND})"})`)
255
+ lines.push(` if (( $+commands[timeout] )); then ids=(\${(f)"$(${PLUGIN_IDS_TIMED})"}); else ids=(\${(f)"$(${PLUGIN_IDS_COMMAND})"}); fi`)
247
256
  lines.push(" if (( ${#ids} )); then compadd -a ids; else _directories; fi")
248
257
  lines.push("}")
249
258
  lines.push("")
@@ -263,7 +272,7 @@ function fish({ subcommands, categories, tags, pin, version }) {
263
272
  lines.push("# left out, read at TAB time; nothing when the shell does not answer, and")
264
273
  lines.push("# the directories offered beside them stand.")
265
274
  lines.push("function __omakit_plugin_ids")
266
- lines.push(` ${PLUGIN_IDS_COMMAND}`)
275
+ lines.push(` if command -q timeout; ${PLUGIN_IDS_TIMED}; else; ${PLUGIN_IDS_COMMAND}; end`)
267
276
  lines.push("end")
268
277
  lines.push("")
269
278
  for (const sub of subcommands) {
@@ -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
  /**
@@ -27,6 +27,7 @@ export const ACCEPTED = Object.freeze({
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
 
@@ -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
 
@@ -416,10 +422,10 @@ export function continuation(value, c) {
416
422
  * under itself. There is exactly one of these under any failure, and it is the
417
423
  * only line in the tool that starts with an arrow, so it can be found by shape.
418
424
  */
419
- export function action(text, c, { indent = GUTTER } = {}) {
425
+ export function action(text, c, { indent = GUTTER, width: total = COLUMNS } = {}) {
420
426
  // Painted after wrapping, and all of it cyan: the whole line is the thing
421
427
  // to do, so a backticked word inside it has nothing to stand out from.
422
- const lines = wrap(text, { indent: indent + 2 })
428
+ const lines = wrap(text, { indent: indent + 2, width: total })
423
429
  return lines.map((line, index) => (index === 0
424
430
  ? `${" ".repeat(indent)}${c("typeable.bold", ARROW)} ${c("typeable", line.trimStart())}`
425
431
  : `${" ".repeat(indent + 2)}${c("typeable", line.trimStart())}`))
@@ -155,7 +155,10 @@ export const COMPLETION_REFRESH_ARGS = Object.freeze(["setup", "--completion"])
155
155
 
156
156
  function refreshCompletionWith(root, stream) {
157
157
  const entryPoint = join(root, "bin/omakit")
158
- if (!existsSync(entryPoint)) return { ran: false, reason: `${entryPoint} is not there` }
158
+ // The reason names the relative path: the root is printed above it, and a
159
+ // temporary directory on macOS is long enough to push an absolute one
160
+ // past eighty columns in a sentence (measured in CI: 109).
161
+ if (!existsSync(entryPoint)) return { ran: false, reason: "this install has no bin/omakit under its root" }
159
162
  try {
160
163
  const out = execFileSync(process.execPath, [entryPoint, ...COMPLETION_REFRESH_ARGS], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
161
164
  stream.write(out)
@@ -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>]",
@@ -5,13 +5,30 @@
5
5
  // typed here is written anywhere.
6
6
 
7
7
  import { createInterface } from "node:readline"
8
- import { action, colourEnabled, styler } from "../marketplace/style.mjs"
8
+ import { action, colourEnabled, COLUMNS, styler } from "../marketplace/style.mjs"
9
9
 
10
10
  /**
11
- * @param {{ input?: NodeJS.ReadStream, output?: NodeJS.WriteStream, colour?: boolean, question: string }} options
12
- * @returns {Promise<boolean>} true only for `y` or `yes`, in any case; the end of stdin is no
11
+ * The question as it is written to the terminal: the arrow line, wrapped
12
+ * at the contract width like every other action, with `[y/N]:` at the end
13
+ * of the last line, and no newline after it, so the cursor waits there.
14
+ * Measured on 0.2.1: the question grew a clause about --runs, wrapped to
15
+ * two lines, and only the first was written, so a person saw "for a" and
16
+ * no prompt, pressed Enter to see the rest, and the empty line was No.
17
+ *
18
+ * @param {string} question
19
+ * @param {(name: string, text: string) => string} c
20
+ * @param {{ width?: number }} [options]
21
+ * @returns {string} every line, joined, ending in `[y/N]: `
13
22
  */
14
- export function askYes({ input = process.stdin, output = process.stderr, colour = colourEnabled(output), question }) {
23
+ export function renderQuestion(question, c, { width = COLUMNS } = {}) {
24
+ return `${action(`${question} [y/N]:`, c, { indent: 0, width }).join("\n")} `
25
+ }
26
+
27
+ /**
28
+ * @param {{ input?: NodeJS.ReadStream, output?: NodeJS.WriteStream, colour?: boolean, question: string, width?: number }} options
29
+ * @returns {Promise<boolean>} true only for `y` or `yes`, in any case; an empty line and the end of stdin are no
30
+ */
31
+ export function askYes({ input = process.stdin, output = process.stderr, colour = colourEnabled(output), question, width = COLUMNS }) {
15
32
  const c = styler(colour)
16
33
  return new Promise((resolve) => {
17
34
  const rl = createInterface({ input, terminal: false })
@@ -27,6 +44,6 @@ export function askYes({ input = process.stdin, output = process.stderr, colour
27
44
  if (!answered) output.write("\n")
28
45
  settle(false)
29
46
  })
30
- output.write(`${action(`${question} [y/N]:`, c, { indent: 0 })[0]} `)
47
+ output.write(renderQuestion(question, c, { width }))
31
48
  })
32
49
  }
@@ -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}`,