omakit 0.1.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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/bin/omakit +3 -0
  4. package/package.json +40 -0
  5. package/skills/omarchy-plugin-submit/SKILL.md +87 -0
  6. package/skills/omarchy-plugin-validation-watch/SKILL.md +62 -0
  7. package/tests/parity/corpus.mjs +62 -0
  8. package/tests/parity/run.mjs +194 -0
  9. package/tools/marketplace/README.md +64 -0
  10. package/tools/marketplace/agent-control.mjs +72 -0
  11. package/tools/marketplace/banner.mjs +307 -0
  12. package/tools/marketplace/cli.mjs +283 -0
  13. package/tools/marketplace/completion.mjs +263 -0
  14. package/tools/marketplace/doctor.mjs +156 -0
  15. package/tools/marketplace/effect.mjs +115 -0
  16. package/tools/marketplace/form.mjs +209 -0
  17. package/tools/marketplace/github.mjs +206 -0
  18. package/tools/marketplace/issue.mjs +80 -0
  19. package/tools/marketplace/local-transport.mjs +162 -0
  20. package/tools/marketplace/parity-output.mjs +26 -0
  21. package/tools/marketplace/paths.mjs +10 -0
  22. package/tools/marketplace/pin.mjs +203 -0
  23. package/tools/marketplace/plugin.mjs +64 -0
  24. package/tools/marketplace/preflight.mjs +164 -0
  25. package/tools/marketplace/progress.mjs +96 -0
  26. package/tools/marketplace/registry.mjs +216 -0
  27. package/tools/marketplace/report.mjs +192 -0
  28. package/tools/marketplace/run-baseline.mjs +72 -0
  29. package/tools/marketplace/setup.mjs +136 -0
  30. package/tools/marketplace/style.mjs +443 -0
  31. package/tools/marketplace/submit.mjs +341 -0
  32. package/tools/marketplace/tree.mjs +50 -0
  33. package/tools/marketplace/upgrade.mjs +179 -0
  34. package/tools/marketplace/usage.mjs +191 -0
  35. package/tools/marketplace/verify.mjs +70 -0
  36. package/tools/marketplace/watch.mjs +262 -0
  37. package/tools/marketplace/yaml.mjs +164 -0
  38. package/tools/subject/resolve.mjs +124 -0
