camstack 1.2.213 → 1.2.215

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/dist/cli.js CHANGED
@@ -1,10 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- FFMPEG_PATH_ENV,
3
+ hubRequest
4
+ } from "./chunk-BYNP7S4W.js";
5
+ import {
6
+ FFMPEG_PATH_ENV
7
+ } from "./chunk-WGHOCSCW.js";
8
+ import {
4
9
  describeHub,
5
10
  findHubs,
6
11
  validateManualHubUrl
7
- } from "./chunk-MJJWVAEM.js";
12
+ } from "./chunk-JPGJK5DC.js";
8
13
  import "./chunk-LMMQX4CK.js";
9
14
 
10
15
  // src/cli.ts
@@ -41,7 +46,7 @@ async function runServe(args) {
41
46
  ...typeof values.data === "string" ? { data: values.data } : {}
42
47
  };
43
48
  Object.assign(process.env, buildServeEnv(opts));
44
- await import("./launcher-6TBFW332.js");
49
+ await import("./launcher-RUHSCWRY.js");
45
50
  }
46
51
 
47
52
  // src/commands/agent.ts
@@ -86,7 +91,7 @@ async function runAgent(args) {
86
91
  ...typeof values.port === "string" ? { port: values.port } : {}
87
92
  };
88
93
  Object.assign(process.env, buildAgentEnv(opts));
89
- await import("./launcher-6TBFW332.js");
94
+ await import("./launcher-RUHSCWRY.js");
90
95
  }
91
96
 
92
97
  // src/commands/setup.ts
@@ -140,56 +145,6 @@ import { execSync } from "child_process";
140
145
  import { randomBytes } from "crypto";
141
146
  import * as fs2 from "fs";
142
147
  import * as path2 from "path";
143
-
144
- // src/http/hub-http.ts
145
- import * as http from "http";
146
- import * as https from "https";
147
- import { Buffer as Buffer2 } from "buffer";
148
- var insecureHubAgent = new https.Agent({ rejectUnauthorized: false, keepAlive: false });
149
- var DEFAULT_TIMEOUT_MS = 3e4;
150
- function hubRequest(url, opts = {}) {
151
- const parsed = new URL(url);
152
- const isHttps = parsed.protocol === "https:";
153
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
154
- return new Promise((resolve6, reject) => {
155
- const requestOptions = {
156
- protocol: parsed.protocol,
157
- hostname: parsed.hostname,
158
- port: parsed.port || (isHttps ? 443 : 80),
159
- path: `${parsed.pathname}${parsed.search}`,
160
- method: opts.method ?? "GET",
161
- headers: { ...opts.headers },
162
- timeout: timeoutMs,
163
- ...isHttps ? { agent: insecureHubAgent } : {}
164
- };
165
- const onResponse = (res) => {
166
- const chunks = [];
167
- res.on("data", (chunk) => chunks.push(chunk));
168
- res.on("end", () => {
169
- const buffer = Buffer2.concat(chunks);
170
- const status = res.statusCode ?? 0;
171
- resolve6({
172
- ok: status >= 200 && status < 300,
173
- status,
174
- statusText: res.statusMessage ?? "",
175
- headers: res.headers,
176
- text: () => Promise.resolve(buffer.toString("utf8")),
177
- json: () => Promise.resolve().then(() => JSON.parse(buffer.toString("utf8")))
178
- });
179
- });
180
- res.on("error", reject);
181
- };
182
- const req = isHttps ? https.request(requestOptions, onResponse) : http.request(requestOptions, onResponse);
183
- req.on("error", reject);
184
- req.on("timeout", () => {
185
- req.destroy(new Error(`Hub request to ${parsed.host} timed out after ${timeoutMs}ms`));
186
- });
187
- if (opts.body !== void 0) req.write(opts.body);
188
- req.end();
189
- });
190
- }
191
-
192
- // src/commands/deploy.ts
193
148
  import { withAuthHint } from "@camstack/develop";
194
149
  function isRecord(value) {
195
150
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -1585,11 +1540,80 @@ async function whoamiCommand(opts) {
1585
1540
  }
1586
1541
  }
1587
1542
 
