@astrofoundry/pi-astro 0.26.4 → 0.27.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/agents/arcane.md CHANGED
@@ -19,5 +19,6 @@ Rules:
19
19
  - Operational actions (restart, logs, stats, redeploy, image pull, prune) go straight through the CLI.
20
20
  - Confirm destructive operations (`down`, `delete`, `prune`) are explicitly requested in the task before running them.
21
21
  - Updating Arcane itself is a hand-over: do the repository change and the sync, then end with the exact hand-over block from the skill (numbered host steps, digests, rollback). Never run `system upgrade` or `projects upgrade arcane`.
22
+ - For update questions run `update-report` and answer with its table verbatim in a code block; never answer from `projects updates`, which is stored and paginated.
22
23
  - Report facts from the JSON output. Sensitive fields are already removed; never guess at what was removed.
23
24
  - Delegate through `subagent` only to another specialist (`astro.identity`, `astro.network`, `astro.dns`, `astro.edge`, `astro.security`, `astro.backup`, `astro.proxmox`, `astro.inference`) and only for that specialist's own area.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.26.4",
3
+ "version": "0.27.0",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -27,13 +27,26 @@ Arcane manages the Docker host on VM 100 (`10.0.40.30`). Every app is a Compose
27
27
 
28
28
  The full `arcane-cli` is available. The tool refuses only `config`, `auth`, `self-update`, `completion`, which would change its own setup.
29
29
 
30
+ ## Available updates
31
+
32
+ For any question about updates (what can be updated, which versions, is a stack current), run `["update-report"]`, or `["update-report", "--all"]` when the caller wants every service listed. It runs a fresh registry check of every image, maps each image to its stack and service, and reads release versions from the registries. It never pulls an image or redeploys anything. Do not answer update questions from `projects updates` or `images updates summary`: they return stored results from Arcane's last scheduled check, 20 projects per page.
33
+
34
+ The output is JSON with `counts`, `rows`, and `table`. Answer with the `table` field verbatim inside a code block, then add anything the caller asked about. Do not rebuild or reorder the table.
35
+
36
+ - `Running` and `Latest` are release tags. `older build`: the running build carries no tag any more. `new build`: no release tag names the newer build. `17 (older build)`: the running build of that major series is no longer tagged.
37
+ - `Update`: `pull` means redeploying the stack with an image pull applies it; `edit tag` means changing the tag in the GitOps repository; `edit digest` means changing the pinned digest in the repository (`latestDigest` in the row holds it); `unknown` has its reason in the notes under the table.
38
+ - A note "major version change" or "newer major ... available" means the update crosses a major version: say so, and apply it only on the caller's explicit go after they have read the release notes.
39
+ - Rows marked private have no version data because the registry needs credentials; their digest result from Arcane still stands.
40
+
41
+ Apply updates only when asked, one stack at a time, through the operations or GitOps procedure above, and run `update-report` again afterwards to show the result.
42
+
30
43
  ## Updating Arcane itself
31
44
 
32
45
  The `arcane` project is GitOps-managed and pinned by digest (`ghcr.io/getarcaneapp/manager@sha256:...`). The manager cannot recreate its own container, so the update is a hand-over: you do the repository and sync work, then give the caller exact steps for VM 100 and verify afterwards. Never use `["system", "upgrade"]` or `["projects", "upgrade", "arcane"]` for the manager; both bypass the pinned digest in the repository.
33
46
 
34
47
  Procedure, in this order:
35
48
 
36
- 1. Facts: `["version"]` (running version and digest), `["projects", "updates"]` (new digest, if any). If there is no update, report that and stop.
49
+ 1. Facts: `["version"]` (running version and digest), then `["update-report"]`: the `arcane` row shows the running and latest release and, for an update, `latestDigest`. If the row is not an update, report that and stop.
37
50
  2. Repository: `git pull`, `git show arcane/compose.yaml`, `git write arcane/compose.yaml <file with the new digest, nothing else changed>`, `git diff`, `git commit "Update Arcane manager to <version>"`, `git push`.
38
51
  3. Sync: `["gitops", "sync", "arcane", "--yes"]`, then `["gitops", "status", "arcane"]` until the last synced commit is yours. This updates `/opt/docker/arcane/compose.yaml` on VM 100; the container keeps running the old image.
39
52
  4. Hand over. End your answer with exactly this block, values filled in:
@@ -53,6 +53,8 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
53
53
  - The wrapper's repository commands are the `git` group; `gitops` must stay free because it is an `arcane-cli` command group (server-side syncs).
54
54
  - Every passthrough call writes a private per-call copy of `arcanecli.yml` with `jwt_token` set, because some `arcane-cli` commands validate the config file before reading `ARCANE_TOKEN`. The copy is removed after the call.
55
55
  - Every wrapper answers `--help` without reading config, so agents can discover subcommands before anything else is set up.
56
+ - `main()` waits for stdout and stderr to flush before `process.exit`: on a pipe, exiting right after a write drops everything past the 64 KiB buffer, which cut large answers such as `projects list --all` until 2026-09-25 (`lib/flush.test.ts`).
57
+ - `arcane update-report [--all]` is `lib/image-updates.ts` over `lib/registry.ts`: a fresh `images updates check --all`, then `projects list --all` for the stack and service of each image reference, then the registries' tag lists and manifest digests (anonymous bearer tokens from each registry's challenge; digests by HEAD, which Docker Hub does not count as a pull; lscr.io is read from ghcr.io). A floating or major-series tag is resolved to the release tag whose digest matches the running and the latest build, searching the newest tags of the three commonest release shapes; a fixed tag is compared with the newest tag of the same shape; a digest pin with the newest release. Checks older than one minute before the run count as stale and show as unknown. Private images keep Arcane's digest result without versions. The table is fixed-width text for a code block, because Discord renders no Markdown tables.
56
58
 
57
59
  ## Gate
58
60
 
@@ -4,7 +4,7 @@ A specialist is a Pi subagent that is the only way to operate one area of the ho
4
4
 
5
5
  | Agent | Area | What it can do |
6
6
  |---|---|---|
7
- | `astro.arcane` | Arcane container platform (VM 100) and its GitOps repository | Every `arcane-cli` operation except its own `config`, `auth`, `self-update`; Compose changes of GitOps-managed projects through `git show|write|commit|push`, then `arcane-cli gitops` sync |
7
+ | `astro.arcane` | Arcane container platform (VM 100) and its GitOps repository | Every `arcane-cli` operation except its own `config`, `auth`, `self-update`; `update-report` (fresh check of every stack with running and latest release versions); Compose changes of GitOps-managed projects through `git show|write|commit|push`, then `arcane-cli gitops` sync |
8
8
  | `astro.identity` | Zitadel; Pomerium, nginx, lego on VM 104; `02-pulsar-proxmox/dmz/` in the homelab repository | Any Zitadel API call (v2, management, admin, auth) with responses redacted; `dmz status|journal|config-template|render|pomerium-restart|nginx-reload|lego-renew|deploy-config`; `git` on the homelab checkout, writes limited to `02-pulsar-proxmox/dmz/` |
9
9
  | `astro.network` | UniFi, FreeRADIUS (LXC 108), Tailscale (LXC 105) | Read UniFi sites, devices, clients, networks, zones, firewall policies, WANs; create, update, delete firewall policies; RADIUS and Tailscale checks on Pulsar |
10
10
  | `astro.dns` | Technitium primary (Synapse) and secondary (LXC 107); Cloudflare zones `37pla.net`, `monadeo.com` | Read zones and records on both resolvers, resolve, stats; create and delete zones, add, update, delete records on the primary; Cloudflare zones and record add, update, delete |
