@dikacode/telegram-mcp 1.0.0 → 1.0.2

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,293 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `telegram-mcp update` — bring the installed copy up to date.
4
+ *
5
+ * The hard part is not the upgrade, it is knowing *how* this copy was
6
+ * installed. A global npm install is upgraded with `npm i -g`, an npx run is
7
+ * replaced by asking npx for @latest, and a git checkout is updated with
8
+ * `git pull` plus a rebuild. Guessing wrong either does nothing (npm refuses
9
+ * to touch a package it does not own) or silently reinstalls the same version.
10
+ *
11
+ * So detection is made from the filesystem, not from a flag the user has to
12
+ * remember:
13
+ * - the real path of this file lives under node_modules -> npm package
14
+ * - under a global prefix (`npm root -g`) -> global install
15
+ * - anywhere else (npx cache, local dep) -> npx
16
+ * - a sibling `.git` directory -> git checkout
17
+ *
18
+ * With `--check` nothing is installed; it only reports. That is what
19
+ * `telegram-mcp update --check` and the doctor use, and it never writes.
20
+ */
21
+ import "../env.js";
22
+ import { spawn, execFileSync } from "node:child_process";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { PACKAGE_NAME, SERVER_VERSION, compareVersions } from "../version.js";
27
+ import { getUpdateInfo } from "../update-check.js";
28
+ import { banner, gap, hint, info, sym, theme } from "../ui.js";
29
+ const out = (text) => void process.stdout.write(text);
30
+ /**
31
+ * Where npm puts globally installed packages. Asking npm is more reliable than
32
+ * reconstructing `process.execPath`, which differs between Homebrew, nvm,
33
+ * Volta and the Windows installer. `NPM_CONFIG_PREFIX` still applies, so a
34
+ * custom prefix is detected too.
35
+ */
36
+ function npmGlobalRoot() {
37
+ try {
38
+ const root = execFileSync("npm", ["root", "-g"], {
39
+ encoding: "utf8",
40
+ stdio: ["ignore", "pipe", "ignore"],
41
+ timeout: 10_000,
42
+ shell: process.platform === "win32",
43
+ }).trim();
44
+ return root || null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * Candidates for the global prefix. `npm root -g` is authoritative, but it is
52
+ * skipped when npm is not on PATH (clients do strip it), and an explicit
53
+ * `npm i -g --prefix <dir>` is easy to miss. The real path of this file gives
54
+ * the answer either way: it ends in
55
+ * <prefix>/lib/node_modules/<name>/dist/cli/update.js (POSIX)
56
+ * <prefix>/node_modules/<name>/dist/cli/update.js (Windows)
57
+ */
58
+ function globalRoots() {
59
+ const candidates = new Set();
60
+ const fromNpm = npmGlobalRoot();
61
+ if (fromNpm)
62
+ candidates.add(fromNpm);
63
+ const marker = process.platform === "win32" ? "/node_modules/" : "/lib/node_modules/";
64
+ const here = fileURLToPath(import.meta.url).replace(/\\/g, "/");
65
+ const at = here.lastIndexOf(marker);
66
+ if (at !== -1)
67
+ candidates.add(here.slice(0, at + marker.length - 1));
68
+ return [...candidates].map((dir) => path.resolve(dir)).filter((dir) => fs.existsSync(dir));
69
+ }
70
+ /** Walk up looking for a package root (a directory with package.json + .git or dist). */
71
+ function findPackageRoot(start) {
72
+ let dir = start;
73
+ for (let i = 0; i < 6; i++) {
74
+ if (fs.existsSync(path.join(dir, "package.json")))
75
+ return dir;
76
+ const parent = path.dirname(dir);
77
+ if (parent === dir)
78
+ return null;
79
+ dir = parent;
80
+ }
81
+ return null;
82
+ }
83
+ export function detectInstall() {
84
+ const here = path.dirname(fileURLToPath(import.meta.url));
85
+ const root = findPackageRoot(here) ?? here;
86
+ const normalized = root.replace(/\\/g, "/");
87
+ // A git checkout is the only layout that carries its own .git.
88
+ if (fs.existsSync(path.join(root, ".git"))) {
89
+ return { kind: "source", location: root };
90
+ }
91
+ if (/\/node_modules\//.test(`${normalized}/`)) {
92
+ const global = globalRoots().map((p) => p.replace(/\\/g, "/").replace(/\/+$/, ""));
93
+ if (global.some((g) => normalized === g || normalized.startsWith(`${g}/`))) {
94
+ return { kind: "global", location: root };
95
+ }
96
+ // Anything else under node_modules was put there by npx, or by a project
97
+ // that lists this package as a dependency, and both are refreshed by
98
+ // asking npm for @latest rather than by a -g install.
99
+ return { kind: "npx", location: root };
100
+ }
101
+ return { kind: "unknown", location: root };
102
+ }
103
+ export async function plan(allowNetwork = true) {
104
+ const { kind, location } = detectInstall();
105
+ const info = await getUpdateInfo({ allowNetwork });
106
+ const latest = info.source === "disabled" ? null : info.latest;
107
+ const outdated = latest !== null ? compareVersions(latest, SERVER_VERSION) > 0 : false;
108
+ const base = {
109
+ kind,
110
+ location,
111
+ current: SERVER_VERSION,
112
+ latest: latest ?? null,
113
+ outdated,
114
+ };
115
+ if (kind === "source") {
116
+ // A detached HEAD, a shallow clone or a checkout without an upstream all
117
+ // fail `pull --ff-only`. Ask git about the tracked upstream instead of
118
+ // assuming `origin/main` exists.
119
+ const upstream = git(location, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
120
+ if (!upstream) {
121
+ return {
122
+ ...base,
123
+ command: null,
124
+ note: "this checkout has no upstream branch; update it with `git pull` yourself, then npm install && npm run build",
125
+ };
126
+ }
127
+ return {
128
+ ...base,
129
+ command: ["git", "-C", location, "pull", "--ff-only"],
130
+ note: "then rebuild: npm install && npm run build",
131
+ };
132
+ }
133
+ if (kind === "global") {
134
+ // Respect a custom prefix: `npm i -g --prefix /opt/x` is a global install
135
+ // too, and upgrading it with a plain `npm i -g` would land in a different
136
+ // tree and leave the old copy in place.
137
+ const prefix = globalPrefix();
138
+ return {
139
+ ...base,
140
+ command: prefix
141
+ ? ["npm", "install", "-g", "--prefix", prefix, `${PACKAGE_NAME}@latest`]
142
+ : ["npm", "install", "-g", `${PACKAGE_NAME}@latest`],
143
+ };
144
+ }
145
+ if (kind === "npx") {
146
+ return {
147
+ ...base,
148
+ command: null,
149
+ note: `this copy was loaded from a node_modules folder; restart with \`npx -y ${PACKAGE_NAME}@latest\` and npx will fetch the newest release`,
150
+ };
151
+ }
152
+ return { ...base, command: null, note: "cannot tell how this copy was installed; use npm install -g @latest" };
153
+ }
154
+ /** Run git and return trimmed stdout, or null when it fails. Never throws. */
155
+ function git(cwd, args) {
156
+ try {
157
+ const result = execFileSync("git", ["-C", cwd, ...args], {
158
+ encoding: "utf8",
159
+ stdio: ["ignore", "pipe", "ignore"],
160
+ timeout: 5_000,
161
+ }).trim();
162
+ return result || null;
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
168
+ /**
169
+ * The prefix a global install lives under, or null when it is the default one.
170
+ * Read from the resolved global root (`<prefix>/lib/node_modules` on POSIX,
171
+ * `<prefix>/node_modules` on Windows) so no extra npm call is needed.
172
+ */
173
+ function globalPrefix() {
174
+ // Applies to the global root that contains the running copy: a custom
175
+ // prefix must be reused, or the upgrade would install next to it instead of
176
+ // over it.
177
+ const here = path.dirname(fileURLToPath(import.meta.url));
178
+ const normalized = here.replace(/\\/g, "/");
179
+ for (const root of globalRoots()) {
180
+ const g = root.replace(/\\/g, "/").replace(/\/+$/, "");
181
+ if (normalized.startsWith(`${g}/`)) {
182
+ return process.platform === "win32" ? path.dirname(root) : path.dirname(path.dirname(root));
183
+ }
184
+ }
185
+ return null;
186
+ }
187
+ function run(command) {
188
+ return new Promise((resolve) => {
189
+ const child = spawn(command[0], command.slice(1), {
190
+ stdio: "inherit",
191
+ shell: process.platform === "win32",
192
+ });
193
+ child.on("error", () => resolve(1));
194
+ child.on("exit", (code) => resolve(code ?? 1));
195
+ });
196
+ }
197
+ function printPlan(p, checkOnly) {
198
+ const label = p.kind === "global"
199
+ ? "global npm package"
200
+ : p.kind === "npx"
201
+ ? "npx or project dependency"
202
+ : p.kind === "source"
203
+ ? "git checkout"
204
+ : "unknown";
205
+ out(`\n ${theme.label("installed")} ${theme.bold(p.current)} ${theme.muted(p.location)}\n`);
206
+ out(` ${theme.label("latest")} ${p.latest ? theme.bold(p.latest) : theme.muted("unknown")}\n`);
207
+ out(` ${theme.label("install")} ${label}\n`);
208
+ if (p.latest === null) {
209
+ hint("could not reach the npm registry; check the network or TELEGRAM_UPDATE_CHECK");
210
+ return;
211
+ }
212
+ if (!p.outdated) {
213
+ out(`\n ${theme.success(`${sym.ok} already up to date`)}\n\n`);
214
+ return;
215
+ }
216
+ out(`\n ${theme.warn(`${sym.warn} ${theme.bold(`${p.current} -> ${p.latest}`)} available`)}\n`);
217
+ if (p.note)
218
+ hint(p.note);
219
+ if (checkOnly) {
220
+ if (p.command)
221
+ hint(`to upgrade, run ${theme.label(p.command.join(" "))}`);
222
+ hint("run without --check to apply it");
223
+ out("\n");
224
+ return;
225
+ }
226
+ }
227
+ async function main() {
228
+ const argv = process.argv.slice(2);
229
+ const checkOnly = argv.includes("--check") || argv.includes("--dry-run");
230
+ if (argv.includes("--help") || argv.includes("-h")) {
231
+ out([
232
+ "",
233
+ ` ${theme.bold("telegram-mcp update")} upgrade this install to the latest release`,
234
+ ` ${theme.bold("telegram-mcp update --check")} report only; install nothing`,
235
+ "",
236
+ " The command is chosen from how this copy was installed:",
237
+ ` ${theme.accent("global install")} npm install -g ${PACKAGE_NAME}@latest`,
238
+ ` ${theme.accent("npx run")} restart with npx -y ${PACKAGE_NAME}@latest`,
239
+ ` ${theme.accent("git checkout")} git pull --ff-only, then npm install && npm run build`,
240
+ "",
241
+ ].join("\n"));
242
+ return;
243
+ }
244
+ banner({ subtitle: "update", compact: true });
245
+ const p = await plan(true);
246
+ printPlan(p, checkOnly);
247
+ if (checkOnly || !p.outdated || !p.command) {
248
+ if (!checkOnly && p.outdated && !p.command && p.note) {
249
+ gap();
250
+ hint("nothing was installed; this copy is refreshed by the command above");
251
+ out("\n");
252
+ }
253
+ return;
254
+ }
255
+ info(`${theme.label("running")} ${p.command.join(" ")}`);
256
+ const code = await run(p.command);
257
+ if (code !== 0) {
258
+ out(`\n ${theme.fail(`${sym.fail} update command exited with ${code}`)}\n\n`);
259
+ process.exitCode = code;
260
+ return;
261
+ }
262
+ if (p.kind === "source") {
263
+ info(`${theme.label("rebuilding")} npm install && npm run build`);
264
+ const install = await run(["npm", "install"]);
265
+ if (install === 0)
266
+ await run(["npm", "run", "build"]);
267
+ }
268
+ gap();
269
+ out(` ${theme.success(`${sym.ok} updated to ${p.latest}`)}\n`);
270
+ hint("restart your MCP client so it spawns the new copy");
271
+ out("\n");
272
+ }
273
+ /**
274
+ * Only run when this file is the entrypoint. `detectInstall` is imported by the
275
+ * unit checks, and importing a module must not print a report or spawn npm.
276
+ */
277
+ function invokedDirectly() {
278
+ const entry = process.argv[1];
279
+ if (!entry)
280
+ return false;
281
+ try {
282
+ return path.resolve(entry) === fileURLToPath(import.meta.url);
283
+ }
284
+ catch {
285
+ return false;
286
+ }
287
+ }
288
+ if (invokedDirectly()) {
289
+ void main().catch((err) => {
290
+ process.stderr.write(`${theme.fail(`${sym.fail} update failed: ${err instanceof Error ? err.message : String(err)}`)}\n`);
291
+ process.exit(1);
292
+ });
293
+ }
package/dist/config.js CHANGED
@@ -11,8 +11,7 @@ import fs from "node:fs";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
  import { lockDir, lockFile } from "./perms.js";
14
- export const SERVER_NAME = "telegram-mcp";
15
- export const SERVER_VERSION = "1.0.0";
14
+ export { SERVER_NAME, SERVER_VERSION } from "./version.js";
16
15
  export class ConfigError extends Error {
17
16
  constructor(message) {
18
17
  super(message);
package/dist/index.js CHANGED
@@ -2,6 +2,9 @@
2
2
  /**
3
3
  * Entrypoint. Loads .env, builds the MCP server, serves over stdio.
4
4
  * stdout = MCP protocol only; diagnostics go to stderr.
5
+ *
6
+ * Exception: `--version` / `--help` are plain CLI invocations. They answer on
7
+ * stdout and exit before the transport is created, so nothing is corrupted.
5
8
  */
6
9
  import "./stream-guard.js";
7
10
  import "./env.js";
@@ -9,27 +12,123 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
9
12
  import { buildServer } from "./server.js";
10
13
  import { ensureDirs, getConfig, redact } from "./config.js";
11
14
  import { shutdownLogin } from "./login-tools.js";
15
+ import { PACKAGE_NAME, SERVER_VERSION } from "./version.js";
16
+ import { warnIfOutdated } from "./update-check.js";
17
+ import { banner, gap, hint, info, muted, sym, theme } from "./ui.js";
18
+ function help() {
19
+ const rows = [
20
+ ["telegram-mcp", "serve MCP on stdio (what an MCP client runs)"],
21
+ ["telegram-mcp --version", "print the version and exit"],
22
+ ["telegram-mcp --help", "print this help and exit"],
23
+ ["telegram-mcp login", "log in from this terminal instead of chat"],
24
+ ["telegram-mcp doctor", "check config, session and connectivity"],
25
+ ["telegram-mcp update", "upgrade this install to the latest release"],
26
+ ["telegram-mcp update --check", "report the available version, install nothing"],
27
+ ];
28
+ const width = Math.max(...rows.map(([cmd]) => cmd.length));
29
+ const usage = rows
30
+ .map(([cmd, desc]) => ` ${theme.accent(cmd.padEnd(width))} ${theme.muted(desc)}`)
31
+ .join("\n");
32
+ return [
33
+ `${theme.title("▀█▀ █▀▀ █░░ █▀▀ █▀▀ █▀█ ▄▀█ █▀▄▀█")}`,
34
+ `${theme.title("░█░ █▀▀ █░░ ██▄ █▄█ █▀▄ █▀█ █░▀░█")} ${theme.muted(SERVER_VERSION)}`,
35
+ "",
36
+ theme.label("An MCP server for a personal Telegram account, over MTProto."),
37
+ "",
38
+ theme.bold("Usage"),
39
+ usage,
40
+ "",
41
+ theme.bold("Setup"),
42
+ ` ${theme.accent("1.")} Get api_id and api_hash from ${theme.underline("https://my.telegram.org/apps")}`,
43
+ ` ${theme.accent("2.")} Provide them as ${theme.label("TELEGRAM_API_ID")} / ${theme.label("TELEGRAM_API_HASH")},`,
44
+ ` or let the agent call ${theme.label("telegram_setup")}`,
45
+ ` ${theme.accent("3.")} Log in with ${theme.label("telegram_login_start")} (the agent can do it),`,
46
+ ` or run ${theme.label("telegram-mcp login")} in a terminal`,
47
+ "",
48
+ theme.bold("Updating"),
49
+ ` ${theme.accent("telegram-mcp update".padEnd(42))} ${theme.muted("detects how it was installed")}`,
50
+ ` ${theme.accent("telegram-mcp update --check".padEnd(42))} ${theme.muted("report only")}`,
51
+ ` ${theme.accent(`npx -y ${PACKAGE_NAME}@latest`.padEnd(42))} ${theme.muted("npx, always current")}`,
52
+ ` ${theme.accent(`npm install -g ${PACKAGE_NAME}@latest`.padEnd(42))} ${theme.muted("global install")}`,
53
+ "",
54
+ `${theme.muted("Docs")} ${theme.underline("https://github.com/dikaglorya/telegram-mcp")}`,
55
+ "",
56
+ ].join("\n");
57
+ }
58
+ /** Subcommands that act like a CLI instead of serving stdio. */
59
+ function dispatchSubcommand(argv) {
60
+ const command = argv[0];
61
+ if (command !== "login" && command !== "doctor" && command !== "update")
62
+ return false;
63
+ // Each module runs its own main() on import; they keep the same behaviour as
64
+ // `npm run login` and `npm run doctor`, just reachable from one binary.
65
+ const targets = {
66
+ login: "./cli/login.js",
67
+ doctor: "./cli/doctor.js",
68
+ update: "./cli/update.js",
69
+ };
70
+ const target = targets[command];
71
+ void import(target).catch((err) => {
72
+ process.stderr.write(`${theme.fail(`${sym.fail} cannot start ${command}: ${err instanceof Error ? err.message : String(err)}`)}\n`);
73
+ process.exit(1);
74
+ });
75
+ return true;
76
+ }
77
+ /** Handled before the transport is created, so stdout stays parseable. */
78
+ function handleCliFlags(argv) {
79
+ // Claude Desktop (and a few other clients) launch the server with
80
+ // `mcp-server-<name>` as an argument. That is not a subcommand, so anything
81
+ // unrecognised must fall through to serving rather than be interpreted.
82
+ const args = argv.slice(2).filter((a) => !/^mcp-server-/.test(a));
83
+ if (args.includes("--version") || args.includes("-v")) {
84
+ process.stdout.write(`${SERVER_VERSION}\n`);
85
+ return true;
86
+ }
87
+ if (args.includes("--help") || args.includes("-h") || args[0] === "help") {
88
+ process.stdout.write(help());
89
+ return true;
90
+ }
91
+ return dispatchSubcommand(args);
92
+ }
93
+ function startupBanner(configured, allowAll, allowlist) {
94
+ if (configured) {
95
+ banner({ subtitle: "your Telegram account, as an MCP server", compact: true });
96
+ info(`${theme.success(sym.ok)} serving over stdio ${theme.muted(`(allowAll=${allowAll}, allowlist=${allowlist})`)}`);
97
+ return;
98
+ }
99
+ banner({ subtitle: "not configured yet", compact: true });
100
+ info(`${theme.warn(`${sym.warn} ${theme.bold("not configured")} — no api_id/api_hash yet`)}`);
101
+ hint(`get them from ${theme.underline("https://my.telegram.org/apps")}`);
102
+ hint(`then call ${theme.label("telegram_setup")} from chat, or run ${theme.label("telegram-mcp login")}`);
103
+ gap();
104
+ muted(`serving anyway — waiting for the agent`);
105
+ }
12
106
  async function main() {
13
107
  // A missing .env must NOT be fatal: the user needs telegram_setup (and the
14
108
  // doctor) to be reachable so they can fix it from chat. Only a genuinely
15
109
  // broken server would exit here.
16
110
  let config = null;
111
+ let reason = "";
17
112
  try {
18
113
  config = getConfig();
19
114
  ensureDirs(config);
20
115
  }
21
116
  catch (err) {
22
- process.stderr.write(`[telegram-mcp] not configured yet: ${err instanceof Error ? err.message : String(err)}\n` +
23
- `[telegram-mcp] serving anyway — call telegram_setup to create .env, then telegram_login_start.\n`);
117
+ reason = redact(err instanceof Error ? err.message : String(err));
24
118
  }
25
119
  const server = buildServer();
26
120
  const transport = new StdioServerTransport();
27
121
  await server.connect(transport);
28
- process.stderr.write(config
29
- ? `[telegram-mcp] serving over stdio (allowAll=${config.allowAllChats}, allowlist=${config.allowlist.length})\n`
30
- : `[telegram-mcp] serving over stdio (unconfigured — telegram_setup required)\n`);
122
+ startupBanner(Boolean(config), config?.allowAllChats ?? false, config?.allowlist.length ?? 0);
123
+ if (!config && reason) {
124
+ hint(theme.muted(reason.split(".")[0]));
125
+ hint(theme.muted("this server keeps running so the agent can call telegram_setup"));
126
+ }
127
+ // After the transport is live, so a slow or unreachable registry can never
128
+ // delay the first tool call.
129
+ warnIfOutdated();
31
130
  const shutdown = async (signal) => {
32
- process.stderr.write(`[telegram-mcp] ${signal}, shutting down\n`);
131
+ muted(`${signal}, shutting down`);
33
132
  try {
34
133
  // Drops any half-finished login socket too, not just the main client.
35
134
  await shutdownLogin();
@@ -42,7 +141,9 @@ async function main() {
42
141
  process.on("SIGINT", () => void shutdown("SIGINT"));
43
142
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
44
143
  }
45
- main().catch((err) => {
46
- process.stderr.write(`[telegram-mcp] fatal: ${redact(err instanceof Error ? err.stack ?? err.message : String(err))}\n`);
47
- process.exit(1);
48
- });
144
+ if (!handleCliFlags(process.argv)) {
145
+ main().catch((err) => {
146
+ process.stderr.write(`${theme.fail(`${sym.fail} fatal: ${redact(err instanceof Error ? err.stack ?? err.message : String(err))}`)}\n`);
147
+ process.exit(1);
148
+ });
149
+ }
@@ -23,6 +23,8 @@ import { getClient } from "./telegram.js";
23
23
  import { lockFile } from "./perms.js";
24
24
  import { normalizeError } from "./errors.js";
25
25
  import { ok, guard } from "./util.js";
26
+ import { PACKAGE_NAME, SERVER_VERSION } from "./version.js";
27
+ import { getUpdateInfo } from "./update-check.js";
26
28
  /** Keys this module owns when writing `.env`; everything else is preserved. */
27
29
  const MANAGED_KEYS = [
28
30
  "TELEGRAM_API_ID",
@@ -131,10 +133,11 @@ function buildEnvBody(values) {
131
133
  export function registerSetupTools(server) {
132
134
  server.registerTool("telegram_doctor", {
133
135
  title: "Telegram health check",
134
- description: "Read-only health check: config, state dir, session presence, session file permissions " +
135
- "(auto-repaired to 0600), and live authorization. Returns { healthy, checks[], account }. " +
136
+ description: "Read-only health check: config, installed version, state dir, session presence, session file " +
137
+ "permissions (auto-repaired to 0600), and live authorization. Returns { healthy, checks[], account }. " +
136
138
  "A missing or unauthorized session is reported as a failed check, not an error — call " +
137
- "telegram_login_start to fix it. Never returns the api_hash or the session string.",
139
+ "telegram_login_start to fix it. An available update is informational and never fails the check. " +
140
+ "Never returns the api_hash or the session string.",
138
141
  inputSchema: {},
139
142
  annotations: { readOnlyHint: true, openWorldHint: false },
140
143
  }, async () => guard(async () => {
@@ -153,6 +156,23 @@ export function registerSetupTools(server) {
153
156
  catch (err) {
154
157
  checks.push({ name: "config", pass: false, detail: normalizeError(err).message });
155
158
  }
159
+ // 2. version — always informational, never a failure. An available
160
+ // update is not a health problem, so it must not flip `healthy` to
161
+ // false and send an agent chasing a working install. Cache-only:
162
+ // a health check must not wait on the network.
163
+ try {
164
+ const info = await getUpdateInfo({ allowNetwork: false });
165
+ checks.push({
166
+ name: "version",
167
+ pass: true,
168
+ detail: info.updateAvailable
169
+ ? `${info.current} (${info.latest} available — \`npm install -g ${PACKAGE_NAME}@latest\`)`
170
+ : `${info.current} (latest known)`,
171
+ });
172
+ }
173
+ catch {
174
+ checks.push({ name: "version", pass: true, detail: SERVER_VERSION });
175
+ }
156
176
  if (!config) {
157
177
  // Without credentials there is no session path to inspect and no client
158
178
  // to connect: report the remaining checks as failed-with-reason rather