1543
+ // src/commands/login-token-file.ts
1544
+ import * as fs5 from "fs";
1545
+ import {
1546
+ jwtClaims as jwtClaims2,
1547
+ jwtExpiryMs,
1548
+ normalizeHubAddress as normalizeHubAddress3,
1549
+ readTokenFile,
1550
+ saveSession as saveSession2
1551
+ } from "@camstack/develop";
1552
+ var GROUP_OR_OTHER_BITS = 63;
1553
+ function assertPrivateTokenFile(filePath, platform2 = process.platform) {
1554
+ let stat;
1555
+ try {
1556
+ stat = fs5.statSync(filePath);
1557
+ } catch (err) {
1558
+ const reason = err instanceof Error ? err.message : String(err);
1559
+ throw new Error(`Cannot read token file ${filePath}: ${reason}`, { cause: err });
1560
+ }
1561
+ if (!stat.isFile()) throw new Error(`Token file ${filePath} is not a regular file.`);
1562
+ if (platform2 !== "win32" && (stat.mode & GROUP_OR_OTHER_BITS) !== 0) {
1563
+ const octal = (stat.mode & 511).toString(8);
1564
+ throw new Error(
1565
+ `Refusing token file ${filePath}: its mode is ${octal} and group/others can read it. Run \`chmod 600 ${filePath}\` (and consider the token exposed if others had access).`
1566
+ );
1567
+ }
1568
+ }
1569
+ function usernameOf(body) {
1570
+ if (body === null || typeof body !== "object") return void 0;
1571
+ const name = Reflect.get(body, "username");
1572
+ return typeof name === "string" && name !== "" ? name : void 0;
1573
+ }
1574
+ async function loginWithTokenFile(opts) {
1575
+ assertPrivateTokenFile(opts.tokenFile, opts.platform);
1576
+ const file = readTokenFile(opts.tokenFile);
1577
+ const typed = opts.server ?? file.server;
1578
+ if (!typed) {
1579
+ throw new Error("login --token-file needs the hub: pass <host> or --server <host>.");
1580
+ }
1581
+ const server = normalizeHubAddress3(typed);
1582
+ const res = await hubRequest(`${server}/api/auth/whoami`, {
1583
+ headers: { Authorization: `Bearer ${file.token}` },
1584
+ timeoutMs: 1e4,
1585
+ ...opts.sessionDir ? { sessionDir: opts.sessionDir } : {}
1586
+ });
1587
+ if (res.status === 401 || res.status === 403) {
1588
+ throw new Error(
1589
+ `The hub ${server} rejected the token in ${opts.tokenFile} (revoked, expired or lacking scope).`
1590
+ );
1591
+ }
1592
+ if (!res.ok) throw new Error(`The hub ${server} answered ${res.status} ${res.statusText}.`);
1593
+ const body = await res.json().catch(() => null);
1594
+ const claims = jwtClaims2(file.token);
1595
+ const isAdmin = claims?.["isAdmin"] === true;
1596
+ const username = usernameOf(body) ?? "token";
1597
+ const expiresAt = jwtExpiryMs(file.token);
1598
+ const session = {
1599
+ server,
1600
+ username,
1601
+ tokenId: "",
1602
+ token: file.token,
1603
+ scopes: [],
1604
+ createdAt: Date.now(),
1605
+ ...isAdmin ? { isAdmin: true } : {},
1606
+ ...expiresAt !== void 0 ? { expiresAt } : {}
1607
+ };
1608
+ const sessionFile = saveSession2(session, opts.sessionDir);
1609
+ return { server, username, isAdmin, sessionFile };
1610
+ }
1611
+
1588
1612
  // src/cli.ts
1589
1613
  import {
1590
1614
  loadSession as loadSession4,
1591
1615
  MissingCredentialsError,
1592
- normalizeHubAddress as normalizeHubAddress3,
1616
+ normalizeHubAddress as normalizeHubAddress4,
1593
1617
  touchSession as touchSession3,
1594
1618
  resolveFreshCredentials as resolveFreshCredentials3
1595
1619
  } from "@camstack/develop";
