@astrofoundry/pi-astro 0.28.0 → 0.28.1
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/package.json
CHANGED
package/skills/arcane/SKILL.md
CHANGED
|
@@ -36,7 +36,7 @@ The output is JSON with `counts`, `rows`, and `table`. Answer with the `table` f
|
|
|
36
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
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
38
|
- A note "major version change" or "newer major ... available" means the update crosses a major version. Read its release notes yourself before asking anything (next section).
|
|
39
|
-
-
|
|
39
|
+
- Private images (the registry refuses anonymous access, such as our own Grimoire builds) are left out of the table and the counts; the summary line counts them and `privateImages` lists them. Mention them only when the caller asks.
|
|
40
40
|
|
|
41
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
42
|
|
package/specialists/AGENTS.md
CHANGED
|
@@ -54,7 +54,7 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
|
|
|
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
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
|
|
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 (anonymous access refused with 401 or 403) are excluded from rows, counts, and table, listed in `privateImages`, and counted in the summary line. The table is fixed-width text for a code block, because Discord renders no Markdown tables.
|
|
58
58
|
- `arcane release-notes <stack>[/<service>]` is `lib/releases.ts`: a single-image check and `resolveRef` give the running and latest versions, `HttpRegistry.labels` reads `org.opencontainers.image.source` from the latest image (index to linux/amd64 manifest to config blob), and the anonymous GitHub releases API (60 requests an hour, up to 3 pages of 100) gives the releases in between, oldest first. `condenseNotes` keeps each release under 12,000 characters by section priority; `capTotal` drops the oldest releases past 40,000 characters in total.
|
|
59
59
|
|
|
60
60
|
## Gate
|
|
@@ -100,7 +100,7 @@ describe("resolveRef", () => {
|
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
it("falls back to the digest result for private images and marks bad checks unknown", async () => {
|
|
103
|
-
expect(await resolveRef("ghcr.io/astrofoundry/app:main", entry("A1", "A2"), registry, NOW)).toMatchObject({ status: "update", latest: "new build",
|
|
103
|
+
expect(await resolveRef("ghcr.io/astrofoundry/app:main", entry("A1", "A2"), registry, NOW)).toMatchObject({ status: "update", latest: "new build", private: true });
|
|
104
104
|
expect(await resolveRef("nginx:1.31.4-alpine", undefined, registry, NOW)).toMatchObject({ status: "unknown", note: "not in the fresh check" });
|
|
105
105
|
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") });
|
|
106
106
|
expect(await resolveRef("nginx:1.31.4-alpine", entry("N", "N", { error: "timeout" }), registry, NOW)).toMatchObject({ status: "unknown", note: "check failed: timeout" });
|
|
@@ -120,17 +120,21 @@ describe("buildUpdateReport", () => {
|
|
|
120
120
|
{ name: "profiles", status: "running", updateInfo: { serviceUpdates: { profiles: { imageRef: "nginx:1.31.4-alpine" } } } },
|
|
121
121
|
{ name: "canary", status: "stopped", updateInfo: { serviceUpdates: { canary: { imageRef: "busybox:latest" } } } },
|
|
122
122
|
{ name: "empty", status: "running" },
|
|
123
|
+
{ name: "ours", status: "running", updateInfo: { serviceUpdates: { api: { imageRef: "ghcr.io/astrofoundry/app:main" }, worker: { imageRef: "ghcr.io/astrofoundry/app:main" } } } },
|
|
123
124
|
],
|
|
124
125
|
};
|
|
125
126
|
const check = {
|
|
126
127
|
"lscr.io/linuxserver/prowlarr:latest": entry("D1", "D2"),
|
|
127
128
|
"nginx:1.31.4-alpine": entry("N", "N"),
|
|
128
129
|
"busybox:latest": entry("B", "B"),
|
|
130
|
+
"ghcr.io/astrofoundry/app:main": entry("A1", "A2"),
|
|
129
131
|
};
|
|
130
132
|
const report = await buildUpdateReport({ check, projects, registry, startedAt: NOW, all: false });
|
|
131
133
|
expect(report.counts).toEqual({ update: 1, release: 0, rebuild: 0, unknown: 1, "up-to-date": 2 });
|
|
132
134
|
expect(report.rows.map((r) => `${r.stack}/${r.service}:${r.status}`)).toEqual(["media/prowlarr:update", "empty/empty:unknown"]);
|
|
133
|
-
expect(report.table).toMatch(/^Fresh check 2026-09-25T18:30:00Z: 4 services in
|
|
135
|
+
expect(report.table).toMatch(/^Fresh check 2026-09-25T18:30:00Z: 4 services in 5 stacks, 3 images\. 1 private images \(2 services\) excluded\./);
|
|
136
|
+
expect(report.privateImages).toEqual(["ghcr.io/astrofoundry/app:main"]);
|
|
137
|
+
expect(report.rows.some((r) => r.stack === "ours")).toBe(false);
|
|
134
138
|
expect(report.table).toContain("media/prowlarr prowlarr 2.0.5.5160-ls123 2.1.0.5190-ls124 pull");
|
|
135
139
|
expect(report.table).toContain("* empty: Arcane lists no images for this stack");
|
|
136
140
|
const all = await buildUpdateReport({ check, projects, registry, startedAt: NOW, all: true });
|
|
@@ -34,6 +34,8 @@ export interface UpdateReport {
|
|
|
34
34
|
checkedAt: string;
|
|
35
35
|
stacks: number;
|
|
36
36
|
images: number;
|
|
37
|
+
/** Private image references, excluded from `rows`, `counts`, and the table. */
|
|
38
|
+
privateImages: string[];
|
|
37
39
|
counts: Record<UpdateStatus, number>;
|
|
38
40
|
rows: UpdateRow[];
|
|
39
41
|
table: string;
|
|
@@ -150,6 +152,8 @@ interface Resolution {
|
|
|
150
152
|
action: string;
|
|
151
153
|
note: string;
|
|
152
154
|
latestDigest?: string;
|
|
155
|
+
/** The registry refused anonymous access: an image of our own, left out of the report. */
|
|
156
|
+
private?: boolean;
|
|
153
157
|
}
|
|
154
158
|
|
|
155
159
|
/** Resolves one image reference to running and latest versions from the fresh check and the registry's tags. */
|
|
@@ -175,7 +179,8 @@ export async function resolveRef(refText: string, entry: CheckEntry | undefined,
|
|
|
175
179
|
tags = (await registry.tags(ref)).map(parseTag).filter((t): t is TagVersion => t !== null);
|
|
176
180
|
} catch (err) {
|
|
177
181
|
const status = err instanceof RegistryError ? err.status : 0;
|
|
178
|
-
|
|
182
|
+
if (status === 401 || status === 403) return { ...digestOnly("private image, the registry needs credentials"), private: true };
|
|
183
|
+
return digestOnly(err instanceof Error ? err.message : String(err));
|
|
179
184
|
}
|
|
180
185
|
|
|
181
186
|
const digests = new Map<string, string | null>();
|
|
@@ -243,7 +248,7 @@ export async function resolveRef(refText: string, entry: CheckEntry | undefined,
|
|
|
243
248
|
const sameShape = tags.filter((t) => shapeKey(t) === shapeKey(r)).sort(byVersionDesc).slice(0, PER_SHAPE);
|
|
244
249
|
latest = (await findTag(entry.latestDigest, sameShape)) ?? latest;
|
|
245
250
|
}
|
|
246
|
-
return { kind, running, latest, status: "update", action: "pull", note: majorChange(running, latest) ? "major version change:
|
|
251
|
+
return { kind, running, latest, status: "update", action: "pull", note: majorChange(running, latest) ? "major version change: read its release notes before pulling" : "" };
|
|
247
252
|
}
|
|
248
253
|
|
|
249
254
|
async function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
@@ -347,7 +352,9 @@ export async function buildUpdateReport(input: { check: unknown; projects: unkno
|
|
|
347
352
|
await mapLimit(refs, CONCURRENCY, async (ref) => {
|
|
348
353
|
resolved.set(ref, await resolveRef(ref, entries[ref], registry, input.startedAt));
|
|
349
354
|
});
|
|
350
|
-
const
|
|
355
|
+
const privateImages = refs.filter((ref) => resolved.get(ref)?.private === true);
|
|
356
|
+
const listed = services.filter((s) => s.ref === null || !privateImages.includes(s.ref));
|
|
357
|
+
const rows: UpdateRow[] = listed.map((s) => {
|
|
351
358
|
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" };
|
|
352
359
|
const r = resolved.get(s.ref) as Resolution & { kind: RefKind };
|
|
353
360
|
const note = [r.note, s.stopped ? "stack is stopped" : ""].filter(Boolean).join("; ");
|
|
@@ -359,9 +366,10 @@ export async function buildUpdateReport(input: { check: unknown; projects: unkno
|
|
|
359
366
|
const shown = input.all ? rows : rows.filter((r) => r.status !== "up-to-date" || r.note.includes("newer major"));
|
|
360
367
|
const checkedAt = new Date(input.startedAt).toISOString().replace(/\.\d+Z$/, "Z");
|
|
361
368
|
const stacks = new Set(services.map((s) => s.stack)).size;
|
|
369
|
+
const excluded = privateImages.length === 0 ? "" : ` ${privateImages.length} private images (${services.length - listed.length} services) excluded.`;
|
|
362
370
|
const summary =
|
|
363
|
-
`Fresh check ${checkedAt}: ${
|
|
371
|
+
`Fresh check ${checkedAt}: ${listed.length} services in ${stacks} stacks, ${refs.length - privateImages.length} images.${excluded} ` +
|
|
364
372
|
`${counts.update} new builds, ${counts.release} new releases, ${counts.rebuild} rebuilds, ${counts.unknown} unknown, ${counts["up-to-date"]} up to date` +
|
|
365
373
|
(input.all ? "." : " (not listed unless a newer major exists).");
|
|
366
|
-
return { checkedAt, stacks, images: refs.length, counts, rows: shown, table: renderTable(shown, summary) };
|
|
374
|
+
return { checkedAt, stacks, images: refs.length - privateImages.length, privateImages, counts, rows: shown, table: renderTable(shown, summary) };
|
|
367
375
|
}
|