@astrofoundry/pi-astro 0.34.0 → 0.35.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.
@@ -40,13 +40,33 @@ describe("specialist-install", () => {
40
40
  [["rollback", "pi"]],
41
41
  [["rollback", "node", "extra"]],
42
42
  [["status", "now"]],
43
+ [["python", "3.14.8"]],
44
+ [["python", "3.14.8+2026101"]],
45
+ [["python", "3.14.8+20261013+1"]],
46
+ [["python", "3.14.8-20261013"]],
47
+ [["gcloud", "589.0"]],
48
+ [["bw", "cli-v2026.10.0"]],
49
+ [["jq", "jq-1.8.2"]],
50
+ [["rollback", "brew"]],
43
51
  ])("refuses %j with a usage error before anything else", (args) => {
44
52
  const result = install(...args);
45
53
  expect(result.status).toBe(2);
46
54
  expect(result.stderr).toMatch(/^usage: specialist-install/);
47
55
  });
48
56
 
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"]]])(
57
+ it.skipIf(isRoot).each([
58
+ [["node", "24.22.0"]],
59
+ [["wrappers", "0.31.0"]],
60
+ [["arcane-cli", "2.16.1"]],
61
+ [["trendarr", "0.6.0"]],
62
+ [["python", "3.14.8+20261013"]],
63
+ [["gcloud", "589.0.0"]],
64
+ [["bw", "2026.10.0"]],
65
+ [["jq", "1.8.2"]],
66
+ [["rollback", "trendarr"]],
67
+ [["rollback", "gcloud"]],
68
+ [["status"]],
69
+ ])(
50
70
  "accepts %j and then requires root",
51
71
  (args) => {
52
72
  const result = install(...args);
@@ -74,6 +94,35 @@ describe("specialist-install", () => {
74
94
  expect(shellFunction("version_lt", `version_lt ${a} ${b}`).status).toBe(older ? 0 : 1);
75
95
  });
76
96
 
97
+ it.each(["3.14.8+20261013", "3.10.0+20250101", "3.15.0+20270401"])("valid_python_version accepts %s", (v) => {
98
+ expect(shellFunction("valid_python_version", `valid_python_version '${v}'`).status).toBe(0);
99
+ });
100
+
101
+ it.each(["", "3.14.8", "3.14+20261013", "3.14.8+2026101", "3.14.8+20261013a", "+20261013", "3.14.8+ 20261013"])("valid_python_version rejects %j", (v) => {
102
+ expect(shellFunction("valid_python_version", `valid_python_version '${v}'`).status).toBe(1);
103
+ });
104
+
105
+ it.each([
106
+ ["3.14.8+20261009", "3.14.8+20261013", true],
107
+ ["3.14.8+20261013", "3.14.8+20261013", false],
108
+ ["3.14.9+20261001", "3.14.8+20261013", false],
109
+ ["3.14.7+20261101", "3.14.8+20261013", true],
110
+ ])("python_lt %s < %s is %s", (a, b, older) => {
111
+ const script = readFileSync(SCRIPT, "utf-8");
112
+ const lt = /^version_lt\(\) \{\n[\s\S]*?^\}/m.exec(script)?.[0] ?? "";
113
+ expect(shellFunction("python_lt", `${lt}\npython_lt '${a}' '${b}'`).status).toBe(older ? 0 : 1);
114
+ });
115
+
116
+ it("installs gcloud from fixed components and checked downloads only", () => {
117
+ const text = readFileSync(SCRIPT, "utf-8");
118
+ expect(/^GCLOUD_COMPONENTS="([^"]+)"$/m.exec(text)?.[1].split(" ")).toEqual(["core", "core-nix", "gcloud-deps", "gcloud-deps-darwin-arm", "gcloud-crc32c-darwin-arm"]);
119
+ expect(text).toContain("channel=https://dl.google.com/dl/cloudsdk/channels/rapid\n");
120
+ expect(text).toContain('fetch "$channel/components-v$version.json"');
121
+ expect(text).toContain("https://github.com/astral-sh/python-build-standalone/releases/download/$tag");
122
+ expect(text).toContain("https://api.github.com/repos/bitwarden/clients/releases/tags/cli-v$version");
123
+ expect(text).toContain("https://github.com/jqlang/jq/releases/download/jq-$version");
124
+ });
125
+
77
126
  it("installs the same services specialist-cli accepts", () => {
78
127
  const services = /^SERVICES="([^"]+)"$/m.exec(readFileSync(SCRIPT, "utf-8"))?.[1].split(" ");
79
128
  const allowed = /^ {2}([a-z|]+)\) ;;$/m.exec(readFileSync(ENTRY, "utf-8"))?.[1].split("|");
