@astrofoundry/pi-astro 0.19.4 → 0.20.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,330 @@
1
+ import { join } from "node:path";
2
+ import { readConfig } from "../lib/config.ts";
3
+ import { ServiceError, UsageError } from "../lib/errors.ts";
4
+ import { main } from "../lib/main.ts";
5
+ import { printJson, printRaw } from "../lib/output.ts";
6
+ import { specialistsHome } from "../lib/paths.ts";
7
+ import { pinnedRequest } from "../lib/pinned.ts";
8
+ import { redactSecrets } from "../lib/sanitize.ts";
9
+ import { readSecret, secretPath } from "../lib/secrets.ts";
10
+ import { sshFixed } from "../lib/ssh.ts";
11
+
12
+ const SERVICE = "proxmox";
13
+
14
+ interface ProxmoxConfig extends Record<string, string> {
15
+ /** e.g. https://10.0.10.37:8006 */
16
+ apiUrl: string;
17
+ /** SHA-256 fingerprint of the pveproxy certificate, colon-separated uppercase hex. */
18
+ certSha256: string;
19
+ node: string;
20
+ pulsarHost: string;
21
+ pulsarUser: string;
22
+ }
23
+
24
+ const SHAPE = { apiUrl: "string", certSha256: "string", node: "string", pulsarHost: "string", pulsarUser: "string" } as const;
25
+
26
+ const TIMEOUT_MS = 30_000;
27
+ const TASK_POLL_MS = 2_000;
28
+ const VMID = /^\d{3,9}$/;
29
+ const SNAPNAME = /^[A-Za-z][A-Za-z0-9_-]{0,39}$/;
30
+ const API_PATH = /^\/[A-Za-z0-9_\-/.?=&%]{1,300}$/;
31
+
32
+ /** Guest power actions; all but start need --confirm. */
33
+ export const POWER_ACTIONS = ["start", "stop", "shutdown", "reboot"] as const;
34
+ type PowerAction = (typeof POWER_ACTIONS)[number];
35
+
36
+ /** Config keys `set` may change, per guest type; the role grants no disk or allocation privilege. */
37
+ export const SET_KEYS: Readonly<Record<"qemu" | "lxc", readonly string[]>> = {
38
+ qemu: ["cores", "sockets", "memory", "balloon", "onboot", "startup", "description", "tags", "protection"],
39
+ lxc: ["cores", "memory", "swap", "onboot", "startup", "description", "tags", "protection"],
40
+ };
41
+ const NET_KEY = /^net\d{1,2}$/;
42
+
43
+ /** Fixed remote commands on Pulsar; the forced command accepts exactly these names. */
44
+ export const HOST_COMMANDS: Readonly<Record<string, { args: number; help: string }>> = {
45
+ "host-status": { args: 0, help: "pveversion, uptime, load, memory, root disk, temperatures" },
46
+ "host-journal": { args: 1, help: "host-journal <since> warnings and errors of the host journal" },
47
+ updates: { args: 0, help: "upgradable packages on the host" },
48
+ "guest-exec": { args: 2, help: "guest-exec <vmid> <status|journal|df|updates> fixed read inside a guest (pct exec or qm guest exec)" },
49
+ };
50
+ export const GUEST_EXEC = ["status", "journal", "df", "updates"];
51
+ const SINCE = /^[A-Za-z0-9 :\-+]{1,40}$/;
52
+
53
+ const HELP = `proxmox specialist (Proxmox VE on Pulsar)
54
+
55
+ guests every VM and container: vmid, name, type, status, resources (JSON)
56
+ guest <vmid> current status and configuration of one guest (JSON)
57
+ get <path> any GET under /api2/json, e.g. /nodes/pulsar/status, /cluster/resources?type=storage
58
+ tasks [n] last n tasks on the node (default 20)
59
+ task <upid> status and log of one task
60
+ ${POWER_ACTIONS.join("|")} <vmid> [--confirm] power action, waits for the task; --confirm needed except for start
61
+ snapshots <vmid> snapshot list
62
+ snapshot-create <vmid> <name> [description]
63
+ snapshot-delete <vmid> <name> --confirm
64
+ snapshot-rollback <vmid> <name> --confirm
65
+ set <vmid> key=value... VM: ${SET_KEYS.qemu.join(", ")}, netN; CT: ${SET_KEYS.lxc.join(", ")}, netN
66
+ ${Object.entries(HOST_COMMANDS)
67
+ .map(([name, c]) => ` ${name.padEnd(35)} ${c.help}`)
68
+ .join("\n")}
69
+
70
+ Not available: create, clone, destroy, disk changes, host power or configuration. Those are operator tasks.`;
71
+
72
+ interface PveEnvelope {
73
+ data?: unknown;
74
+ errors?: Record<string, string>;
75
+ message?: string;
76
+ }
77
+
78
+ interface GuestResource {
79
+ vmid: number;
80
+ name?: string;
81
+ type: "qemu" | "lxc";
82
+ node: string;
83
+ status: string;
84
+ maxmem?: number;
85
+ mem?: number;
86
+ maxcpu?: number;
87
+ cpu?: number;
88
+ maxdisk?: number;
89
+ uptime?: number;
90
+ tags?: string;
91
+ }
92
+
93
+ async function api(config: ProxmoxConfig, method: string, path: string, form?: Record<string, string>): Promise<unknown> {
94
+ const tokenId = readSecret(SERVICE, "PVE_TOKEN_ID");
95
+ const secret = readSecret(SERVICE, "PVE_TOKEN_SECRET");
96
+ const body = form === undefined ? undefined : new URLSearchParams(form).toString();
97
+ const response = await pinnedRequest(new URL(`/api2/json${path}`, config.apiUrl), {
98
+ method,
99
+ headers: {
100
+ Authorization: `PVEAPIToken=${tokenId}=${secret}`,
101
+ Accept: "application/json",
102
+ ...(body === undefined ? {} : { "Content-Type": "application/x-www-form-urlencoded", "Content-Length": String(Buffer.byteLength(body)) }),
103
+ },
104
+ body,
105
+ expectedFingerprint: config.certSha256,
106
+ timeoutMs: TIMEOUT_MS,
107
+ });
108
+ let envelope: PveEnvelope = {};
109
+ try {
110
+ envelope = JSON.parse(response.body) as PveEnvelope;
111
+ } catch {
112
+ /* error bodies can be plain text */
113
+ }
114
+ if (response.status < 200 || response.status >= 300) {
115
+ const detail = envelope.errors ? Object.entries(envelope.errors).map(([k, v]) => `${k}: ${v}`).join("; ") : envelope.message ?? response.body.trim();
116
+ throw new ServiceError(`Proxmox ${method} ${path} answered ${response.status}${detail ? `: ${detail}` : ""}`);
117
+ }
118
+ return envelope.data;
119
+ }
120
+
121
+ export function parseVmid(value: string | undefined): number {
122
+ if (value === undefined || !VMID.test(value)) throw new UsageError("vmid must be a number from 100 upwards");
123
+ return Number(value);
124
+ }
125
+
126
+ export function validateApiPath(path: string | undefined): string {
127
+ if (path === undefined || !API_PATH.test(path) || path.includes("..")) throw new UsageError("path must start with / and contain no spaces");
128
+ return path;
129
+ }
130
+
131
+ export function parseSetPairs(type: "qemu" | "lxc", pairs: string[]): Record<string, string> {
132
+ if (pairs.length === 0) throw new UsageError("set <vmid> key=value...");
133
+ const form: Record<string, string> = {};
134
+ for (const pair of pairs) {
135
+ const eq = pair.indexOf("=");
136
+ if (eq <= 0) throw new UsageError(`expected key=value, got ${pair}`);
137
+ const key = pair.slice(0, eq);
138
+ if (!SET_KEYS[type].includes(key) && !NET_KEY.test(key)) throw new UsageError(`${key} is not a key this specialist may set on a ${type} guest`);
139
+ form[key] = pair.slice(eq + 1);
140
+ }
141
+ return form;
142
+ }
143
+
144
+ export function needsConfirm(args: string[]): string[] {
145
+ const index = args.indexOf("--confirm");
146
+ if (index === -1) throw new UsageError(`${args[0]} interrupts a guest; add --confirm when the task asks for it explicitly`);
147
+ return args.filter((_, i) => i !== index);
148
+ }
149
+
150
+ async function guestResource(config: ProxmoxConfig, vmid: number): Promise<GuestResource> {
151
+ const resources = (await api(config, "GET", "/cluster/resources?type=vm")) as GuestResource[];
152
+ const guest = resources.find((r) => r.vmid === vmid);
153
+ if (!guest) throw new UsageError(`no guest with vmid ${vmid}`);
154
+ return guest;
155
+ }
156
+
157
+ function guestPath(guest: GuestResource): string {
158
+ return `/nodes/${guest.node}/${guest.type}/${guest.vmid}`;
159
+ }
160
+
161
+ interface TaskStatus {
162
+ status: string;
163
+ exitstatus?: string;
164
+ type?: string;
165
+ starttime?: number;
166
+ endtime?: number;
167
+ }
168
+
169
+ async function waitTask(config: ProxmoxConfig, upid: string, timeoutMs: number): Promise<TaskStatus> {
170
+ const deadline = Date.now() + timeoutMs;
171
+ for (;;) {
172
+ const status = (await api(config, "GET", `/nodes/${config.node}/tasks/${encodeURIComponent(upid)}/status`)) as TaskStatus;
173
+ if (status.status === "stopped") return status;
174
+ if (Date.now() > deadline) throw new ServiceError(`task ${upid} still running after ${timeoutMs} ms`);
175
+ await new Promise((resolve) => setTimeout(resolve, TASK_POLL_MS));
176
+ }
177
+ }
178
+
179
+ async function runTask(config: ProxmoxConfig, method: string, path: string, form: Record<string, string> | undefined, label: string): Promise<number> {
180
+ const upid = (await api(config, method, path, form)) as string;
181
+ const status = await waitTask(config, upid, 300_000);
182
+ printJson({ action: label, upid, exitstatus: status.exitstatus, endtime: status.endtime });
183
+ if (status.exitstatus !== "OK") throw new ServiceError(`${label} failed: ${status.exitstatus ?? "unknown"}`);
184
+ return 0;
185
+ }
186
+
187
+ async function apiCommand(config: ProxmoxConfig, args: string[]): Promise<number> {
188
+ const [name, ...rest] = args;
189
+ switch (name) {
190
+ case "guests": {
191
+ const resources = (await api(config, "GET", "/cluster/resources?type=vm")) as GuestResource[];
192
+ printJson(
193
+ resources
194
+ .sort((a, b) => a.vmid - b.vmid)
195
+ .map(({ vmid, name: guestName, type, status, node, maxmem, mem, maxcpu, cpu, maxdisk, uptime, tags }) => ({
196
+ vmid,
197
+ name: guestName,
198
+ type,
199
+ status,
200
+ node,
201
+ maxmem,
202
+ mem,
203
+ maxcpu,
204
+ cpu,
205
+ maxdisk,
206
+ uptime,
207
+ tags,
208
+ })),
209
+ );
210
+ return 0;
211
+ }
212
+ case "guest": {
213
+ const guest = await guestResource(config, parseVmid(rest[0]));
214
+ const base = guestPath(guest);
215
+ const [status, guestConfig] = await Promise.all([api(config, "GET", `${base}/status/current`), api(config, "GET", `${base}/config`)]);
216
+ printJson({ guest, status, config: redactSecrets(guestConfig) });
217
+ return 0;
218
+ }
219
+ case "get":
220
+ if (rest.length !== 1) throw new UsageError("get <path>");
221
+ printJson(redactSecrets(await api(config, "GET", validateApiPath(rest[0]))));
222
+ return 0;
223
+ case "tasks": {
224
+ const n = rest[0] === undefined ? 20 : Number(rest[0]);
225
+ if (!Number.isInteger(n) || n < 1 || n > 200) throw new UsageError("tasks [n]: n must be 1..200");
226
+ printJson(await api(config, "GET", `/nodes/${config.node}/tasks?limit=${n}`));
227
+ return 0;
228
+ }
229
+ case "task": {
230
+ if (rest.length !== 1 || !/^UPID:[A-Za-z0-9:_.@-]+$/.test(rest[0])) throw new UsageError("task <upid>");
231
+ const upid = encodeURIComponent(rest[0]);
232
+ const [status, log] = await Promise.all([
233
+ api(config, "GET", `/nodes/${config.node}/tasks/${upid}/status`),
234
+ api(config, "GET", `/nodes/${config.node}/tasks/${upid}/log?limit=200`),
235
+ ]);
236
+ printJson({ status, log });
237
+ return 0;
238
+ }
239
+ case "start":
240
+ case "stop":
241
+ case "shutdown":
242
+ case "reboot": {
243
+ const action = name as PowerAction;
244
+ const positional = action === "start" ? rest : needsConfirm(args).slice(1);
245
+ if (positional.length !== 1) throw new UsageError(`${action} <vmid>${action === "start" ? "" : " --confirm"}`);
246
+ const guest = await guestResource(config, parseVmid(positional[0]));
247
+ return runTask(config, "POST", `${guestPath(guest)}/status/${action}`, {}, `${action} ${guest.vmid}`);
248
+ }
249
+ case "snapshots": {
250
+ const guest = await guestResource(config, parseVmid(rest[0]));
251
+ printJson(await api(config, "GET", `${guestPath(guest)}/snapshot`));
252
+ return 0;
253
+ }
254
+ case "snapshot-create": {
255
+ if (rest.length < 2 || rest.length > 3) throw new UsageError("snapshot-create <vmid> <name> [description]");
256
+ if (!SNAPNAME.test(rest[1])) throw new UsageError("snapshot name: letter first, then letters, digits, dash, underscore");
257
+ const guest = await guestResource(config, parseVmid(rest[0]));
258
+ const form: Record<string, string> = { snapname: rest[1], ...(rest[2] === undefined ? {} : { description: rest[2] }) };
259
+ return runTask(config, "POST", `${guestPath(guest)}/snapshot`, form, `snapshot-create ${guest.vmid} ${rest[1]}`);
260
+ }
261
+ case "snapshot-delete":
262
+ case "snapshot-rollback": {
263
+ const positional = needsConfirm(args).slice(1);
264
+ if (positional.length !== 2 || !SNAPNAME.test(positional[1])) throw new UsageError(`${name} <vmid> <name> --confirm`);
265
+ const guest = await guestResource(config, parseVmid(positional[0]));
266
+ const snapshotPath = `${guestPath(guest)}/snapshot/${positional[1]}`;
267
+ return name === "snapshot-delete"
268
+ ? runTask(config, "DELETE", snapshotPath, undefined, `snapshot-delete ${guest.vmid} ${positional[1]}`)
269
+ : runTask(config, "POST", `${snapshotPath}/rollback`, {}, `snapshot-rollback ${guest.vmid} ${positional[1]}`);
270
+ }
271
+ case "set": {
272
+ const guest = await guestResource(config, parseVmid(rest[0]));
273
+ const form = parseSetPairs(guest.type, rest.slice(1));
274
+ await api(config, "PUT", `${guestPath(guest)}/config`, form);
275
+ printJson({ vmid: guest.vmid, set: form, config: redactSecrets(await api(config, "GET", `${guestPath(guest)}/config`)) });
276
+ return 0;
277
+ }
278
+ default:
279
+ throw new UsageError(`unknown command: ${name ?? "(none)"}\n${HELP}`);
280
+ }
281
+ }
282
+
283
+ export function buildHostRemote(args: string[]): string {
284
+ const [name, ...rest] = args;
285
+ const spec = name === undefined ? undefined : HOST_COMMANDS[name];
286
+ if (!spec) throw new UsageError(`unknown host command: ${name ?? "(none)"}`);
287
+ if (rest.length !== spec.args) throw new UsageError(`${name} takes ${spec.args} argument(s)`);
288
+ if (name === "host-journal" && !SINCE.test(rest[0])) throw new UsageError("since: letters, digits, spaces, colon, plus, minus only");
289
+ if (name === "guest-exec") {
290
+ parseVmid(rest[0]);
291
+ if (!GUEST_EXEC.includes(rest[1])) throw new UsageError(`guest-exec action must be one of ${GUEST_EXEC.join(", ")}`);
292
+ }
293
+ return [name, ...rest].join(" ");
294
+ }
295
+
296
+ async function host(config: ProxmoxConfig, args: string[]): Promise<number> {
297
+ const remote = buildHostRemote(args);
298
+ const result = await sshFixed(
299
+ {
300
+ host: config.pulsarHost,
301
+ user: config.pulsarUser,
302
+ keyFile: secretPath(SERVICE, "SSH_KEY_PULSAR"),
303
+ knownHostsFile: join(specialistsHome(), "config", "known_hosts"),
304
+ },
305
+ remote,
306
+ 120_000,
307
+ );
308
+ if (result.stdout.length > 0) printRaw(result.stdout);
309
+ if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `${args[0]} exited ${result.code}`);
310
+ return 0;
311
+ }
312
+
313
+ export async function command(args: string[]): Promise<number> {
314
+ if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
315
+ printRaw(HELP);
316
+ return 0;
317
+ }
318
+ const config = readConfig<ProxmoxConfig>(SERVICE, SHAPE);
319
+ if (args[0] === "fingerprint") {
320
+ const probe = await pinnedRequest(new URL("/", config.apiUrl), { method: "GET", headers: {}, expectedFingerprint: null, timeoutMs: TIMEOUT_MS });
321
+ printJson({ host: new URL(config.apiUrl).host, fingerprint256: probe.fingerprint });
322
+ return 0;
323
+ }
324
+ if (args[0] in HOST_COMMANDS) return host(config, args);
325
+ return apiCommand(config, args);
326
+ }
327
+
328
+ if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
329
+ await main(SERVICE, command);
330
+ }
@@ -3,12 +3,15 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { refusedPrefix, command as arcaneCommand } from "./arcane/run.ts";
6
+ import { buildBackupRemote, command as backupCommand } from "./backup/run.ts";
6
7
  import { parseParams, parseRecordFilters, technitiumRequest, command as dnsCommand } from "./dns/run.ts";
