omakit 0.3.0 → 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,7 +25,7 @@ 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
31
  | [`omakit audit [<plugin>]`](docs/AUDIT.md) | Installed third-party commits against the exact commits the marketplace validated. |
@@ -35,6 +35,11 @@ See [docs/INSTALL.md](docs/INSTALL.md) for the clone route, PATH, requirements a
35
35
  | [`omakit upgrade`](docs/COMMANDS.md) | Updates omakit through its own installer: npm, or a fast-forward. |
36
36
  | [`omakit help --agent`](docs/COMMANDS.md) | The operating instructions, for the agent running this. |
37
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
+
38
43
  ### `submit`
39
44
 
40
45
  ```bash
@@ -51,10 +56,15 @@ Read more: [docs/SUBMIT.md](docs/SUBMIT.md).
51
56
 
52
57
  ```bash
53
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
54
62
  ```
55
63
 
56
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).
57
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
+
58
68
  ![omakit watch reporting that a validated commit has fallen behind](docs/media/watch.gif)
59
69
 
60
70
  Read more: [docs/VALIDATION_WATCH.md](docs/VALIDATION_WATCH.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.3.0",
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",
@@ -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
@@ -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,15 +21,16 @@ 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"
@@ -62,6 +63,7 @@ const REMEDY = Object.freeze({
62
63
  "github-unavailable": "Wait for GitHub, then run it again. `gh auth login` raises the rate limit if that is what ran out.",
63
64
  "not-found": "Check the issue URL: it has to be an existing issue on the marketplace repository.",
64
65
  "head-unreadable": "Check that the plugin repository is public and its URL is right.",
66
+ "login-required": "gh auth login",
65
67
  "not-confirmed": "Run it again and answer y, or pass --yes when the person whose shell it is has agreed.",
66
68
  "interrupted": "shell.json was restored; run it again when the desktop is yours to restart.",
67
69
  })
@@ -180,18 +182,41 @@ async function cmdSubmit(args) {
180
182
 
181
183
  async function cmdWatch(args) {
182
184
  const issueUrl = positionals(args)[0]
183
- 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
+ }
184
195
  const spinner = spinnerFor(args)
185
196
  let result
186
197
  try {
187
- 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
+ }
188
212
  } catch (error) {
189
213
  spinner.done()
190
214
  failFrom(error)
191
215
  }
192
216
  spinner.done()
193
- emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderWatch(result))
194
- 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)
195
220
  }
196
221
 
197
222
  async function cmdFrontDoor() {
@@ -460,6 +485,11 @@ const [command, ...rest] = process.argv.slice(2)
460
485
  // completion script names another omakit, so `omakit we<TAB>` may not know
461
486
  // `weigh`. One stat and one short read; never under a pipe, whose stderr
462
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
+
463
493
  if (command !== "setup" && process.stderr.isTTY) {
464
494
  const notice = staleCompletionNotice({ version: VERSION })
465
495
  if (notice) process.stderr.write(`${styler(colourEnabled(process.stderr))("label", notice)}\n`)
@@ -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
  }
@@ -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,7 +21,7 @@ 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 }),
@@ -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 })
@@ -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 }
@@ -69,11 +69,18 @@ export const COMMANDS = Object.freeze([
69
69
  ],
70
70
  },
71
71
  {
72
- 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
+ ],
73
76
  lines: [
74
77
  "Compare the commit the marketplace validated on a submission issue with",
75
78
  "the plugin repository's current default-branch HEAD, and say what makes",
76
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.",
77
84
  ],
78
85
  },
79
86
  {
@@ -104,6 +111,8 @@ export const COMMANDS = Object.freeze([
104
111
  lines: [
105
112
  "What is installed, what is pinned, and what has moved since. Reads and",
106
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.",
107
116
  ],
108
117
  },
109
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
  }