@@ -4,8 +4,10 @@ import { dirname, join } from "node:path";
4
4
  import { readConfig } from "../lib/config.ts";
5
5
  import { ServiceError, UsageError } from "../lib/errors.ts";
6
6
  import { main } from "../lib/main.ts";
7
- import { printRaw } from "../lib/output.ts";
7
+ import { buildUpdateReport } from "../lib/image-updates.ts";
8
+ import { printJson, printRaw } from "../lib/output.ts";
8
9
  import { run } from "../lib/proc.ts";
10
+ import { HttpRegistry } from "../lib/registry.ts";
9
11
  import { REPO_HELP, repoCommand, type RepoSpec } from "../lib/repo.ts";
10
12
  import { ARCANE_DENIED_KEYS, filterJsonText, stripKeys } from "../lib/sanitize.ts";
11
13
  import { readSecret } from "../lib/secrets.ts";
@@ -66,6 +68,9 @@ const HELP = `arcane specialist
66
68
 
67
69
  <arcane-cli args...> run arcane-cli with a fresh federated token; JSON output, sensitive fields removed
68
70
  (arcane-cli gitops ... manages the server-side syncs)
71
+ update-report [--all] fresh image update check for every stack, with running and latest release versions
72
+ from the registries; JSON with a ready table (--all also lists up-to-date services).
73
+ Checks only: never pulls images or redeploys.
69
74
  ${REPO_HELP}
70
75
  Refused: ${REFUSED_PREFIXES.join(", ")}. GitOps-managed projects (gitOpsManagedBy set) change in the repository, then sync.`;
71
76
 
@@ -122,25 +127,56 @@ function writeCallConfig(config: ArcaneConfig, token: string): string {
122
127
  return file;
123
128
  }
124
129
 
125
- async function passthrough(config: ArcaneConfig, args: string[]): Promise<number> {
126
- const refused = refusedPrefix(args);
127
- if (refused) throw new UsageError(`"${refused}" is refused by the wrapper`);
130
+ type Cli = (args: string[]) => Promise<{ stdout: string; stderr: string; code: number }>;
131
+
132
+ /** One federated token and one private config copy for every arcane-cli call of this invocation. */
133
+ async function withCli<T>(config: ArcaneConfig, fn: (cli: Cli) => Promise<T>): Promise<T> {
128
134
  const token = await federatedToken(config);
129
135
  const callConfig = writeCallConfig(config, token);
130
136
  try {
137
+ return await fn((args) => run(config.arcaneCli, ["--config", callConfig, "--no-color", ...args], { env: { ARCANE_TOKEN: token }, timeoutMs: TIMEOUT_MS }));
138
+ } finally {
139
+ rmSync(dirname(callConfig), { recursive: true, force: true });
140
+ }
141
+ }
142
+
143
+ async function passthrough(config: ArcaneConfig, args: string[]): Promise<number> {
144
+ const refused = refusedPrefix(args);
145
+ if (refused) throw new UsageError(`"${refused}" is refused by the wrapper`);
146
+ return withCli(config, async (cli) => {
131
147
  const wantsJson = args.includes("--json") || args.includes("--output");
132
- const cliArgs = ["--config", callConfig, ...(wantsJson ? [] : ["--output", "json"]), "--no-color", ...args];
133
- const result = await run(config.arcaneCli, cliArgs, { env: { ARCANE_TOKEN: token }, timeoutMs: TIMEOUT_MS });
148
+ const result = await cli([...(wantsJson ? [] : ["--output", "json"]), ...args]);
134
149
  if (result.stdout.trim().length > 0) {
135
150
  printRaw(filterJsonText(result.stdout, (v) => stripKeys(v, ARCANE_DENIED_KEYS)));
136
151
  }
137
152
  if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `arcane-cli exited ${result.code}`);
138
153
  return 0;
139
- } finally {
140
- rmSync(dirname(callConfig), { recursive: true, force: true });
154
+ });
155
+ }
156
+
157
+ async function cliJson(cli: Cli, args: string[]): Promise<unknown> {
158
+ const result = await cli(["--output", "json", ...args]);
159
+ if (result.code !== 0) throw new ServiceError(`${args.join(" ")}: ${result.stderr.trim() || `exited ${result.code}`}`);
160
+ try {
161
+ return JSON.parse(result.stdout);
162
+ } catch {
163
+ throw new ServiceError(`${args.join(" ")}: output is not JSON`);
141
164
  }
142
165
  }
143
166
 
167
+ /** A fresh registry check first, then the stack list, so every result the report uses is from this run. */
168
+ async function updateReport(config: ArcaneConfig, args: string[]): Promise<number> {
169
+ const extra = args.filter((a) => a !== "--all");
170
+ if (extra.length > 0) throw new UsageError(`update-report takes only --all, got: ${extra.join(" ")}`);
171
+ const startedAt = Date.now();
172
+ return withCli(config, async (cli) => {
173
+ const check = await cliJson(cli, ["images", "updates", "check", "--all"]);
174
+ const projects = await cliJson(cli, ["projects", "list", "--all"]);
175
+ printJson(await buildUpdateReport({ check, projects, registry: new HttpRegistry(), startedAt, all: args.includes("--all") }));
176
+ return 0;
177
+ });
178
+ }
179
+
144
180
  export async function command(args: string[]): Promise<number> {
145
181
  if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
146
182
  printRaw(HELP);
@@ -148,6 +184,7 @@ export async function command(args: string[]): Promise<number> {
148
184
  }
149
185
  const config = readConfig<ArcaneConfig>(SERVICE, SHAPE);
150
186
  if (args[0] === "git") return repoCommand(repoSpec(config), args.slice(1));
187
+ if (args[0] === "update-report") return updateReport(config, args.slice(1));
151
188
  return passthrough(config, args);
152
189
  }
153
190
 
