@astrofoundry/pi-astro 0.31.0 → 0.32.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/media.md CHANGED
@@ -19,6 +19,6 @@ Rules:
19
19
  - Movies go to `add-movies`, series to `add-series`; batch independent IDs in one call.
20
20
  - Success needs an explicit `added` or `already_exists` in `results`; `already_exists` means it was already there, not newly added. Report every ID of a batch.
21
21
  - Pause or resume downloads only when the task asks for it, then report the returned state. Adding never implies a queue change.
22
- - Keep replies short: canonical title and year, outcome, and the SABnzbd state an add returned. Mention `/trending` and `/search` when the user would rather click Add buttons.
22
+ - Keep replies short: canonical title and year, outcome, and the SABnzbd state an add returned.
23
23
  - Library settings, quality profiles, Radarr and Sonarr configuration, and deletions are operator tasks; say so and stop.
24
24
  - Delegate through `subagent` only to another specialist (`astro.arcane` for the media containers on the Arcane VM) and only for that specialist's own area.
@@ -7,7 +7,7 @@ import { chunkMessage, fenceState } from "./chunk.ts";
7
7
  import { applyAdminCommand, describeConfig } from "./admin.ts";
8
8
  import { helpText, needsPrefixText, parseCommand } from "./commands.ts";
9
9
  import { allowedSpecialists, canUse, channelSpecialists, loadConfig, loadToken, validateConfig } from "./config.ts";
10
- import { INTENTS, initialState, reduce } from "./gateway.ts";
10
+ import { GatewayClient, INTENTS, initialState, reduce } from "./gateway.ts";
11
11
  import type { RunResult } from "../astro-subagents/child.ts";
12
12
  import { failureReply } from "./index.ts";
13
13
  import { DiscordRest } from "./rest.ts";
@@ -181,6 +181,85 @@ describe("gateway reducer", () => {
181
181
  });
182
182
  });
183
183
 