@@ -13,30 +13,16 @@ const SERVICE = "inference";
13
13
  interface InferenceConfig extends Record<string, string> {
14
14
  nexusHost: string;
15
15
  nexusUser: string;
16
- hermesHost: string;
17
- hermesUser: string;
18
- pulsarHost: string;
19
- pulsarUser: string;
20
16
  europaHealthUrl: string;
21
- /** GitHub repository whose releases carry the Hermes upgrade notes, owner/name. */
22
- hermesRepo: string;
23
17
  }
24
18
 
25
19
  const SHAPE = {
26
20
  nexusHost: "string",
27
21
  nexusUser: "string",
28
- hermesHost: "string",
29
- hermesUser: "string",
30
- pulsarHost: "string",
31
- pulsarUser: "string",
32
22
  europaHealthUrl: "string",
33
- hermesRepo: "string",
34
23
  } as const;
35
24
 
36
- type Target = "nexus" | "hermes" | "pulsar";
37
-
38
25
  interface RemoteSpec {
39
- target: Target;
40
26
  args: number;
41
27
  help: string;
42
28
  timeoutMs: number;
@@ -45,40 +31,27 @@ interface RemoteSpec {
45
31
 
46
32
  const MINUTE = 60_000;
47
33
 
48
- /** Fixed remote commands; the forced commands on Nexus (nexus), LXC 101 (hermes), and Pulsar (root) accept exactly these names. */
34
+ /** Fixed remote commands; the forced command on Nexus (user nexus) accepts exactly these names. */
49
35
  export const INFERENCE_COMMANDS: Readonly<Record<string, RemoteSpec>> = {
50
- "nexus-status": { target: "nexus", args: 0, help: "LaunchAgent states and /health codes of the embed and rerank servers", timeoutMs: MINUTE },
51
- "nexus-restart": { target: "nexus", args: 1, help: "nexus-restart <embed|rerank> launchctl kickstart -k", timeoutMs: 2 * MINUTE },
52
- "nexus-start": { target: "nexus", args: 1, help: "nexus-start <embed|rerank> launchctl bootstrap from the tracked plist", timeoutMs: 2 * MINUTE },
53
- "nexus-stop": { target: "nexus", args: 1, help: "nexus-stop <embed|rerank> --confirm launchctl bootout", timeoutMs: 2 * MINUTE, confirm: true },
54
- "nexus-log": { target: "nexus", args: 2, help: "nexus-log <embed|rerank> <lines>", timeoutMs: MINUTE },
55
- "nexus-disk": { target: "nexus", args: 0, help: "free space on the Nexus disk", timeoutMs: MINUTE },
56
- "hermes-status": { target: "hermes", args: 0, help: "both gateway user services", timeoutMs: MINUTE },
57
- "hermes-journal": { target: "hermes", args: 1, help: "hermes-journal <since> user journal of both gateways", timeoutMs: MINUTE },
58
- "hermes-errors": { target: "hermes", args: 0, help: "hermes logs errors", timeoutMs: MINUTE },
59
- "hermes-restart": { target: "hermes", args: 1, help: "hermes-restart <gateway|api>", timeoutMs: 2 * MINUTE },
60
- "hermes-api-health": { target: "hermes", args: 0, help: "/health and /v1/models codes on localhost:8650 (expect 200 and 401)", timeoutMs: MINUTE },
61
- "hermes-version": { target: "hermes", args: 0, help: "installed Hermes version", timeoutMs: MINUTE },
62
- "hermes-guest-status": { target: "pulsar", args: 0, help: "pct status 101", timeoutMs: MINUTE },
63
- "hermes-snapshots": { target: "pulsar", args: 0, help: "pct listsnapshot 101", timeoutMs: MINUTE },
64
- "hermes-upgrade": { target: "pulsar", args: 0, help: "hermes-upgrade --confirm snapshot LXC 101, hermes update, restart, health check", timeoutMs: 20 * MINUTE, confirm: true },
36
+ "nexus-status": { args: 0, help: "LaunchAgent states and /health codes of the embed and rerank servers", timeoutMs: MINUTE },
37
+ "nexus-restart": { args: 1, help: "nexus-restart <embed|rerank> launchctl kickstart -k", timeoutMs: 2 * MINUTE },
38
+ "nexus-start": { args: 1, help: "nexus-start <embed|rerank> launchctl bootstrap from the tracked plist", timeoutMs: 2 * MINUTE },
39
+ "nexus-stop": { args: 1, help: "nexus-stop <embed|rerank> --confirm launchctl bootout", timeoutMs: 2 * MINUTE, confirm: true },
40
+ "nexus-log": { args: 2, help: "nexus-log <embed|rerank> <lines>", timeoutMs: MINUTE },
41
+ "nexus-disk": { args: 0, help: "free space on the Nexus disk", timeoutMs: MINUTE },
65
42
  };
66
43
 
67
44
  const NEXUS_SERVICES = ["embed", "rerank"];
68
- const HERMES_SERVICES = ["gateway", "api"];
69
- const SINCE = /^[A-Za-z0-9 :\-+]{1,40}$/;
70
45
 
71
- const HELP = `inference specialist (llama.cpp on Nexus, Hermes on LXC 101, Europa health)
46
+ const HELP = `inference specialist (llama.cpp on Nexus, Europa health)
72
47
 
73
48
  <command> [args] fixed operations through forced-command SSH keys
74
49
  ${Object.entries(INFERENCE_COMMANDS)
75
50
  .map(([name, c]) => ` ${name.padEnd(22)} ${c.help}`)
76
51
  .join("\n")}
77
52
  europa-health GET the Europa embedding /health over HTTP (Europa is usually off by design)
78
- hermes-releases [n] latest n upstream Hermes releases, notes cut at 6000 chars (default 5)
79
- hermes-release <tag> one release with its full notes; read every release between the installed version and the target before hermes-upgrade
80
53
 
81
- Europa control and Hermes configuration are operator tasks.`;
54
+ Europa control is an operator task.`;
82
55
 
83
56
  export function buildInferenceRemote(args: string[]): { remote: string; spec: RemoteSpec } {
84
57
  const [name, ...given] = args;
@@ -91,63 +64,14 @@ export function buildInferenceRemote(args: string[]): { remote: string; spec: Re
91
64
  rest = rest.filter((_, i) => i !== index);
92
65
  }
93
66
  if (rest.length !== spec.args) throw new UsageError(`${name} takes ${spec.args} argument(s)`);
94
- if (name.startsWith("nexus-") && spec.args >= 1 && !NEXUS_SERVICES.includes(rest[0])) throw new UsageError(`service must be one of ${NEXUS_SERVICES.join(", ")}`);
67
+ if (spec.args >= 1 && !NEXUS_SERVICES.includes(rest[0])) throw new UsageError(`service must be one of ${NEXUS_SERVICES.join(", ")}`);
95
68
  if (name === "nexus-log") {
96
69
  const n = Number(rest[1]);
97
70
  if (!Number.isInteger(n) || n < 1 || n > 2000) throw new UsageError("lines must be an integer from 1 to 2000");
98
71
  }
99
- if (name === "hermes-restart" && !HERMES_SERVICES.includes(rest[0])) throw new UsageError(`service must be one of ${HERMES_SERVICES.join(", ")}`);
100
- if (name === "hermes-journal" && !SINCE.test(rest[0])) throw new UsageError("since: letters, digits, spaces, colon, plus, minus only");
101
72
  return { remote: [name, ...rest].join(" "), spec };
102
73
  }
103
74
 
104
- interface GithubRelease {
105
- tag_name?: string;
106
- name?: string;
107
- published_at?: string;
108
- html_url?: string;
109
- prerelease?: boolean;
110
- body?: string;
111
- }
112
-
113
- /** Trims GitHub release objects to what an upgrade decision needs. */
114
- export function compactReleases(releases: GithubRelease[]): { tag: string; name: string; published: string; prerelease: boolean; url: string; notes: string }[] {
115
- return releases.map((r) => ({
116
- tag: r.tag_name ?? "?",
117
- name: r.name ?? "",
118
- published: r.published_at ?? "",
119
- prerelease: r.prerelease === true,
120
- url: r.html_url ?? "",
121
- notes: (r.body ?? "").slice(0, 6000),
122
- }));
123
- }
124
-
125
- const RELEASE_TAG = /^[A-Za-z0-9._-]{1,60}$/;
126
-
127
- async function github(config: InferenceConfig, path: string): Promise<unknown> {
128
- if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(config.hermesRepo)) throw new ServiceError("hermesRepo must be owner/name");
129
- const response = await fetch(`https://api.github.com/repos/${config.hermesRepo}${path}`, {
130
- headers: { Accept: "application/vnd.github+json", "User-Agent": "pi-astro-inference-specialist" },
131
- signal: AbortSignal.timeout(30_000),
132
- });
133
- if (!response.ok) throw new ServiceError(`GitHub answered ${response.status} for ${config.hermesRepo}${path}`);
134
- return response.json();
135
- }
136
-
137
- async function hermesReleases(config: InferenceConfig, countArg: string | undefined): Promise<number> {
138
- const n = countArg === undefined ? 5 : Number(countArg);
139
- if (!Number.isInteger(n) || n < 1 || n > 30) throw new UsageError("hermes-releases [n]: n must be 1..30");
140
- printJson({ repository: config.hermesRepo, releases: compactReleases((await github(config, `/releases?per_page=${n}`)) as GithubRelease[]) });
141
- return 0;
142
- }
143
-
144
- async function hermesRelease(config: InferenceConfig, tag: string | undefined): Promise<number> {
145
- if (tag === undefined || !RELEASE_TAG.test(tag)) throw new UsageError("hermes-release <tag>, e.g. v2026.9.14");
146
- const release = (await github(config, `/releases/tags/${encodeURIComponent(tag)}`)) as GithubRelease;
147
- printJson({ repository: config.hermesRepo, ...compactReleases([release])[0], notes: release.body ?? "" });
148
- return 0;
149
- }
150
-
151
75
  async function europaHealth(config: InferenceConfig): Promise<number> {
152
76
  const result = await run("/usr/bin/curl", ["--silent", "--output", "/dev/null", "--max-time", "5", "--write-out", "%{http_code}", config.europaHealthUrl], { timeoutMs: 15_000 });
153
77
  const httpCode = result.stdout.trim();
@@ -163,15 +87,9 @@ export async function command(args: string[]): Promise<number> {
163
87
  }
164
88
  const config = readConfig<InferenceConfig>(SERVICE, SHAPE);
165
89
  if (args[0] === "europa-health") return europaHealth(config);
166
- if (args[0] === "hermes-releases") return hermesReleases(config, args[1]);
167
- if (args[0] === "hermes-release") return hermesRelease(config, args[1]);
168
90
  const { remote, spec } = buildInferenceRemote(args);
169
- const targets: Record<Target, { host: string; user: string; keyFile: string }> = {
170
- nexus: { host: config.nexusHost, user: config.nexusUser, keyFile: secretPath(SERVICE, "SSH_KEY_NEXUS") },
171
- hermes: { host: config.hermesHost, user: config.hermesUser, keyFile: secretPath(SERVICE, "SSH_KEY_HERMES") },
172
- pulsar: { host: config.pulsarHost, user: config.pulsarUser, keyFile: secretPath(SERVICE, "SSH_KEY_PULSAR") },
173
- };
174
- const result = await sshFixed({ ...targets[spec.target], knownHostsFile: join(specialistsHome(), "config", "known_hosts") }, remote, spec.timeoutMs);
91
+ const nexus = { host: config.nexusHost, user: config.nexusUser, keyFile: secretPath(SERVICE, "SSH_KEY_NEXUS") };
92
+ const result = await sshFixed({ ...nexus, knownHostsFile: join(specialistsHome(), "config", "known_hosts") }, remote, spec.timeoutMs);
175
93
  if (result.stdout.length > 0) printRaw(result.stdout);
176
94
  if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `${args[0]} exited ${result.code}`);
177
95
  return 0;
@@ -75,6 +75,29 @@ describe("Arcane host commands", () => {
75
75
  ["arcane-backup.timer", null],
76
76
  ]);
77
77
  });