@@ -0,0 +1,21 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ describe("main output on a pipe", () => {
8
+ it("delivers output larger than the pipe buffer before exiting", () => {
9
+ const dir = mkdtempSync(join(tmpdir(), "astro-flush-"));
10
+ try {
11
+ const script = join(dir, "big.ts");
12
+ const mainPath = join(import.meta.dirname, "main.ts");
13
+ writeFileSync(script, `import { main } from ${JSON.stringify(mainPath)};\nawait main("test", async () => { process.stdout.write("x".repeat(300_000)); return 0; }, ["--caller", "t", "--", "go"]);\n`);
14
+ const result = spawnSync(process.execPath, ["--disable-warning=ExperimentalWarning", script], { env: { ...process.env, HOME: dir }, maxBuffer: 10_000_000 });
15
+ expect(result.stdout.length).toBe(300_000);
16
+ expect(result.status).toBe(0);
17
+ } finally {
18
+ rmSync(dir, { recursive: true, force: true });
19
+ }
20
+ });
21
+ });
@@ -0,0 +1,190 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildUpdateReport, cachingRegistry, checkEntries, classify, compareVersions, parseTag, releaseCandidates, resolveRef, stackServices, type CheckEntry } from "./image-updates.ts";
3
+ import { HttpRegistry, nextLink, parseChallenge, parseImageRef, RegistryError, type ImageRef, type Registry } from "./registry.ts";
4
+
5
+ const NOW = Date.parse("2026-09-25T18:30:00Z");
6
+ const FRESH = "2026-09-25T18:30:05Z";
7
+
8
+ /** Registry double: tags and digests per repository, errors for private ones. */
9
+ class FakeRegistry implements Registry {
10
+ heads = 0;
11
+ private readonly repos: Record<string, { tags: string[]; digests: Record<string, string> } | "private">;
12
+ constructor(repos: Record<string, { tags: string[]; digests: Record<string, string> } | "private">) {
13
+ this.repos = repos;
14
+ }
15
+ async tags(ref: ImageRef): Promise<string[]> {
16
+ const repo = this.repos[ref.repository];
17
+ if (repo === undefined || repo === "private") throw new RegistryError(`${ref.repository}: private or not found`, 401);
18
+ return repo.tags;
19
+ }
20
+ async digest(ref: ImageRef, tag: string): Promise<string | null> {
21
+ this.heads++;
22
+ const repo = this.repos[ref.repository];
23
+ return repo && repo !== "private" ? (repo.digests[tag] ?? null) : null;
24
+ }
25
+ }
26
+
27
+ const entry = (current: string, latest: string, extra: Partial<CheckEntry> = {}): CheckEntry => ({ checkTime: FRESH, currentDigest: current, latestDigest: latest, hasUpdate: current !== latest, currentVersion: "", ...extra });
28
+
29
+ describe("image references and tags", () => {
30
+ it("parses references the way Docker resolves them", () => {
31
+ expect(parseImageRef("nginx:1.31.4-alpine")).toEqual({ registry: "docker.io", repository: "library/nginx", tag: "1.31.4-alpine", digest: null });
32
+ expect(parseImageRef("lscr.io/linuxserver/prowlarr:latest")).toEqual({ registry: "lscr.io", repository: "linuxserver/prowlarr", tag: "latest", digest: null });
33
+ expect(parseImageRef("ghcr.io/getarcaneapp/manager@sha256:abc")).toEqual({ registry: "ghcr.io", repository: "getarcaneapp/manager", tag: null, digest: "sha256:abc" });
34
+ expect(parseImageRef("localhost:5000/tool")).toEqual({ registry: "localhost:5000", repository: "tool", tag: "latest", digest: null });
35
+ expect(parseImageRef("recyclarr/recyclarr:8")).toMatchObject({ registry: "docker.io", repository: "recyclarr/recyclarr", tag: "8" });
36
+ });
37
+
38
+ it("reads release tags and orders them", () => {
39
+ expect(parseTag("v2.197.0")).toEqual({ tag: "v2.197.0", prefix: "v", nums: [2, 197, 0], suffix: "" });
40
+ expect(parseTag("2.0.5.5160-ls123")).toMatchObject({ nums: [2, 0, 5, 5160], suffix: "-ls123" });
41
+ expect(parseTag("latest")).toBeNull();
42
+ expect(parseTag("1.0.0-rc1")).toBeNull();
43
+ expect(parseTag("nightly-20260901")).toBeNull();
44
+ expect(compareVersions(parseTag("1.2.0-ls124")!, parseTag("1.2.0-ls123")!)).toBeGreaterThan(0);
45
+ expect(compareVersions(parseTag("1.10.0")!, parseTag("1.9.9")!)).toBeGreaterThan(0);
46
+ expect(classify(parseImageRef("postgres:18"))).toBe("series");
47
+ expect(classify(parseImageRef("nginx:1.31.4-alpine"))).toBe("fixed");
48
+ expect(classify(parseImageRef("syncthing/syncthing"))).toBe("floating");
49
+ expect(classify(parseImageRef("x@sha256:1"))).toBe("pinned");
50
+ });
51
+
52
+ it("prefers plain versions and falls back to the dominant release shape", () => {
53
+ const tags = ["1.2.0", "1.3.0", "1.3.0-fat", "1.4.0-rc1"].map(parseTag).filter((t) => t !== null);
54
+ expect(releaseCandidates(tags).map((t) => t.tag)).toEqual(["1.3.0", "1.2.0"]);
55
+ const ls = ["2.0.5.5160-ls123", "2.1.0.5190-ls124", "1.0-armhf"].map(parseTag).filter((t) => t !== null);
56
+ expect(releaseCandidates(ls).map((t) => t.tag)).toEqual(["2.1.0.5190-ls124", "2.0.5.5160-ls123"]);
57
+ });
58
+ });
59
+
60
+ describe("resolveRef", () => {
61
+ const registry = new FakeRegistry({
62
+ "linuxserver/prowlarr": { tags: ["2.0.4.5100-ls120", "2.0.5.5160-ls123", "2.1.0.5190-ls124", "develop-2.1", "nightly"], digests: { "2.1.0.5190-ls124": "D2", "2.0.5.5160-ls123": "D1" } },
63
+ "library/postgres": { tags: ["17", "17.5", "17.6", "17.6-alpine", "18", "18.0"], digests: { "17.6": "P2", "17.5": "P1" } },
64
+ "supabase/gotrue": { tags: ["v2.196.0", "v2.197.0", "v2.199.1", "v2.200.0-rc1"], digests: {} },
65
+ "library/nginx": { tags: ["1.31.4-alpine"], digests: {} },
66
+ "getarcaneapp/manager": { tags: ["v1.2.0", "v1.3.0", "latest"], digests: { "v1.3.0": "sha256:new", "v1.2.0": "sha256:old" } },
67
+ "astrofoundry/app": "private",
68
+ });
69
+
70
+ it("resolves a floating tag to the release versions behind both digests", async () => {
71
+ expect(await resolveRef("lscr.io/linuxserver/prowlarr:latest", entry("D1", "D2"), registry, NOW)).toMatchObject({ kind: "floating", running: "2.0.5.5160-ls123", latest: "2.1.0.5190-ls124", status: "update", action: "pull" });
72
+ });
73
+
74
+ it("finds an older build under a more specific tag and flags a major jump", async () => {
75
+ const ls = new FakeRegistry({
76
+ "linuxserver/radarr": { tags: ["6.4.4", "6.4.4.10050-ls300", "6.4.2.10000-ls299", "6.4.2"], digests: { "6.4.4": "R2", "6.4.4.10050-ls300": "R2", "6.4.2.10000-ls299": "R1", "6.4.2": "R0" } },
77
+ "stirlingtools/stirling-pdf": { tags: ["2.14.3", "3.0.0"], digests: { "3.0.0": "S2", "2.14.3": "S1" } },
78
+ });
79
+ expect(await resolveRef("lscr.io/linuxserver/radarr:latest", entry("R1", "R2"), ls, NOW)).toMatchObject({ running: "6.4.2.10000-ls299", latest: "6.4.4.10050-ls300", note: "" });
80
+ expect(await resolveRef("stirlingtools/stirling-pdf:latest", entry("S1", "S2"), ls, NOW)).toMatchObject({ running: "2.14.3", latest: "3.0.0", note: expect.stringContaining("major version change") });
81
+ });
82
+
83
+ it("stays inside a major series and names a newer major", async () => {
84
+ expect(await resolveRef("postgres:17", entry("P1", "P2"), registry, NOW)).toMatchObject({ kind: "series", running: "17.5", latest: "17.6", status: "update", note: "newer major 18 available (tag change)" });
85
+ });
86
+
87
+ it("finds a newer release for a fixed tag and a rebuild when there is none", async () => {
88
+ expect(await resolveRef("supabase/gotrue:v2.197.0", entry("G", "G"), registry, NOW)).toMatchObject({ running: "v2.197.0", latest: "v2.199.1", status: "release", action: "edit tag" });
89
+ expect(await resolveRef("nginx:1.31.4-alpine", entry("N1", "N2"), registry, NOW)).toMatchObject({ status: "rebuild", action: "pull", latest: "1.31.4-alpine" });
90
+ expect(await resolveRef("nginx:1.31.4-alpine", entry("N1", "N1"), registry, NOW)).toMatchObject({ status: "up-to-date" });
91
+ });
92
+
93
+ it("compares a digest pin with the newest release", async () => {
94
+ expect(await resolveRef("ghcr.io/getarcaneapp/manager@sha256:old", undefined, registry, NOW)).toMatchObject({ kind: "pinned", running: "v1.2.0", latest: "v1.3.0", status: "update", action: "edit digest", latestDigest: "sha256:new" });
95
+ expect(await resolveRef("ghcr.io/getarcaneapp/manager@sha256:new", undefined, registry, NOW)).toMatchObject({ running: "v1.3.0", status: "up-to-date" });
96
+ });
97
+
98
+ it("falls back to the digest result for private images and marks bad checks unknown", async () => {
99
+ expect(await resolveRef("ghcr.io/astrofoundry/app:main", entry("A1", "A2"), registry, NOW)).toMatchObject({ status: "update", latest: "new build", note: "versions unavailable: private image, the registry needs credentials" });
100
+ expect(await resolveRef("nginx:1.31.4-alpine", undefined, registry, NOW)).toMatchObject({ status: "unknown", note: "not in the fresh check" });
101
+ expect(await resolveRef("nginx:1.31.4-alpine", entry("N", "N", { checkTime: "2026-09-24T00:00:00Z" }), registry, NOW)).toMatchObject({ status: "unknown", note: expect.stringContaining("stale") });
102
+ expect(await resolveRef("nginx:1.31.4-alpine", entry("N", "N", { error: "timeout" }), registry, NOW)).toMatchObject({ status: "unknown", note: "check failed: timeout" });
103
+ });
104
+ });
105
+
106
+ describe("buildUpdateReport", () => {
107
+ it("lists updates per stack service and renders a table", async () => {
108
+ const registry = new FakeRegistry({
109
+ "linuxserver/prowlarr": { tags: ["2.0.5.5160-ls123", "2.1.0.5190-ls124"], digests: { "2.1.0.5190-ls124": "D2", "2.0.5.5160-ls123": "D1" } },
110
+ "library/nginx": { tags: ["1.31.4-alpine"], digests: {} },
111
+ "library/busybox": { tags: ["1.37.0"], digests: { "1.37.0": "B" } },
112
+ });
113
+ const projects = {
114
+ data: [
115
+ { name: "media", status: "running", updateInfo: { serviceUpdates: { prowlarr: { imageRef: "lscr.io/linuxserver/prowlarr:latest" } } } },
116
+ { name: "profiles", status: "running", updateInfo: { serviceUpdates: { profiles: { imageRef: "nginx:1.31.4-alpine" } } } },
117
+ { name: "canary", status: "stopped", updateInfo: { serviceUpdates: { canary: { imageRef: "busybox:latest" } } } },
118
+ { name: "empty", status: "running" },
119
+ ],
120
+ };
121
+ const check = {
122
+ "lscr.io/linuxserver/prowlarr:latest": entry("D1", "D2"),
123
+ "nginx:1.31.4-alpine": entry("N", "N"),
124
+ "busybox:latest": entry("B", "B"),
125
+ };
126
+ const report = await buildUpdateReport({ check, projects, registry, startedAt: NOW, all: false });
127
+ expect(report.counts).toEqual({ update: 1, release: 0, rebuild: 0, unknown: 1, "up-to-date": 2 });
128
+ expect(report.rows.map((r) => `${r.stack}/${r.service}:${r.status}`)).toEqual(["media/prowlarr:update", "empty/empty:unknown"]);
129
+ expect(report.table).toMatch(/^Fresh check 2026-09-25T18:30:00Z: 4 services in 4 stacks, 3 images\./);
130
+ expect(report.table).toContain("media/prowlarr prowlarr 2.0.5.5160-ls123 2.1.0.5190-ls124 pull");
131
+ expect(report.table).toContain("* empty: Arcane lists no images for this stack");
132
+ const all = await buildUpdateReport({ check, projects, registry, startedAt: NOW, all: true });
133
+ expect(all.rows).toHaveLength(4);
134
+ expect(all.table).toContain("* canary: stack is stopped");
135
+ });
136
+
137
+ it("fetches each repository's tags once", async () => {
138
+ let lists = 0;
139
+ const inner: Registry = {
140
+ tags: async () => {
141
+ lists++;
142
+ return ["17.1", "18.1"];
143
+ },
144
+ digest: async () => null,
145
+ };
146
+ const registry = cachingRegistry(inner);
147
+ await Promise.all([registry.tags(parseImageRef("postgres:17")), registry.tags(parseImageRef("postgres:18"))]);
148
+ expect(lists).toBe(1);
149
+ });
150
+
151
+ it("reads Arcane's envelopes", () => {
152
+ expect(Object.keys(checkEntries({ data: { "a:1": { hasUpdate: false } } }))).toEqual(["a:1"]);
153
+ expect(Object.keys(checkEntries({ "a:1": { hasUpdate: true }, success: true }))).toEqual(["a:1"]);
154
+ expect(stackServices({ data: "nope" })).toEqual([]);
155
+ });
156
+ });
157
+
158
+ describe("HttpRegistry", () => {
159
+ it("follows the bearer challenge, paginates tags, and reads digests by HEAD", async () => {
160
+ const calls: string[] = [];
161
+ const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
162
+ const url = String(input);
163
+ const auth = (init?.headers as Record<string, string> | undefined)?.authorization;
164
+ calls.push(`${init?.method ?? "GET"} ${url}${auth ? " +auth" : ""}`);
165
+ if (url.startsWith("https://auth.example/token")) return new Response(JSON.stringify({ token: "t1" }), { status: 200 });
166
+ if (!auth) return new Response("", { status: 401, headers: { "www-authenticate": 'Bearer realm="https://auth.example/token",service="reg",scope="repository:library/nginx:pull"' } });
167
+ if (url.includes("/tags/list?n=1000")) return new Response(JSON.stringify({ tags: ["1.0"] }), { status: 200, headers: { link: '</v2/library/nginx/tags/list?last=1.0&n=1000>; rel="next"' } });
168
+ if (url.includes("/tags/list?last=1.0")) return new Response(JSON.stringify({ tags: ["1.1"] }), { status: 200 });
169
+ if (url.endsWith("/manifests/1.1")) return new Response(null, { status: 200, headers: { "docker-content-digest": "sha256:abc" } });
170
+ if (url.endsWith("/manifests/9.9")) return new Response(null, { status: 404 });
171
+ if (url.endsWith("/manifests/busy")) return new Response(null, { status: 429 });
172
+ return new Response("", { status: 500 });
173
+ }) as typeof fetch;
174
+ const registry = new HttpRegistry(fetchImpl);
175
+ const ref = parseImageRef("nginx:1.1");
176
+ expect(await registry.tags(ref)).toEqual(["1.0", "1.1"]);
177
+ expect(await registry.digest(ref, "1.1")).toBe("sha256:abc");
178
+ expect(await registry.digest(ref, "9.9")).toBeNull();
179
+ await expect(registry.digest(ref, "busy")).rejects.toThrow(/rate limited/);
180
+ expect(calls.filter((c) => c.startsWith("GET https://auth.example/token"))).toHaveLength(1);
181
+ expect(calls[0]).toBe("GET https://registry-1.docker.io/v2/library/nginx/tags/list?n=1000");
182
+ });
183
+
184
+ it("parses challenges and link headers", () => {
185
+ expect(parseChallenge('Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:a/b:pull"')).toEqual({ realm: "https://ghcr.io/token", service: "ghcr.io", scope: "repository:a/b:pull" });
186
+ expect(parseChallenge("Basic realm=x")).toBeNull();
187
+ expect(nextLink('</v2/a/tags/list?last=x&n=100>; rel="next"')).toBe("/v2/a/tags/list?last=x&n=100");
188
+ expect(nextLink(null)).toBeNull();
189
+ });
190
+ });
@@ -0,0 +1,366 @@
1
+ import { parseImageRef, RegistryError, type ImageRef, type Registry } from "./registry.ts";
2
+
3
+ /** One entry of `arcane-cli images updates check --all --json`, keyed by image reference. */
4
+ export interface CheckEntry {
5
+ checkTime?: string;
6
+ currentVersion?: string;
7
+ currentDigest?: string;
8
+ latestDigest?: string;
9
+ hasUpdate?: boolean;
10
+ error?: string;
11
+ }
12
+
13
+ /** How the image reference in the Compose file moves: never (pinned), with its tag (floating, series), or only by editing it (fixed). */
14
+ export type RefKind = "floating" | "series" | "fixed" | "pinned";
15
+ export type UpdateStatus = "update" | "release" | "rebuild" | "up-to-date" | "unknown";
16
+
17
+ export interface UpdateRow {
18
+ stack: string;
19
+ service: string;
20
+ ref: string;
21
+ image: string;
22
+ kind: RefKind;
23
+ running: string;
24
+ latest: string;
25
+ status: UpdateStatus;
26
+ /** What applying the update means: "pull" (redeploy with a pull) or "edit tag" / "edit digest" in the GitOps repository. */
27
+ action: string;
28
+ note: string;
29
+ /** For `edit digest` rows: the manifest digest of the latest release, to write into the Compose file. */
30
+ latestDigest?: string;
31
+ }
32
+
33
+ export interface UpdateReport {
34
+ checkedAt: string;
35
+ stacks: number;
36
+ images: number;
37
+ counts: Record<UpdateStatus, number>;
38
+ rows: UpdateRow[];
39
+ table: string;
40
+ }
41
+
42
+ export interface TagVersion {
43
+ tag: string;
44
+ prefix: string;
45
+ nums: number[];
46
+ suffix: string;
47
+ }
48
+
49
+ const PRERELEASE = /alpha|beta|rc|dev|nightly|develop|preview|test|snapshot|canary|edge|unstable|pr-/i;
50
+ /** Registry lookups per image reference; enough to walk back a few weeks of releases. */
51
+ const DIGEST_BUDGET = 30;
52
+ const CONCURRENCY = 8;
53
+ /** Tags searched per release shape when matching a digest; the newest builds come first. */
54
+ const PER_SHAPE = 10;
55
+
56
+ /** A tag that reads as a release version: optional `v`, dotted numbers, optional suffix such as `-alpine` or `-ls123`. */
57
+ export function parseTag(tag: string): TagVersion | null {
58
+ const m = /^(v?)(\d+(?:\.\d+)*)([-_+.][A-Za-z0-9._+-]*)?$/.exec(tag);
59
+ if (!m) return null;
60
+ const suffix = m[3] ?? "";
61
+ if (PRERELEASE.test(suffix)) return null;
62
+ return { tag, prefix: m[1], nums: m[2].split(".").map(Number), suffix };
63
+ }
64
+
65
+ function suffixShape(t: TagVersion): string {
66
+ return t.suffix.replace(/\d+/g, "#");
67
+ }
68
+
69
+ /** Tags of one release line compare with each other: same prefix, number count, and suffix apart from its digits. */
70
+ export function shapeKey(t: TagVersion): string {
71
+ return `${t.prefix}|${t.nums.length}|${suffixShape(t)}`;
72
+ }
73
+
74
+ export function compareVersions(a: TagVersion, b: TagVersion): number {
75
+ const n = Math.max(a.nums.length, b.nums.length);
76
+ for (let i = 0; i < n; i++) {
77
+ const d = (a.nums[i] ?? -1) - (b.nums[i] ?? -1);
78
+ if (d !== 0) return d;
79
+ }
80
+ const sa = Number(/(\d+)$/.exec(a.suffix)?.[1] ?? -1);
81
+ const sb = Number(/(\d+)$/.exec(b.suffix)?.[1] ?? -1);
82
+ return sa - sb;
83
+ }
84
+
85
+ const byVersionDesc = (a: TagVersion, b: TagVersion) => compareVersions(b, a);
86
+
87
+ export function classify(ref: ImageRef): RefKind {
88
+ if (ref.digest) return "pinned";
89
+ const v = ref.tag ? parseTag(ref.tag) : null;
90
+ if (!v) return "floating";
91
+ return v.nums.length === 1 ? "series" : "fixed";
92
+ }
93
+
94
+ /** Release tags a floating tag such as `latest` can resolve to: plain versions when the image has them, else its most common release shape. */
95
+ export function releaseCandidates(tags: TagVersion[]): TagVersion[] {
96
+ const multi = tags.filter((t) => t.nums.length >= 2);
97
+ const plain = multi.filter((t) => t.suffix === "");
98
+ let pool = plain;
99
+ if (pool.length === 0) {
100
+ const counts = new Map<string, number>();
101
+ for (const t of multi) counts.set(shapeKey(t), (counts.get(shapeKey(t)) ?? 0) + 1);
102
+ const dominant = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
103
+ pool = multi.filter((t) => shapeKey(t) === dominant);
104
+ }
105
+ return [...pool].sort(byVersionDesc);
106
+ }
107
+
108
+ /**
109
+ * Order for matching a digest to a tag: the newest few of each release shape,
110
+ * the preferred shape first. An older build often survives only under a more
111
+ * specific tag (linuxserver's `-ls129`) once the plain version tag moved on.
112
+ */
113
+ export function searchOrder(preferred: TagVersion[], all: TagVersion[]): TagVersion[] {
114
+ const byShape = new Map<string, TagVersion[]>();
115
+ for (const t of all.filter((t) => t.nums.length >= 2)) {
116
+ const list = byShape.get(shapeKey(t)) ?? [];
117
+ list.push(t);
118
+ byShape.set(shapeKey(t), list);
119
+ }
120
+ const shapes = [...byShape.entries()].sort((a, b) => b[1].length - a[1].length).slice(0, 3);
121
+ const seen = new Set<string>();
122
+ const out: TagVersion[] = [];
123
+ for (const t of [...preferred.slice(0, PER_SHAPE), ...shapes.flatMap(([, list]) => [...list].sort(byVersionDesc).slice(0, PER_SHAPE))]) {
124
+ if (!seen.has(t.tag)) {
125
+ seen.add(t.tag);
126
+ out.push(t);
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+
132
+ function majorChange(running: string, latest: string): boolean {
133
+ const a = parseTag(running);
134
+ const b = parseTag(latest);
135
+ return a !== null && b !== null && b.nums[0] > a.nums[0];
136
+ }
137
+
138
+ function shortImage(ref: ImageRef): string {
139
+ return ref.repository.split("/").pop() ?? ref.repository;
140
+ }
141
+
142
+ function meaningfulVersion(value: string | undefined): string | null {
143
+ return value && value !== "latest" ? value : null;
144
+ }
145
+
146
+ interface Resolution {
147
+ running: string;
148
+ latest: string;
149
+ status: UpdateStatus;
150
+ action: string;
151
+ note: string;
152
+ latestDigest?: string;
153
+ }
154
+
155
+ /** Resolves one image reference to running and latest versions from the fresh check and the registry's tags. */
156
+ export async function resolveRef(refText: string, entry: CheckEntry | undefined, registry: Registry, startedAt: number): Promise<Resolution & { kind: RefKind }> {
157
+ const ref = parseImageRef(refText);
158
+ const kind = classify(ref);
159
+ const unknown = (note: string, running = "?"): Resolution & { kind: RefKind } => ({ kind, running, latest: "?", status: "unknown", action: "", note });
160
+ if (kind !== "pinned") {
161
+ if (!entry) return unknown("not in the fresh check");
162
+ if (entry.error) return unknown(`check failed: ${entry.error}`);
163
+ const checked = entry.checkTime ? Date.parse(entry.checkTime) : Number.NaN;
164
+ if (Number.isNaN(checked) || checked < startedAt - 60_000) return unknown(`stale check from ${entry.checkTime ?? "an unknown time"}`);
165
+ }
166
+ const digestOnly = (reason: string): Resolution & { kind: RefKind } => {
167
+ if (kind === "pinned") return unknown(`pinned; versions unavailable: ${reason}`);
168
+ const running = (kind === "fixed" ? ref.tag : meaningfulVersion(entry?.currentVersion)) ?? "?";
169
+ if (!entry?.hasUpdate) return { kind, running, latest: running, status: "up-to-date", action: "", note: `versions unavailable: ${reason}` };
170
+ return { kind, running, latest: "new build", status: "update", action: "pull", note: `versions unavailable: ${reason}` };
171
+ };
172
+
173
+ let tags: TagVersion[];
174
+ try {
175
+ tags = (await registry.tags(ref)).map(parseTag).filter((t): t is TagVersion => t !== null);
176
+ } catch (err) {
177
+ const status = err instanceof RegistryError ? err.status : 0;
178
+ return digestOnly(status === 401 || status === 403 ? "private image, the registry needs credentials" : err instanceof Error ? err.message : String(err));
179
+ }
180
+
181
+ const digests = new Map<string, string | null>();
182
+ let budget = DIGEST_BUDGET;
183
+ const digestOf = async (tag: string): Promise<string | null> => {
184
+ if (digests.has(tag)) return digests.get(tag) ?? null;
185
+ if (budget <= 0) return null;
186
+ budget--;
187
+ const d = await registry.digest(ref, tag).catch(() => null);
188
+ digests.set(tag, d);
189
+ return d;
190
+ };
191
+ const findTag = async (digest: string | undefined | null, candidates: TagVersion[]): Promise<string | null> => {
192
+ if (!digest) return null;
193
+ for (const c of candidates) {
194
+ if (budget <= 0 && !digests.has(c.tag)) return null;
195
+ if ((await digestOf(c.tag)) === digest) return c.tag;
196
+ }
197
+ return null;
198
+ };
199
+
200
+ if (kind === "fixed") {
201
+ const current = parseTag(ref.tag as string) as TagVersion;
202
+ const newest = tags.filter((t) => shapeKey(t) === shapeKey(current)).sort(byVersionDesc)[0];
203
+ if (newest && compareVersions(newest, current) > 0) return { kind, running: current.tag, latest: newest.tag, status: "release", action: "edit tag", note: "" };
204
+ if (entry?.hasUpdate) return { kind, running: current.tag, latest: current.tag, status: "rebuild", action: "pull", note: "same tag, new build" };
205
+ return { kind, running: current.tag, latest: current.tag, status: "up-to-date", action: "", note: "" };
206
+ }
207
+
208
+ if (kind === "series") {
209
+ const series = parseTag(ref.tag as string) as TagVersion;
210
+ const members = tags
211
+ .filter((t) => t.prefix === series.prefix && t.nums.length >= 2 && t.nums[0] === series.nums[0] && suffixShape(t) === suffixShape(series))
212
+ .sort(byVersionDesc);
213
+ const newerSeries = tags.filter((t) => shapeKey(t) === shapeKey(series) && t.nums[0] > series.nums[0]).sort(byVersionDesc)[0];
214
+ const note = newerSeries ? `newer major ${newerSeries.tag} available (tag change)` : "";
215
+ const running = (await findTag(entry?.currentDigest, searchOrder(members, tags.filter((t) => t.nums[0] === series.nums[0] && t.prefix === series.prefix)))) ?? meaningfulVersion(entry?.currentVersion) ?? `${series.tag} (older build)`;
216
+ if (!entry?.hasUpdate) return { kind, running, latest: running, status: "up-to-date", action: "", note };
217
+ const latest = (await findTag(entry.latestDigest, members)) ?? "new build";
218
+ return { kind, running, latest, status: "update", action: "pull", note };
219
+ }
220
+
221
+ const candidates = releaseCandidates(tags);
222
+ if (kind === "pinned") {
223
+ const newest = candidates[0];
224
+ if (!newest) return unknown("pinned; the registry lists no release tags");
225
+ const running = (await findTag(ref.digest, searchOrder(candidates, tags))) ?? "?";
226
+ const newestDigest = await digestOf(newest.tag);
227
+ if (newestDigest && newestDigest !== ref.digest) return { kind, running, latest: newest.tag, status: "update", action: "edit digest", note: "", latestDigest: newestDigest };
228
+ return { kind, running, latest: running === "?" ? newest.tag : running, status: "up-to-date", action: "", note: "" };
229
+ }
230
+
231
+ const order = searchOrder(candidates, tags);
232
+ if (!entry?.hasUpdate) {
233
+ const running = (await findTag(entry?.currentDigest, order)) ?? meaningfulVersion(entry?.currentVersion) ?? "?";
234
+ return { kind, running, latest: running, status: "up-to-date", action: "", note: "" };
235
+ }
236
+ // The latest build is nearly always the newest tag: resolve it first so the search budget goes to the older running build.
237
+ let latest = (await findTag(entry.latestDigest, order)) ?? "new build";
238
+ const running = (await findTag(entry.currentDigest, order)) ?? meaningfulVersion(entry.currentVersion) ?? "older build";
239
+ // Name both builds in the same form, so "2.6.5.5623-ls161 -> 2.6.5.5623-ls162" shows a rebuild of one release.
240
+ const r = parseTag(running);
241
+ const l = parseTag(latest);
242
+ if (r && l && shapeKey(r) !== shapeKey(l)) {
243
+ const sameShape = tags.filter((t) => shapeKey(t) === shapeKey(r)).sort(byVersionDesc).slice(0, PER_SHAPE);
244
+ latest = (await findTag(entry.latestDigest, sameShape)) ?? latest;
245
+ }
246
+ return { kind, running, latest, status: "update", action: "pull", note: majorChange(running, latest) ? "major version change: check the release notes before pulling" : "" };
247
+ }
248
+
249
+ async function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
250
+ const out: R[] = new Array<R>(items.length);
251
+ let next = 0;
252
+ const worker = async () => {
253
+ while (next < items.length) {
254
+ const i = next++;
255
+ out[i] = await fn(items[i]);
256
+ }
257
+ };
258
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
259
+ return out;
260
+ }
261
+
262
+ /** The dictionary of check results, with or without Arcane's response envelope. */
263
+ export function checkEntries(raw: unknown): Record<string, CheckEntry> {
264
+ if (typeof raw !== "object" || raw === null) return {};
265
+ const r = raw as Record<string, unknown>;
266
+ const body = typeof r.data === "object" && r.data !== null && !Array.isArray(r.data) ? (r.data as Record<string, unknown>) : r;
267
+ const out: Record<string, CheckEntry> = {};
268
+ for (const [key, value] of Object.entries(body)) {
269
+ if (typeof value === "object" && value !== null && "hasUpdate" in value) out[key] = value as CheckEntry;
270
+ }
271
+ return out;
272
+ }
273
+
274
+ interface StackService {
275
+ stack: string;
276
+ service: string;
277
+ ref: string | null;
278
+ stopped: boolean;
279
+ }
280
+
281
+ /** Stack and service names with their image references from `projects list --all --json`. */
282
+ export function stackServices(raw: unknown): StackService[] {
283
+ const data = (raw as { data?: unknown } | null)?.data;
284
+ if (!Array.isArray(data)) return [];
285
+ const out: StackService[] = [];
286
+ for (const p of data as Record<string, unknown>[]) {
287
+ const stack = String(p.name ?? "?");
288
+ const stopped = p.status === "stopped";
289
+ const services = (p.updateInfo as { serviceUpdates?: Record<string, { imageRef?: string }> } | undefined)?.serviceUpdates;
290
+ if (!services || Object.keys(services).length === 0) {
291
+ out.push({ stack, service: stack, ref: null, stopped });
292
+ continue;
293
+ }
294
+ for (const [service, s] of Object.entries(services)) out.push({ stack, service, ref: s.imageRef ?? null, stopped });
295
+ }
296
+ return out;
297
+ }
298
+
299
+ const STATUS_ORDER: Record<UpdateStatus, number> = { update: 0, release: 1, rebuild: 2, unknown: 3, "up-to-date": 4 };
300
+
301
+ function cell(text: string, width: number): string {
302
+ return text.length > width ? `${text.slice(0, width - 1)}…` : text.padEnd(width);
303
+ }
304
+
305
+ /** Fixed-width table for a code block: renders the same in Discord and a terminal. */
306
+ export function renderTable(rows: UpdateRow[], summary: string): string {
307
+ const head = ["Stack", "Image", "Running", "Latest", "Update"];
308
+ const body = rows.map((r) => [r.service === r.stack ? r.stack : `${r.stack}/${r.service}`, r.image, r.running, r.latest, r.status === "unknown" ? "unknown" : r.action || "-"]);
309
+ const limits = [26, 18, 20, 20, 11];
310
+ const widths = head.map((h, i) => Math.min(limits[i], Math.max(h.length, ...body.map((b) => b[i].length))));
311
+ const line = (cols: string[]) => cols.map((c, i) => cell(c, widths[i])).join(" ").trimEnd();
312
+ const lines = [line(head), widths.map((w) => "-".repeat(w)).join(" "), ...body.map(line)];
313
+ const notes = rows.filter((r) => r.note).map((r) => `* ${r.service === r.stack ? r.stack : `${r.stack}/${r.service}`}: ${r.note}`);
314
+ return [summary, "", ...lines, ...(notes.length > 0 ? ["", ...notes] : [])].join("\n");
315
+ }
316
+
317
+ /** Shares tag lists and digests between references to the same repository, such as `postgres:17` and `postgres:18`. */
318
+ export function cachingRegistry(inner: Registry): Registry {
319
+ const tags = new Map<string, Promise<string[]>>();
320
+ const digests = new Map<string, Promise<string | null>>();
321
+ const key = (ref: ImageRef) => `${ref.registry}/${ref.repository}`;
322
+ return {
323
+ tags(ref) {
324
+ const k = key(ref);
325
+ if (!tags.has(k)) tags.set(k, inner.tags(ref));
326
+ return tags.get(k) as Promise<string[]>;
327
+ },
328
+ digest(ref, tag) {
329
+ const k = `${key(ref)}:${tag}`;
330
+ if (!digests.has(k)) digests.set(k, inner.digest(ref, tag));
331
+ return digests.get(k) as Promise<string | null>;
332
+ },
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Builds the update report: every service image of every stack, against a check
338
+ * that ran moments before, with release versions read from the registries.
339
+ */
340
+ export async function buildUpdateReport(input: { check: unknown; projects: unknown; registry: Registry; startedAt: number; all: boolean }): Promise<UpdateReport> {
341
+ const entries = checkEntries(input.check);
342
+ const services = stackServices(input.projects);
343
+ const refs = [...new Set(services.map((s) => s.ref).filter((r): r is string => r !== null))];
344
+ const resolved = new Map<string, Resolution & { kind: RefKind }>();
345
+ const registry = cachingRegistry(input.registry);
346
+ await mapLimit(refs, CONCURRENCY, async (ref) => {
347
+ resolved.set(ref, await resolveRef(ref, entries[ref], registry, input.startedAt));
348
+ });
349
+ const rows: UpdateRow[] = services.map((s) => {
350
+ if (s.ref === null) return { stack: s.stack, service: s.service, ref: "", image: "?", kind: "floating", running: "?", latest: "?", status: "unknown", action: "", note: "Arcane lists no images for this stack" };
351
+ const r = resolved.get(s.ref) as Resolution & { kind: RefKind };
352
+ const note = [r.note, s.stopped ? "stack is stopped" : ""].filter(Boolean).join("; ");
353
+ return { stack: s.stack, service: s.service, ref: s.ref, image: shortImage(parseImageRef(s.ref)), kind: r.kind, running: r.running, latest: r.latest, status: r.status, action: r.action, note, ...(r.latestDigest ? { latestDigest: r.latestDigest } : {}) };
354
+ });
355
+ rows.sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || a.stack.localeCompare(b.stack) || a.service.localeCompare(b.service));
356
+ const counts: Record<UpdateStatus, number> = { update: 0, release: 0, rebuild: 0, unknown: 0, "up-to-date": 0 };
357
+ for (const r of rows) counts[r.status]++;
358
+ const shown = input.all ? rows : rows.filter((r) => r.status !== "up-to-date" || r.note.includes("newer major"));
359
+ const checkedAt = new Date(input.startedAt).toISOString().replace(/\.\d+Z$/, "Z");
360
+ const stacks = new Set(services.map((s) => s.stack)).size;
361
+ const summary =
362
+ `Fresh check ${checkedAt}: ${services.length} services in ${stacks} stacks, ${refs.length} images. ` +
363
+ `${counts.update} new builds, ${counts.release} new releases, ${counts.rebuild} rebuilds, ${counts.unknown} unknown, ${counts["up-to-date"]} up to date` +
364
+ (input.all ? "." : " (not listed unless a newer major exists).");
365
+ return { checkedAt, stacks, images: refs.length, counts, rows: shown, table: renderTable(shown, summary) };
366
+ }
@@ -5,6 +5,11 @@ import { printError } from "./output.ts";
5
5
 
6
6
  export type Command = (args: string[]) => Promise<number>;
7
7
 
8
+ /** Resolves once everything written so far has left the process; on a pipe, process.exit would drop what is still queued past the 64 KiB buffer. */
9
+ export function flushed(stream: NodeJS.WritableStream): Promise<void> {
10
+ return new Promise((resolve) => stream.write("", () => resolve()));
11
+ }
12
+
8
13
  /** Shared entry: parse argv, run, audit, map errors to exit codes. */
9
14
  export async function main(service: string, command: Command, argv = process.argv.slice(2)): Promise<never> {
10
15
  const started = Date.now();
@@ -30,5 +35,6 @@ export async function main(service: string, command: Command, argv = process.arg
30
35
  } catch (err) {
31
36
  printError(`${service}: audit log failed: ${err instanceof Error ? err.message : String(err)}`);
32
37
  }
38
+ await Promise.all([flushed(process.stdout), flushed(process.stderr)]);
33
39
  process.exit(code);
34
40
  }
@@ -0,0 +1,153 @@
1
+ import { ServiceError } from "./errors.ts";
2
+
3
+ /** A container image reference split into the parts the registry API needs. */
4
+ export interface ImageRef {
5
+ registry: string;
6
+ repository: string;
7
+ tag: string | null;
8
+ digest: string | null;
9
+ }
10
+
11
+ const DEFAULT_REGISTRY = "docker.io";
12
+
13
+ /** Parses `[registry/]repo[:tag][@digest]` the way Docker resolves it. */
14
+ export function parseImageRef(ref: string): ImageRef {
15
+ let rest = ref.trim();
16
+ let digest: string | null = null;
17
+ const at = rest.indexOf("@");
18
+ if (at >= 0) {
19
+ digest = rest.slice(at + 1);
20
+ rest = rest.slice(0, at);
21
+ }
22
+ let tag: string | null = null;
23
+ const colon = rest.lastIndexOf(":");
24
+ if (colon > rest.lastIndexOf("/")) {
25
+ tag = rest.slice(colon + 1);
26
+ rest = rest.slice(0, colon);
27
+ }
28
+ const parts = rest.split("/");
29
+ let registry = DEFAULT_REGISTRY;
30
+ if (parts.length > 1 && (parts[0].includes(".") || parts[0].includes(":") || parts[0] === "localhost")) registry = parts.shift() as string;
31
+ let repository = parts.join("/");
32
+ if (registry === DEFAULT_REGISTRY && !repository.includes("/")) repository = `library/${repository}`;
33
+ if (tag === null && digest === null) tag = "latest";
34
+ return { registry, repository, tag, digest };
35
+ }
36
+
37
+ /** Host that serves the registry API; lscr.io images are the linuxserver ones published on ghcr.io. */
38
+ export function apiHost(registry: string): string {
39
+ if (registry === "docker.io") return "registry-1.docker.io";
40
+ if (registry === "lscr.io") return "ghcr.io";
41
+ return registry;
42
+ }
43
+
44
+ /** What the update report needs from a registry. */
45
+ export interface Registry {
46
+ tags(ref: ImageRef): Promise<string[]>;
47
+ /** Manifest digest of one tag, or null when the tag does not exist. */
48
+ digest(ref: ImageRef, tag: string): Promise<string | null>;
49
+ }
50
+
51
+ export class RegistryError extends ServiceError {
52
+ readonly status: number;
53
+ constructor(message: string, status: number) {
54
+ super(message);
55
+ this.status = status;
56
+ }
57
+ }
58
+
59
+ const MANIFEST_ACCEPT = [
60
+ "application/vnd.oci.image.index.v1+json",
61
+ "application/vnd.docker.distribution.manifest.list.v2+json",
62
+ "application/vnd.oci.image.manifest.v1+json",
63
+ "application/vnd.docker.distribution.manifest.v2+json",
64
+ ].join(", ");
65
+
66
+ const MAX_TAG_PAGES = 30;
67
+
68
+ /** Parses `Bearer realm="...",service="...",scope="..."` into its parameters. */
69
+ export function parseChallenge(header: string): Record<string, string> | null {
70
+ if (!/^bearer\s/i.test(header)) return null;
71
+ const params: Record<string, string> = {};
72
+ for (const m of header.slice(7).matchAll(/(\w+)="([^"]*)"/g)) params[m[1].toLowerCase()] = m[2];
73
+ return params.realm ? params : null;
74
+ }
75
+
76
+ /** Relative or absolute URL of the `rel="next"` page in a Link header. */
77
+ export function nextLink(header: string | null): string | null {
78
+ if (!header) return null;
79
+ const m = /<([^>]+)>\s*;\s*rel="?next"?/i.exec(header);
80
+ return m ? m[1] : null;
81
+ }
82
+
83
+ /**
84
+ * Anonymous client for the OCI distribution API: bearer tokens from the
85
+ * registry's own challenge, tag lists with pagination, and manifest digests by
86
+ * HEAD, which Docker Hub does not count as a pull.
87
+ */
88
+ export class HttpRegistry implements Registry {
89
+ private readonly tokens = new Map<string, string>();
90
+ private readonly fetchImpl: typeof fetch;
91
+ private readonly timeoutMs: number;
92
+
93
+ constructor(fetchImpl: typeof fetch = fetch, timeoutMs = 15_000) {
94
+ this.fetchImpl = fetchImpl;
95
+ this.timeoutMs = timeoutMs;
96
+ }
97
+
98
+ async tags(ref: ImageRef): Promise<string[]> {
99
+ const host = apiHost(ref.registry);
100
+ const all: string[] = [];
101
+ let path: string | null = `/v2/${ref.repository}/tags/list?n=1000`;
102
+ for (let page = 0; path !== null && page < MAX_TAG_PAGES; page++) {
103
+ const res = await this.request(ref, path, "GET", "application/json");
104
+ if (!res.ok) throw new RegistryError(`${host}/${ref.repository}: tag list returned ${res.status}`, res.status);
105
+ const body = (await res.json()) as { tags?: string[] | null };
106
+ all.push(...(body.tags ?? []));
107
+ const next = nextLink(res.headers.get("link"));
108
+ path = next === null ? null : next.startsWith("http") ? new URL(next).pathname + new URL(next).search : next;
109
+ }
110
+ return all;
111
+ }
112
+
113
+ async digest(ref: ImageRef, tag: string): Promise<string | null> {
114
+ const res = await this.request(ref, `/v2/${ref.repository}/manifests/${encodeURIComponent(tag)}`, "HEAD", MANIFEST_ACCEPT);
115
+ if (res.status === 404) return null;
116
+ if (!res.ok) throw new RegistryError(`${apiHost(ref.registry)}/${ref.repository}:${tag}: manifest returned ${res.status}`, res.status);
117
+ return res.headers.get("docker-content-digest");
118
+ }
119
+
120
+ private async request(ref: ImageRef, path: string, method: string, accept: string): Promise<Response> {
121
+ const host = apiHost(ref.registry);
122
+ const key = `${host}/${ref.repository}`;
123
+ const send = (token: string | undefined) =>
124
+ this.fetchImpl(`https://${host}${path}`, {
125
+ method,
126
+ headers: { accept, "user-agent": "pi-astro-arcane-updates", ...(token ? { authorization: `Bearer ${token}` } : {}) },
127
+ signal: AbortSignal.timeout(this.timeoutMs),
128
+ });
129
+ let res = await send(this.tokens.get(key));
130
+ if (res.status === 401) {
131
+ const challenge = parseChallenge(res.headers.get("www-authenticate") ?? "");
132
+ if (!challenge) throw new RegistryError(`${key}: authentication required`, 401);
133
+ const token = await this.token(challenge, ref);
134
+ this.tokens.set(key, token);
135
+ res = await send(token);
136
+ }
137
+ if (res.status === 429) throw new RegistryError(`${host}: rate limited`, 429);
138
+ if (res.status === 401 || res.status === 403) throw new RegistryError(`${key}: private or not found`, res.status);
139
+ return res;
140
+ }
141
+
142
+ private async token(challenge: Record<string, string>, ref: ImageRef): Promise<string> {
143
+ const url = new URL(challenge.realm);
144
+ if (challenge.service) url.searchParams.set("service", challenge.service);
145
+ url.searchParams.set("scope", challenge.scope ?? `repository:${ref.repository}:pull`);
146
+ const res = await this.fetchImpl(url, { headers: { "user-agent": "pi-astro-arcane-updates" }, signal: AbortSignal.timeout(this.timeoutMs) });
147
+ if (!res.ok) throw new RegistryError(`${apiHost(ref.registry)}/${ref.repository}: token request returned ${res.status}`, res.status);
148
+ const body = (await res.json()) as { token?: string; access_token?: string };
149
+ const token = body.token ?? body.access_token;
150
+ if (!token) throw new RegistryError(`${apiHost(ref.registry)}/${ref.repository}: token response had no token`, 401);
151
+ return token;
152
+ }
153
+ }
@@ -32,7 +32,12 @@ describe("main", () => {
32
32
  process.env.HOME = home;
33
33
  mkdirSync(join(home, ".specialists"), { recursive: true });
34
34
  exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
35
- stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
35
+ // Like a real stream, call the write callback: main waits for it before exiting.
36
+ stderr = vi.spyOn(process.stderr, "write").mockImplementation(((...args: unknown[]) => {
37
+ const done = args.find((a) => typeof a === "function") as (() => void) | undefined;
38
+ done?.();
39
+ return true;
40
+ }) as typeof process.stderr.write);
36
41
  });
37
42
 
38
43
  afterEach(() => {