@@ -1648,7 +1672,7 @@ import { spawn } from "child_process";
1648
1672
  import * as clack2 from "@clack/prompts";
1649
1673
 
1650
1674
  // src/update-notifier.ts
1651
- import * as fs5 from "fs";
1675
+ import * as fs6 from "fs";
1652
1676
  import * as path5 from "path";
1653
1677
  import * as os3 from "os";
1654
1678
  var CACHE_DIR = path5.join(os3.homedir(), ".camstack");
@@ -1658,8 +1682,8 @@ var REGISTRY_BASE = "https://registry.npmjs.org";
1658
1682
  var FETCH_TIMEOUT_MS = 2500;
1659
1683
  function readCache() {
1660
1684
  try {
1661
- if (!fs5.existsSync(CACHE_FILE)) return null;
1662
- const parsed = JSON.parse(fs5.readFileSync(CACHE_FILE, "utf8"));
1685
+ if (!fs6.existsSync(CACHE_FILE)) return null;
1686
+ const parsed = JSON.parse(fs6.readFileSync(CACHE_FILE, "utf8"));
1663
1687
  if (!parsed || typeof parsed !== "object") return null;
1664
1688
  const c = parsed;
1665
1689
  if (typeof c.lastCheckAt !== "number") return null;
@@ -1674,8 +1698,8 @@ function readCache() {
1674
1698
  }
1675
1699
  function writeCache(c) {
1676
1700
  try {
1677
- if (!fs5.existsSync(CACHE_DIR)) fs5.mkdirSync(CACHE_DIR, { recursive: true, mode: 448 });
1678
- fs5.writeFileSync(CACHE_FILE, JSON.stringify(c, null, 2));
1701
+ if (!fs6.existsSync(CACHE_DIR)) fs6.mkdirSync(CACHE_DIR, { recursive: true, mode: 448 });
1702
+ fs6.writeFileSync(CACHE_FILE, JSON.stringify(c, null, 2));
1679
1703
  } catch {
1680
1704
  }
1681
1705
  }
@@ -1834,7 +1858,7 @@ async function runUpdate(args) {
1834
1858
  }
1835
1859
 
1836
1860
  // src/commands/dev.ts
1837
- import * as fs6 from "fs";
1861
+ import * as fs7 from "fs";
1838
1862
  import * as path6 from "path";
1839
1863
  import { parseArgs as parseArgs6 } from "util";
1840
1864
  import * as clack3 from "@clack/prompts";
@@ -1859,14 +1883,14 @@ function resolveAddonDir(arg) {
1859
1883
  throw new Error("`dev` requires a source directory, not a tarball (nothing to watch).");
1860
1884
  }
1861
1885
  const absolute = path6.resolve(arg);
1862
- if (fs6.existsSync(absolute) && fs6.statSync(absolute).isDirectory()) {
1886
+ if (fs7.existsSync(absolute) && fs7.statSync(absolute).isDirectory()) {
1863
1887
  return absolute;
1864
1888
  }
1865
1889
  for (const candidate of [
1866
1890
  path6.resolve("packages", `addon-${arg}`),
1867
1891
  path6.resolve("packages", arg)
1868
1892
  ]) {
1869
- if (fs6.existsSync(candidate) && fs6.statSync(candidate).isDirectory()) {
1893
+ if (fs7.existsSync(candidate) && fs7.statSync(candidate).isDirectory()) {
1870
1894
  return candidate;
1871
1895
  }
1872
1896
  }
@@ -1876,10 +1900,10 @@ function resolveAddonDir(arg) {
1876
1900
  }
1877
1901
  function watchRecursive(target, onEvent) {
1878
1902
  try {
1879
- const w = fs6.watch(target, { recursive: true }, onEvent);
1903
+ const w = fs7.watch(target, { recursive: true }, onEvent);
1880
1904
  return { close: () => w.close(), recursive: true };
1881
1905
  } catch {
1882
- const w = fs6.watch(target, onEvent);
1906
+ const w = fs7.watch(target, onEvent);
1883
1907
  return { close: () => w.close(), recursive: false };
1884
1908
  }
1885
1909
  }
@@ -1948,8 +1972,8 @@ async function runDev(args) {
1948
1972
  token: (await resolveFreshCredentials(credentialInputs)).token
1949
1973
  });
1950
1974
  const watchOverride = typeof values["watch-path"] === "string" ? values["watch-path"] : void 0;
1951
- const watchTarget = watchOverride ? path6.resolve(addonDir, watchOverride) : fs6.existsSync(path6.join(addonDir, DEFAULT_WATCH_SUBDIR)) ? path6.join(addonDir, DEFAULT_WATCH_SUBDIR) : addonDir;
1952
- if (!fs6.existsSync(watchTarget)) {
1975
+ const watchTarget = watchOverride ? path6.resolve(addonDir, watchOverride) : fs7.existsSync(path6.join(addonDir, DEFAULT_WATCH_SUBDIR)) ? path6.join(addonDir, DEFAULT_WATCH_SUBDIR) : addonDir;
1976
+ if (!fs7.existsSync(watchTarget)) {
1953
1977
  throw new Error(`Watch path does not exist: ${watchTarget}`);
1954
1978
  }
1955
1979
  const debounceMs = typeof values.debounce === "string" ? parseInt(values.debounce, 10) : DEFAULT_DEBOUNCE_MS;
@@ -2528,6 +2552,7 @@ function parseToolsets(raw) {
2528
2552
  function mcpHelp() {
2529
2553
  return [
2530
2554
  "Usage: camstack mcp [options]",
2555
+ " camstack mcp doctor",
2531
2556
  "",
2532
2557
  "Runs the CamStack MCP server on stdio. Register it with your assistant:",
2533
2558
  " claude mcp add camstack -- camstack mcp",
@@ -2538,12 +2563,23 @@ function mcpHelp() {
2538
2563
  " -s, --server <host> Hub to use instead of the cached login session",
2539
2564
  " --token-file <p> Read an explicit token from a file",
2540
2565
  "",
2566
+ "Diagnose: `camstack mcp doctor` checks, read-only, Node >= 24, the hub, its pinned CA,",
2567
+ "the session or token file, the token scope (admin or not) and the CLI version, each",
2568
+ "ok / fail / unknown with a fix hint. It never prints a token.",
2569
+ "No TTY (CI, a service): `camstack login <host> --token-file <path>` with a chmod 600 file.",
2570
+ "MCP clients such as OpenCode keep the old process: restart them after `camstack mcp` updates.",
2571
+ "",
2541
2572
  "Auth is the CLI's own: run `camstack login <host>` once in a terminal.",
2542
2573
  "The MCP never asks for or accepts a password. Every write is two-step:",
2543
2574
  "a preview with a one-shot token, then the same call with that token."
2544
2575
  ].join("\n");
2545
2576
  }
2546
2577
  async function runMcp(args, cliVersion) {
2578
+ if (args[0] === "doctor") {
2579
+ const { runDoctorCommand } = await import("./doctor-command-S7Q2U6HO.js");
2580
+ process.exitCode = await runDoctorCommand(args.slice(1), cliVersion);
2581
+ return;
2582
+ }
2547
2583
  const parsed = parseArgs9({
2548
2584
  args: [...args],
2549
2585
  options: {
@@ -2561,7 +2597,7 @@ async function runMcp(args, cliVersion) {
2561
2597
  process.exitCode = 1;
2562
2598
  return;
2563
2599
  }
2564
- const { runPublicMcp } = await import("./server-EFXGQ7FD.js");
2600
+ const { runPublicMcp } = await import("./server-7C635XXX.js");
2565
2601
  await runPublicMcp({
2566
2602
  cliVersion,
2567
2603
  toolsets: selection.toolsets,
@@ -2660,6 +2696,10 @@ function buildCommands() {
2660
2696
  " https://hub.local, [fe80::1]:4443. Port defaults to 4443, scheme is",
2661
2697
  " https (http:// is refused). Example: camstack login 192.0.2.20",
2662
2698
  "",
2699
+ " --token-file <path> No TTY / automation: read a token from a file readable only by you",
2700
+ " (chmod 600; a looser file is refused), check it against the hub and",
2701
+ " cache the session. A token is never taken from argv or the environment.",
2702
+ "",
2663
2703
  "Default flow (no flags): finds the hub from what the LAN announces over mDNS",
2664
2704
  " plus the hubs this machine knows (saved sessions, $CAMSTACK_HUB_URL),",
2665
2705
  " then prompts for user + password. Found hubs show a picker (the first, the default, is",
@@ -2902,6 +2942,7 @@ async function runLogin(args) {
2902
2942
  username: { type: "string", short: "u" },
2903
2943
  password: { type: "string", short: "p" },
2904
2944
  "token-name": { type: "string" },
2945
+ "token-file": { type: "string" },
2905
2946
  // Removed (D895), still parsed so an old script does not abort: each one
2906
2947
  // present prints a one-line warning and the login goes on.
2907
2948
  ...LEGACY_LOGIN_FLAG_OPTIONS,
@@ -2926,6 +2967,22 @@ async function runLogin(args) {
2926
2967
  console.warn("--namespace is ignored: hubs are found over mDNS, not the mesh channel.");
2927
2968
  }
2928
2969
  for (const warning of legacyLoginFlagWarnings(parsed.values)) console.warn(warning);
2970
+ const tokenFile = stringOpt(parsed.values, "token-file");
2971
+ if (tokenFile !== void 0) {
2972
+ try {
2973
+ const done = await loginWithTokenFile({
2974
+ tokenFile,
2975
+ ...explicitServer ? { server: explicitServer } : {}
2976
+ });
2977
+ console.log(
2978
+ `[camstack] Logged in to ${done.server} as ${done.username} (${done.isAdmin ? "admin" : "not admin"}). Session cached at ${done.sessionFile}`
2979
+ );
2980
+ } catch (err) {
2981
+ console.error(`[camstack] ${err instanceof Error ? err.message : String(err)}`);
2982
+ process.exitCode = 1;
2983
+ }
2984
+ return;
2985
+ }
2929
2986
  await loginCommand({
2930
2987
  ...explicitServer ? { server: explicitServer } : {},
2931
2988
  ...optionalString(parsed.values, "username"),
@@ -2945,7 +3002,7 @@ async function runLogout(args) {
2945
3002
  console.log(commandHelp("logout"));
2946
3003
  return;
2947
3004
  }
2948
- const server = normalizeHubAddress3(
3005
+ const server = normalizeHubAddress4(
2949
3006
  stringOpt(parsed.values, "server") ?? process.env.CAMSTACK_SERVER ?? DEFAULT_SERVER_URL
2950
3007
  );
2951
3008
  await logoutCommand({ server });
@@ -2963,7 +3020,7 @@ async function runWhoami(args) {
2963
3020
  return;
2964
3021
  }
2965
3022
  const server = stringOpt(parsed.values, "server");
2966
- await whoamiCommand(server ? { server: normalizeHubAddress3(server) } : {});
3023
+ await whoamiCommand(server ? { server: normalizeHubAddress4(server) } : {});
2967
3024
  }
2968
3025
  async function resolveClosureDeployOpts(values) {
2969
3026
  const nodeId = stringOpt(values, "node");
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ hubRequest
4
+ } from "./chunk-BYNP7S4W.js";
5
+ import {
6
+ isOlder,
7
+ latestOnNpm
8
+ } from "./chunk-37A556YF.js";
9
+ import "./chunk-JPGJK5DC.js";
10
+ import "./chunk-LMMQX4CK.js";
11
+
12
+ // src/mcp/doctor-command.ts
13
+ import { parseArgs } from "util";
14
+ import { tryConnectHub } from "@camstack/develop";
15
+
16
+ // src/mcp/doctor.ts
17
+ import {
18
+ explainTlsFailure,
19
+ hubCaFileForServer,
20
+ resolveHubTls
21
+ } from "@camstack/develop";
22
+ var MIN_NODE_MAJOR = 24;
23
+ function nodeCheck(version) {
24
+ const major = Number.parseInt(version.replace(/^v/, "").split(".")[0] ?? "", 10);
25
+ if (Number.isNaN(major)) {
26
+ return {
27
+ id: "node",
28
+ label: "Node.js version",
29
+ status: "unknown",
30
+ detail: `cannot read "${version}"`
31
+ };
32
+ }
33
+ if (major >= MIN_NODE_MAJOR) {
34
+ return {
35
+ id: "node",
36
+ label: "Node.js version",
37
+ status: "ok",
38
+ detail: `${version} (>= ${MIN_NODE_MAJOR})`
39
+ };
40
+ }
41
+ return {
42
+ id: "node",
43
+ label: "Node.js version",
44
+ status: "fail",
45
+ detail: `${version} is older than the required ${MIN_NODE_MAJOR}`,
46
+ fix: `Install Node.js ${MIN_NODE_MAJOR} or newer, then reinstall: npm install -g camstack@latest`
47
+ };
48
+ }
49
+ async function versionCheck(deps) {
50
+ const latest = await latestOnNpm("camstack", deps.fetch);
51
+ if (latest.latest === null) {
52
+ return {
53
+ id: "version",
54
+ label: "CLI version",
55
+ status: "unknown",
56
+ detail: `${deps.cliVersion} installed; npm latest unreadable (${latest.error ?? "no answer"})`
57
+ };
58
+ }
59
+ if (isOlder(deps.cliVersion, latest.latest)) {
60
+ return {
61
+ id: "version",
62
+ label: "CLI version",
63
+ status: "fail",
64
+ detail: `${deps.cliVersion} installed, ${latest.latest} is on npm`,
65
+ fix: "npm install -g camstack@latest, then restart the MCP in your client (OpenCode and similar keep the old process)."
66
+ };
67
+ }
68
+ return {
69
+ id: "version",
70
+ label: "CLI version",
71
+ status: "ok",
72
+ detail: `${deps.cliVersion} (npm latest ${latest.latest})`
73
+ };
74
+ }
75
+ var NO_HUB = "no hub to check (see the session line)";
76
+ async function runDoctorChecks(deps) {
77
+ const checks = [nodeCheck(deps.nodeVersion)];
78
+ let outcome;
79
+ try {
80
+ outcome = await deps.connect();
81
+ } catch (err) {
82
+ const message = err instanceof Error ? err.message : String(err);
83
+ const tls = explainTlsFailure("the hub", err);
84
+ outcome = {
85
+ ok: false,
86
+ reason: "no-session",
87
+ message: tls ?? message,
88
+ action: "camstack login <host>"
89
+ };
90
+ }
91
+ if (!outcome.ok) {
92
+ checks.push(
93
+ { id: "hub", label: "Hub reachable", status: "unknown", detail: NO_HUB },
94
+ { id: "ca", label: "Hub CA pinned", status: "unknown", detail: NO_HUB },
95
+ {
96
+ id: "session",
97
+ label: "Session or token file",
98
+ status: "fail",
99
+ detail: outcome.message,
100
+ fix: `${outcome.action}; without a TTY: camstack login <host> --token-file <path-to-0600-file>`
101
+ },
102
+ { id: "scope", label: "Token scope", status: "unknown", detail: "no credential to inspect" },
103
+ await versionCheck(deps)
104
+ );
105
+ return checks;
106
+ }
107
+ const { connection } = outcome;
108
+ const server = connection.server;
109
+ const probe = await deps.probeHub(server);
110
+ const tlsExplained = probe.reachable ? null : explainTlsFailure(server, probe.cause);
111
+ checks.push(
112
+ probe.reachable ? { id: "hub", label: "Hub reachable", status: "ok", detail: server } : {
113
+ id: "hub",
114
+ label: "Hub reachable",
115
+ status: "fail",
116
+ detail: `${server}: ${probe.error ?? "no answer"}`,
117
+ fix: tlsExplained === null ? "Check the hub is running and the address is right (camstack login <host>)." : "Pin the hub CA (see the next line)."
118
+ }
119
+ );
120
+ const pinned = resolveHubTls(server, deps.env ?? process.env, deps.sessionDir);
121
+ if (!server.startsWith("https:")) {
122
+ checks.push({
123
+ id: "ca",
124
+ label: "Hub CA pinned",
125
+ status: "unknown",
126
+ detail: "plain http hub: TLS is not used"
127
+ });
128
+ } else if (pinned.ca) {
129
+ checks.push({
130
+ id: "ca",
131
+ label: "Hub CA pinned",
132
+ status: "ok",
133
+ detail: pinned.caFile ?? "pinned"
134
+ });
135
+ } else if (tlsExplained !== null) {
136
+ checks.push({
137
+ id: "ca",
138
+ label: "Hub CA pinned",
139
+ status: "fail",
140
+ detail: "no pinned CA and the hub certificate is not publicly trusted",
141
+ fix: `Save the hub's certificate or CA PEM to ${hubCaFileForServer(server, deps.sessionDir)} (or set $CAMSTACK_HUB_CA_FILE).`
142
+ });
143
+ } else {
144
+ checks.push({
145
+ id: "ca",
146
+ label: "Hub CA pinned",
147
+ status: "unknown",
148
+ detail: "no pin; fine while the hub certificate is publicly trusted"
149
+ });
150
+ }
151
+ checks.push({
152
+ id: "session",
153
+ label: "Session or token file",
154
+ status: "ok",
155
+ detail: `usable credential from ${connection.source}`
156
+ });
157
+ checks.push(
158
+ connection.tier === "admin" ? { id: "scope", label: "Token scope", status: "ok", detail: "admin" } : {
159
+ id: "scope",
160
+ label: "Token scope",
161
+ status: "unknown",
162
+ detail: "not admin: read tools work, configure and update tools are refused",
163
+ fix: connection.adminHint ?? "camstack login <host> as an administrator"
164
+ }
165
+ );
166
+ checks.push(await versionCheck(deps));
167
+ return checks;
168
+ }
169
+ var MARK = {
170
+ ok: "ok ",
171
+ fail: "FAIL ",
172
+ unknown: "unknown"
173
+ };
174
+ function formatDoctor(checks) {
175
+ const lines = ["camstack mcp doctor", ""];
176
+ for (const c of checks) {
177
+ lines.push(`[${MARK[c.status]}] ${c.label}: ${c.detail}`);
178
+ if (c.fix && c.status !== "ok") lines.push(` fix: ${c.fix}`);
179
+ }
180
+ const failed = checks.filter((c) => c.status === "fail").length;
181
+ lines.push("", failed === 0 ? "No failing check." : `${failed} failing check(s).`);
182
+ return lines.join("\n");
183
+ }
184
+ function doctorExitCode(checks) {
185
+ return checks.some((c) => c.status === "fail") ? 1 : 0;
186
+ }
187
+
188
+ // src/mcp/doctor-command.ts
189
+ async function probeHub(server) {
190
+ try {
191
+ const res = await hubRequest(`${server}/health`, { timeoutMs: 5e3 });
192
+ return res.ok ? { reachable: true } : { reachable: false, error: `answered ${res.status}` };
193
+ } catch (err) {
194
+ return {
195
+ reachable: false,
196
+ error: err instanceof Error ? err.message : String(err),
197
+ cause: err instanceof Error && err.cause !== void 0 ? err.cause : err
198
+ };
199
+ }
200
+ }
201
+ async function runDoctorCommand(args, cliVersion) {
202
+ const parsed = parseArgs({
203
+ args: [...args],
204
+ options: { server: { type: "string", short: "s" }, "token-file": { type: "string" } },
205
+ allowPositionals: false,
206
+ strict: true
207
+ });
208
+ const server = parsed.values.server;
209
+ const tokenFile = parsed.values["token-file"];
210
+ const checks = await runDoctorChecks({
211
+ cliVersion,
212
+ nodeVersion: process.version,
213
+ connect: () => tryConnectHub({
214
+ workspaceRoot: process.cwd(),
215
+ ...server ? { server } : {},
216
+ ...tokenFile ? { tokenFile } : {}
217
+ }),
218
+ probeHub,
219
+ fetch: (url, init) => fetch(url, init)
220
+ });
221
+ process.stdout.write(`${formatDoctor(checks)}
222
+ `);
223
+ return doctorExitCode(checks);
224
+ }
225
+ export {
226
+ runDoctorCommand
227
+ };