7
8
  import { buildVpsRemote, command as edgeCommand } from "./edge/run.ts";
8
9
  import { API_PREFIXES, buildDmzRemote, validateApiPath, command as identityCommand } from "./identity/run.ts";
10
+ import { buildInferenceRemote, compactReleases, command as inferenceCommand } from "./inference/run.ts";
9
11
  import { UsageError } from "./lib/errors.ts";
10
12
  import { safeRepoPath, safeWritePath } from "./lib/repo.ts";
11
13
  import { buildPulsarRemote, parseQuery, unifiPath, command as networkCommand } from "./network/run.ts";
14
+ import { buildHostRemote, needsConfirm, parseSetPairs, parseVmid, validateApiPath as validatePvePath, command as proxmoxCommand } from "./proxmox/run.ts";
12
15
  import { buildObsRemote, compactDecisions, summariseAlerts, summariseWazuh, command as securityCommand } from "./security/run.ts";
13
16
 
14
17
  describe("arcane wrapper", () => {
@@ -268,6 +271,94 @@ describe("security wrapper", () => {
268
271
  });
269
272
  });
270
273
 
274
+ describe("backup wrapper", () => {
275
+ it("maps commands to targets and validates arguments", () => {
276
+ expect(buildBackupRemote(["vzdump-tasks"])).toMatchObject({ remote: "vzdump-tasks", spec: { target: "pulsar" } });
277
+ expect(buildBackupRemote(["vzdump-run", "107"]).remote).toBe("vzdump-run 107");
278
+ expect(buildBackupRemote(["restic-snapshots", "pulsar-host"]).remote).toBe("restic-snapshots pulsar-host");
279
+ expect(buildBackupRemote(["restic-ls", "latest", "/etc/pve"]).remote).toBe("restic-ls latest /etc/pve");
280
+ expect(buildBackupRemote(["restic-restore", "08a509d6", "/etc/pve/storage.cfg"]).remote).toBe("restic-restore 08a509d6 /etc/pve/storage.cfg");
281
+ expect(buildBackupRemote(["arcane-backup-journal", "12 hours ago"])).toMatchObject({ spec: { target: "arcane" } });
282
+ expect(() => buildBackupRemote(["vzdump-run", "200"])).toThrow(/vmid/);
283
+ expect(() => buildBackupRemote(["restic-restore", "latest", "../etc"])).toThrow(/absolute/);
284
+ expect(() => buildBackupRemote(["restic-restore", "latest", "/etc/pve; rm -rf /"])).toThrow(/absolute/);
285
+ expect(() => buildBackupRemote(["restic-ls", "xyz"])).toThrow(/snapshot/);
286
+ expect(() => buildBackupRemote(["restic-snapshots", "a b"])).toThrow(/tag/);
287
+ expect(() => buildBackupRemote(["gcs-journal", "x; id"])).toThrow(/since/);
288
+ expect(() => buildBackupRemote(["restic-forget"])).toThrow(/unknown command/);
289
+ });
290
+
291
+ it("prints help without touching config", async () => {
292
+ const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
293
+ expect(await backupCommand(["--help"])).toBe(0);
294
+ expect(String(write.mock.calls[0][0])).toContain("restore-clean");
295
+ write.mockRestore();
296
+ });
297
+ });
298
+
299
+ describe("proxmox wrapper", () => {
300
+ it("validates vmids, paths, confirmation, and set keys", () => {
301
+ expect(parseVmid("106")).toBe(106);
302
+ expect(() => parseVmid("6")).toThrow(/vmid/);
303
+ expect(validatePvePath("/nodes/pulsar/status")).toBe("/nodes/pulsar/status");
304
+ expect(validatePvePath("/cluster/resources?type=storage")).toBe("/cluster/resources?type=storage");
305
+ expect(() => validatePvePath("nodes")).toThrow(UsageError);
306
+ expect(() => validatePvePath("/nodes/../access")).toThrow(UsageError);
307
+ expect(needsConfirm(["stop", "106", "--confirm"])).toEqual(["stop", "106"]);
308
+ expect(() => needsConfirm(["stop", "106"])).toThrow(/--confirm/);
309
+ expect(parseSetPairs("qemu", ["memory=4096", "cores=2", "net0=virtio,bridge=vmbr0"])).toEqual({ memory: "4096", cores: "2", net0: "virtio,bridge=vmbr0" });
310
+ expect(parseSetPairs("lxc", ["swap=512"])).toEqual({ swap: "512" });
311
+ expect(() => parseSetPairs("qemu", ["swap=512"])).toThrow(/may set/);
312
+ expect(() => parseSetPairs("lxc", ["scsi0=local-lvm:32"])).toThrow(/may set/);
313
+ expect(() => parseSetPairs("qemu", [])).toThrow(/key=value/);
314
+ });
315
+
316
+ it("maps host commands to fixed remote strings", () => {
317
+ expect(buildHostRemote(["host-status"])).toBe("host-status");
318
+ expect(buildHostRemote(["guest-exec", "101", "df"])).toBe("guest-exec 101 df");
319
+ expect(() => buildHostRemote(["guest-exec", "101", "rm"])).toThrow(/action/);
320
+ expect(() => buildHostRemote(["host-journal", "1 hour ago; id"])).toThrow(/since/);
321
+ expect(() => buildHostRemote(["shell"])).toThrow(/unknown host command/);
322
+ });
323
+
324
+ it("prints help without touching config", async () => {
325
+ const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
326
+ expect(await proxmoxCommand([])).toBe(0);
327
+ expect(String(write.mock.calls[0][0])).toContain("snapshot-rollback");
328
+ write.mockRestore();
329
+ });
330
+ });
331
+
332
+ describe("inference wrapper", () => {
333
+ it("maps commands to targets and enforces confirmation", () => {
334
+ expect(buildInferenceRemote(["nexus-status"])).toMatchObject({ remote: "nexus-status", spec: { target: "nexus" } });
335
+ expect(buildInferenceRemote(["nexus-log", "embed", "100"]).remote).toBe("nexus-log embed 100");
336
+ expect(buildInferenceRemote(["nexus-stop", "rerank", "--confirm"]).remote).toBe("nexus-stop rerank");
337
+ expect(buildInferenceRemote(["hermes-upgrade", "--confirm"])).toMatchObject({ remote: "hermes-upgrade", spec: { target: "pulsar" } });
338
+ expect(buildInferenceRemote(["hermes-journal", "1 hour ago"])).toMatchObject({ spec: { target: "hermes" } });
339
+ expect(() => buildInferenceRemote(["nexus-stop", "rerank"])).toThrow(/--confirm/);
340
+ expect(() => buildInferenceRemote(["hermes-upgrade"])).toThrow(/--confirm/);
341
+ expect(() => buildInferenceRemote(["nexus-restart", "server"])).toThrow(/service/);
342
+ expect(() => buildInferenceRemote(["nexus-log", "embed", "5000"])).toThrow(/lines/);
343
+ expect(() => buildInferenceRemote(["hermes-restart", "discord"])).toThrow(/service/);
344
+ expect(() => buildInferenceRemote(["europa-start"])).toThrow(/unknown command/);
345
+ });
346
+
347
+ it("compacts github releases", () => {
348
+ 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) }, {}]);
349
+ expect(releases[0]).toMatchObject({ tag: "v2026.9.1", name: "Sept", prerelease: false, url: "https://x/y" });
350
+ expect(releases[0].notes.length).toBe(6000);
351
+ expect(releases[1]).toEqual({ tag: "?", name: "", published: "", prerelease: false, url: "", notes: "" });
352
+ });
353
+
354
+ it("prints help without touching config", async () => {
355
+ const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
356
+ expect(await inferenceCommand(["help"])).toBe(0);
357
+ expect(String(write.mock.calls[0][0])).toContain("hermes-releases");
358
+ write.mockRestore();
359
+ });
360
+ });
361
+
271
362
  describe("wrapper config errors", () => {
272
363
  let home: string;
273
364
  const originalHome = process.env.HOME;
@@ -288,5 +379,8 @@ describe("wrapper config errors", () => {
288
379
  await expect(dnsCommand(["technitium", "primary", "zones"])).rejects.toThrow(/dns\.json/);
289
380
  await expect(edgeCommand(["probe"])).rejects.toThrow(/edge\.json/);
290
381
  await expect(securityCommand(["attention"])).rejects.toThrow(/security\.json/);
382
+ await expect(backupCommand(["vzdump-tasks"])).rejects.toThrow(/backup\.json/);
383
+ await expect(proxmoxCommand(["guests"])).rejects.toThrow(/proxmox\.json/);
384
+ await expect(inferenceCommand(["nexus-status"])).rejects.toThrow(/inference\.json/);
291
385
  });
292
386
  });