184
+ class FakeSocket {
185
+ readyState = 1;
186
+ sent: { op: number; d?: unknown }[] = [];
187
+ closedWith: number | null = null;
188
+ private readonly listeners = new Map<string, ((event: unknown) => void)[]>();
189
+ addEventListener(type: string, fn: (event: unknown) => void): void {
190
+ this.listeners.set(type, [...(this.listeners.get(type) ?? []), fn]);
191
+ }
192
+ send(data: string): void {
193
+ this.sent.push(JSON.parse(data) as { op: number; d?: unknown });
194
+ }
195
+ close(code: number): void {
196
+ this.closedWith = code;
197
+ }
198
+ emit(payload: object): void {
199
+ for (const fn of this.listeners.get("message") ?? []) fn({ data: JSON.stringify(payload) });
200
+ }
201
+ emitClose(code: number): void {
202
+ this.readyState = 3;
203
+ for (const fn of this.listeners.get("close") ?? []) fn({ code, reason: "" });
204
+ }
205
+ }
206
+
207
+ describe("gateway client", () => {
208
+ beforeEach(() => vi.useFakeTimers());
209
+ afterEach(() => vi.useRealTimers());
210
+
211
+ function client() {
212
+ const sockets: FakeSocket[] = [];
213
+ const log = vi.fn();
214
+ const gateway = new GatewayClient({
215
+ token: "t",
216
+ gatewayUrl: "wss://gateway",
217
+ handlers: { dispatch: vi.fn(), log },
218
+ socketFactory: () => {
219
+ const socket = new FakeSocket();
220
+ sockets.push(socket);
221
+ return socket as unknown as WebSocket;
222
+ },
223
+ });
224
+ return { gateway, sockets, log };
225
+ }
226
+
227
+ it("opens exactly one new connection after a reconnect request, and the replaced socket's close changes nothing", () => {
228
+ const { gateway, sockets, log } = client();
229
+ gateway.start();
230
+ sockets[0].emit({ op: 10, d: { heartbeat_interval: 1000 } });
231
+ sockets[0].emit({ op: 0, t: "READY", s: 1, d: { session_id: "s1", resume_gateway_url: "wss://resume" } });
232
+ sockets[0].emit({ op: 7 });
233
+ expect(sockets[0].closedWith).toBe(4000);
234
+ sockets[0].emitClose(4000);
235
+ vi.advanceTimersByTime(5_000);
236
+ expect(sockets).toHaveLength(2);
237
+
238
+ sockets[1].emit({ op: 10, d: { heartbeat_interval: 1000 } });
239
+ expect(sockets[1].sent.at(-1)).toMatchObject({ op: 6, d: { session_id: "s1", seq: 1 } });
240
+ sockets[0].emitClose(1000);
241
+ sockets[0].emit({ op: 7 });
242
+ vi.advanceTimersByTime(1_000);
243
+ expect(sockets).toHaveLength(2);
244
+ expect(sockets[1].sent.at(-1)).toEqual({ op: 1, d: 1 });
245
+ expect(log).not.toHaveBeenCalledWith("gateway closed (1000); reconnecting");
246
+ gateway.stop();
247
+ });
248
+
249
+ it("reconnects and resumes once when the server closes the current socket", () => {
250
+ const { gateway, sockets } = client();
251
+ gateway.start();
252
+ sockets[0].emit({ op: 10, d: { heartbeat_interval: 1000 } });
253
+ sockets[0].emit({ op: 0, t: "READY", s: 3, d: { session_id: "s1", resume_gateway_url: "wss://resume" } });
254
+ sockets[0].emitClose(1000);
255
+ vi.advanceTimersByTime(5_000);
256
+ expect(sockets).toHaveLength(2);
257
+ sockets[1].emit({ op: 10, d: { heartbeat_interval: 1000 } });
258
+ expect(sockets[1].sent.at(-1)).toMatchObject({ op: 6, d: { session_id: "s1", seq: 3 } });
259
+ gateway.stop();
260
+ });
261
+ });
262
+
184
263
  describe("rest", () => {
185
264
  it("sends JSON messages, multipart attachments, and retries once after 429", async () => {
186
265
  const calls: { url: string; init: RequestInit }[] = [];
@@ -137,6 +137,7 @@ export class GatewayClient {
137
137
  const socket = factory(this.url(resume));
138
138
  this.socket = socket;
139
139
  socket.addEventListener("message", (event: MessageEvent) => {
140
+ if (this.socket !== socket) return;
140
141
  let payload: GatewayPayload;
141
142
  try {
142
143
  payload = JSON.parse(String(event.data)) as GatewayPayload;
@@ -146,6 +147,9 @@ export class GatewayClient {
146
147
  this.act(reduce(this.state, payload, this.options.handlers));
147
148
  });
148
149
  socket.addEventListener("close", (event: CloseEvent) => {
150
+ // A socket already replaced by a reconnect must not stop the new one's heartbeat or start another connection.
151
+ if (this.socket !== socket) return;
152
+ this.socket = null;
149
153
  this.clearHeartbeat();
150
154
  if (this.stopped) return;
151
155
  if (FATAL_CLOSE_CODES.has(event.code)) {
@@ -186,12 +190,15 @@ export class GatewayClient {
186
190
  case "heartbeat-now":
187
191
  this.send({ op: 1, d: this.state.seq });
188
192
  break;
189
- case "reconnect":
193
+ case "reconnect": {
194
+ const old = this.socket;
195
+ this.socket = null;
190
196
  this.clearHeartbeat();
191
- this.socket?.close(4000);
197
+ old?.close(4000);
192
198
  if (!action.resume) this.state.sessionId = null;
193
199
  this.scheduleReconnect(action.resume);
194
200
  break;
201
+ }
195
202
  default:
196
203
  break;
197
204
  }
@@ -62,6 +62,7 @@ describe("media slash commands", () => {
62
62
  expect(commandArgs("search", [{ name: "title", type: 3, value: " dune " }])).toEqual({ view: "search", args: ["search", "dune", "--max-entries", "10"], query: "dune" });
63
63
  expect(commandArgs("search", [{ name: "title", type: 3, value: " " }])).toBeNull();
64
64
  expect(commandArgs("downloads", [{ name: "action", type: 3, value: "pause" }])).toEqual({ view: "downloads", args: ["sabnzbd", "pause"] });
65
+ expect(commandArgs("downloads", [{ name: "action", type: 3, value: "unpause" }])).toEqual({ view: "downloads", args: ["sabnzbd", "resume"] });
65
66
  expect(commandArgs("downloads", [{ name: "action", type: 3, value: "delete" }])).toBeNull();
66
67
  expect(commandArgs("ban", [])).toBeNull();
67
68
  });
@@ -73,7 +73,7 @@ export const MEDIA_COMMANDS = [
73
73
  {
74
74
  name: "downloads",
75
75
  type: 1,
76
- description: "SABnzbd queue: status, pause, or resume",
76
+ description: "SABnzbd queue: status, pause, or resume (unpause)",
77
77
  contexts: [GUILD_CONTEXT],
78
78
  options: [
79
79
  {
@@ -85,6 +85,7 @@ export const MEDIA_COMMANDS = [
85
85
  { name: "status", value: "status" },
86
86
  { name: "pause", value: "pause" },
87
87
  { name: "resume", value: "resume" },
88
+ { name: "unpause", value: "unpause" },
88
89
  ],
89
90
  },
90
91
  ],
@@ -130,8 +131,8 @@ export function commandArgs(name: string, options: readonly CommandOption[]): Me
130
131
  }
131
132
  if (name === "downloads") {
132
133
  const action = value(options, "action");
133
- if (action !== "status" && action !== "pause" && action !== "resume") return null;
134
- return { view: "downloads", args: ["sabnzbd", action] };
134
+ if (action !== "status" && action !== "pause" && action !== "resume" && action !== "unpause") return null;
135
+ return { view: "downloads", args: ["sabnzbd", action === "unpause" ? "resume" : action] };
135
136
  }
136
137
  return null;
137
138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -41,7 +41,7 @@ Answer "what is trending" with `trending` and list the items in this format, one
41
41
 
42
42
  `{rank}. [{title} ({year})](<https://www.imdb.com/title/{imdb_id}/>) — {imdb_rating} · 🔥{popularity rounded} {✅ in library | ❌ not | ❓ unknown}`
43
43
 
44
- Leave out the rating part when it is null. Add nothing else but one closing line that `/trending` shows the same list with Add buttons. When the user then names items by number ("add 2 and 5 from the movies"), map the numbers to that list's `imdb_id` values; if the list is not in this conversation, run `trending` again.
44
+ Leave out the rating part when it is null. Add nothing else. When the user then names items by number ("add 2 and 5 from the movies"), map the numbers to that list's `imdb_id` values; if the list is not in this conversation, run `trending` again.
45
45
 
46
46
  ## Failures
47
47
 
@@ -25,11 +25,11 @@ specialists/<name>/run.ts one wrapper per specialist (arcane, identity, ne
25
25
  specialists/wrappers.test.ts validators, tables, help
26
26
  specialists/provision/ provision.sh (Vaultwarden to files)
27
27
  specialists/install/ install.sh (package to ~/.specialists/bin, arcane-cli download)
28
- specialists/entry/ specialist-cli.sh (sudo entry point), remote/ (forced commands for SSH targets, the VPS sudoers list and its install script)
28
+ specialists/entry/ specialist-cli.sh (sudo entry point), specialist-install.sh (root installer for Node, wrappers, arcane-cli, trendarr), remote/ (forced commands for SSH targets, the VPS sudoers list and its install script)
29
29
  agents/<name>.md, skills/<name>/SKILL.md
30
30
  ```
31
31
 
32
- Wrappers ship as TypeScript and run under Node 24 type stripping: erasable syntax only (no enums, namespaces, parameter properties), explicit `.ts` import extensions. `install.sh` copies `lib/` and each `run.ts` into `~/.specialists/bin/`; the entry point runs `node ~/.specialists/bin/<service>/run.ts`.
32
+ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable syntax only (no enums, namespaces, parameter properties), explicit `.ts` import extensions. `install.sh` (first install) and `specialist-install wrappers` (updates) copy `lib/` and each `run.ts` into `~/.specialists/bin/`; the entry point runs them with the root-owned Node, `/usr/local/libexec/specialist-node/current/bin/node`.
33
33
 
34
34
  ## Rules for wrapper code
35
35
 
@@ -53,6 +53,7 @@ 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
+ - `entry/specialist-install.sh` runs as root, so it runs only `/usr/bin`, `/bin`, `/usr/sbin`, `/sbin` and the Node it installed, never Homebrew or anything else the main user can write; it checks that `/usr/local/libexec/specialist-node` and its parents belong to root and are writable by nobody else. Arguments are checked before the root test (tests exercise that part). Downloads go to a root-only temporary directory and are verified before use: Node against `SHASUMS256.txt` and the LTS flag in `index.json`, npm tarballs against the registry's `sha512` integrity, arcane-cli against the release's checksums file. Inside `/Users/specialist` it works only as `specialist` (`sudo -u specialist -H`), from a staging directory root hands over, so a link the specialist plants cannot redirect a root write. Each component switches by rename after a check (every installed wrapper's `--help`, `--version` of the new binary) and restores the previous copy when the check fails. Older versions are refused; `rollback` swaps current and previous. Homebrew's `bw` and `jq` (provision) and `gcloud` (edge) still run as `specialist`; that is a separate issue.
56
57
  - `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
58
  - `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
59
  - `media` runs trendarr unchanged: `trendarrCli` in `media.json` is its `dist/cli.js`, started with `process.execPath` because sudo's PATH has no node. Each call gets `HOME` set to a fresh `0700` directory under `~/.specialists/work/media/` that holds only `.trendarr/config.json` (`0600`, trendarr's own schema, built from the note fields and `media.json`) and is removed in `finally`; trendarr reads no environment variables and has no config flag, and Node's `os.homedir()` follows `$HOME` on POSIX. Every call passes `--json`; `init` is refused. An add prints trendarr's per-ID `results` and exits 0 even when trendarr exits 1 for a failed ID, because the gate shows only stderr on failure; other failures carry trendarr's stderr without its `warning:` lines, which go into the JSON as `warnings`. The bridge's slash commands and Add buttons call the same wrapper through the entry point (`extensions/astro-discord/media.ts`).
@@ -66,7 +67,7 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
66
67
 
67
68
  1. Identity: a Vaultwarden note named like the specialist; a Zitadel service user when the service accepts OIDC, else the service's own key; SSH key per target when the only path is SSH.
68
69
  2. `specialists/<name>/run.ts` with `main("<name>", command)`, typed subcommands, `--help`, and tables for any fixed remote commands. Add the entry script under `entry/remote/` and its copy in the homelab repository.
69
- 3. `install.sh`: add the name to the copy loop and the `rm -rf` line. `entry/specialist-cli.sh`: add the name to the allowlist, then reinstall the root-owned copy on the host.
70
+ 3. `install.sh`: add the name to the copy loop and the `rm -rf` line. `entry/specialist-cli.sh` and `entry/specialist-install.sh`: add the name to the allowlist and to `SERVICES` (a test keeps them equal), then reinstall both root-owned copies on the host.
70
71
  4. `agents/<name>.md` with `tools: read, <name>, subagent`, `skills: <name>`, `systemPromptMode: replace`, `inheritProjectContext: false`, `inheritSkills: false`, `maxSubagentDepth: 1`. `skills/<name>/SKILL.md` with services, subcommands, output shapes, limits, and when to stop.
71
72
  5. Tests: validators and tables in `wrappers.test.ts`; anything shared in `lib/`.
72
73
  6. Host: note fields, `provision`, config file, `specialists.json` entry, target entry script. Document the fields in the homelab `03-cortex-macmini/ROTATION.md` and host facts in `03-cortex-macmini/README.md`.
@@ -21,6 +21,7 @@ Call one with `/run astro.<name> -- <task>` or through the `subagent` tool (both
21
21
 
22
22
  - Credentials and CLIs belong to a dedicated macOS user `specialist` on the host. Agents run as the main user and reach the wrappers only through `sudo -n -u specialist -H /usr/local/bin/specialist-cli`, the single passwordless sudo rule. General sudo asks for a password.
23
23
  - Credentials come from Vaultwarden (`homelab` / `specialists`, one Secure Note per specialist) through `specialist-cli provision`, into `0600` files. Wrappers never call `bw`.
24
+ - The wrappers run on a root-owned Node in `/usr/local/libexec/specialist-node/` (latest LTS from nodejs.org), not on Homebrew's, because the main user owns all of `/opt/homebrew` and could otherwise swap the interpreter that reads every secret. `/usr/local/sbin/specialist-install` (root, one passwordless sudo rule) installs that Node, the wrappers, arcane-cli, and trendarr from downloads it checks against the publisher's checksums; it refuses older versions and keeps the previous one for `rollback`. Still open: `provision` runs Homebrew's `bw` and `jq`, and `edge` runs Homebrew's `gcloud`, both as `specialist`.
24
25
  - Arcane is reached with a Zitadel service user: a fresh token per call, exchanged for a 300 s Arcane token. Other services use API keys, a PAT, tokens, or SSH keys that the targets pin to forced commands. The VPS is reached through `gcloud compute start-iap-tunnel` with a service account that may only open IAP tunnels and read instances; its gcloud state lives in `/Users/specialist/.specialists/work/gcloud`.
25
26
  - Every call is logged in `/Users/specialist/.specialists/audit.log`.
26
27
  - The `specialist-gate` extension registers the tool only inside the matching specialist child and blocks other agents from calling the CLIs or reading `/Users/specialist`. `security-guard` carries the same blocks.
@@ -29,7 +30,7 @@ Limits: `network` writes only firewall policies on UniFi. `security` is read-onl
29
30
 
30
31
  ## Enable specialists on a host
31
32
 
32
- Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14`, git; for `media`, trendarr (`@astrofoundry/trendarr`) where the `specialist` user can read its `dist/cli.js`). Steps 1 to 3 are one-time on the services; 4 to 8 are per host.
33
+ Host: Cortex (macOS on Apple silicon; Homebrew `bw`, `jq`, `gcloud-cli` with `python@3.14`, git; the specialists' Node and trendarr come from `specialist-install`). Steps 1 to 3 are one-time on the services; 4 to 8 are per host.
33
34
 
34
35
  1. **Vaultwarden.** Account `specialists@monadeo.com` with **Can view** on collection `specialists` in organization `homelab`. Secure Notes with Hidden fields (`_B64` fields hold `base64 -i <keyfile> | tr -d '\n'`):
35
36
  - `arcane`: `ZITADEL_KEY_JSON`, `GITOPS_DEPLOY_KEY_B64`, `ZITADEL_USER_ID` (plain).
@@ -51,10 +52,14 @@ Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14
51
52
  sudo chmod 0440 /etc/sudoers.d/cortex /etc/sudoers.d/specialists && sudo rm -f /etc/sudoers.d/cortex-nopasswd && sudo visudo -c
52
53
  sudo sysadminctl -addUser specialist -fullName "Specialists" -home /Users/specialist -shell /bin/zsh -password -
53
54
  sudo dscl . create /Users/specialist IsHidden 1 && sudo createhomedir -c -u specialist && sudo chmod 700 /Users/specialist
55
+ sudo install -d -m 0755 -o root -g wheel /usr/local/sbin
56
+ sudo install -m 0755 -o root -g wheel <package>/specialists/entry/specialist-install.sh /usr/local/sbin/specialist-install
57
+ printf 'cortex ALL=(root) NOPASSWD: /usr/local/sbin/specialist-install\n' > /tmp/specialist-install.sudoers && sudo visudo -cf /tmp/specialist-install.sudoers && sudo install -m 0440 -o root -g wheel /tmp/specialist-install.sudoers /etc/sudoers.d/specialist-install && rm /tmp/specialist-install.sudoers
58
+ sudo /usr/local/sbin/specialist-install node 24.22.0
54
59
  sudo install -m 0755 -o root -g wheel <package>/specialists/entry/specialist-cli.sh /usr/local/bin/specialist-cli
55
60
  brew install --cask gcloud-cli
56
61
  ```
57
- Add `cortex ALL=(specialist) NOPASSWD: ALL` as `/etc/sudoers.d/specialists-setup` (mode `0440`) for the duration of steps 5 to 7, then delete it. `specialist-cli` holds the list of services it accepts; after an update that adds one (such as `media`), install it again with the same `sudo install` line.
62
+ `specialist-cli` runs the wrappers with the Node that `specialist-install node` put in place, so that Node comes first. Add `cortex ALL=(specialist) NOPASSWD: ALL` as `/etc/sudoers.d/specialists-setup` (mode `0440`) for the duration of steps 5 to 7, then delete it. `specialist-cli` and `specialist-install` hold the same list of services; after an update that adds one (such as `media`), install both again with the same `sudo install` lines.
58
63
  5. **Bootstrap**, as `specialist` (`sudo -u specialist -H zsh`, `umask 077`): `~/.config/specialists/client.env` with `BW_SERVER=https://vault.37pla.net`, `BW_CLIENTID`, `BW_CLIENTSECRET`; `~/.config/specialists/master-password`. Both `0600`.
59
64
  6. **Install and configure**, as `specialist`: `sh <package>/specialists/install/install.sh <package> 2.12.0` (`<package>` is the installed pi-astro directory, `~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro`). Write `~/.specialists/config/<name>.json` for every specialist:
60
65
  ```json
@@ -74,13 +79,28 @@ Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14
74
79
  { "apiUrl": "https://10.0.10.37:8006", "certSha256": "<from: specialist-cli proxmox -- fingerprint>", "node": "pulsar", "pulsarHost": "10.0.10.37", "pulsarUser": "root" }
75
80
  { "nexusHost": "10.0.40.38", "nexusUser": "nexus", "hermesHost": "10.0.40.31", "hermesUser": "hermes", "pulsarHost": "10.0.10.37", "pulsarUser": "root",
76
81
  "europaHealthUrl": "http://10.0.60.5:8081/health", "hermesRepo": "NousResearch/hermes-agent" }
77
- { "trendarrCli": "<absolute path to trendarr's dist/cli.js>",
82
+ { "trendarrCli": "/Users/specialist/.specialists/bin/trendarr/node_modules/@astrofoundry/trendarr/dist/cli.js",
78
83
  "radarrUrl": "http://10.0.40.30:7878", "radarrQualityProfileId": 7, "radarrRootFolderPath": "/media/movies",
79
84
  "sonarrUrl": "http://10.0.40.30:8989", "sonarrQualityProfileId": 7, "sonarrRootFolderPath": "/media/tv", "sonarrMonitor": "all",
80
85
  "sabnzbdUrl": "http://10.0.40.30:8084", "maxEntries": 10 }
81
86
  ```
82
87
  The `media` wrapper writes trendarr's `~/.trendarr/config.json` for each call into a private temporary HOME under `~/.specialists/work/media/` and removes it afterwards, so trendarr itself is never configured with `trendarr init`. Cortex reaches the three ports on `10.0.40.30` within its own VLAN.
83
- Then `sudo -n -u specialist -H /usr/local/bin/specialist-cli provision`; it lists every note and field.
88
+ `sudo /usr/local/sbin/specialist-install trendarr <version>` installs trendarr behind the link `~/.specialists/bin/trendarr`, so that path stays valid across updates. Then `sudo -n -u specialist -H /usr/local/bin/specialist-cli provision`; it lists every note and field.
89
+
90
+ Later updates go through the installer, one component at a time, each checked before and after the switch:
91
+ ```sh
92
+ sudo /usr/local/sbin/specialist-install node <LTS version>
93
+ sudo /usr/local/sbin/specialist-install wrappers <pi-astro version>
94
+ sudo /usr/local/sbin/specialist-install arcane-cli <Arcane server version>
95
+ sudo /usr/local/sbin/specialist-install trendarr <version>
96
+ sudo /usr/local/sbin/specialist-install rollback node|wrappers|arcane-cli|trendarr
97
+ sudo /usr/local/sbin/specialist-install status
98
+ ```
99
+ - `node` accepts only an LTS release listed in `https://nodejs.org/dist/index.json`, checks the tarball against `SHASUMS256.txt`, and switches `current` only after every installed wrapper answers `--help` with it; `previous` keeps the last one.
100
+ - `wrappers` takes the pi-astro tarball from the npm registry, checks its `sha512` integrity, copies what `install.sh` copies, writes `~/.specialists/bin/VERSION`, and restores the old files if any wrapper fails `--help`. `install.sh` stays the first-time installer (it also writes the default config files).
101
+ - `arcane-cli` checks the release tarball against `arcane_<version>_checksums.txt`; it follows the Arcane server's version.
102
+ - `trendarr` installs with the root-owned Node's npm, without install scripts, into `trendarr-<version>`.
103
+ - A version older than the installed one is refused; `rollback` swaps back to the kept previous copy. `status` prints the installed versions as JSON.
84
104
  7. **SSH targets.** Each target gets an entry script from `entry/remote/` as `/usr/local/sbin/<entry>` (`0755`, root) and the public key in the right `authorized_keys` as
85
105
  `command="/usr/local/sbin/<entry>",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 ...`:
86
106
  - VM 104, user `dmz`: `spc-identity-dmz-entry.sh`.
@@ -114,7 +134,7 @@ Check: `sudo -n -u specialist -H /usr/local/bin/specialist-cli arcane --caller t
114
134
  The `astro-discord` extension gives Discord users access to the specialists from a channel. It runs in a headless Pi host on Cortex (LaunchAgent `com.astrofoundry.astro-discord`, user `cortex`), never in interactive sessions.
115
135
 
116
136
  - Channels are configured one by one (`channels.<id>`): `trigger` is `always` (every message that names a specialist counts) or `mention` (only messages that start with `@Cortex` or reply to the bot); `specialists` is `*` or a list. A channel with one specialist needs no prefix: every message there is a task for it. In a channel with several, `dns list the zones` names the specialist; an addressed message without a name gets a reply asking for one. Threads inherit their parent channel's entry; unlisted channels are ignored. The bot replies at once with "<specialist> is working on your task" and shows the typing indicator until the answer replaces that reply. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` shows the specialists you may use in that channel and the commands (`status` lists running tasks; owners in the admin channel also see the `config` commands). Each channel and specialist has one conversation: a reply to the bot's answer, or another task for that specialist within 30 minutes, continues it with everything the specialist saw before; otherwise a new one starts, and a task beginning with `new` forces that. Replied-to and forwarded messages (text, embeds, attachment names) travel with the task, so "check this" on a forwarded alert works. Image attachments on the message or on what it quotes (up to 4, 8 MiB each) are downloaded and passed to the specialist, which sees them; other file types are named only. Sessions live under `~/.config/astro-discord/sessions/<specialist>/` and are deleted after 7 days. Long answers arrive as a `.md` attachment. When the bridge restarts, open approval requests are marked cancelled and running tasks interrupted in Discord; ask again. With `dailyReport` in the config, the bridge posts an "Arcane Daily Report:" message every morning in that channel: each service with an update as current to target version, from a fresh `update-report`; replying to it asks the arcane specialist with the report as context.
117
- - Media: where the `media` specialist is present (`config channel <#channel> always media`), `/trending [type] [min_rating] [count]`, `/search <title> [type]`, and `/downloads <status|pause|resume>` run the wrapper without a model. Lists arrive as two messages, Movies and Series, each title with a button beside it: **Add** adds it at once and turns into Added, In library, or Not found (Retry after a failure); a greyed-out In library or No IMDb ID button needs nothing. Notes such as "downloads are paused" go only to the user who clicked. Owners and `access.media` users may use them; anyone else gets a refusal only they see, and in a channel without `media` the bot names the channels that have it. Plain messages in the channel still go to the agent, for requests by name ("add the new Dune"). At startup the bridge sets the three commands as the application's global commands (servers only).
137
+ - Media: where the `media` specialist is present (`config channel <#channel> always media`), `/trending [type] [min_rating] [count]`, `/search <title> [type]`, and `/downloads <status|pause|resume|unpause>` run the wrapper without a model. Lists arrive as two messages, Movies and Series, each title with a button beside it: **Add** adds it at once and turns into Added, In library, or Not found (Retry after a failure); a greyed-out In library or No IMDb ID button needs nothing. Notes such as "downloads are paused" go only to the user who clicked. Owners and `access.media` users may use them; anyone else gets a refusal only they see, and in a channel without `media` the bot names the channels that have it. Plain messages in the channel still go to the agent, for requests by name ("add the new Dune"). At startup the bridge sets the three commands as the application's global commands (servers only).
118
138
  - Access: `owners` may use every specialist and decide approvals; `access.<specialist>` lists extra users for that specialist; everyone else gets no reply. Owners change the configuration from the admin channel (`adminChannelId`) without a shell: `config show`, `config channel <#channel> <always|mention> <*|dns,edge>`, `config channel <#channel> remove`, `config access <specialist> add|remove <@user>`; the bot writes `~/.config/astro-discord/config.json` on Cortex, which is also editable by hand and re-read on every message. Deny the bot's role View Channel on channels it must never read; that is the boundary the map cannot provide.
119
139
  - Approvals: a risky call (anything with `--confirm`, plus writes such as record or zone deletes, Zitadel changes, Pomerium deploys, service restarts, and on Arcane only destructive actions: `down`, deletes, prunes, image, volume, or network removal, `system upgrade`, `gitops delete`; help lookups never ask) pauses the specialist and posts Approve/Deny buttons; only owners' clicks count; no decision within `approvalTimeoutMinutes` denies it. The specialist then reports the denial. Ambiguous tasks get a question back instead of an action.
120
140
  - Setup, as `cortex`: `d=$(mktemp -d) && cp ~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro/extensions/astro-discord/*.ts "$d" && node --disable-warning=ExperimentalWarning "$d/setup.ts"` (Node does not strip types inside `node_modules`, so the files are copied first) prompts for the bot token and the ids, checks each against Discord, and writes `~/.config/astro-discord/{token,config.json,run.sh}` and the LaunchAgent; it prints the `launchctl bootstrap` line. After a pi-astro update restart the bridge with `launchctl kickstart -k gui/$(id -u)/com.astrofoundry.astro-discord`. Log: `~/Library/Logs/astro-discord.log`.
@@ -17,4 +17,5 @@ if [[ "${1:-}" == "--caller" ]]; then caller="${2:-unknown}"; shift 2; fi
17
17
 
18
18
  runner="$HOME/.specialists/bin/$service/run.ts"
19
19
  [[ -f "$runner" ]] || { print -u2 "specialist '$service' is not installed"; exit 2; }
20
- exec /opt/homebrew/bin/node --disable-warning=ExperimentalWarning "$runner" --caller "$caller" -- "$@"
20
+ # The root-owned Node from specialist-install; Homebrew's belongs to the cortex user.
21
+ exec /usr/local/libexec/specialist-node/current/bin/node --disable-warning=ExperimentalWarning "$runner" --caller "$caller" -- "$@"
@@ -0,0 +1,459 @@
1
+ #!/bin/sh
2
+ # /usr/local/sbin/specialist-install: installs the specialist user's Node, wrappers,
3
+ # arcane-cli, and trendarr from downloads it checks itself. Installed root-owned 0755.
4
+ # Runs as root through
5
+ # cortex ALL=(root) NOPASSWD: /usr/local/sbin/specialist-install
6
+ # It runs only system binaries and the Node it installed, never anything the cortex
7
+ # user can write (all of Homebrew), and it touches the specialist's home only as that
8
+ # user, so links the specialist plants there cannot redirect a root write.
9
+ #
10
+ # specialist-install node|wrappers|arcane-cli|trendarr <version>
11
+ # specialist-install rollback node|wrappers|arcane-cli|trendarr
12
+ # specialist-install status
13
+ set -eu
14
+ PATH=/usr/bin:/bin:/usr/sbin:/sbin
15
+ export PATH
16
+ umask 077
17
+ cd /
18
+
19
+ SPECIALIST=specialist
20
+ NODE_BASE=/usr/local/libexec/specialist-node
21
+ # Same list as the allowlist in specialist-cli; a new specialist needs both reinstalled.
22
+ SERVICES="arcane identity network dns edge security backup proxmox inference media"
23
+
24
+ usage() {
25
+ echo "usage: specialist-install node|wrappers|arcane-cli|trendarr <version> | rollback node|wrappers|arcane-cli|trendarr | status" >&2
26
+ exit 2
27
+ }
28
+
29
+ fail() {
30
+ echo "specialist-install: $*" >&2
31
+ exit 1
32
+ }
33
+
34
+ # x.y.z with plain decimal parts; the character check also rules out newlines.
35
+ valid_version() {
36
+ case "$1" in '' | *[!0-9.]*) return 1 ;; esac
37
+ printf '%s\n' "$1" | /usr/bin/grep -Eq '^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})$'
38
+ }
39
+
40
+ # True when version $1 is older than version $2.
41
+ version_lt() {
42
+ old_ifs=$IFS
43
+ IFS=.
44
+ set -- $1 $2
45
+ IFS=$old_ifs
46
+ [ "$1" -lt "$4" ] && return 0
47
+ [ "$1" -gt "$4" ] && return 1
48
+ [ "$2" -lt "$5" ] && return 0
49
+ [ "$2" -gt "$5" ] && return 1
50
+ [ "$3" -lt "$6" ]
51
+ }
52
+
53
+ component_ok() {
54
+ case "$1" in node | wrappers | arcane-cli | trendarr) return 0 ;; esac
55
+ return 1
56
+ }
57
+
58
+ command=${1:-}
59
+ case "$command" in
60
+ node | wrappers | arcane-cli | trendarr)
61
+ [ $# -eq 2 ] || usage
62
+ valid_version "$2" || usage
63
+ ;;
64
+ rollback)
65
+ [ $# -eq 2 ] || usage
66
+ component_ok "$2" || usage
67
+ ;;
68
+ status) [ $# -eq 1 ] || usage ;;
69
+ *) usage ;;
70
+ esac
71
+
72
+ [ "$(/usr/bin/id -u)" = 0 ] || fail "must run as root: sudo /usr/local/sbin/specialist-install $*"
73
+ [ "$(/usr/bin/uname -s)" = Darwin ] && [ "$(/usr/bin/uname -m)" = arm64 ] || fail "only macOS on arm64 is supported"
74
+ /usr/bin/id -u "$SPECIALIST" >/dev/null 2>&1 || fail "user $SPECIALIST does not exist"
75
+
76
+ work=$(/usr/bin/mktemp -d /private/var/tmp/specialist-install.XXXXXX)
77
+ trap '/bin/rm -rf "$work"' EXIT
78
+ trap 'exit 1' HUP INT TERM
79
+
80
+ # A directory only root can change: owned by uid 0, not a link, writable by nobody else.
81
+ check_root_dir() {
82
+ [ -d "$1" ] && [ ! -L "$1" ] || fail "$1 is not a directory"
83
+ set -- "$1" $(/usr/bin/stat -f '%u %Lp' "$1")
84
+ [ "$2" = 0 ] || fail "$1 is not owned by root"
85
+ case "$3" in *[2367] | *[2367]?) fail "$1 is writable by group or others" ;; esac
86
+ }
87
+
88
+ ensure_root_dir() {
89
+ [ -e "$1" ] || { /bin/mkdir -m 0755 "$1" && /usr/sbin/chown root:wheel "$1"; }
90
+ check_root_dir "$1"
91
+ }
92
+
93
+ check_node_base() {
94
+ check_root_dir /
95
+ check_root_dir /usr
96
+ check_root_dir /usr/local
97
+ ensure_root_dir /usr/local/libexec
98
+ ensure_root_dir "$NODE_BASE"
99
+ }
100
+
101
+ fetch() {
102
+ /usr/bin/curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL --retry 2 --max-time 900 -o "$2" "$1" || fail "download failed: $1"
103
+ }
104
+
105
+ json_field() {
106
+ /usr/bin/plutil -extract "$2" raw -o - "$1" 2>/dev/null
107
+ }
108
+
109
+ # $1 directory, $2 file name in it, $3 a "<sha256> <name>" list.
110
+ check_sha256() {
111
+ want=$(/usr/bin/awk -v f="$2" '$2 == f && length($1) == 64 { print $1; exit }' "$3")
112
+ [ -n "$want" ] || fail "no checksum for $2"
113
+ got=$(/usr/bin/shasum -a 256 "$1/$2" | /usr/bin/awk '{ print $1 }')
114
+ [ "$got" = "$want" ] || fail "checksum mismatch for $2"
115
+ }
116
+
117
+ # Downloads an npm package tarball after checking it against the registry's sha512 integrity.
118
+ fetch_npm() {
119
+ meta="$work/$3.meta.json"
120
+ fetch "https://registry.npmjs.org/$1/$2" "$meta"
121
+ [ "$(json_field "$meta" version)" = "$2" ] || fail "$1@$2 is not in the npm registry"
122
+ tarball=$(json_field "$meta" dist.tarball) || fail "$1@$2 has no tarball"
123
+ integrity=$(json_field "$meta" dist.integrity) || fail "$1@$2 has no integrity"
124
+ case "$tarball" in https://registry.npmjs.org/*) ;; *) fail "unexpected tarball location for $1@$2" ;; esac
125
+ fetch "$tarball" "$work/$3.tgz"
126
+ [ "sha512-$(/usr/bin/openssl dgst -sha512 -binary "$work/$3.tgz" | /usr/bin/base64)" = "$integrity" ] || fail "integrity mismatch for $1@$2"
127
+ }
128
+
129
+ # Runs a script as the specialist user with fixed arguments; the script never sees root's environment.
130
+ as_specialist() {
131
+ script=$1
132
+ shift
133
+ /usr/bin/sudo -n -u "$SPECIALIST" -H /bin/sh -c "set -eu; umask 077; PATH=/usr/bin:/bin:/usr/sbin:/sbin; cd \"\$HOME\"; $script" specialist-install "$@"
134
+ }
135
+
136
+ # Root-owned staging for files the specialist then copies into its home.
137
+ make_stage() {
138
+ /bin/chmod 0711 "$work"
139
+ stage="$work/stage"
140
+ /bin/mkdir -m 0700 "$stage"
141
+ }
142
+
143
+ hand_stage() {
144
+ /usr/sbin/chown -R "$SPECIALIST:$(/usr/bin/id -gn "$SPECIALIST")" "$stage"
145
+ }
146
+
147
+ node_link() {
148
+ target=$(/usr/bin/readlink "$NODE_BASE/$1" 2>/dev/null) || return 0
149
+ valid_version "$target" && [ -x "$NODE_BASE/$target/bin/node" ] && printf '%s' "$target"
150
+ return 0
151
+ }
152
+
153
+ switch_node_link() {
154
+ /bin/rm -f "$NODE_BASE/.$1.tmp"
155
+ (umask 022 && /bin/ln -s "$2" "$NODE_BASE/.$1.tmp")
156
+ /bin/mv -h "$NODE_BASE/.$1.tmp" "$NODE_BASE/$1"
157
+ }
158
+
159
+ current_node() {
160
+ node=$(node_link current)
161
+ [ -n "$node" ] || fail "no specialist Node yet: run specialist-install node <version> first"
162
+ printf '%s' "$NODE_BASE/$node/bin/node"
163
+ }
164
+
165
+ # Specialist side: every installed wrapper answers --help with Node $1; nothing to check before the first install.
166
+ SMOKE_INSTALLED='node=$1; shift
167
+ bin="$HOME/.specialists/bin"
168
+ [ -d "$bin/lib" ] || exit 0
169
+ for s in "$@"; do
170
+ "$node" --disable-warning=ExperimentalWarning "$bin/$s/run.ts" --caller specialist-install -- --help >/dev/null 2>&1 || { echo "$s --help failed with $node" >&2; exit 1; }
171
+ done'
172
+
173
+ smoke_installed_wrappers() {
174
+ as_specialist "$SMOKE_INSTALLED" "$1" $SERVICES
175
+ }
176
+
177
+ install_node() {
178
+ version=$1
179
+ check_node_base
180
+ current=$(node_link current)
181
+ if [ -n "$current" ] && version_lt "$version" "$current"; then fail "node $version is older than the installed $current; use rollback"; fi
182
+ [ "$current" != "$version" ] || { echo "node $version is already current"; return 0; }
183
+
184
+ fetch https://nodejs.org/dist/index.json "$work/index.json"
185
+ i=0
186
+ found=""
187
+ while [ "$i" -lt 400 ]; do
188
+ v=$(json_field "$work/index.json" "$i.version") || break
189
+ if [ "$v" = "v$version" ]; then found=$i; break; fi
190
+ i=$((i + 1))
191
+ done
192
+ [ -n "$found" ] || fail "node $version is not among the 400 newest releases"
193
+ [ "$(json_field "$work/index.json" "$found.lts")" != false ] || fail "node $version is not an LTS release"
194
+ /usr/bin/plutil -extract "$found.files" json -o - "$work/index.json" | /usr/bin/grep -q '"osx-arm64-tar"' || fail "node $version has no macOS arm64 build"
195
+
196
+ name="node-v$version-darwin-arm64.tar.gz"
197
+ fetch "https://nodejs.org/dist/v$version/$name" "$work/$name"
198
+ fetch "https://nodejs.org/dist/v$version/SHASUMS256.txt" "$work/SHASUMS256.txt"
199
+ check_sha256 "$work" "$name" "$work/SHASUMS256.txt"
200
+
201
+ dest="$NODE_BASE/$version"
202
+ /bin/rm -rf "$dest.partial"
203
+ /bin/mkdir -m 0755 "$dest.partial"
204
+ /usr/bin/tar -xzf "$work/$name" -C "$dest.partial" --strip-components 1
205
+ /usr/sbin/chown -R root:wheel "$dest.partial"
206
+ /bin/chmod -R u=rwX,go=rX "$dest.partial"
207
+ [ "$("$dest.partial/bin/node" --version)" = "v$version" ] || fail "node $version does not run"
208
+ /bin/rm -rf "$dest"
209
+ /bin/mv "$dest.partial" "$dest"
210
+ smoke_installed_wrappers "$dest/bin/node" || fail "the installed wrappers fail with node $version; current stays ${current:-unset}"
211
+
212
+ [ -z "$current" ] || switch_node_link previous "$current"
213
+ switch_node_link current "$version"
214
+ for d in "$NODE_BASE"/*; do
215
+ n=${d##*/}
216
+ case "$n" in current | previous | "$version" | "$current") continue ;; esac
217
+ /bin/rm -rf "$d"
218
+ done
219
+ echo "node $version is current${current:+; previous $current}"
220
+ }
221
+
222
+ rollback_node() {
223
+ check_node_base
224
+ current=$(node_link current)
225
+ previous=$(node_link previous)
226
+ [ -n "$current" ] && [ -n "$previous" ] || fail "no previous node to roll back to"
227
+ smoke_installed_wrappers "$NODE_BASE/$previous/bin/node" || fail "the installed wrappers fail with node $previous"
228
+ switch_node_link current "$previous"
229
+ switch_node_link previous "$current"
230
+ echo "node $previous is current; previous $current"
231
+ }
232
+
233
+ WRAPPER_VERSION='v=$(cat "$HOME/.specialists/bin/VERSION" 2>/dev/null) || exit 0; printf "%s" "$v"'
234
+
235
+ # Moves the new wrapper files in and the old ones aside, two renames per item, then
236
+ # checks every wrapper; on failure the old files come back.
237
+ SWAP_WRAPPERS='node=$1; stage=$2; shift 2
238
+ bin="$HOME/.specialists/bin"; back="$HOME/.specialists/rollback/wrappers"
239
+ items="lib provision.sh VERSION $*"
240
+ smoke() { d=$1; shift; for s in "$@"; do "$node" --disable-warning=ExperimentalWarning "$d/$s/run.ts" --caller specialist-install -- --help >/dev/null 2>&1 || { echo "$s --help failed" >&2; return 1; }; done; }
241
+ smoke "$stage" "$@" || exit 1
242
+ mkdir -p "$bin" "$HOME/.specialists/rollback"
243
+ rm -rf "$back.new"; mkdir "$back.new"
244
+ for i in $items; do rm -rf "$bin/.new-$i"; cp -R "$stage/$i" "$bin/.new-$i"; done
245
+ for i in $items; do
246
+ [ ! -e "$bin/$i" ] || mv "$bin/$i" "$back.new/$i"
247
+ mv "$bin/.new-$i" "$bin/$i"
248
+ done
249
+ chmod -R go-rwx "$bin"
250
+ if ! smoke "$bin" "$@"; then
251
+ for i in $items; do rm -rf "$bin/$i"; [ ! -e "$back.new/$i" ] || mv "$back.new/$i" "$bin/$i"; done
252
+ rm -rf "$back.new"
253
+ echo "restored the previous wrappers" >&2
254
+ exit 1
255
+ fi
256
+ rm -rf "$back"; mv "$back.new" "$back"'
257
+
258
+ install_wrappers() {
259
+ version=$1
260
+ node=$(current_node)
261
+ current=$(as_specialist "$WRAPPER_VERSION")
262
+ valid_version "$current" || current=""
263
+ if [ -n "$current" ] && version_lt "$version" "$current"; then fail "wrappers $version are older than the installed $current; use rollback"; fi
264
+
265
+ fetch_npm @astrofoundry/pi-astro "$version" pi-astro
266
+ /bin/mkdir "$work/pkg"
267
+ /usr/bin/tar -xzf "$work/pi-astro.tgz" -C "$work/pkg" --strip-components 1
268
+ src="$work/pkg/specialists"
269
+ [ -d "$src/lib" ] && [ -f "$src/provision/provision.sh" ] || fail "pi-astro $version has no specialists/lib or provision.sh"
270
+ make_stage
271
+ /bin/cp -R "$src/lib" "$stage/lib"
272
+ for s in $SERVICES; do
273
+ [ -f "$src/$s/run.ts" ] || fail "pi-astro $version has no $s wrapper"
274
+ /bin/mkdir "$stage/$s"
275
+ /bin/cp "$src/$s/run.ts" "$stage/$s/run.ts"
276
+ done
277
+ /bin/cp "$src/provision/provision.sh" "$stage/provision.sh"
278
+ /usr/bin/find "$stage" -type f -name '*.test.ts' -delete
279
+ printf '%s\n' "$version" > "$stage/VERSION"
280
+ hand_stage
281
+ as_specialist "$SWAP_WRAPPERS" "$node" "$stage" $SERVICES || fail "wrappers $version failed their check; ${current:-no} previous version kept"
282
+ echo "wrappers $version installed${current:+; previous $current}"
283
+ }
284
+
285
+ ROLLBACK_WRAPPERS='node=$1; shift
286
+ bin="$HOME/.specialists/bin"; back="$HOME/.specialists/rollback/wrappers"
287
+ [ -f "$back/VERSION" ] || { echo "no previous wrappers to roll back to" >&2; exit 1; }
288
+ items="lib provision.sh VERSION $*"
289
+ swap() { for i in $items; do
290
+ [ ! -e "$bin/$i" ] || mv "$bin/$i" "$bin/.old-$i"
291
+ [ ! -e "$back/$i" ] || mv "$back/$i" "$bin/$i"
292
+ [ ! -e "$bin/.old-$i" ] || mv "$bin/.old-$i" "$back/$i"
293
+ done; }
294
+ swap
295
+ for s in "$@"; do
296
+ "$node" --disable-warning=ExperimentalWarning "$bin/$s/run.ts" --caller specialist-install -- --help >/dev/null 2>&1 || { swap; echo "$s --help failed; nothing changed" >&2; exit 1; }
297
+ done
298
+ cat "$bin/VERSION"'
299
+
300
+ rollback_wrappers() {
301
+ node=$(current_node)
302
+ restored=$(as_specialist "$ROLLBACK_WRAPPERS" "$node" $SERVICES) || fail "wrapper rollback failed"
303
+ echo "wrappers $restored restored"
304
+ }
305
+
306
+ ARCANE_VERSION='out=$("$HOME/.specialists/bin/arcane-cli" --version 2>/dev/null) || exit 0
307
+ printf "%s\n" "$out" | awk '"'"'{ for (i = 1; i < NF; i++) if ($i == "version") { print $(i + 1); exit } }'"'"''
308
+
309
+ INSTALL_ARCANE='stage=$1; v=$2
310
+ bin="$HOME/.specialists/bin"
311
+ check() { "$1" --version 2>/dev/null | awk -v v="$v" '"'"'{ for (i = 1; i < NF; i++) if ($i == "version" && $(i + 1) == v) f = 1 } END { exit !f }'"'"'; }
312
+ mkdir -p "$bin"
313
+ cp "$stage/arcane-cli" "$bin/.arcane-cli.new"
314
+ chmod 0700 "$bin/.arcane-cli.new"
315
+ check "$bin/.arcane-cli.new" || { rm -f "$bin/.arcane-cli.new"; echo "the new arcane-cli does not report version $v" >&2; exit 1; }
316
+ had=no
317
+ if [ -e "$bin/arcane-cli" ]; then mv "$bin/arcane-cli" "$bin/arcane-cli.previous"; had=yes; fi
318
+ mv "$bin/.arcane-cli.new" "$bin/arcane-cli"
319
+ if ! check "$bin/arcane-cli"; then
320
+ rm -f "$bin/arcane-cli"
321
+ [ "$had" = no ] || mv "$bin/arcane-cli.previous" "$bin/arcane-cli"
322
+ echo "restored the previous arcane-cli" >&2
323
+ exit 1
324
+ fi'
325
+
326
+ install_arcane_cli() {
327
+ version=$1
328
+ current=$(as_specialist "$ARCANE_VERSION")
329
+ valid_version "$current" || current=""
330
+ if [ -n "$current" ] && version_lt "$version" "$current"; then fail "arcane-cli $version is older than the installed $current; use rollback"; fi
331
+
332
+ asset=arcane-cli_darwin_arm64.tar.gz
333
+ base="https://github.com/getarcaneapp/arcane/releases/download/v$version"
334
+ fetch "$base/$asset" "$work/$asset"
335
+ fetch "$base/arcane_${version}_checksums.txt" "$work/checksums.txt"
336
+ check_sha256 "$work" "$asset" "$work/checksums.txt"
337
+ /bin/mkdir "$work/acli"
338
+ /usr/bin/tar -xzf "$work/$asset" -C "$work/acli"
339
+ cli=$(/usr/bin/find "$work/acli" -type f -name arcane-cli | /usr/bin/head -n 1)
340
+ [ -n "$cli" ] || fail "no arcane-cli binary in $asset"
341
+ make_stage
342
+ /bin/cp "$cli" "$stage/arcane-cli"
343
+ hand_stage
344
+ as_specialist "$INSTALL_ARCANE" "$stage" "$version" || fail "arcane-cli $version failed its check"
345
+ echo "arcane-cli $version installed${current:+; previous $current}"
346
+ }
347
+
348
+ ROLLBACK_ARCANE='bin="$HOME/.specialists/bin"
349
+ [ -e "$bin/arcane-cli.previous" ] || { echo "no previous arcane-cli to roll back to" >&2; exit 1; }
350
+ mv "$bin/arcane-cli" "$bin/.arcane-cli.old"
351
+ mv "$bin/arcane-cli.previous" "$bin/arcane-cli"
352
+ mv "$bin/.arcane-cli.old" "$bin/arcane-cli.previous"
353
+ "$bin/arcane-cli" --version'
354
+
355
+ rollback_arcane_cli() {
356
+ out=$(as_specialist "$ROLLBACK_ARCANE") || fail "arcane-cli rollback failed"
357
+ echo "restored $out"
358
+ }
359
+
360
+ # trendarr lives in versioned directories behind the stable link bin/trendarr, so
361
+ # media.json's trendarrCli path never changes; bin/trendarr.previous keeps the last one.
362
+ TRENDARR_COMMON='bin="$HOME/.specialists/bin"
363
+ cli() { printf "%s/node_modules/@astrofoundry/trendarr/dist/cli.js" "$1"; }
364
+ version_of() { plutil -extract version raw -o - "$1/node_modules/@astrofoundry/trendarr/package.json" 2>/dev/null; }
365
+ link() { rm -f "$bin/.$1.tmp"; ln -s "$2" "$bin/.$1.tmp"; mv -h "$bin/.$1.tmp" "$bin/$1"; }'
366
+
367
+ TRENDARR_VERSION="$TRENDARR_COMMON"'
368
+ [ -e "$bin/trendarr" ] || exit 0
369
+ version_of "$bin/trendarr" || exit 0'
370
+
371
+ # A hand-installed bin/trendarr directory (the first media setup) becomes trendarr-<version>
372
+ # behind the link; one without a readable package moves aside to bin/trendarr.unknown.
373
+ INSTALL_TRENDARR="$TRENDARR_COMMON"'
374
+ node=$1; npm=$2; v=$3
375
+ if [ -d "$bin/trendarr" ] && [ ! -L "$bin/trendarr" ]; then
376
+ old=$(version_of "$bin/trendarr" || true)
377
+ case "$old" in
378
+ "" | *[!0-9.]*) rm -rf "$bin/trendarr.unknown"; mv "$bin/trendarr" "$bin/trendarr.unknown" ;;
379
+ *) rm -rf "$bin/trendarr-$old"; mv "$bin/trendarr" "$bin/trendarr-$old"; link trendarr "trendarr-$old" ;;
380
+ esac
381
+ fi
382
+ dir="$bin/trendarr-$v"
383
+ mkdir -p "$bin"
384
+ rm -rf "$dir.partial"
385
+ "$node" "$npm" install --prefix "$dir.partial" --omit=dev --ignore-scripts --no-audit --no-fund --no-update-notifier "@astrofoundry/trendarr@$v" >/dev/null
386
+ [ "$("$node" "$(cli "$dir.partial")" --version)" = "$v" ] || { rm -rf "$dir.partial"; echo "trendarr $v does not report its version" >&2; exit 1; }
387
+ before=$(readlink "$bin/trendarr" 2>/dev/null || true)
388
+ rm -rf "$dir"
389
+ mv "$dir.partial" "$dir"
390
+ link trendarr "trendarr-$v"
391
+ if [ "$("$node" "$(cli "$bin/trendarr")" --version)" != "$v" ]; then
392
+ if [ -n "$before" ]; then link trendarr "$before"; else rm -f "$bin/trendarr"; fi
393
+ echo "restored the previous trendarr" >&2
394
+ exit 1
395
+ fi
396
+ if [ -n "$before" ] && [ "$before" != "trendarr-$v" ]; then link trendarr.previous "$before"; fi
397
+ keep=$(readlink "$bin/trendarr.previous" 2>/dev/null || true)
398
+ for d in "$bin"/trendarr-*; do
399
+ [ -d "$d" ] || continue
400
+ n=${d##*/}
401
+ [ "$n" = "trendarr-$v" ] || [ "$n" = "$keep" ] || rm -rf "$d"
402
+ done'
403
+
404
+ install_trendarr() {
405
+ version=$1
406
+ node=$(current_node)
407
+ npm="${node%/bin/node}/lib/node_modules/npm/bin/npm-cli.js"
408
+ [ -f "$npm" ] || fail "the specialist Node has no npm"
409
+ current=$(as_specialist "$TRENDARR_VERSION")
410
+ valid_version "$current" || current=""
411
+ if [ -n "$current" ] && version_lt "$version" "$current"; then fail "trendarr $version is older than the installed $current; use rollback"; fi
412
+ [ "$current" != "$version" ] || { echo "trendarr $version is already current"; return 0; }
413
+ as_specialist "$INSTALL_TRENDARR" "$node" "$npm" "$version" || fail "trendarr $version failed its check"
414
+ echo "trendarr $version installed${current:+; previous $current}"
415
+ }
416
+
417
+ ROLLBACK_TRENDARR="$TRENDARR_COMMON"'
418
+ node=$1
419
+ previous=$(readlink "$bin/trendarr.previous" 2>/dev/null) || { echo "no previous trendarr to roll back to" >&2; exit 1; }
420
+ current=$(readlink "$bin/trendarr")
421
+ [ -d "$bin/$previous" ] || { echo "$previous is missing" >&2; exit 1; }
422
+ link trendarr "$previous"
423
+ link trendarr.previous "$current"
424
+ "$node" "$(cli "$bin/trendarr")" --version'
425
+
426
+ rollback_trendarr() {
427
+ node=$(current_node)
428
+ out=$(as_specialist "$ROLLBACK_TRENDARR" "$node") || fail "trendarr rollback failed"
429
+ echo "trendarr $out restored"
430
+ }
431
+
432
+ json_version() {
433
+ if valid_version "$1"; then printf '"%s"' "$1"; else printf 'null'; fi
434
+ }
435
+
436
+ status() {
437
+ wrappers=$(as_specialist "$WRAPPER_VERSION" || true)
438
+ arcane=$(as_specialist "$ARCANE_VERSION" || true)
439
+ trendarr=$(as_specialist "$TRENDARR_VERSION" || true)
440
+ printf '{"node":{"current":%s,"previous":%s},"wrappers":%s,"arcaneCli":%s,"trendarr":%s}\n' \
441
+ "$(json_version "$(node_link current)")" "$(json_version "$(node_link previous)")" \
442
+ "$(json_version "$wrappers")" "$(json_version "$arcane")" "$(json_version "$trendarr")"
443
+ }
444
+
445
+ case "$command" in
446
+ node) install_node "$2" ;;
447
+ wrappers) install_wrappers "$2" ;;
448
+ arcane-cli) install_arcane_cli "$2" ;;
449
+ trendarr) install_trendarr "$2" ;;
450
+ rollback)
451
+ case "$2" in
452
+ node) rollback_node ;;
453
+ wrappers) rollback_wrappers ;;
454
+ arcane-cli) rollback_arcane_cli ;;
455
+ trendarr) rollback_trendarr ;;
456
+ esac
457
+ ;;
458
+ status) status ;;
459
+ esac
@@ -0,0 +1,95 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ const SCRIPT = join(import.meta.dirname, "specialist-install.sh");
7
+ const ENTRY = join(import.meta.dirname, "specialist-cli.sh");
8
+ // As root the script would really install; these tests only exercise what runs before the root check.
9
+ const isRoot = process.getuid?.() === 0;
10
+
11
+ function install(...args: string[]) {
12
+ return spawnSync("/bin/sh", [SCRIPT, ...args], { encoding: "utf-8" });
13
+ }
14
+
15
+ /** Runs one shell function of the script on its own, followed by `call`. */
16
+ function shellFunction(name: string, call: string) {
17
+ const match = new RegExp(`^${name}\\(\\) \\{\\n[\\s\\S]*?^\\}`, "m").exec(readFileSync(SCRIPT, "utf-8"));
18
+ if (!match) throw new Error(`${name} not found in the script`);
19
+ return spawnSync("/bin/sh", ["-c", `${match[0]}\n${call}`], { encoding: "utf-8" });
20
+ }
21
+
22
+ describe("specialist-install", () => {
23
+ it("parses as POSIX sh", () => {
24
+ expect(spawnSync("/bin/sh", ["-n", SCRIPT]).status).toBe(0);
25
+ });
26
+
27
+ it.each([
28
+ [[]],
29
+ [["upgrade", "1.2.3"]],
30
+ [["node"]],
31
+ [["node", "24.22"]],
32
+ [["node", "v24.22.0"]],
33
+ [["node", "024.22.0"]],
34
+ [["node", "24.22.0\nrm"]],
35
+ [["node", "24.22.0", "extra"]],
36
+ [["wrappers", "../../1.2.3"]],
37
+ [["arcane-cli", "2.16.1-beta"]],
38
+ [["trendarr", ""]],
39
+ [["rollback"]],
40
+ [["rollback", "pi"]],
41
+ [["rollback", "node", "extra"]],
42
+ [["status", "now"]],
43
+ ])("refuses %j with a usage error before anything else", (args) => {
44
+ const result = install(...args);
45
+ expect(result.status).toBe(2);
46
+ expect(result.stderr).toMatch(/^usage: specialist-install/);
47
+ });
48
+
49
+ it.skipIf(isRoot).each([[["node", "24.22.0"]], [["wrappers", "0.31.0"]], [["arcane-cli", "2.16.1"]], [["trendarr", "0.6.0"]], [["rollback", "trendarr"]], [["status"]]])(
50
+ "accepts %j and then requires root",
51
+ (args) => {
52
+ const result = install(...args);
53
+ expect(result.status).toBe(1);
54
+ expect(result.stderr).toContain("must run as root");
55
+ },
56
+ );
57
+
58
+ it.each(["0.0.0", "1.2.3", "24.22.0", "2.16.1", "100000.0.1"])("valid_version accepts %s", (v) => {
59
+ expect(shellFunction("valid_version", `valid_version '${v}'`).status).toBe(0);
60
+ });
61
+
62
+ it.each(["", "1.2", "1.2.3.4", "01.2.3", "1.2.3a", "-1.2.3", "1..3", "1000000.0.0"])("valid_version rejects %j", (v) => {
63
+ expect(shellFunction("valid_version", `valid_version '${v}'`).status).toBe(1);
64
+ });
65
+
66
+ it.each([
67
+ ["24.21.0", "24.22.0", true],
68
+ ["24.22.0", "24.22.0", false],
69
+ ["24.22.1", "24.22.0", false],
70
+ ["2.9.9", "2.16.1", true],
71
+ ["10.0.0", "9.99.99", false],
72
+ ["0.6.0", "0.31.0", true],
73
+ ])("version_lt %s < %s is %s", (a, b, older) => {
74
+ expect(shellFunction("version_lt", `version_lt ${a} ${b}`).status).toBe(older ? 0 : 1);
75
+ });
76
+
77
+ it("installs the same services specialist-cli accepts", () => {
78
+ const services = /^SERVICES="([^"]+)"$/m.exec(readFileSync(SCRIPT, "utf-8"))?.[1].split(" ");
79
+ const allowed = /^ {2}([a-z|]+)\) ;;$/m.exec(readFileSync(ENTRY, "utf-8"))?.[1].split("|");
80
+ expect(services).toBeDefined();
81
+ expect(services).toEqual(allowed);
82
+ });
83
+
84
+ it("never runs Homebrew binaries", () => {
85
+ expect(readFileSync(SCRIPT, "utf-8")).not.toContain("/opt/homebrew");
86
+ });
87
+ });
88
+
89
+ describe("specialist-cli", () => {
90
+ it("runs the wrappers with the root-owned Node only", () => {
91
+ const text = readFileSync(ENTRY, "utf-8");
92
+ expect(text).toContain("exec /usr/local/libexec/specialist-node/current/bin/node ");
93
+ expect(text).not.toContain("/opt/homebrew/bin/node");
94
+ });
95
+ });