@astrofoundry/pi-astro 0.32.0 → 0.33.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.
@@ -0,0 +1,109 @@
1
+ import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { configDir } from "./config.ts";
4
+ import type { Command, Service } from "./tools-report.ts";
5
+
6
+ /** A service that needs a restart before an update takes effect, waiting until nothing runs on it. */
7
+ export interface PendingRestart {
8
+ service: Service;
9
+ /** When the first update that needs this restart finished. */
10
+ since: string;
11
+ /** What was updated, for the messages. */
12
+ reasons: string[];
13
+ /** The "Restart now" message posted after a day of waiting. */
14
+ prompt?: { channelId: string; messageId: string };
15
+ }
16
+
17
+ /** After this long a busy service gets a Restart now button, so an update is never stuck behind a session that never ends. */
18
+ export const RESTART_WAIT_MS = 24 * 60 * 60_000;
19
+ /** Herdr and Astrogate first: restarting the bridge ends this process. */
20
+ export const SERVICE_ORDER: readonly Service[] = ["herdr", "astrogate", "bridge"];
21
+
22
+ export const SERVICE_LABEL: Record<Service, string> = { bridge: "the Discord bridge", herdr: "herdr", astrogate: "Astrogate" };
23
+
24
+ function path(): string {
25
+ return join(configDir(), "restarts.json");
26
+ }
27
+
28
+ export function readRestarts(): PendingRestart[] {
29
+ try {
30
+ const raw = JSON.parse(readFileSync(path(), "utf-8")) as unknown;
31
+ return Array.isArray(raw) ? (raw as PendingRestart[]).filter((p) => SERVICE_ORDER.includes(p.service) && typeof p.since === "string") : [];
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
36
+
37
+ export function writeRestarts(pending: PendingRestart[]): void {
38
+ if (pending.length === 0) {
39
+ rmSync(path(), { force: true });
40
+ return;
41
+ }
42
+ const tmp = `${path()}.tmp`;
43
+ writeFileSync(tmp, `${JSON.stringify(pending, null, 2)}\n`, { mode: 0o600 });
44
+ renameSync(tmp, path());
45
+ }
46
+
47
+ /** Adds the services an update needs restarted; a service already waiting keeps its start time and gains the reason. */
48
+ export function addRestarts(pending: readonly PendingRestart[], services: readonly Service[], reason: string, now: Date): PendingRestart[] {
49
+ const out = pending.map((p) => ({ ...p, reasons: [...p.reasons] }));
50
+ for (const service of services) {
51
+ const existing = out.find((p) => p.service === service);
52
+ if (existing) {
53
+ if (!existing.reasons.includes(reason)) existing.reasons.push(reason);
54
+ } else out.push({ service, since: now.toISOString(), reasons: [reason] });
55
+ }
56
+ return out.sort((a, b) => SERVICE_ORDER.indexOf(a.service) - SERVICE_ORDER.indexOf(b.service));
57
+ }
58
+
59
+ export function needsPrompt(p: PendingRestart, now: Date): boolean {
60
+ return !p.prompt && now.getTime() - new Date(p.since).getTime() >= RESTART_WAIT_MS;
61
+ }
62
+
63
+ /** What still runs on a service; an empty list means it may restart now. */
64
+ export interface Activity {
65
+ idle: boolean;
66
+ running: string[];
67
+ }
68
+
69
+ /**
70
+ * `herdr agent list` prints `{ "result": { "agents": [...] } }`. A server
71
+ * restart stops every pane process, so any agent, idle or not, counts as running.
72
+ */
73
+ export function herdrActivity(code: number, stdout: string, stderr: string): Activity {
74
+ if (code !== 0) return { idle: false, running: [`herdr agent list failed: ${(stderr || stdout).trim().split("\n")[0]?.slice(0, 120) ?? ""}`] };
75
+ let agents: unknown;
76
+ try {
77
+ agents = (JSON.parse(stdout) as { result?: { agents?: unknown } }).result?.agents;
78
+ } catch {
79
+ return { idle: false, running: ["herdr agent list printed no JSON"] };
80
+ }
81
+ if (!Array.isArray(agents)) return { idle: false, running: ["herdr agent list has no agents field"] };
82
+ const running = agents.map((a) => {
83
+ const r = a as { name?: unknown; pane_id?: unknown; state?: unknown; status?: unknown };
84
+ const who = typeof r.name === "string" ? r.name : typeof r.pane_id === "string" ? r.pane_id : "agent";
85
+ const state = typeof r.state === "string" ? r.state : typeof r.status === "string" ? r.status : "";
86
+ return state ? `${who} (${state})` : who;
87
+ });
88
+ return { idle: running.length === 0, running };
89
+ }
90
+
91
+ /** `astrogate status` prints exactly "No running sessions." when idle, otherwise one line per session. */
92
+ export function astrogateActivity(code: number, stdout: string, stderr: string): Activity {
93
+ if (code !== 0) return { idle: false, running: [`astrogate status failed: ${(stderr || stdout).trim().split("\n")[0]?.slice(0, 120) ?? ""}`] };
94
+ const text = stdout.trim();
95
+ if (text === "No running sessions.") return { idle: true, running: [] };
96
+ return { idle: false, running: text.split("\n").map((l) => l.trim()).filter((l) => l.length > 0) };
97
+ }
98
+
99
+ export const ACTIVITY_COMMAND: Record<Exclude<Service, "bridge">, Command> = {
100
+ herdr: { file: "/opt/homebrew/bin/herdr", args: ["agent", "list"] },
101
+ astrogate: { file: "/opt/homebrew/bin/astrogate", args: ["status"] },
102
+ };
103
+
104
+ /** The restart for each service; the bridge's own runs detached because it ends this process. */
105
+ export function restartCommand(service: Service, uid: number): Command {
106
+ if (service === "herdr") return { file: "/opt/homebrew/bin/brew", args: ["services", "restart", "herdr"] };
107
+ if (service === "astrogate") return { file: "/bin/launchctl", args: ["kickstart", "-k", `gui/${uid}/com.monadeo.astrogate`] };
108
+ return { file: "/bin/launchctl", args: ["kickstart", "-k", `gui/${uid}/com.astrofoundry.astro-discord`] };
109
+ }
@@ -0,0 +1,344 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { countComponents } from "./media.ts";
6
+ import { addRestarts, astrogateActivity, herdrActivity, needsPrompt, readRestarts, RESTART_WAIT_MS, restartCommand, SERVICE_ORDER, writeRestarts } from "./restarts.ts";
7
+ import {
8
+ allId,
9
+ arcaneServerVersion,
10
+ brewHasOutdated,
11
+ buildLines,
12
+ type Collected,
13
+ collectTools,
14
+ compareLoose,
15
+ type Exec,
16
+ latestLts,
17
+ layoutMessages,
18
+ lineText,
19
+ majorChange,
20
+ messageComponents,
21
+ notesFor,
22
+ npmGlobalVersion,
23
+ parseToolsButton,
24
+ parseVerdict,
25
+ readPostedReport,
26
+ reviewPrompt,
27
+ rollbackCommand,
28
+ servicesFor,
29
+ type ToolLine,
30
+ type ToolsReport,
31
+ updateCommand,
32
+ updateId,
33
+ verifiedVersion,
34
+ verifyCommand,
35
+ writePostedReport,
36
+ } from "./tools-report.ts";
37
+
38
+ /** `brew outdated --json=v2` on Cortex, 2026-10-19 (a subset). */
39
+ const OUTDATED = {
40
+ formulae: [
41
+ { name: "bitwarden-cli", installed_versions: ["2026.8.0"], current_version: "2026.9.0", pinned: false, pinned_version: null },
42
+ { name: "gh", installed_versions: ["2.97.0"], current_version: "2.101.0", pinned: false, pinned_version: null },
43
+ { name: "herdr", installed_versions: ["0.9.0"], current_version: "0.9.1", pinned: false, pinned_version: null },
44
+ { name: "pcre2", installed_versions: ["10.47_1"], current_version: "10.48", pinned: false, pinned_version: null },
45
+ { name: "pnpm", installed_versions: ["11.22.0"], current_version: "12.6.0", pinned: false, pinned_version: null },
46
+ { name: "ruby", installed_versions: ["4.0.6"], current_version: "4.0.7", pinned: false, pinned_version: null },
47
+ { name: "fnm", installed_versions: ["1.39.0"], current_version: "1.40.0", pinned: true, pinned_version: "1.39.0" },
48
+ ],
49
+ casks: [
50
+ { name: "codex", installed_versions: ["0.152.1"], current_version: "0.156.1", pinned: false, pinned_version: null },
51
+ { name: "temurin@17", installed_versions: ["17.0.20,8"], current_version: "17.0.20.1,1", pinned: false, pinned_version: null },
52
+ ],
53
+ };
54
+
55
+ /** `brew info --json=v2 --installed` on Cortex, reduced to the fields the report reads. */
56
+ const INFO = {
57
+ formulae: [
58
+ { name: "astrogate", full_name: "monadeo/tap/astrogate", installed: [{ installed_on_request: true }] },
59
+ { name: "bitwarden-cli", full_name: "bitwarden-cli", installed: [{ installed_on_request: true }] },
60
+ { name: "fnm", full_name: "fnm", installed: [{ installed_on_request: true }] },
61
+ { name: "gh", full_name: "gh", installed: [{ installed_on_request: true }] },
62
+ { name: "herdr", full_name: "herdr", installed: [{ installed_on_request: true }] },
63
+ { name: "node", full_name: "node", installed: [{ installed_on_request: false }, { installed_on_request: true }] },
64
+ { name: "pcre2", full_name: "pcre2", installed: [{ installed_on_request: false }] },
65
+ { name: "pnpm", full_name: "pnpm", installed: [{ installed_on_request: true }] },
66
+ { name: "ruby", full_name: "ruby", installed: [{ installed_on_request: false }] },
67
+ ],
68
+ casks: [
69
+ { token: "codex", installed: "0.152.1", version: "0.156.1", auto_updates: null },
70
+ { token: "gcloud-cli", installed: "585.0.0", version: "586.0.0", auto_updates: true },
71
+ { token: "google-chrome", installed: "152.0.7977.83", version: "154.0.8037.58", auto_updates: true },
72
+ { token: "temurin@17", installed: "17.0.20,8", version: "17.0.20.1,1", auto_updates: null },
73
+ ],
74
+ };
75
+
76
+ const COLLECTED: Collected = {
77
+ brewInfo: INFO,
78
+ brewOutdated: OUTDATED,
79
+ piInstalled: "0.87.1",
80
+ piLatest: "1.3.0",
81
+ piAstroInstalled: "0.33.0",
82
+ piAstroLatest: "0.33.0",
83
+ installer: { node: { current: "24.22.0", previous: null }, wrappers: "0.32.0", arcaneCli: "2.16.1", trendarr: "0.6.0" },
84
+ arcaneServer: "2.17.0",
85
+ trendarrLatest: "0.6.0",
86
+ nodeLts: "24.22.0",
87
+ warnings: [],
88
+ };
89
+
90
+ describe("versions", () => {
91
+ it("compares dotted versions with revisions and build numbers", () => {
92
+ expect(compareLoose("10.48", "10.47_1")).toBeGreaterThan(0);
93
+ expect(compareLoose("17.0.20.1,1", "17.0.20,8")).toBeGreaterThan(0);
94
+ expect(compareLoose("2.101.0", "2.97.0")).toBeGreaterThan(0);
95
+ expect(compareLoose("1.0.0", "1.0.0")).toBe(0);
96
+ expect(compareLoose("17.0.20,9", "17.0.20,8")).toBeGreaterThan(0);
97
+ expect(majorChange("0.87.1", "1.3.0")).toBe(true);
98
+ expect(majorChange("2026.8.0", "2026.9.0")).toBe(false);
99
+ });
100
+
101
+ it("reads the newest macOS arm64 LTS, the Arcane server version, and npm's global list", () => {
102
+ const index = [
103
+ { version: "v26.12.0", lts: false, files: ["osx-arm64-tar"] },
104
+ { version: "v24.22.0", lts: "Krypton", files: ["osx-arm64-tar", "linux-x64"] },
105
+ { version: "v24.23.0", lts: "Krypton", files: ["linux-x64"] },
106
+ { version: "v22.21.0", lts: "Jod", files: ["osx-arm64-tar"] },
107
+ ];
108
+ expect(latestLts(index)).toBe("24.22.0");
109
+ expect(latestLts({})).toBeNull();
110
+ expect(arcaneServerVersion({ currentVersion: "v2.16.1", newestVersion: "v2.16.1" })).toBe("2.16.1");
111
+ expect(arcaneServerVersion({ currentVersion: "latest" })).toBeNull();
112
+ expect(npmGlobalVersion({ dependencies: { "@earendil-works/pi-coding-agent": { version: "0.87.1" } } }, "@earendil-works/pi-coding-agent")).toBe("0.87.1");
113
+ });
114
+ });
115
+
116
+ describe("report lines", () => {
117
+ it("lists our tools first, then requested formulae and casks; dependencies and pinned packages stay out", () => {
118
+ const lines = buildLines(COLLECTED);
119
+ expect(lines.map((l) => `${l.key} ${l.installed}->${l.latest}`)).toEqual([
120
+ "pi 0.87.1->1.3.0",
121
+ "spc:wrappers 0.32.0->0.33.0",
122
+ "spc:arcane-cli 2.16.1->2.17.0",
123
+ "brew:bitwarden-cli 2026.8.0->2026.9.0",
124
+ "brew:gh 2.97.0->2.101.0",
125
+ "brew:herdr 0.9.0->0.9.1",
126
+ "brew:pnpm 11.22.0->12.6.0",
127
+ "cask:codex 0.152.1->0.156.1",
128
+ "cask:temurin@17 17.0.20,8->17.0.20.1,1",
129
+ "cask:gcloud-cli 585.0.0->586.0.0",
130
+ ]);
131
+ expect(lines[0].major).toBe(true);
132
+ expect(lines.find((l) => l.key === "cask:gcloud-cli")?.action).toEqual({ kind: "cask", token: "gcloud-cli", greedy: true });
133
+ expect(lines.find((l) => l.key === "cask:codex")?.action).toEqual({ kind: "cask", token: "codex", greedy: false });
134
+ expect(lines.every((l) => l.state === "offered")).toBe(true);
135
+ expect(brewHasOutdated(OUTDATED)).toBe(true);
136
+ expect(brewHasOutdated({ formulae: [OUTDATED.formulae[6]], casks: [] })).toBe(false);
137
+ });
138
+
139
+ it("uses full names for tap formulae and never offers an older or equal version", () => {
140
+ const tap = {
141
+ ...COLLECTED,
142
+ brewInfo: { formulae: INFO.formulae, casks: [] },
143
+ brewOutdated: { formulae: [{ name: "astrogate", installed_versions: ["0.0.10"], current_version: "0.0.11", pinned: false }], casks: [] },
144
+ installer: { node: { current: "24.22.0" }, wrappers: "0.34.0", arcaneCli: "2.17.0", trendarr: "0.7.0" },
145
+ piLatest: "0.80.0",
146
+ };
147
+ expect(buildLines(tap).map((l) => l.key)).toEqual(["brew:monadeo/tap/astrogate"]);
148
+ expect(buildLines(tap)[0].action).toEqual({ kind: "brew", name: "monadeo/tap/astrogate" });
149
+ });
150
+ });
151
+
152
+ describe("collection", () => {
153
+ it("runs brew update first, then reads everything else, and turns failures into warnings", async () => {
154
+ const calls: string[] = [];
155
+ const exec: Exec = async (file, args) => {
156
+ calls.push(`${file.split("/").pop()} ${args.join(" ")}`);
157
+ if (args[0] === "update") return { code: 1, stdout: "", stderr: "Error: network down" };
158
+ if (args[0] === "info") return { code: 0, stdout: JSON.stringify(INFO), stderr: "" };
159
+ if (args[0] === "outdated") return { code: 0, stdout: JSON.stringify(OUTDATED), stderr: "" };
160
+ if (args[0] === "ls") return { code: 0, stdout: JSON.stringify({ dependencies: { "@earendil-works/pi-coding-agent": { version: "0.87.1" } } }), stderr: "" };
161
+ if (args.includes("status")) return { code: 0, stdout: JSON.stringify(COLLECTED.installer), stderr: "" };
162
+ if (args[0] === "view" && args[1] === "@astrofoundry/trendarr") return { code: 1, stdout: "", stderr: "npm error 404" };
163
+ if (args[0] === "view") return { code: 0, stdout: args[1] === "@earendil-works/pi-coding-agent" ? "1.3.0\n" : "0.33.0\n", stderr: "" };
164
+ return { code: 1, stdout: "", stderr: "unexpected" };
165
+ };
166
+ const dir = mkdtempSync(join(tmpdir(), "tools-pkg-"));
167
+ try {
168
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ version: "0.33.0" }));
169
+ const fetchImpl = vi.fn(async () => new Response(JSON.stringify([{ version: "v24.22.0", lts: "Krypton", files: ["osx-arm64-tar"] }]))) as unknown as typeof fetch;
170
+ const c = await collectTools({ exec, fetchImpl, piAstroPackage: join(dir, "package.json"), arcaneServer: async () => "2.16.1" });
171
+ expect(calls[0]).toBe("brew update");
172
+ expect(c.warnings.join(" | ")).toMatch(/brew update failed, versions may be stale: Error: network down/);
173
+ expect(c.warnings.join(" | ")).toMatch(/npm view @astrofoundry\/trendarr failed/);
174
+ expect(c).toMatchObject({ piInstalled: "0.87.1", piLatest: "1.3.0", piAstroInstalled: "0.33.0", nodeLts: "24.22.0", arcaneServer: "2.16.1", trendarrLatest: null });
175
+ } finally {
176
+ rmSync(dir, { recursive: true, force: true });
177
+ }
178
+ });
179
+ });
180
+
181
+ function sampleReport(lines: ToolLine[], brewOutdated = true): ToolsReport {
182
+ return { date: "2026-10-20", checkedAt: "2026-10-20T04:00:30Z", channelId: "1549855700388151398", timeZone: "Europe/Madrid", lines, warnings: [], brewOutdated, brewAll: "offered", messages: [] };
183
+ }
184
+
185
+ describe("report layout", () => {
186
+ it("splits a long report into messages of at most 40 components, header first and footer last", () => {
187
+ const many = Array.from({ length: 15 }, (_, i) => ({ ...buildLines(COLLECTED)[3], key: `brew:pkg-${i}`, label: `pkg-${i}` }));
188
+ const report = sampleReport(many);
189
+ const pages = layoutMessages(report);
190
+ expect(pages.length).toBe(2);
191
+ expect(pages[0].head).toBe(true);
192
+ expect(pages[pages.length - 1].tail).toBe(true);
193
+ expect(pages[pages.length - 1].all).toBe(true);
194
+ expect(pages.flatMap((p) => p.keys)).toEqual(many.map((l) => l.key));
195
+ for (const p of pages) expect(countComponents(messageComponents(report, p))).toBeLessThanOrEqual(40);
196
+ const first = messageComponents(report, pages[0]);
197
+ expect(first[0].content).toMatch(/^\*\*Cortex Tools Report:\*\* 15 updates \(checked 06:00 CEST\)\.$/);
198
+ });
199
+
200
+ it("says everything is up to date without buttons", () => {
201
+ const report = sampleReport([], false);
202
+ const pages = layoutMessages(report);
203
+ expect(pages).toEqual([{ keys: [], head: true, all: false, tail: false }]);
204
+ expect(messageComponents(report, pages[0])).toEqual([{ type: 10, content: "**Cortex Tools Report:** everything is up to date (checked 06:00 CEST)." }]);
205
+ });
206
+
207
+ it("shows the review, a major change, and each button state", () => {
208
+ const [pi] = buildLines(COLLECTED);
209
+ pi.review = { verdict: "breaking", reason: "Removes the `--tools` flag in favor of `--allow`" };
210
+ expect(lineText(pi)).toBe("**Pi** 0.87.1 → 1.3.0 · major version\n-# 🔴 breaking: Removes the \\`--tools\\` flag in favor of \\`--allow\\`");
211
+ const report = sampleReport([pi]);
212
+ const page = layoutMessages(report)[0];
213
+ const button = () => messageComponents(report, page)[1].accessory;
214
+ expect(button()).toMatchObject({ label: "Update", style: 1, custom_id: "tools:up:pi|1.3.0" });
215
+ pi.state = "running";
216
+ expect(button()).toMatchObject({ label: "Updating…", disabled: true });
217
+ pi.state = "failed";
218
+ expect(button()).toMatchObject({ label: "Retry", style: 4 });
219
+ expect(button()?.disabled).toBeUndefined();
220
+ pi.state = "updated";
221
+ expect(button()).toMatchObject({ label: "Updated", style: 3, disabled: true });
222
+ expect(messageComponents(report, page)[2].accessory).toMatchObject({ label: "Upgrade everything", custom_id: "tools:all:2026-10-20" });
223
+ });
224
+ });
225
+
226
+ describe("buttons and commands", () => {
227
+ it("round-trips button ids and refuses anything else", () => {
228
+ const lines = buildLines(COLLECTED);
229
+ for (const l of lines) expect(parseToolsButton(updateId(l))).toEqual({ kind: "update", key: l.key, version: l.latest });
230
+ expect(parseToolsButton(allId("2026-10-20"))).toEqual({ kind: "all", date: "2026-10-20" });
231
+ expect(parseToolsButton("tools:restart:herdr")).toEqual({ kind: "restart", service: "herdr" });
232
+ expect(parseToolsButton("tools:up:brew:gh;rm -rf|2.101.0")).toBeNull();
233
+ expect(parseToolsButton("tools:up:spc:everything|1.0.0")).toBeNull();
234
+ expect(parseToolsButton("tools:up:pi|1.3.0 && x")).toBeNull();
235
+ expect(parseToolsButton("tools:restart:sshd")).toBeNull();
236
+ });
237
+
238
+ it("runs one fixed command per kind, reads the version back, and knows what to roll back and restart", () => {
239
+ expect(updateCommand({ kind: "brew", name: "monadeo/tap/astrogate" }, "0.0.11")).toEqual({ file: "/opt/homebrew/bin/brew", args: ["upgrade", "monadeo/tap/astrogate"] });
240
+ expect(updateCommand({ kind: "cask", token: "gcloud-cli", greedy: true }, "586.0.0").args).toEqual(["upgrade", "--cask", "--greedy", "gcloud-cli"]);
241
+ expect(updateCommand({ kind: "cask", token: "codex", greedy: false }, "0.156.1").args).toEqual(["upgrade", "--cask", "codex"]);
242
+ expect(updateCommand({ kind: "pi" }, "1.3.0")).toEqual({ file: "/opt/homebrew/bin/pi", args: ["update", "--self"] });
243
+ expect(updateCommand({ kind: "pi-astro" }, "0.33.0").args).toEqual(["update", "npm:@astrofoundry/pi-astro"]);
244
+ expect(updateCommand({ kind: "installer", component: "arcane-cli" }, "2.17.0")).toEqual({ file: "/usr/bin/sudo", args: ["-n", "/usr/local/sbin/specialist-install", "arcane-cli", "2.17.0"] });
245
+ expect(rollbackCommand({ kind: "pi" }, "0.87.1")?.args).toEqual(["install", "-g", "@earendil-works/pi-coding-agent@0.87.1"]);
246
+ expect(rollbackCommand({ kind: "brew", name: "gh" }, "2.97.0")).toBeNull();
247
+ expect(verifyCommand({ kind: "brew", name: "gh" })?.args).toEqual(["list", "--versions", "gh"]);
248
+ expect(verifyCommand({ kind: "pi-astro" })).toBeNull();
249
+ expect(verifiedVersion({ kind: "brew", name: "node" }, "node 26.7.0 26.9.0\n", "26.9.0")).toBe(true);
250
+ expect(verifiedVersion({ kind: "brew", name: "gh" }, "gh 2.97.0\n", "2.101.0")).toBe(false);
251
+ expect(verifiedVersion({ kind: "installer", component: "node" }, JSON.stringify({ node: { current: "24.23.0" } }), "24.23.0")).toBe(true);
252
+ expect(verifiedVersion({ kind: "pi" }, "not json", "1.3.0")).toBe(false);
253
+ expect(servicesFor({ kind: "pi" })).toEqual(["bridge"]);
254
+ expect(servicesFor({ kind: "brew", name: "node" })).toEqual(["astrogate", "bridge"]);
255
+ expect(servicesFor({ kind: "brew", name: "herdr" })).toEqual(["herdr"]);
256
+ expect(servicesFor({ kind: "installer", component: "node" })).toEqual([]);
257
+ expect(servicesFor("brew-all")).toEqual(["herdr", "astrogate", "bridge"]);
258
+ });
259
+ });
260
+
261
+ describe("release-notes review", () => {
262
+ const release = (tag: string, body: string, prerelease = false) => ({ tag_name: tag, name: tag, body, published_at: "2026-10-15T00:00:00Z", html_url: `https://github.com/x/releases/${tag}`, draft: false, prerelease });
263
+
264
+ it("reads only the tool's own releases in a shared repository, between the two versions", async () => {
265
+ const fetchImpl = vi.fn(async () => new Response(JSON.stringify([release("cli-v2026.9.0", "bw: new list flag"), release("desktop-v2026.9.0", "desktop only"), release("cli-v2026.8.0", "old")]))) as unknown as typeof fetch;
266
+ const line = buildLines(COLLECTED).find((l) => l.key === "brew:bitwarden-cli") as ToolLine;
267
+ const notes = await notesFor(line, fetchImpl);
268
+ expect(notes.notes?.map((n) => n.tag)).toEqual(["v2026.9.0"]);
269
+ const prompt = reviewPrompt(line, notes.notes ?? [], []);
270
+ expect(prompt).toMatch(/How Cortex uses it: specialist-cli provision runs bw/);
271
+ expect(prompt).toMatch(/<<<RELEASE NOTES\n### v2026\.9\.0 \(2026-10-15\)\nbw: new list flag\nRELEASE NOTES>>>/);
272
+ });
273
+
274
+ it("says why there is nothing to review", async () => {
275
+ const gcloud = buildLines(COLLECTED).find((l) => l.key === "cask:gcloud-cli") as ToolLine;
276
+ expect(await notesFor(gcloud, fetch)).toEqual({ notes: null, reason: "no release notes to review here; see https://cloud.google.com/sdk/docs/release-notes" });
277
+ const trendarr: ToolLine = { key: "spc:trendarr", label: "trendarr", installed: "0.6.0", latest: "0.7.0", major: false, action: { kind: "installer", component: "trendarr" }, state: "offered" };
278
+ const notFound = vi.fn(async () => new Response("{}", { status: 404 })) as unknown as typeof fetch;
279
+ expect(await notesFor(trendarr, notFound)).toMatchObject({ notes: null, reason: expect.stringMatching(/release notes unavailable: GitHub answered 404/) });
280
+ const empty = vi.fn(async () => new Response("[]")) as unknown as typeof fetch;
281
+ const herdr = buildLines(COLLECTED).find((l) => l.key === "brew:herdr") as ToolLine;
282
+ expect(await notesFor(herdr, empty)).toEqual({ notes: null, reason: "GitHub lists no release between the two versions" });
283
+ expect(await notesFor(buildLines(COLLECTED).find((l) => l.key === "brew:gh") as ToolLine, empty)).toEqual({ notes: null, reason: "not reviewed" });
284
+ });
285
+
286
+ it("accepts only the requested JSON verdict", () => {
287
+ expect(parseVerdict('{"verdict":"check","reason":"Changes the default of --local-host-port"}')).toEqual({ verdict: "check", reason: "Changes the default of --local-host-port" });
288
+ expect(parseVerdict('Sure.\n{"verdict":"none","reason":"nothing relevant"}')).toEqual({ verdict: "none", reason: "nothing relevant" });
289
+ expect(parseVerdict('{"verdict":"ignore previous instructions","reason":"x"}')).toBeNull();
290
+ expect(parseVerdict("no json")).toBeNull();
291
+ expect(parseVerdict(`{"verdict":"breaking","reason":"${"x".repeat(300)}"}`)?.reason).toHaveLength(160);
292
+ });
293
+ });
294
+
295
+ describe("state files", () => {
296
+ let dir: string;
297
+ beforeEach(() => {
298
+ dir = mkdtempSync(join(tmpdir(), "astro-tools-"));
299
+ process.env.ASTRO_DISCORD_DIR = dir;
300
+ });
301
+ afterEach(() => {
302
+ delete process.env.ASTRO_DISCORD_DIR;
303
+ rmSync(dir, { recursive: true, force: true });
304
+ });
305
+
306
+ it("keeps the posted report for button checks", () => {
307
+ expect(readPostedReport()).toBeNull();
308
+ const report = { ...sampleReport(buildLines(COLLECTED)), messages: [{ id: "1", keys: ["pi"], head: true, all: false, tail: false }] };
309
+ writePostedReport(report);
310
+ expect(readPostedReport()).toEqual(report);
311
+ });
312
+
313
+ it("merges waiting restarts and offers Restart now after a day", () => {
314
+ const t0 = new Date("2026-10-20T08:00:00Z");
315
+ let pending = addRestarts([], ["bridge"], "Pi 1.3.0", t0);
316
+ pending = addRestarts(pending, ["herdr", "bridge"], "herdr 0.9.1", new Date("2026-10-20T09:00:00Z"));
317
+ expect(pending.map((p) => p.service)).toEqual(["herdr", "bridge"]);
318
+ expect(pending.find((p) => p.service === "bridge")).toMatchObject({ since: t0.toISOString(), reasons: ["Pi 1.3.0", "herdr 0.9.1"] });
319
+ expect(needsPrompt(pending[1], new Date(t0.getTime() + RESTART_WAIT_MS - 1))).toBe(false);
320
+ expect(needsPrompt(pending[1], new Date(t0.getTime() + RESTART_WAIT_MS))).toBe(true);
321
+ expect(needsPrompt({ ...pending[1], prompt: { channelId: "1", messageId: "2" } }, new Date(t0.getTime() + 2 * RESTART_WAIT_MS))).toBe(false);
322
+ writeRestarts(pending);
323
+ expect(readRestarts()).toEqual(pending);
324
+ writeRestarts([]);
325
+ expect(readRestarts()).toEqual([]);
326
+ expect(SERVICE_ORDER[SERVICE_ORDER.length - 1]).toBe("bridge");
327
+ });
328
+ });
329
+
330
+ describe("service activity", () => {
331
+ it("counts every herdr agent and every Astrogate session as running", () => {
332
+ expect(herdrActivity(0, '{"id":"cli:agent:list","result":{"agents":[],"type":"agent_list"}}', "")).toEqual({ idle: true, running: [] });
333
+ expect(herdrActivity(0, '{"result":{"agents":[{"name":"worker-1","state":"working"},{"pane_id":"w1:p2","state":"idle"}]}}', "")).toEqual({ idle: false, running: ["worker-1 (working)", "w1:p2 (idle)"] });
334
+ expect(herdrActivity(1, "", "server not running").idle).toBe(false);
335
+ expect(astrogateActivity(0, "No running sessions.\n", "")).toEqual({ idle: true, running: [] });
336
+ expect(astrogateActivity(0, "worker monadeo/16db.org#1 attempt 1 pane w1:p1\n", "")).toEqual({ idle: false, running: ["worker monadeo/16db.org#1 attempt 1 pane w1:p1"] });
337
+ });
338
+
339
+ it("restarts each service through its manager", () => {
340
+ expect(restartCommand("herdr", 501)).toEqual({ file: "/opt/homebrew/bin/brew", args: ["services", "restart", "herdr"] });
341
+ expect(restartCommand("astrogate", 501).args).toEqual(["kickstart", "-k", "gui/501/com.monadeo.astrogate"]);
342
+ expect(restartCommand("bridge", 501).args).toEqual(["kickstart", "-k", "gui/501/com.astrofoundry.astro-discord"]);
343
+ });
344
+ });