@@ -0,0 +1,341 @@
1
+ // `omakit submit`: everything that is knowable before a submission is posted,
2
+ // with the measured reason for every check stated next to the check.
3
+ //
4
+ // The command produces an issue title and body. It never posts them. Creating
5
+ // the issue happens only after the plugin owner explicitly approves, which is
6
+ // also what the marketplace's own agent instructions require.
7
+ //
8
+ // Two sources of authority, never mixed:
9
+ // source: "marketplace-pin", the rule is the marketplace's, read from the
10
+ // pinned checkout at an exact commit. A pin update can change it.
11
+ // source: "omakit", the check is Omakit's own, derived from public
12
+ // issue text. It is not marketplace policy and never claims to be.
13
+
14
+ import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
15
+ import { requirePin } from "./pin.mjs"
16
+ import { submissionContract, resolveCategory, resolveTags } from "./form.mjs"
17
+ import { idUniverse, checkIdentity, baselineFigures, figure } from "./registry.mjs"
18
+ import { readTree } from "./tree.mjs"
19
+ import { inspectTree } from "./plugin.mjs"
20
+ import { findAgentControl, REMEDY as AGENT_CONTROL_REMEDY } from "./agent-control.mjs"
21
+ import { baselinePreflight } from "./preflight.mjs"
22
+ import { renderIssue, verifyAgainstOfficialParser } from "./issue.mjs"
23
+ import { defaultBranchHead } from "./github.mjs"
24
+ import { REFRESH_ACTION } from "./watch.mjs"
25
+ import { omakitCacheDir } from "./paths.mjs"
26
+
27
+ export class SubmitError extends Error {
28
+ constructor(code, message) {
29
+ super(message)
30
+ this.name = "SubmitError"
31
+ this.code = code
32
+ }
33
+ }
34
+
35
+ function check(id, fields) {
36
+ return {
37
+ id,
38
+ source: fields.source,
39
+ why: fields.why,
40
+ severity: fields.severity || "blocking",
41
+ verdict: fields.verdict ? "pass" : "fail",
42
+ detail: fields.detail || "",
43
+ paths: fields.paths || [],
44
+ remedy: fields.remedy || null,
45
+ }
46
+ }
47
+
48
+ /**
49
+ * @param {{ repoRoot: string, target: string, category?: string, tags?: string|string[],
50
+ * notes?: string, suggestedTag?: string, pluginName?: string,
51
+ * allowDirty?: boolean, offline?: boolean }} options
52
+ */
53
+ export async function submitPreflight(options) {
54
+ const { repoRoot } = options
55
+ // Optional: called with the name of the step about to run, so a terminal can
56
+ // say what is happening. Never affects what is produced.
57
+ const phase = options.onPhase || (() => {})
58
+
59
+ phase("verifying the pinned marketplace checkout")
60
+ const { identity: pinIdentity } = requirePin(repoRoot)
61
+ phase("reading the submission contract from the pin")
62
+ const contract = await submissionContract({ repoRoot })
63
+ phase("reading the listed and retired plugin ids")
64
+ const universe = idUniverse({ repoRoot })
65
+ const figures = baselineFigures({ repoRoot })
66
+
67
+ phase("resolving the subject commit")
68
+ let subject
69
+ try {
70
+ subject = resolveSubject(options.target, {
71
+ cacheRoot: omakitCacheDir(),
72
+ allowDirty: options.allowDirty === true,
73
+ })
74
+ } catch (error) {
75
+ if (error instanceof SubjectError) throw new SubmitError(error.code, error.message)
76
+ throw error
77
+ }
78
+
79
+ phase("reading the installable tree at that commit")
80
+ const entries = readTree(subject.dir, subject.commit)
81
+ const tree = inspectTree({ dir: subject.dir, entries })
82
+ const checks = []
83
+
84
+ // --- the installable tree -------------------------------------------------
85
+
86
+ checks.push(check("plugin.root-manifest", {
87
+ source: "marketplace-pin",
88
+ why: "The marketplace refuses a submission that is not exactly one manifest at the repository root: \"New submissions require exactly one plugin manifest at the repository root\" (scripts/build-catalog.mjs at the pin). A layout failure costs a full round trip through a queue whose median submission-to-publication time rose sevenfold between 2026-W33 and 2026-W36.",
89
+ verdict: tree.manifestPaths.length === 1 && tree.rootManifestPath === "manifest.json" && !tree.manifestError && Boolean(tree.pluginId),
90
+ detail: tree.manifestError
91
+ ? `manifest.json is not valid JSON: ${tree.manifestError}`
92
+ : tree.manifestPaths.length === 1 && tree.rootManifestPath
93
+ ? `manifest.json declares id "${tree.pluginId}"${tree.pluginName ? ` and name "${tree.pluginName}"` : ""}`
94
+ : `found ${tree.manifestPaths.length} manifest(s): ${tree.manifestPaths.join(", ") || "none"}`,
95
+ paths: tree.manifestPaths,
96
+ remedy: "Keep exactly one manifest.json, at the repository root.",
97
+ }))
98
+
99
+ checks.push(check("plugin.root-readme", {
100
+ source: "marketplace-pin",
101
+ why: "`validateRepositoryDocs` at the pin fails a submission with `readme-missing` when no root README exists. A deterministic failure like this one returns the submission to the author's own court, and that court is where submissions die: of the 464 parked there the median item has not moved in 5.6 days, and 77% never produce the fresh validation that would revive them. Knowable here in one read of the tree.",
102
+ verdict: Boolean(tree.readme),
103
+ detail: tree.readme ? `root README: ${tree.readme}` : "no root README",
104
+ remedy: "Add a README at the repository root.",
105
+ }))
106
+
107
+ checks.push(check("plugin.root-license", {
108
+ source: "marketplace-pin",
109
+ why: "`validateRepositoryDocs` at the pin fails a submission with `license-missing` when no root license or COPYING file exists. Same cost as a missing README: all 2,963 listed sources in the pinned registry satisfied this rule before they were listed, so it is absolute, and it is knowable here in one read of the tree.",
110
+ verdict: Boolean(tree.license),
111
+ detail: tree.license ? `root license: ${tree.license}` : "no root license or COPYING file",
112
+ remedy: "Add a LICENSE (or COPYING) file at the repository root.",
113
+ }))
114
+
115
+ checks.push(check("plugin.readme-install-removal", {
116
+ source: "omakit",
117
+ why: "Checklist item 1 of the form is a statement the author signs: \"The repository is public and contains installation and removal instructions.\" Omakit generates that checklist pre-checked, so it refuses to sign a claim it cannot see evidence for. This is a keyword probe on the README text, not a reading of it, and it is not a marketplace rule.",
118
+ verdict: tree.readmeMentionsInstall && tree.readmeMentionsRemoval,
119
+ detail: tree.readme
120
+ ? `README mentions installation: ${tree.readmeMentionsInstall ? "yes" : "no"}; removal or uninstall: ${tree.readmeMentionsRemoval ? "yes" : "no"}`
121
+ : "no root README to read",
122
+ paths: tree.readme ? [tree.readme] : [],
123
+ remedy: "Document both installing and removing the plugin in the root README, or submit by hand without the generated checklist.",
124
+ }))
125
+
126
+ // --- the agent-control warning --------------------------------------------
127
+ // Advisory, not blocking: the marketplace lists plugins that ship these files
128
+ // (docs/MEASUREMENTS.md M3), so a refusal here would refuse what it accepts.
129
+
130
+ const agentControl = findAgentControl(entries)
131
+ checks.push(check("tree.agent-control", {
132
+ source: "omakit",
133
+ severity: "advisory",
134
+ why: "103 marketplace issues mention agent-control files, and 24 of 328 sampled maintainer review comments are about them, so an instruction file inside an installed plugin draws review attention and can cost a round. It is not a listing rule: at their listed commits, 6 of 34 listed plugins inspected on 2026-09-13 ship one, so this is a review-cost warning derived from public issue text, labelled `omakit` for that reason. Nothing in the marketplace's automated baseline reports them.",
135
+ verdict: agentControl.length === 0,
136
+ detail: agentControl.length
137
+ ? `${agentControl.length} agent-control file(s) in the installable tree`
138
+ : "no agent-control files in the installable tree",
139
+ paths: agentControl.map((hit) => `${hit.path}: ${hit.reason}`),
140
+ remedy: AGENT_CONTROL_REMEDY.join(" "),
141
+ }))
142
+
143
+ // --- identity -------------------------------------------------------------
144
+
145
+ const identity = checkIdentity(universe, { id: tree.pluginId, repositoryUrl: subject.repository.url })
146
+ checks.push(check("identity.available", {
147
+ source: "marketplace-pin",
148
+ why: `The marketplace refuses \`plugin-id-listed\`, \`plugin-id-retired\`, \`reserved-plugin-id\` and \`submission-repository-listed\`. Checked here against ${universe.counts.listedIds} listed ids, ${universe.counts.retiredIds} retired ids and ${universe.counts.listedRepositories} listed repositories read from the pinned registry and catalog.`,
149
+ verdict: identity.ok,
150
+ detail: identity.ok
151
+ ? `id "${tree.pluginId}" is unused, outside the reserved ${universe.reservedPrefix}* namespace, and the repository is not listed`
152
+ : identity.problems.map((problem) => `${problem.code}: ${problem.detail}`).join("; "),
153
+ remedy: identity.ok ? null : "Choose an unused plugin id outside the reserved namespace.",
154
+ }))
155
+
156
+ // --- the submission itself ------------------------------------------------
157
+
158
+ const pluginName = String(options.pluginName || tree.pluginName || "").trim()
159
+ const category = resolveCategory(contract, options.category)
160
+ const tags = resolveTags(contract, options.tags)
161
+
162
+ checks.push(check("submission.title", {
163
+ source: "marketplace-pin",
164
+ why: "39 submissions fell out on the title prefix alone: 14 of them are still open and 7 had to be rescued by hand. The prefix and the requirement that a plugin name follow it are read from the form's own title template at the pin.",
165
+ verdict: Boolean(pluginName),
166
+ detail: pluginName
167
+ ? `title will be "${contract.titleTemplate}${pluginName}"`
168
+ : "no plugin name: manifest.json declares none and --name was not given",
169
+ remedy: "Give the plugin a name in manifest.json, or pass --name.",
170
+ }))
171
+
172
+ checks.push(check("submission.category", {
173
+ source: "marketplace-pin",
174
+ why: `Exactly one category from the form's controlled list; the marketplace refuses \`submission-category-invalid\` otherwise. The list (${contract.categories.length} options) is read from ${contract.formPath} at the pin.`,
175
+ verdict: category.ok,
176
+ detail: category.ok ? `category: ${category.value}` : `${category.reason}. Choose one of: ${contract.categories.join(", ")}`,
177
+ remedy: category.ok ? null : "Pass --category with one of the listed values.",
178
+ }))
179
+
180
+ checks.push(check("submission.tags", {
181
+ source: "marketplace-pin",
182
+ why: `1 to ${contract.maximumTags} tags from the form's controlled list; the marketplace refuses \`submission-tag-count-invalid\` and \`submission-tags-invalid\` otherwise. The list (${contract.tagLabels.length} options) and the maximum are read from the pin.`,
183
+ verdict: tags.ok,
184
+ detail: tags.ok ? `tags: ${tags.value.join(", ")}` : `${tags.reason}. Choose from: ${contract.tagLabels.join(", ")}`,
185
+ remedy: tags.ok ? null : "Pass --tags with 1 to 3 comma-separated values from the list.",
186
+ }))
187
+
188
+ let issue = null
189
+ let parsed = null
190
+ if (pluginName && category.ok && tags.ok && subject.repository.url) {
191
+ issue = renderIssue(contract, {
192
+ pluginName,
193
+ repositoryUrl: subject.repository.url,
194
+ category: category.value,
195
+ tags: tags.value,
196
+ suggestedTag: options.suggestedTag,
197
+ notes: options.notes,
198
+ })
199
+ parsed = verifyAgainstOfficialParser(contract, issue)
200
+ }
201
+
202
+ checks.push(check("submission.repository-url", {
203
+ source: "marketplace-pin",
204
+ why: "The marketplace refuses `submission-repository-invalid` unless the Repository URL field is a public GitHub repository root URL, and it is the field every later step depends on: all 2,963 listed sources in the pinned registry are identified by exactly that URL. Taken from the subject's `origin`, never invented, because a URL typed by hand is a URL that can point at the wrong repository.",
205
+ verdict: Boolean(subject.repository.url),
206
+ detail: subject.repository.url || "the subject has no github.com origin, so no repository URL can be declared",
207
+ remedy: subject.repository.url ? null : "Give the repository a github.com origin remote.",
208
+ }))
209
+
210
+ checks.push(check("submission.headings", {
211
+ source: "marketplace-pin",
212
+ why: `The six form headings must appear in exact order: ${contract.headings.join(", ")}. 11 open submissions are malformed in the body and receive "The validation result could not be published to the issue. A maintainer must review the workflow.", which blames the maintainer for the author's mistake; one of them differs from a valid submission by the single word "Suggested" instead of "Suggest". The headings are rendered from the form at the pin, never typed.`,
213
+ verdict: Boolean(issue),
214
+ detail: issue ? `${contract.headings.length} headings rendered in form order` : "not rendered: an earlier submission field is missing",
215
+ }))
216
+
217
+ checks.push(check("submission.checklist", {
218
+ source: "marketplace-pin",
219
+ why: `All ${contract.checklist.length} checklist items must be present with their exact text and checked; the marketplace refuses \`submission-checklist-unconfirmed\` otherwise. The text is read from the form at the pin, character for character.`,
220
+ verdict: Boolean(issue),
221
+ detail: issue ? `${contract.checklist.length} items rendered with the form's exact text, all checked` : "not rendered",
222
+ }))
223
+
224
+ checks.push(check("submission.official-parser", {
225
+ source: "marketplace-pin",
226
+ why: "The strongest available proof that the body is well formed: the marketplace's own `parseCurrentSubmission` from the pinned commit is run over the rendered title and body. If it accepts them here it accepts them there, and 0 of the marketplace's heading, tag and checklist rules are duplicated in Omakit, so none of them can drift. This is the check that closes all 50 measured title-and-body failures at once: 39 on the title prefix plus 11 malformed bodies.",
227
+ verdict: Boolean(parsed?.ok),
228
+ detail: parsed
229
+ ? parsed.ok
230
+ ? `accepted: repo ${parsed.submission.repo}, category ${parsed.submission.category}, tags ${parsed.submission.tags.join(", ")}`
231
+ : `refused by the marketplace's own parser: ${parsed.code}, ${parsed.message}`
232
+ : "not run: no body was rendered",
233
+ }))
234
+
235
+ // --- the commit the marketplace will actually validate ---------------------
236
+
237
+ let head = null
238
+ let headError = null
239
+ if (!options.offline && subject.repository.url) {
240
+ phase("reading the repository's default-branch HEAD")
241
+ try {
242
+ head = await defaultBranchHead(subject.repository.url)
243
+ } catch (error) {
244
+ headError = { code: error.code || "head-unreadable", message: error.message }
245
+ }
246
+ }
247
+ const validationMatches = head ? head.commit === subject.commit.toLowerCase() : null
248
+ checks.push(check("submission.validation-commit", {
249
+ source: "omakit",
250
+ why: "The marketplace validates the default-branch HEAD it resolves when the issue is opened or edited, not the commit checked here. 73% of the 464 submissions parked in the author's court have a HEAD ahead of their validated commit, so a preflight against a commit that is not the pushed HEAD describes a tree nobody will review. Not a marketplace rule; an Omakit refusal to report on the wrong tree.",
251
+ severity: options.offline ? "advisory" : "blocking",
252
+ verdict: options.offline ? true : validationMatches === true,
253
+ detail: options.offline
254
+ ? `not checked (--offline). Local commit ${subject.commit}.`
255
+ : head
256
+ ? validationMatches
257
+ ? `local commit ${subject.commit} is the current ${head.branch || "default"}-branch HEAD`
258
+ : `local commit ${subject.commit} is not the current ${head.branch || "default"}-branch HEAD (${head.commit})`
259
+ : `could not read the default-branch HEAD (${headError?.code || "unknown"}): ${headError?.message || ""}`,
260
+ remedy: validationMatches === false
261
+ ? "Push this commit to the default branch before submitting, then run submit again."
262
+ : headError
263
+ ? "Connect to the network and run submit again, or pass --offline to skip this one check."
264
+ : null,
265
+ }))
266
+
267
+ // --- the baseline preflight ----------------------------------------------
268
+
269
+ phase("running the official security baseline over a local snapshot")
270
+ const preflight = await baselinePreflight({ repoRoot, subject })
271
+ phase("assembling the submission")
272
+ const consequence = preflight.consequence
273
+ const baselineBlocking = Boolean(consequence?.blocksApproval) || Boolean(preflight.refusal)
274
+ checks.push(check("baseline.preflight", {
275
+ source: "marketplace-pin",
276
+ why: `The official baseline decides whether a human has to look at all: of the ${figure(figures.withBaseline)} listed sources with a recorded baseline at the pin, it produced ${figure(figures.outcomes.passed || 0)} \`passed\`, ${figure(figures.outcomes["review-required"] || 0)} \`review-required\` and ${figure(figures.outcomes["needs-fixes"] || 0)} \`needs-fixes\`. It is the pinned marketplace code itself, run over a local snapshot; Omakit adds no rule and renames no outcome.`,
277
+ severity: baselineBlocking ? "blocking" : "advisory",
278
+ // `passed` and `review-required` are both acceptable submission states:
279
+ // review-required means a maintainer must look, not that anything is wrong.
280
+ // `needs-fixes` is reported as a failure either way; it is only blocking
281
+ // when one of the two selectively blocking rules fired.
282
+ verdict: consequence?.outcome === "passed" || consequence?.outcome === "review-required",
283
+ detail: preflight.refusal
284
+ ? `the official code refused the snapshot: ${preflight.refusal.code}, ${preflight.refusal.message}`
285
+ : preflight.invoked
286
+ ? `${consequence.outcome} (disposition ${consequence.disposition}, enforcement ${consequence.enforcementMode}, blocksApproval ${consequence.blocksApproval}). ${consequence.meaning}`
287
+ : `not run: ${preflight.skipReason}`,
288
+ paths: (preflight.official?.findings || []).flatMap((finding) =>
289
+ (finding.evidence || []).map((entry) => `${finding.ruleId}: ${entry.path}:${entry.line}`),
290
+ ),
291
+ remedy: consequence?.blocksApproval
292
+ ? "Fix every selectively blocking finding in a new commit before submitting."
293
+ : consequence?.outcome === "needs-fixes"
294
+ ? "These findings do not block publication under the current enforcement mode, but a maintainer must accept them for this exact commit. Fixing them first avoids that round."
295
+ : null,
296
+ }))
297
+
298
+ const blocking = checks.filter((entry) => entry.severity === "blocking" && entry.verdict === "fail")
299
+ const advisory = checks.filter((entry) => entry.severity === "advisory" && entry.verdict === "fail")
300
+ const ready = blocking.length === 0
301
+
302
+ return {
303
+ pin: {
304
+ repository: preflight.pin?.repository || null,
305
+ commit: pinIdentity.commit,
306
+ baselineVersion: pinIdentity.baselineVersion,
307
+ enforcementMode: pinIdentity.enforcementMode,
308
+ },
309
+ subject: {
310
+ mode: subject.mode,
311
+ directory: subject.dir,
312
+ repository: subject.repository.url,
313
+ commit: subject.commit,
314
+ cleanTree: subject.clean,
315
+ },
316
+ validationCommit: {
317
+ local: subject.commit,
318
+ defaultBranchHead: head?.commit || null,
319
+ branch: head?.branch || null,
320
+ matches: validationMatches,
321
+ note: "The marketplace validates the default-branch HEAD it resolves when the issue is opened or edited. After submitting, use `omakit watch <issue-url>` to see whether that validated commit has fallen behind.",
322
+ },
323
+ plugin: { id: tree.pluginId, name: pluginName },
324
+ checks,
325
+ ready,
326
+ blocking: blocking.map((entry) => entry.id),
327
+ advisory: advisory.map((entry) => entry.id),
328
+ issue: ready ? issue : null,
329
+ baseline: preflight.invoked
330
+ ? {
331
+ transport: preflight.transport,
332
+ assumedByAdapter: preflight.assumedByAdapter,
333
+ official: preflight.official,
334
+ consequence,
335
+ officialReport: preflight.officialReport,
336
+ statement: preflight.statement,
337
+ }
338
+ : { invoked: false, skipReason: preflight.skipReason, statement: preflight.statement },
339
+ afterSubmitting: REFRESH_ACTION,
340
+ }
341
+ }
@@ -0,0 +1,50 @@
1
+ // The installable tree of a plugin repository at one exact commit, read from
2
+ // the local Git object database. Read-only: `git ls-tree` and `git cat-file`,
3
+ // never a working-tree walk, so what is inspected is exactly what the
4
+ // marketplace would fetch for that commit and nothing the author left
5
+ // untracked.
6
+
7
+ import { execFileSync } from "node:child_process"
8
+
9
+ function git(dir, args, encoding = "utf8") {
10
+ return execFileSync("git", ["-C", dir, ...args], {
11
+ encoding,
12
+ maxBuffer: 256 * 1024 * 1024,
13
+ stdio: ["ignore", "pipe", "pipe"],
14
+ })
15
+ }
16
+
17
+ /**
18
+ * @param {string} dir
19
+ * @param {string} commit
20
+ * @returns {Array<{ path: string, mode: string, type: string, sha: string, size?: number }>}
21
+ */
22
+ export function readTree(dir, commit) {
23
+ const raw = git(dir, ["ls-tree", "-r", "-t", "-l", commit])
24
+ const entries = []
25
+ for (const line of raw.split("\n")) {
26
+ if (!line) continue
27
+ const tab = line.indexOf("\t")
28
+ const [mode, type, sha, size] = line.slice(0, tab).split(/\s+/)
29
+ const entry = { path: line.slice(tab + 1), mode, type, sha }
30
+ if (type === "blob") entry.size = Number(size)
31
+ entries.push(entry)
32
+ }
33
+ return entries
34
+ }
35
+
36
+ export function readBlob(dir, sha) {
37
+ return git(dir, ["cat-file", "blob", sha], "buffer")
38
+ }
39
+
40
+ export function readText(dir, sha, limit = 1024 * 1024) {
41
+ return readBlob(dir, sha).subarray(0, limit).toString("utf8")
42
+ }
43
+
44
+ export function isBlob(entry) {
45
+ return entry?.type === "blob" && entry.mode !== "120000"
46
+ }
47
+
48
+ export function rootEntries(entries) {
49
+ return entries.filter((entry) => !entry.path.includes("/"))
50
+ }
@@ -0,0 +1,179 @@
1
+ // `omakit upgrade`: update the tool, and only the tool.
2
+ //
3
+ // This command exists against my earlier judgement, and the reason is worth
4
+ // writing down. The objection to an upgrade command was that "upgrade" can mean
5
+ // two things here and one of them must never happen on its own: bumping the
6
+ // marketplace pin changes where the submission contract and the baseline policy
7
+ // are read from, and the procedure for that ends in re-proving transport parity
8
+ // and committing the evidence. That objection stands, and it is enforced below
9
+ // rather than argued: this command fast-forwards the tool's own checkout and
10
+ // touches nothing in .cache. The pin moves when a human edits MARKETPLACE_PIN,
11
+ // never here.
12
+ //
13
+ // What changed my mind is the ordinary case. After a global install nobody
14
+ // remembers where the checkout went, and a tool people cannot update is a tool
15
+ // people run stale. That is a worse outcome than the one I was protecting
16
+ // against.
17
+ //
18
+ // It is not a self-updater in the sense this repository warns other people
19
+ // about. It does not fetch and execute arbitrary code: it fast-forwards a Git
20
+ // checkout the user cloned themselves, from the remote they cloned it from, and
21
+ // it refuses if any of that is not true.
22
+
23
+ import { execFileSync } from "node:child_process"
24
+ import { existsSync } from "node:fs"
25
+ import { join, resolve, sep } from "node:path"
26
+ import { progress } from "./progress.mjs"
27
+ import { action, colourEnabled, GUTTER, mark, styler, verdict, wrap } from "./style.mjs"
28
+
29
+ export const REPOSITORY = "https://github.com/mtolhuys/omakit"
30
+
31
+ /** Identify only the three supported delivery shapes; npm wins under /usr/node_modules. */
32
+ export function installKind(repoRoot) {
33
+ const root = resolve(repoRoot)
34
+ if (existsSync(join(root, ".git"))) return "git"
35
+ if (root.split(sep).includes("node_modules")) return "npm"
36
+ if (root === "/usr/lib/omakit" || root.startsWith("/usr/lib/omakit/")) return "distro"
37
+ return "npm"
38
+ }
39
+
40
+ export function upgradeCommand(repoRoot, name = "omakit") {
41
+ return {
42
+ git: "omakit upgrade",
43
+ npm: `npm i -g ${name}@latest`,
44
+ distro: `sudo pacman -Syu ${name}`,
45
+ }[installKind(repoRoot)]
46
+ }
47
+
48
+ function git(dir, args) {
49
+ return execFileSync("git", ["-C", dir, ...args], {
50
+ encoding: "utf8",
51
+ stdio: ["ignore", "pipe", "pipe"],
52
+ }).trim()
53
+ }
54
+
55
+ /** Same repository, whatever spelling the remote uses. */
56
+ export function isExpectedRemote(url, expected = REPOSITORY) {
57
+ const normalise = (value) => String(value || "")
58
+ .trim()
59
+ .replace(/^git@github\.com:/i, "https://github.com/")
60
+ .replace(/\.git$/i, "")
61
+ .replace(/\/+$/, "")
62
+ .toLowerCase()
63
+ return normalise(url) === normalise(expected)
64
+ }
65
+
66
+ /**
67
+ * @param {{ repoRoot: string, stream?: NodeJS.WriteStream, dryRun?: boolean,
68
+ * expectedRemote?: string }} options `expectedRemote` exists so the
69
+ * successful path can be tested end to end against a local remote; it is not
70
+ * a way to point the command at somebody else's repository, because nothing
71
+ * on the command line reaches it.
72
+ */
73
+ export async function upgrade({ repoRoot, stream = process.stdout, dryRun = false, expectedRemote = REPOSITORY }) {
74
+ const c = styler(colourEnabled(stream))
75
+ const out = (line = "") => stream.write(`${line}\n`)
76
+ const lines = (list) => { for (const line of list) out(line) }
77
+ const refuse = (reason, fix = null) => {
78
+ lines(verdict("fail", "REFUSED", reason, c))
79
+ if (fix) lines(action(fix, c, { indent: 0 }))
80
+ return { ok: false, reason }
81
+ }
82
+ const note = (text) => out(`${mark("advisory", c)}${wrap(text, { indent: GUTTER }, c).join("\n").trimStart()}`)
83
+ const ok = (text) => out(`${mark("pass", c)}${wrap(text, { indent: GUTTER }, c).join("\n").trimStart()}`)
84
+
85
+ if (!existsSync(join(repoRoot, ".git"))) {
86
+ const kind = installKind(repoRoot)
87
+ return refuse(
88
+ kind === "distro"
89
+ ? "this is a distro package under /usr, so omakit leaves upgrades to pacman."
90
+ : "this is an npm package install, so omakit leaves upgrades to npm.",
91
+ upgradeCommand(repoRoot),
92
+ )
93
+ }
94
+
95
+ let remote
96
+ try {
97
+ remote = git(repoRoot, ["remote", "get-url", "origin"])
98
+ } catch {
99
+ return refuse("this checkout has no `origin` remote, so there is nowhere to update from.")
100
+ }
101
+ if (!isExpectedRemote(remote, expectedRemote)) {
102
+ return refuse(
103
+ `the \`origin\` of this checkout is ${remote}, not ${expectedRemote}. Pulling code from somewhere else is not this command's business.`,
104
+ `git -C ${repoRoot} pull`,
105
+ )
106
+ }
107
+
108
+ if (git(repoRoot, ["status", "--porcelain"]).length) {
109
+ return refuse(
110
+ "this checkout has local changes. Updating would either lose them or leave you mid-merge; both are worse than stopping.",
111
+ `git -C ${repoRoot} status`,
112
+ )
113
+ }
114
+
115
+ const before = git(repoRoot, ["rev-parse", "HEAD"])
116
+ const branch = git(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"])
117
+ if (branch === "HEAD") {
118
+ return refuse("this checkout is on a detached HEAD, so there is no branch to fast-forward.")
119
+ }
120
+
121
+ const spinner = progress({ stream: stream === process.stdout ? process.stderr : stream })
122
+ spinner.phase(`fetching origin/${branch}`)
123
+ try {
124
+ git(repoRoot, ["fetch", "--quiet", "origin", branch])
125
+ } catch (error) {
126
+ spinner.done()
127
+ const reason = String(error?.stderr || "").trim().split("\n").filter((line) => /^fatal:/.test(line)).pop()
128
+ || "git fetch failed"
129
+ return refuse(
130
+ `origin could not be fetched: ${reason.replace(/^fatal:\s*/, "")}`,
131
+ "Connect to the network, then run `omakit upgrade` again.",
132
+ )
133
+ }
134
+ spinner.done()
135
+ const target = git(repoRoot, ["rev-parse", `origin/${branch}`])
136
+
137
+ if (target === before) {
138
+ ok(`already current at ${before.slice(0, 7)} on ${branch}`)
139
+ out()
140
+ lines(wrap("The marketplace pin is a separate thing and is never touched here. `omakit doctor` says whether it is behind.", {}, c))
141
+ return { ok: true, changed: false, commit: before }
142
+ }
143
+
144
+ // Fast-forward only. A merge or a rebase here would be this command deciding
145
+ // what to do with someone else's history.
146
+ try {
147
+ git(repoRoot, ["merge-base", "--is-ancestor", before, target])
148
+ } catch {
149
+ return refuse(
150
+ `this checkout at ${before.slice(0, 7)} is not an ancestor of origin/${branch} at ${target.slice(0, 7)}, so it cannot be fast-forwarded.`,
151
+ `git -C ${repoRoot} log --oneline HEAD..origin/${branch}`,
152
+ )
153
+ }
154
+
155
+ const log = git(repoRoot, ["log", "--oneline", `${before}..${target}`]).split("\n").filter(Boolean)
156
+ // A commit subject is the one thing a person actually reads here, so the sha
157
+ // takes the emphasis and the subject keeps the terminal's own foreground.
158
+ const body = " ".repeat(GUTTER)
159
+ const subject = (line) => {
160
+ const split = line.match(/^(\S+)\s+([\s\S]*)$/)
161
+ return split ? `${body}${c("name", split[1])} ${c("prose", split[2])}` : `${body}${c("prose", line)}`
162
+ }
163
+ if (dryRun) {
164
+ note(`${log.length} commit(s) available, not applied (--dry-run)`)
165
+ for (const line of log) out(subject(line))
166
+ out()
167
+ lines(action("omakit upgrade", c, { indent: 0 }))
168
+ return { ok: true, changed: false, commit: before, available: log.length }
169
+ }
170
+
171
+ git(repoRoot, ["merge", "--ff-only", `origin/${branch}`])
172
+ const after = git(repoRoot, ["rev-parse", "HEAD"])
173
+
174
+ ok(`${before.slice(0, 7)} to ${after.slice(0, 7)} on ${branch}, ${log.length} commit(s)`)
175
+ for (const line of log) out(subject(line))
176
+ out()
177
+ lines(wrap("The marketplace pin did not move: this updated the tool, not the commit its rules are read from. `omakit doctor` says whether that pin is behind, and docs/UPSTREAM_CONTRACT.md says what moving it involves.", {}, c))
178
+ return { ok: true, changed: true, from: before, to: after, commits: log.length }
179
+ }