camstack 1.2.212 → 1.2.214

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,822 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ findHubs
4
+ } from "./chunk-JPGJK5DC.js";
5
+
6
+ // src/mcp/tool-result.ts
7
+ import { text as plainText, textError } from "@camstack/develop";
8
+ var FAILURE_STATUSES = /* @__PURE__ */ new Set([
9
+ "no-session",
10
+ "refused",
11
+ "failed",
12
+ "fetch-failed",
13
+ "unreachable",
14
+ "invalid-address",
15
+ "unsupported",
16
+ "unavailable",
17
+ "not-exposed"
18
+ ]);
19
+ function isFailureResult(value) {
20
+ if (value === null || typeof value !== "object") return false;
21
+ const status = Reflect.get(value, "status");
22
+ return typeof status === "string" && FAILURE_STATUSES.has(status);
23
+ }
24
+ function text(value) {
25
+ const ok = plainText(value);
26
+ if (!isFailureResult(value)) return ok;
27
+ const body = ok.content[0]?.text ?? "";
28
+ return textError(body);
29
+ }
30
+
31
+ // src/mcp/setup-tools.ts
32
+ import { z as z4 } from "zod";
33
+
34
+ // src/mcp/setup-hubs.ts
35
+ import * as https from "https";
36
+ import {
37
+ createHubAgent,
38
+ explainTlsFailure,
39
+ HubAddressError,
40
+ hubCaFileForServer,
41
+ loadSession,
42
+ normalizeHubAddress,
43
+ resolveHubTls
44
+ } from "@camstack/develop";
45
+ import { z as z3 } from "zod";
46
+
47
+ // src/mcp/setup-install.ts
48
+ import { z as z2 } from "zod";
49
+
50
+ // src/mcp/setup-install-rules.ts
51
+ import { z } from "zod";
52
+ var HOST_KINDS = [
53
+ "docker-compose",
54
+ "docker",
55
+ "unraid",
56
+ "proxmox-lxc",
57
+ "macos-desktop"
58
+ ];
59
+ var HostKindSchema = z.enum(HOST_KINDS);
60
+ var ROLES = ["hub", "agent"];
61
+ var RoleSchema = z.enum(ROLES);
62
+ var ACCELERATORS = ["intel", "amd", "coral", "apple", "cpu"];
63
+ var AcceleratorSchema = z.enum(ACCELERATORS);
64
+ var InstallPathsSchema = z.object({
65
+ data: z.string().min(1),
66
+ recordings: z.string().min(1),
67
+ config: z.string().min(1).optional(),
68
+ backups: z.string().min(1).optional()
69
+ });
70
+ var InstallGuideInputSchema = z.object({
71
+ hostKind: HostKindSchema.optional(),
72
+ role: RoleSchema.optional(),
73
+ accelerator: AcceleratorSchema.optional(),
74
+ paths: InstallPathsSchema.optional(),
75
+ /** Agents only: the hub the agent should dial (host or IP). */
76
+ hubAddress: z.string().min(1).optional()
77
+ });
78
+ var RAW = "https://raw.githubusercontent.com/camstack";
79
+ var TEMPLATE_URLS = {
80
+ compose: {
81
+ hub: `${RAW}/proxmox/main/compose/hub.yml`,
82
+ agent: `${RAW}/proxmox/main/compose/agent.yml`
83
+ },
84
+ proxmoxInstaller: `${RAW}/proxmox/main/install-camstack-proxmox.sh`,
85
+ unraid: {
86
+ hub: `${RAW}/unraid-templates/main/templates/camstack.xml`,
87
+ agent: `${RAW}/unraid-templates/main/templates/camstack-agent.xml`
88
+ },
89
+ latestRelease: "https://api.github.com/repos/camstack/server/releases/latest"
90
+ };
91
+ var IMAGE_REPOSITORY = "ghcr.io/camstack/camstack";
92
+ var IMAGE_TAG_BY_ACCELERATOR = {
93
+ intel: "intel-latest",
94
+ amd: "amd-latest",
95
+ // No Coral- or CPU-specific image: Intel hosts and the Unraid templates track intel-latest.
96
+ coral: "intel-latest",
97
+ cpu: "intel-latest",
98
+ apple: "intel-latest"
99
+ };
100
+ var NO_BARE_LATEST = "There is no bare :latest tag: use intel-<version>/intel-latest or amd-<version>/amd-latest, and never mix them across vendors.";
101
+ var MUST_NOT_OMIT = [
102
+ "--pids-limit=8192 (pids_limit: 8192): the default 2048 counts threads and silently stops media on a busy hub.",
103
+ "CAMSTACK_MEDIA_ROOT=/recordings: without it recordings land in /data and fill the cache drive.",
104
+ "/config mapped: it is a VOLUME, so an unmapped path mints an anonymous volume on every re-create.",
105
+ "--restart unless-stopped: the NVR must come back after a host reboot.",
106
+ "CAMSTACK_MOLECULER_USE_HOSTNAME=false: otherwise a multi-machine cluster never forms.",
107
+ "Coral USB: /dev/bus/usb AND the device-cgroup rule c 189:* rmw."
108
+ ];
109
+ var COMPOSE_MARKERS_BY_ACCELERATOR = {
110
+ intel: ["devices", "dri", "accel"],
111
+ amd: ["devices", "dri"],
112
+ coral: ["devices", "usb"],
113
+ cpu: [],
114
+ apple: []
115
+ };
116
+ function firstRunHub(host) {
117
+ return [
118
+ `Open https://${host}:4443 (a self-signed certificate: accept the browser warning).`,
119
+ "Sign in as admin / changeme; CamStack asks for a new password at once. Change it now.",
120
+ `Then, where your MCP client runs: camstack login ${host}`
121
+ ];
122
+ }
123
+
124
+ // src/mcp/setup-compose.ts
125
+ var MARKER_LINE = /^(\s*)# (.*#@([A-Za-z0-9_]+)\s*)$/;
126
+ var VOLUME_HOST_PATHS = [
127
+ { container: "/data", key: "data" },
128
+ { container: "/recordings", key: "recordings" },
129
+ { container: "/config", key: "config" },
130
+ { container: "/backups", key: "backups" }
131
+ ];
132
+ function swapImageTag(body, imageTag) {
133
+ const escaped = IMAGE_REPOSITORY.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
134
+ return body.replace(new RegExp(`(${escaped}):[A-Za-z0-9._-]+`), `$1:${imageTag}`);
135
+ }
136
+ function enableMarkers(body, markers) {
137
+ return body.split("\n").map((line) => {
138
+ const match = MARKER_LINE.exec(line);
139
+ if (match === null) return line;
140
+ const tag = match[3];
141
+ return tag !== void 0 && markers.includes(tag) ? `${match[1] ?? ""}${match[2] ?? ""}` : line;
142
+ }).join("\n");
143
+ }
144
+ function setVolumeHosts(body, paths) {
145
+ const edits = [];
146
+ let out = body;
147
+ for (const { container, key } of VOLUME_HOST_PATHS) {
148
+ const wanted = paths?.[key];
149
+ const volume = new RegExp(`^(\\s*- )([^\\s:]+)(:${container}\\s*)$`, "m");
150
+ if (!volume.test(out)) {
151
+ if (wanted !== void 0) {
152
+ edits.push(
153
+ `${container}: no volume line found in the template; map ${wanted} to it yourself`
154
+ );
155
+ }
156
+ continue;
157
+ }
158
+ if (wanted === void 0) {
159
+ edits.push(
160
+ `${container}: left at the template's host path, change it if that is not where you want it`
161
+ );
162
+ continue;
163
+ }
164
+ out = out.replace(
165
+ volume,
166
+ (_m, pre, _host, post) => `${pre}${wanted}${post}`
167
+ );
168
+ }
169
+ return { body: out, edits };
170
+ }
171
+ function setHubAddress(body, hubAddress) {
172
+ const line = /^(\s*- CAMSTACK_HUB_ADDRESS=).*$/m;
173
+ if (!line.test(body)) return { body, edits: [] };
174
+ if (hubAddress === void 0) {
175
+ return {
176
+ body,
177
+ edits: [
178
+ "CAMSTACK_HUB_ADDRESS: export it before `docker compose up` (or edit the line) with the hub IP or hostname"
179
+ ]
180
+ };
181
+ }
182
+ return {
183
+ body: body.replace(line, (_m, pre) => `${pre}${hubAddress}`),
184
+ edits: []
185
+ };
186
+ }
187
+ function customizeCompose(source, c) {
188
+ const tagged = enableMarkers(swapImageTag(source, c.imageTag), c.markers);
189
+ const volumes = setVolumeHosts(tagged, c.paths);
190
+ const hub = setHubAddress(volumes.body, c.hubAddress);
191
+ return { body: hub.body, edits: [...volumes.edits, ...hub.edits] };
192
+ }
193
+ function unquote(value) {
194
+ const v = value.trim();
195
+ if (v.length >= 2 && (v.startsWith('"') || v.startsWith("'")) && v.endsWith(v[0] ?? "")) {
196
+ return v.slice(1, -1);
197
+ }
198
+ return v;
199
+ }
200
+ function stripComment(line) {
201
+ return line.replace(/\s+#.*$/, "");
202
+ }
203
+ function readService(body) {
204
+ const fields = {
205
+ scalars: /* @__PURE__ */ new Map(),
206
+ lists: /* @__PURE__ */ new Map(),
207
+ maps: /* @__PURE__ */ new Map()
208
+ };
209
+ let key = null;
210
+ let sub = null;
211
+ for (const raw of body.split("\n")) {
212
+ if (raw.trim() === "" || raw.trim().startsWith("#")) continue;
213
+ const line = stripComment(raw);
214
+ const indent = line.length - line.trimStart().length;
215
+ const text2 = line.trim();
216
+ if (indent === 4) {
217
+ sub = null;
218
+ const m = /^([A-Za-z_]+):\s*(.*)$/.exec(text2);
219
+ if (m === null) continue;
220
+ key = m[1] ?? null;
221
+ if (key !== null && (m[2] ?? "") !== "") fields.scalars.set(key, unquote(m[2] ?? ""));
222
+ } else if (indent === 6 && key !== null) {
223
+ if (text2.startsWith("- ")) {
224
+ const list = fields.lists.get(key) ?? [];
225
+ list.push(unquote(text2.slice(2)));
226
+ fields.lists.set(key, list);
227
+ } else {
228
+ const m = /^([A-Za-z_-]+):\s*(.*)$/.exec(text2);
229
+ if (m === null) continue;
230
+ sub = m[1] ?? null;
231
+ const map = fields.maps.get(key) ?? /* @__PURE__ */ new Map();
232
+ if ((m[2] ?? "") !== "" && sub !== null) map.set(sub, unquote(m[2] ?? ""));
233
+ fields.maps.set(key, map);
234
+ }
235
+ } else if (indent === 8 && key !== null && sub !== null) {
236
+ const m = /^([A-Za-z_-]+):\s*(.*)$/.exec(text2);
237
+ if (m === null) continue;
238
+ const map = fields.maps.get(`${key}.${sub}`) ?? /* @__PURE__ */ new Map();
239
+ map.set(m[1] ?? "", unquote(m[2] ?? ""));
240
+ fields.maps.set(`${key}.${sub}`, map);
241
+ }
242
+ }
243
+ return fields;
244
+ }
245
+ function shellQuote(value) {
246
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
247
+ }
248
+ function composeToDockerRun(body) {
249
+ const f = readService(body);
250
+ const image = f.scalars.get("image");
251
+ if (image === void 0) return null;
252
+ const args = ["docker run -d"];
253
+ const name = f.scalars.get("container_name");
254
+ if (name !== void 0) args.push(`--name ${shellQuote(name)}`);
255
+ const restart = f.scalars.get("restart");
256
+ if (restart !== void 0) args.push(`--restart ${shellQuote(restart)}`);
257
+ const network = f.scalars.get("network_mode");
258
+ if (network !== void 0) args.push(`--network ${shellQuote(network)}`);
259
+ const pids = f.scalars.get("pids_limit");
260
+ if (pids !== void 0) args.push(`--pids-limit=${pids}`);
261
+ for (const d of f.lists.get("devices") ?? []) args.push(`--device ${shellQuote(d)}`);
262
+ for (const r of f.lists.get("device_cgroup_rules") ?? []) {
263
+ args.push(`--device-cgroup-rule ${shellQuote(r)}`);
264
+ }
265
+ for (const g of f.lists.get("group_add") ?? []) args.push(`--group-add ${shellQuote(g)}`);
266
+ for (const v of f.lists.get("volumes") ?? []) args.push(`-v ${shellQuote(v)}`);
267
+ for (const e of f.lists.get("environment") ?? []) {
268
+ if (!e.includes("${")) args.push(`-e ${shellQuote(e)}`);
269
+ }
270
+ const driver = f.maps.get("logging")?.get("driver");
271
+ if (driver !== void 0) args.push(`--log-driver ${shellQuote(driver)}`);
272
+ for (const [k, v] of f.maps.get("logging.options") ?? []) {
273
+ args.push(`--log-opt ${shellQuote(`${k}=${v}`)}`);
274
+ }
275
+ args.push(shellQuote(image));
276
+ return args.join(" \\\n ");
277
+ }
278
+
279
+ // src/mcp/setup-install.ts
280
+ var FETCH_TIMEOUT_MS = 1e4;
281
+ var CONTAINER_ROUTES = ["docker-compose", "docker"];
282
+ var Q_HOST = {
283
+ id: "hostKind",
284
+ question: "Where will CamStack run?",
285
+ options: ["docker-compose", "docker", "unraid", "proxmox-lxc", "macos-desktop"]
286
+ };
287
+ var Q_ROLE = {
288
+ id: "role",
289
+ question: "Which role? Start with a hub; an agent only adds a second machine to an existing hub.",
290
+ options: ["hub", "agent"]
291
+ };
292
+ var Q_ACCEL = {
293
+ id: "accelerator",
294
+ question: "Which accelerator does this host have?",
295
+ options: ["intel", "amd", "coral", "cpu"]
296
+ };
297
+ var Q_PATHS = {
298
+ id: "paths",
299
+ question: "Host paths as {data, recordings} (optionally config, backups). Recordings is the one that grows: put it on a roomy disk.",
300
+ options: []
301
+ };
302
+ function missingQuestions(input) {
303
+ const out = [];
304
+ if (input.hostKind === void 0) out.push(Q_HOST);
305
+ if (input.role === void 0) out.push(Q_ROLE);
306
+ const desktop = input.hostKind === "macos-desktop";
307
+ if (!desktop && (input.accelerator === void 0 || input.accelerator === "apple")) {
308
+ out.push(Q_ACCEL);
309
+ }
310
+ if (input.hostKind !== void 0 && CONTAINER_ROUTES.includes(input.hostKind) && input.paths === void 0) {
311
+ out.push(Q_PATHS);
312
+ }
313
+ return out;
314
+ }
315
+ async function fetchText(url, fetchFn) {
316
+ try {
317
+ const res = await fetchFn(url, {
318
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
319
+ });
320
+ if (!res.ok) {
321
+ return {
322
+ ok: false,
323
+ failure: {
324
+ status: "fetch-failed",
325
+ url,
326
+ error: `answered ${res.status}`
327
+ }
328
+ };
329
+ }
330
+ return { ok: true, body: await res.text() };
331
+ } catch (err) {
332
+ const error = err instanceof Error ? err.message : String(err);
333
+ return { ok: false, failure: { status: "fetch-failed", url, error } };
334
+ }
335
+ }
336
+ var ReleaseSchema = z2.object({
337
+ tag_name: z2.string(),
338
+ assets: z2.array(z2.object({ name: z2.string(), browser_download_url: z2.string() }))
339
+ });
340
+ var DMG = /^CamStack-[0-9][0-9A-Za-z.-]*-(arm64|x64)\.dmg$/;
341
+ function acceleratorEdits(accelerator, role) {
342
+ const note = `Image tag ${IMAGE_TAG_BY_ACCELERATOR[accelerator]} for ${accelerator}. ${NO_BARE_LATEST}`;
343
+ return role === "agent" ? [note, "Do not run a hub and an agent on the same host."] : [note];
344
+ }
345
+ async function composeGuide(input, fetchFn, asDockerRun) {
346
+ const url = TEMPLATE_URLS.compose[input.role];
347
+ const fetched = await fetchText(url, fetchFn);
348
+ if (!fetched.ok) return fetched.failure;
349
+ const imageTag = IMAGE_TAG_BY_ACCELERATOR[input.accelerator];
350
+ const custom = customizeCompose(fetched.body, {
351
+ imageTag,
352
+ markers: COMPOSE_MARKERS_BY_ACCELERATOR[input.accelerator],
353
+ paths: input.paths,
354
+ hubAddress: input.hubAddress
355
+ });
356
+ const edits = [...custom.edits, ...acceleratorEdits(input.accelerator, input.role)];
357
+ const first = firstRun(input);
358
+ if (!asDockerRun) {
359
+ return ready(input, imageTag, {
360
+ steps: [
361
+ "Save docker-compose.yml (below) in an empty directory on the host.",
362
+ "docker compose up -d && docker compose logs -f camstack",
363
+ "The first boot seeds addons and pulls models: allow a few minutes.",
364
+ ...first
365
+ ],
366
+ files: [{ path: "docker-compose.yml", body: custom.body, source: url }],
367
+ edits
368
+ });
369
+ }
370
+ const run = composeToDockerRun(custom.body);
371
+ if (run === null) {
372
+ return {
373
+ status: "fetch-failed",
374
+ url,
375
+ error: "the official compose file has no image line; cannot derive docker run"
376
+ };
377
+ }
378
+ return ready(input, imageTag, {
379
+ steps: [
380
+ "Create the host directories named by the -v lines, then run the file below as a shell script.",
381
+ "docker logs -f camstack (the first boot seeds addons and pulls models).",
382
+ ...first
383
+ ],
384
+ files: [{ path: "run-camstack.sh", body: `#!/bin/sh
385
+ ${run}
386
+ `, source: url }],
387
+ edits
388
+ });
389
+ }
390
+ async function unraidGuide(input, fetchFn) {
391
+ const url = TEMPLATE_URLS.unraid[input.role];
392
+ const fetched = await fetchText(url, fetchFn);
393
+ if (!fetched.ok) return fetched.failure;
394
+ const imageTag = IMAGE_TAG_BY_ACCELERATOR[input.accelerator];
395
+ const edits = [
396
+ `In the template form, set Repository to ${IMAGE_REPOSITORY}:${imageTag} (the template ships intel-latest). ${NO_BARE_LATEST}`,
397
+ "Recordings: point it at a roomy array share, not the cache; leave Media root as /recordings.",
398
+ "Keep ExtraParams as shipped: --device-cgroup-rule and --pids-limit=8192.",
399
+ ...unraidDeviceEdits(input.accelerator)
400
+ ];
401
+ const first = firstRun(input);
402
+ return ready(input, imageTag, {
403
+ steps: [
404
+ `Unraid: Docker > Add Container > Template, paste ${url}`,
405
+ "Review the paths and Repository (edits below), then Apply.",
406
+ ...first
407
+ ],
408
+ files: [{ path: urlName(url), body: fetched.body, source: url }],
409
+ edits
410
+ });
411
+ }
412
+ function unraidDeviceEdits(accelerator) {
413
+ if (accelerator === "intel" || accelerator === "amd") {
414
+ return ["Intel/AMD iGPU: enter /dev/dri in that field. Intel NPU: enter /dev/accel."];
415
+ }
416
+ if (accelerator === "coral") {
417
+ return ["Coral USB: enter /dev/bus/usb (the whole bus). Coral PCIe/M.2: /dev/apex_0 (and _1)."];
418
+ }
419
+ return [];
420
+ }
421
+ function urlName(url) {
422
+ return url.slice(url.lastIndexOf("/") + 1);
423
+ }
424
+ async function proxmoxGuide(input, fetchFn) {
425
+ const url = TEMPLATE_URLS.proxmoxInstaller;
426
+ const fetched = await fetchText(url, fetchFn);
427
+ if (!fetched.ok) return fetched.failure;
428
+ const imageTag = IMAGE_TAG_BY_ACCELERATOR[input.accelerator];
429
+ const vars = [`ROLE=${input.role}`];
430
+ vars.push(`IMAGE_TAG=${IMAGE_REPOSITORY}:${imageTag}`);
431
+ if (input.accelerator === "cpu") vars.push("PASS_ACCEL=none");
432
+ if (input.role === "agent") vars.push(`CAMSTACK_HUB_ADDRESS=${input.hubAddress ?? "<hub-ip>"}`);
433
+ if (input.paths !== void 0) vars.push(`RECORDINGS_HOST_PATH=${input.paths.recordings}`);
434
+ const header = fetched.body.split("\n").slice(1, 20).filter((l) => l.startsWith("#")).join("\n");
435
+ const first = firstRun(input);
436
+ return ready(input, imageTag, {
437
+ steps: [
438
+ "Run on the Proxmox HOST (needs `pct`), not inside a container. It asks nothing: everything is an environment variable (defaults: VMID 200 hub / 201 agent, 8 cores, 8 GB, 64 GB disk, bridge vmbr0).",
439
+ `${vars.join(" ")} bash -c "$(curl -fsSL ${url})"`,
440
+ "It builds an Ubuntu 24.04 LXC with Docker, passes through the accelerators the host has (PASS_ACCEL=none skips it) and starts CamStack.",
441
+ ...first
442
+ ],
443
+ files: [
444
+ {
445
+ path: "install-camstack-proxmox.sh (header)",
446
+ body: header,
447
+ source: url
448
+ }
449
+ ],
450
+ edits: [
451
+ `The script defaults to the amd image; IMAGE_TAG above sets ${imageTag}. ${NO_BARE_LATEST}`,
452
+ "A hub and an agent on one machine need two CTs (host networking collides otherwise)."
453
+ ]
454
+ });
455
+ }
456
+ async function macGuide(input, fetchFn) {
457
+ const url = TEMPLATE_URLS.latestRelease;
458
+ const fetched = await fetchText(url, fetchFn);
459
+ if (!fetched.ok) return fetched.failure;
460
+ let release;
461
+ try {
462
+ const parsed = ReleaseSchema.safeParse(JSON.parse(fetched.body));
463
+ if (!parsed.success) {
464
+ return {
465
+ status: "fetch-failed",
466
+ url,
467
+ error: "the release answer had no assets"
468
+ };
469
+ }
470
+ release = parsed.data;
471
+ } catch {
472
+ return {
473
+ status: "fetch-failed",
474
+ url,
475
+ error: "the release answer was not JSON"
476
+ };
477
+ }
478
+ const dmgs = release.assets.filter((a) => DMG.test(a.name));
479
+ const first = firstRun(input);
480
+ return ready(input, null, {
481
+ steps: [
482
+ `Download the disk image for your Mac from release ${release.tag_name}: ${dmgs.map((a) => `${a.name} (${a.browser_download_url})`).join(" or ")}. Apple silicon (M1 and later) takes arm64; Intel takes x64. The CamStackViewer files are a different app.`,
483
+ "Drag CamStack into Applications and open it.",
484
+ `The setup window asks what this Mac should be: choose ${input.role === "hub" ? "Hub (full server)" : "Agent (worker)"}, then Save and start.`,
485
+ ...first
486
+ ],
487
+ files: [],
488
+ edits: ["A Mac uses the app, not Docker: a container there cannot reach the Neural Engine."]
489
+ });
490
+ }
491
+ function firstRun(input) {
492
+ const host = input.hostKind === "macos-desktop" ? "localhost" : "<host>";
493
+ if (input.role === "hub") return firstRunHub(host);
494
+ return [
495
+ "The agent status dashboard answers on http://<agent-host>:4444.",
496
+ "Pair it from the hub: run setup_add_node (it lists agents found on the network), then in the admin UI Cluster > Nodes > Agents found on the network. Type the six digits shown on the other screen on BOTH sides."
497
+ ];
498
+ }
499
+ function ready(input, imageTag, body) {
500
+ return {
501
+ status: "ready",
502
+ hostKind: input.hostKind,
503
+ role: input.role,
504
+ imageTag,
505
+ steps: body.steps,
506
+ files: body.files,
507
+ edits: body.edits,
508
+ mustNotOmit: input.hostKind === "macos-desktop" ? [] : MUST_NOT_OMIT
509
+ };
510
+ }
511
+ async function installGuide(input, fetchFn) {
512
+ const questions = missingQuestions(input);
513
+ if (questions.length > 0) return { status: "need-answers", questions };
514
+ const { hostKind, role } = input;
515
+ if (hostKind === void 0 || role === void 0) {
516
+ return { status: "need-answers", questions: missingQuestions({}) };
517
+ }
518
+ const resolved = {
519
+ hostKind,
520
+ role,
521
+ accelerator: hostKind === "macos-desktop" ? "apple" : input.accelerator ?? "cpu",
522
+ paths: input.paths,
523
+ hubAddress: input.hubAddress
524
+ };
525
+ switch (hostKind) {
526
+ case "docker-compose":
527
+ return composeGuide(resolved, fetchFn, false);
528
+ case "docker":
529
+ return composeGuide(resolved, fetchFn, true);
530
+ case "unraid":
531
+ return unraidGuide(resolved, fetchFn);
532
+ case "proxmox-lxc":
533
+ return proxmoxGuide(resolved, fetchFn);
534
+ case "macos-desktop":
535
+ return macGuide(resolved, fetchFn);
536
+ }
537
+ }
538
+
539
+ // src/mcp/setup-hubs.ts
540
+ var DEFAULT_DISCOVER_TIMEOUT_MS = 2500;
541
+ var MAX_DISCOVER_TIMEOUT_MS = 15e3;
542
+ var HEALTH_TIMEOUT_MS = 5e3;
543
+ var LOGIN = (server) => `run \`camstack login ${server}\` in a terminal`;
544
+ var httpsHealth = (url, tls) => new Promise((resolve) => {
545
+ const req = https.get(
546
+ `${url}/health`,
547
+ { agent: createHubAgent(tls), timeout: HEALTH_TIMEOUT_MS },
548
+ (res) => {
549
+ res.resume();
550
+ resolve({ ok: true, status: res.statusCode ?? 0 });
551
+ }
552
+ );
553
+ req.on("timeout", () => req.destroy(new Error("timed out")));
554
+ req.on("error", (err) => {
555
+ resolve({
556
+ ok: false,
557
+ error: err.message,
558
+ tlsHint: explainTlsFailure(url, err)
559
+ });
560
+ });
561
+ });
562
+ async function discoverHubsTool(timeoutMs, deps) {
563
+ const wait = Math.min(timeoutMs ?? DEFAULT_DISCOVER_TIMEOUT_MS, MAX_DISCOVER_TIMEOUT_MS);
564
+ const finder = deps.findHubs ?? ((ms) => findHubs({
565
+ mdnsTimeoutMs: ms,
566
+ ...deps.sessionDir !== void 0 ? { sessionDir: deps.sessionDir } : {},
567
+ ...deps.env !== void 0 ? { env: deps.env } : {}
568
+ }));
569
+ const found = await finder(wait);
570
+ const hubs = found.map((h) => ({
571
+ host: h.host,
572
+ port: h.port,
573
+ name: h.nodeId,
574
+ version: h.version,
575
+ sources: h.sources
576
+ }));
577
+ return {
578
+ status: "ok",
579
+ hubs,
580
+ next: hubs.length === 0 ? "no hub answered: install one with setup_install_guide, or pass its address to setup_connect" : "pass the host of the hub you want to setup_connect"
581
+ };
582
+ }
583
+ function pinnedTls(url, deps) {
584
+ const caFile = hubCaFileForServer(url, deps.sessionDir);
585
+ try {
586
+ return {
587
+ tls: resolveHubTls(url, deps.env ?? process.env, deps.sessionDir),
588
+ caFile
589
+ };
590
+ } catch {
591
+ return { tls: {}, caFile };
592
+ }
593
+ }
594
+ async function connectTool(server, deps) {
595
+ let url;
596
+ try {
597
+ url = normalizeHubAddress(server);
598
+ } catch (err) {
599
+ const message = err instanceof HubAddressError ? err.message : String(err);
600
+ return { status: "invalid-address", server, message };
601
+ }
602
+ const { tls, caFile } = pinnedTls(url, deps);
603
+ const outcome = await (deps.health ?? httpsHealth)(url, tls);
604
+ const caPinned = tls.ca !== void 0;
605
+ const session = loadSession(url, deps.sessionDir);
606
+ const sessionInfo = {
607
+ exists: session !== null,
608
+ username: session?.username ?? null
609
+ };
610
+ if (!outcome.ok) {
611
+ return {
612
+ status: "unreachable",
613
+ server: url,
614
+ message: outcome.error,
615
+ tls: { caPinned, caFile, hint: outcome.tlsHint },
616
+ session: sessionInfo,
617
+ next: outcome.tlsHint === null ? "check the host, that the hub is running, and that port 4443 is reachable" : `the hub answers but its certificate is not trusted: ${LOGIN(url)}; login pins the CA (never disable verification)`
618
+ };
619
+ }
620
+ return {
621
+ status: "reachable",
622
+ server: url,
623
+ health: { status: outcome.status },
624
+ tls: { caPinned, caFile, hint: null },
625
+ session: sessionInfo,
626
+ next: session === null ? LOGIN(url) : "a session exists: the ops tools can use it"
627
+ };
628
+ }
629
+ var DiscoveredAgentsSchema = z3.object({
630
+ agents: z3.array(
631
+ z3.object({
632
+ nodeId: z3.string(),
633
+ host: z3.string(),
634
+ port: z3.number(),
635
+ version: z3.string().nullable(),
636
+ name: z3.string().nullable(),
637
+ state: z3.enum(["unpaired", "pending", "paired"])
638
+ })
639
+ )
640
+ });
641
+ var PAIRING_STEPS = [
642
+ "Pairing is started from the HUB (D857): admin UI, Cluster > Nodes > Agents found on the network. Pick the agent, or add it by address (host and port 4444).",
643
+ "Both screens now show six digits. Type the digits shown on the OTHER screen, on BOTH sides: the hub operator types the code the agent shows, and the agent operator confirms on the agent.",
644
+ "The agent installs its certificate only after its own operator confirmed. Nothing here (and no tool of this MCP) confirms a pairing for you.",
645
+ "The agent then appears under Cluster > Nodes."
646
+ ];
647
+ async function addNodeTool(input, ctx) {
648
+ const guide = await installGuide({ ...input, role: "agent" }, ctx.fetch);
649
+ const outcome = await ctx.hub();
650
+ if (!outcome.ok) {
651
+ return {
652
+ guide,
653
+ pairing: PAIRING_STEPS,
654
+ agentsOnNetwork: { status: "no-session", action: outcome.action }
655
+ };
656
+ }
657
+ try {
658
+ const raw = await outcome.connection.client.call("GET", "agentPairing.listDiscoveredAgents", {});
659
+ const parsed = DiscoveredAgentsSchema.safeParse(raw);
660
+ return {
661
+ guide,
662
+ pairing: PAIRING_STEPS,
663
+ agentsOnNetwork: parsed.success ? { status: "ok", agents: parsed.data.agents } : {
664
+ status: "unavailable",
665
+ error: "the hub answered in an unexpected shape"
666
+ }
667
+ };
668
+ } catch (err) {
669
+ return {
670
+ guide,
671
+ pairing: PAIRING_STEPS,
672
+ agentsOnNetwork: {
673
+ status: "unavailable",
674
+ error: err instanceof Error ? err.message : String(err)
675
+ }
676
+ };
677
+ }
678
+ }
679
+
680
+ // src/mcp/setup-tools.ts
681
+ var SUPPORTED_HOST_KINDS = HOST_KINDS;
682
+ var REGISTRY = "https://registry.npmjs.org";
683
+ var REGISTRY_TIMEOUT_MS = 5e3;
684
+ var RegistryLatestSchema = z4.object({ version: z4.string() });
685
+ async function latestOnNpm(name, fetchFn) {
686
+ const url = `${REGISTRY}/${name.replace("/", "%2f")}/latest`;
687
+ try {
688
+ const res = await fetchFn(url, {
689
+ signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS)
690
+ });
691
+ if (!res.ok) return { latest: null, error: `registry answered ${res.status}` };
692
+ const parsed = RegistryLatestSchema.safeParse(await res.json());
693
+ if (!parsed.success) return { latest: null, error: "registry answer had no version" };
694
+ return { latest: parsed.data.version };
695
+ } catch (err) {
696
+ return {
697
+ latest: null,
698
+ error: err instanceof Error ? err.message : String(err)
699
+ };
700
+ }
701
+ }
702
+ async function about(ctx) {
703
+ const [cli, server] = await Promise.all([
704
+ latestOnNpm("camstack", ctx.fetch),
705
+ latestOnNpm("@camstack/server", ctx.fetch)
706
+ ]);
707
+ return {
708
+ cli: { installed: ctx.cliVersion, ...cli },
709
+ server,
710
+ supportedHostKinds: SUPPORTED_HOST_KINDS
711
+ };
712
+ }
713
+ function registerSetupTools(server, ctx, deps = {}) {
714
+ server.tool(
715
+ "setup_about",
716
+ "Versions of the camstack CLI (installed and latest on npm), the latest @camstack/server on npm, and the host kinds CamStack supports. Needs no hub and no login.",
717
+ {},
718
+ async () => text(await about(ctx))
719
+ );
720
+ server.tool(
721
+ "setup_install_guide",
722
+ 'Interview-style install guide. Call it with whatever you know; while an answer is missing it returns {status:"need-answers", questions} listing only what is still unknown. When complete it returns the steps and the OFFICIAL template files (fetched live) for that route: docker-compose, docker, unraid, proxmox-lxc or macos-desktop. Start with a hub.',
723
+ {
724
+ hostKind: HostKindSchema.optional(),
725
+ role: RoleSchema.optional(),
726
+ accelerator: AcceleratorSchema.optional(),
727
+ paths: InstallPathsSchema.optional(),
728
+ hubAddress: z4.string().min(1).optional().describe("Agents only: the hub to dial.")
729
+ },
730
+ async (input) => text(await installGuide(input, ctx.fetch))
731
+ );
732
+ server.tool(
733
+ "setup_discover_hubs",
734
+ "Find CamStack hubs on the local network (mDNS) plus the hubs this machine already knows, with host, port, name and version. Needs no login.",
735
+ { timeoutMs: z4.number().int().min(200).max(15e3).optional() },
736
+ async ({ timeoutMs }) => text(await discoverHubsTool(timeoutMs, deps))
737
+ );
738
+ server.tool(
739
+ "setup_connect",
740
+ "Check that a hub answers on https://<server>:4443/health, whether its CA is already pinned for TLS, and whether a `camstack login` session exists. Never takes a password: when there is no session it says to run `camstack login <server>` in a terminal.",
741
+ { server: z4.string().min(1) },
742
+ async ({ server: address }) => text(await connectTool(address, deps))
743
+ );
744
+ server.tool(
745
+ "setup_add_node",
746
+ "Guide to add an AGENT (a second machine) to a hub: the install route for that host, then the pairing flow where the operator types the six digits shown on the other screen on both sides. With a login session it also lists the agents found on the network. It never confirms a pairing.",
747
+ {
748
+ hostKind: HostKindSchema.optional(),
749
+ accelerator: AcceleratorSchema.optional(),
750
+ paths: InstallPathsSchema.optional(),
751
+ hubAddress: z4.string().min(1).optional()
752
+ },
753
+ async (input) => text(await addNodeTool(input, ctx))
754
+ );
755
+ }
756
+
757
+ // src/mcp/ops-update-cli.ts
758
+ import { execFile } from "child_process";
759
+ var CLI_PACKAGE = "camstack";
760
+ var NPM_GLOBAL = /[\\/]lib[\\/]node_modules[\\/]camstack[\\/]|[\\/]npm[\\/]node_modules[\\/]camstack[\\/]/;
761
+ var OTHER_MANAGER = /[\\/]\.bun[\\/]|[\\/]pnpm[\\/]|[\\/]\.yarn[\\/]|[\\/]yarn[\\/]|[\\/]\.volta[\\/]/;
762
+ function detectCliInstall(entryPath) {
763
+ const manual = `Update it with the package manager it was installed with, for example \`npm install -g ${CLI_PACKAGE}@latest\` (no sudo: fix the npm prefix permissions instead).`;
764
+ if (/[\\/]_npx[\\/]/.test(entryPath)) {
765
+ return {
766
+ method: "npx",
767
+ command: null,
768
+ instructions: `The CLI runs through npx: there is nothing installed to update. Start it as \`npx ${CLI_PACKAGE}@latest mcp\` (or clear the npx cache), then restart the MCP in your client.`
769
+ };
770
+ }
771
+ if (OTHER_MANAGER.test(entryPath) || !NPM_GLOBAL.test(entryPath)) {
772
+ return { method: "unknown", command: null, instructions: manual };
773
+ }
774
+ return {
775
+ method: "npm-global",
776
+ command: ["npm", "install", "-g", `${CLI_PACKAGE}@latest`],
777
+ instructions: `npm install -g ${CLI_PACKAGE}@latest`
778
+ };
779
+ }
780
+ var runCommand = (file, args) => new Promise((resolve) => {
781
+ execFile(file, [...args], { timeout: 5 * 6e4 }, (err, stdout, stderr) => {
782
+ const out = `${stdout}${stderr}`.trim().slice(-600);
783
+ resolve({
784
+ ok: err === null,
785
+ output: err ? `${err.message} ${out}`.trim() : out
786
+ });
787
+ });
788
+ });
789
+ function parts(v) {
790
+ return v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
791
+ }
792
+ function isOlder(a, b) {
793
+ const x = parts(a);
794
+ const y = parts(b);
795
+ for (let i = 0; i < 3; i++) {
796
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
797
+ if (d !== 0) return d < 0;
798
+ }
799
+ return false;
800
+ }
801
+ async function cliVerdict(installed, fetchFn, entryPath) {
802
+ const latest = await latestOnNpm(CLI_PACKAGE, fetchFn);
803
+ return {
804
+ installed,
805
+ latest: latest.latest,
806
+ updateAvailable: latest.latest === null ? null : isOlder(installed, latest.latest),
807
+ ...latest.error !== void 0 ? { error: latest.error } : {},
808
+ install: detectCliInstall(entryPath)
809
+ };
810
+ }
811
+
812
+ export {
813
+ text,
814
+ IMAGE_TAG_BY_ACCELERATOR,
815
+ NO_BARE_LATEST,
816
+ MUST_NOT_OMIT,
817
+ latestOnNpm,
818
+ registerSetupTools,
819
+ runCommand,
820
+ isOlder,
821
+ cliVerdict
822
+ };