78
+
79
+ it("compares the probes in their subfolder and ignores names outside host/", () => {
80
+ dir = mkdtempSync(join(tmpdir(), "audit-"));
81
+ mkdirSync(join(dir, "host", "spc-arcane-probes"), { recursive: true });
82
+ writeFileSync(join(dir, "host", "spc-arcane-probes", "host_probe.py"), "print(1)\n");
83
+ writeFileSync(join(dir, "outside.txt"), "x\n");
84
+ const probe = createHash("sha256").update("print(1)\n").digest("hex");
85
+ const outside = createHash("sha256").update("x\n").digest("hex");
86
+ expect(
87
+ compareAudit(
88
+ [
89
+ { name: "spc-arcane-probes/host_probe.py", state: "present", sha256: probe },
90
+ { name: "../outside.txt", state: "present", sha256: outside },
91
+ { name: "/etc/passwd", state: "present", sha256: outside },
92
+ ],
93
+ dir,
94
+ ).map((r) => [r.name, r.match]),
95
+ ).toEqual([
96
+ ["spc-arcane-probes/host_probe.py", true],
97
+ ["../outside.txt", null],
98
+ ["/etc/passwd", null],
99
+ ]);
100
+ });
78
101
  });
79
102
  });
80
103
 
@@ -115,7 +138,7 @@ describe("deployment checks", () => {
115
138
  const expect_ = { project: "grimoire", commit: "abc1234", services: { "grimoire-api-1": D1 }, jobs: { "grimoire-migrate": D2 }, noHealth: [] };
116
139
  const sync = syncFacts({ lastSyncCommit: "abc1234ffff", lastSyncStatus: "success", lastSyncAt: "t1" });
117
140
 
118
- it("parses the arguments arcane-ops took", () => {
141
+ it("parses the host command arguments", () => {
119
142
  expect(parseDeploymentArgs(["wait", "grimoire", "--commit", "abc1234", "--container", `grimoire-api-1=${D1}`, "--timeout", "120"])).toMatchObject({
120
143
  wait: true,
121
144
  timeoutS: 120,
@@ -134,15 +134,19 @@ export interface AuditFile {
134
134
  gid?: number;
135
135
  }
136
136
 
137
+ /** A tracked name under `host/`, such as `arcane-backup.sh` or `spc-arcane-probes/host_probe.py`; no `..`, no absolute path. */
138
+ const AUDIT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
139
+
137
140
  /**
138
141
  * Compares the host's file hashes with `host/<name>` in the specialist's
139
- * checkout of the arcane repository; `match` is null when either side is missing.
142
+ * checkout of the arcane repository; `match` is null when either side is missing
143
+ * or the host reports a name outside `host/`.
140
144
  */
141
145
  export function compareAudit(files: readonly AuditFile[], checkout: string): { name: string; state: string; match: boolean | null; mode?: number; uid?: number; gid?: number }[] {
142
146
  return files.map((f) => {
143
147
  const source = join(checkout, "host", f.name);
144
148
  let expected: string | null = null;
145
- if (existsSync(source) && lstatSync(source).isFile()) expected = createHash("sha256").update(readFileSync(source)).digest("hex");
149
+ if (AUDIT_NAME.test(f.name) && existsSync(source) && lstatSync(source).isFile()) expected = createHash("sha256").update(readFileSync(source)).digest("hex");
146
150
  return { name: f.name, state: f.state, match: f.state !== "present" || expected === null ? null : f.sha256 === expected, mode: f.mode, uid: f.uid, gid: f.gid };
147
151
  });
148
152
  }
@@ -119,7 +119,7 @@ export function syncFacts(row: Record<string, unknown>): SyncFacts {
119
119
  };
120
120
  }
121
121
 
122
- /** The checks of arcane-ops `deployment check`, as one pure comparison. */
122
+ /** The deployment checks (sync commit and state, hook, each container's project, image, state, health) as one pure comparison. */
123
123
  export function deploymentChecks(expect: DeploymentExpectation, sync: SyncFacts, containers: readonly (ContainerFacts | null)[], syncAfter: SyncFacts): { status: "ok" | "failed" | "unknown"; checks: DeploymentCheck[] } {
124
124
  const checks: DeploymentCheck[] = [];
125
125
  const commitOk = sync.commit !== null && (sync.commit.startsWith(expect.commit) || expect.commit.startsWith(sync.commit));
@@ -12,8 +12,8 @@ client_file="$config_dir/client.env"
12
12
  password_file="$config_dir/master-password"
13
13
  root="$home/.specialists"
14
14
  collection_name="${SPECIALISTS_COLLECTION:-specialists}"
15
- BW=/opt/homebrew/bin/bw
16
- JQ=/opt/homebrew/bin/jq
15
+ BW=/usr/local/libexec/specialist-bw/current/bw
16
+ JQ=/usr/local/libexec/specialist-jq/current/jq
17
17
 
18
18
  for f in "$client_file" "$password_file"; do
19
19
  [ -f "$f" ] || { echo "missing $f" >&2; exit 2; }
@@ -7,7 +7,7 @@ import { buildBackupRemote, command as backupCommand } from "./backup/run.ts";
7
7
  import { parseParams, parseRecordFilters, technitiumRequest, command as dnsCommand } from "./dns/run.ts";
8
8
  import { buildVpsRemote, command as edgeCommand, isTransientSshFailure, PUBLIC_ROUTES, routeProbe } from "./edge/run.ts";
9
9
  import { API_PREFIXES, buildDmzRemote, validateApiPath, command as identityCommand } from "./identity/run.ts";
10
- import { buildInferenceRemote, compactReleases, command as inferenceCommand } from "./inference/run.ts";
10
+ import { buildInferenceRemote, command as inferenceCommand } from "./inference/run.ts";
11
11
  import { MAX_ADD_IDS, buildTrendarrCall, interpretRun, parseImdbIds, trendarrConfig, trendarrError, trendarrWarnings, command as mediaCommand } from "./media/run.ts";
12
12
  import { ServiceError, UsageError } from "./lib/errors.ts";
13
13
  import { safeRepoPath, safeWritePath } from "./lib/repo.ts";
@@ -189,7 +189,7 @@ describe("edge wrapper", () => {
189
189
  const curl = async (args: string[]) => {
190
190
  seen.push(args);
191
191
  const url = args.at(-1) ?? "";
192
- return { stdout: url.includes("/auth/v1/settings") ? "401 0.12" : url.includes("studio") ? "302 0.10" : "200 0.05", code: 0 };
192
+ return { stdout: url.includes("/auth/v1/settings") ? "401 0.12" : url.includes("oauth-authorization-server") ? "404 0.08" : url.includes("studio") || url.includes("mealie") ? "302 0.10" : "200 0.05", code: 0 };
193
193
  };
194
194
  const all = await routeProbe([], curl);
195
195
  expect(all).toHaveLength(Object.keys(PUBLIC_ROUTES).length);
@@ -429,31 +429,21 @@ describe("proxmox wrapper", () => {
429
429
  });
430
430
 
431
431
  describe("inference wrapper", () => {
432
- it("maps commands to targets and enforces confirmation", () => {
433
- expect(buildInferenceRemote(["nexus-status"])).toMatchObject({ remote: "nexus-status", spec: { target: "nexus" } });
432
+ it("maps commands to fixed remote strings and enforces confirmation", () => {
433
+ expect(buildInferenceRemote(["nexus-status"]).remote).toBe("nexus-status");
434
434
  expect(buildInferenceRemote(["nexus-log", "embed", "100"]).remote).toBe("nexus-log embed 100");
435
435
  expect(buildInferenceRemote(["nexus-stop", "rerank", "--confirm"]).remote).toBe("nexus-stop rerank");
436
- expect(buildInferenceRemote(["hermes-upgrade", "--confirm"])).toMatchObject({ remote: "hermes-upgrade", spec: { target: "pulsar" } });
437
- expect(buildInferenceRemote(["hermes-journal", "1 hour ago"])).toMatchObject({ spec: { target: "hermes" } });
438
436
  expect(() => buildInferenceRemote(["nexus-stop", "rerank"])).toThrow(/--confirm/);
439
- expect(() => buildInferenceRemote(["hermes-upgrade"])).toThrow(/--confirm/);
440
437
  expect(() => buildInferenceRemote(["nexus-restart", "server"])).toThrow(/service/);
441
438
  expect(() => buildInferenceRemote(["nexus-log", "embed", "5000"])).toThrow(/lines/);
442
- expect(() => buildInferenceRemote(["hermes-restart", "discord"])).toThrow(/service/);
443
439
  expect(() => buildInferenceRemote(["europa-start"])).toThrow(/unknown command/);
444
- });
445
-
446
- it("compacts github releases", () => {
447
- const releases = compactReleases([{ tag_name: "v2026.9.1", name: "Sept", published_at: "2026-09-10T00:00:00Z", html_url: "https://x/y", prerelease: false, body: "x".repeat(7000) }, {}]);
448
- expect(releases[0]).toMatchObject({ tag: "v2026.9.1", name: "Sept", prerelease: false, url: "https://x/y" });
449
- expect(releases[0].notes.length).toBe(6000);
450
- expect(releases[1]).toEqual({ tag: "?", name: "", published: "", prerelease: false, url: "", notes: "" });
440
+ expect(() => buildInferenceRemote(["hermes-status"])).toThrow(/unknown command/);
451
441
  });
452
442
 
453
443
  it("prints help without touching config", async () => {
454
444
  const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
455
445
  expect(await inferenceCommand(["help"])).toBe(0);
456
- expect(String(write.mock.calls[0][0])).toContain("hermes-releases");
446
+ expect(String(write.mock.calls[0][0])).toContain("europa-health");
457
447
  write.mockRestore();
458
448
  });
459
449
  });
@@ -1,27 +0,0 @@
1
- #!/bin/sh
2
- # Forced command for the spc-inference key on LXC 101 (user hermes, no sudo).
3
- # authorized_keys line in /home/hermes/.ssh/authorized_keys:
4
- # command="/usr/local/sbin/spc-inference-hermes-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-inference-hermes@cortex
5
- set -eu
6
- export PATH="$HOME/.local/bin:$HOME/.hermes/hermes-agent/venv/bin:/usr/local/bin:/usr/bin:/bin"
7
- export XDG_RUNTIME_DIR="/run/user/$(id -u)"
8
- cmd="${SSH_ORIGINAL_COMMAND:-}"
9
- name="${cmd%% *}"
10
- rest=""
11
- [ "$cmd" != "$name" ] && rest="${cmd#* }"
12
- since_ok() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9 :+-]{1,40}$'; }
13
- units="hermes-gateway.service hermes-gateway-api.service"
14
-
15
- case "$name" in
16
- hermes-status) systemctl --user status $units --no-pager --lines=5 || true ;;
17
- hermes-journal) since_ok "$rest" || { echo "bad since" >&2; exit 2; }; journalctl --user -u hermes-gateway.service -u hermes-gateway-api.service --since "$rest" --no-pager -n 200 ;;
18
- hermes-errors) hermes logs errors ;;
19
- hermes-restart)
20
- case "$rest" in gateway) u=hermes-gateway.service ;; api) u=hermes-gateway-api.service ;; *) echo "bad service" >&2; exit 2 ;; esac
21
- systemctl --user restart "$u"; sleep 3; systemctl --user status "$u" --no-pager --lines=5 || true ;;
22
- hermes-api-health)
23
- printf 'health=%s\n' "$(curl -s -o /dev/null -w '%{http_code}' -m 5 http://localhost:8650/health || echo 000)"
24
- printf 'models=%s (401 expected)\n' "$(curl -s -o /dev/null -w '%{http_code}' -m 5 http://localhost:8650/v1/models || echo 000)" ;;
25
- hermes-version) hermes --version | head -1 ;;
26
- *) echo "refused: $cmd" >&2; exit 2 ;;
27
- esac
@@ -1,14 +0,0 @@
1
- #!/bin/sh
2
- # Forced command for the spc-inference key on Pulsar (root): Hermes guest operations.
3
- # authorized_keys line:
4
- # command="/usr/local/sbin/spc-inference-pulsar-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-inference-pulsar@cortex
5
- set -eu
6
- export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
7
- cmd="${SSH_ORIGINAL_COMMAND:-}"
8
-
9
- case "$cmd" in
10
- hermes-guest-status) pct status 101 ;;
11
- hermes-snapshots) pct listsnapshot 101 ;;
12
- hermes-upgrade) /usr/local/bin/hermes-upgrade ;;
13
- *) echo "refused: $cmd" >&2; exit 2 ;;
14
- esac