@bridge_gpt/mcp-server 0.2.68 → 0.2.69

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,86 @@
1
+ /**
2
+ * The single "should this session be told about an upgrade?" decision (BAPI-1446).
3
+ *
4
+ * Every notice surface asks here: the startup instructions, the first completed
5
+ * tool result, the Claude SessionStart hook, the `ping` advice, and the
6
+ * `start-tickets` reminder. One synchronous, non-throwing function holds every
7
+ * eligibility and silence rule, so the surfaces cannot drift apart.
8
+ *
9
+ * Nothing in this module touches the network or starts asynchronous work. The
10
+ * only I/O is the synchronous read of the local update cache.
11
+ */
12
+ import { parseDefaultOnEnvFlag } from "./env-flags.js";
13
+ import { isNewerVersion, isPlainReleaseVersion, readCachedLatestVersion, readFakeLatestOverride, } from "./update-check.js";
14
+ import { formatSessionUpgradeNotice } from "./upgrade-advice.js";
15
+ /** The kill switch for every update check and notice. Default on. */
16
+ export const UPDATE_CHECK_ENABLED_ENV = "BAPI_MCP_UPDATE_CHECK_ENABLED";
17
+ /** Worker markers: either one means no person is watching the session. */
18
+ export const UNATTENDED_MARKER_ENVS = [
19
+ "BAPI_WORKER_BRANCH",
20
+ "BAPI_EPIC_IMPLEMENTER_WORKER_ID",
21
+ ];
22
+ const SILENT = { show: false };
23
+ /** True when either worker marker holds a non-empty string. */
24
+ export function isUnattendedSession(env) {
25
+ return UNATTENDED_MARKER_ENVS.some((name) => {
26
+ const value = env[name];
27
+ return typeof value === "string" && value.trim().length > 0;
28
+ });
29
+ }
30
+ /**
31
+ * Decide whether to show the upgrade notice. Never throws and never starts
32
+ * asynchronous work.
33
+ *
34
+ * Silence comes first: a disabled kill switch, an unattended session, or an
35
+ * invalid current version returns `{ show: false }` before any latest-version
36
+ * source is read.
37
+ *
38
+ * Latest-version sources, in order:
39
+ * 1. a valid `BAPI_MCP_UPDATE_CHECK_FAKE_LATEST` override;
40
+ * 2. a reliable, known authoritative latest from a settled live check, which
41
+ * stays authoritative even when it says "current" and an older cache
42
+ * would suggest an update;
43
+ * 3. the local cache, when the authoritative source is absent, still in
44
+ * flight, or failed.
45
+ *
46
+ * The cache ignores its own age on purpose. It is a lower bound: it can
47
+ * under-claim an update but never claims one that does not exist. Unknown,
48
+ * equal, older, or invalid versions are silent.
49
+ */
50
+ export function getNoticeDecision(env, currentVersion, authoritativeLatest) {
51
+ try {
52
+ if (!parseDefaultOnEnvFlag(env[UPDATE_CHECK_ENABLED_ENV]))
53
+ return SILENT;
54
+ if (isUnattendedSession(env))
55
+ return SILENT;
56
+ if (!isPlainReleaseVersion(currentVersion))
57
+ return SILENT;
58
+ let latest = readFakeLatestOverride(env);
59
+ if (latest === null &&
60
+ authoritativeLatest &&
61
+ authoritativeLatest.known === true &&
62
+ isPlainReleaseVersion(authoritativeLatest.latest)) {
63
+ latest = authoritativeLatest.latest;
64
+ }
65
+ if (latest === null)
66
+ latest = readCachedLatestVersion(env);
67
+ if (!isPlainReleaseVersion(latest))
68
+ return SILENT;
69
+ if (!isNewerVersion(currentVersion, latest))
70
+ return SILENT;
71
+ return { show: true, current: currentVersion, latest };
72
+ }
73
+ catch {
74
+ return SILENT;
75
+ }
76
+ }
77
+ /**
78
+ * Append the session notice to the server's startup instructions when eligible.
79
+ * The base string is returned unchanged when the decision is silent.
80
+ */
81
+ export function composeServerInstructions(baseInstructions, env, currentVersion) {
82
+ const decision = getNoticeDecision(env, currentVersion);
83
+ if (!decision.show)
84
+ return baseInstructions;
85
+ return `${baseInstructions} ${formatSessionUpgradeNotice(decision.current, decision.latest)}`;
86
+ }
@@ -63,6 +63,8 @@ import path from "path";
63
63
  import { fileURLToPath } from "node:url";
64
64
  import { validateHttpBaseUrl } from "./base-url.js";
65
65
  import { VERSION } from "./version.generated.js";
66
+ import { getNoticeDecision } from "./session-upgrade-notice.js";
67
+ import { formatUpdateAdvice } from "./upgrade-advice.js";
66
68
  import { resolveBapiCredentials, getPrimaryCredentialStorePath, } from "./credential-store.js";
67
69
  import { LEGACY_SERVER_NAMES, MCP_PACKAGE_NAME, MCP_SERVER_NAME } from "./mcp-identity.js";
68
70
  // BAPI-941: the shared drift diagnostic formatter. The leaf drift module imports
@@ -4150,6 +4152,19 @@ export async function runStartTicketsCli(argv, overrides = {}) {
4150
4152
  const options = parsed.options;
4151
4153
  const rawDeps = overrides.deps ?? createDefaultStartTicketsDeps();
4152
4154
  const orchestrate = overrides.orchestrate ?? orchestrateStartTickets;
4155
+ // BAPI-1446: one cache-only upgrade reminder per run, on the existing stderr
4156
+ // sink. The decision is evaluated over the EFFECTIVE environment, so a worker's
4157
+ // markers (set when this CLI is itself run by an unattended process) keep it
4158
+ // silent. It reads the local cache only, never the network, and any failure is
4159
+ // contained: it never changes command execution or the exit status.
4160
+ try {
4161
+ const decision = (overrides.upgradeNotice ?? ((env) => getNoticeDecision(env, VERSION)))(rawDeps.env);
4162
+ if (decision.show)
4163
+ errorLog(formatUpdateAdvice(decision.current, decision.latest));
4164
+ }
4165
+ catch {
4166
+ // Advisory only.
4167
+ }
4153
4168
  // The parser validates `--agent`, so this resolves to a real spec; the guard
4154
4169
  // is defensive only.
4155
4170
  const agent = resolveAgentSpec(options.agentName);
@@ -0,0 +1,162 @@
1
+ /**
2
+ * First-completed-tool-result upgrade notice (BAPI-1446).
3
+ *
4
+ * Wraps the SDK's `tools/call` handler so the first eligible completed result in
5
+ * a stdio session carries one extra text content item naming both versions and
6
+ * `/upgrade-bridge`. Tool execution is untouched: the original handler runs
7
+ * outside the decoration error boundary, so a thrown tool error propagates
8
+ * exactly as it would without this wrapper, and any failure while decorating
9
+ * returns the original result.
10
+ *
11
+ * Eligibility is decided synchronously, after the original handler resolves, by
12
+ * `getNoticeDecision`. It never waits for a background lookup. The once-per-
13
+ * session flag is set only after a successful decoration, so an unknown latest
14
+ * version, a silent decision, or a result that cannot be decorated safely leaves
15
+ * the opportunity for a later call.
16
+ *
17
+ * Like `installToolSurfaceListOverride`, this reads the SDK's private
18
+ * `_requestHandlers` map through one localized compatibility adapter.
19
+ */
20
+ import { getMethodLiteral } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
21
+ import { getNoticeDecision } from "./session-upgrade-notice.js";
22
+ import { formatSessionUpgradeNotice } from "./upgrade-advice.js";
23
+ /** Fixed diagnostic. It never carries exception text or private map contents. */
24
+ export const TOOL_CALL_ADVISORY_INSTALL_FAILURE = "Bridge MCP upgrade notice: tools/call advisory could not be installed (incompatible MCP SDK).";
25
+ /**
26
+ * Private provenance mark for the genuine, server-sourced `ping` advice item. A
27
+ * symbol-keyed property survives a shallow copy and is ignored by
28
+ * `JSON.stringify`, so it never reaches the wire. The symbol is not exported, so
29
+ * only this module can create or recognize the mark.
30
+ */
31
+ const SERVER_ADVICE_MARK = Symbol("bridge.serverUpgradeAdvice");
32
+ /** Mark a content item as the genuine server-sourced upgrade advice. Returns it. */
33
+ export function markServerUpgradeAdvice(item) {
34
+ Object.defineProperty(item, SERVER_ADVICE_MARK, {
35
+ value: true,
36
+ enumerable: true,
37
+ configurable: true,
38
+ writable: false,
39
+ });
40
+ return item;
41
+ }
42
+ function isServerUpgradeAdvice(item) {
43
+ return (item !== null &&
44
+ typeof item === "object" &&
45
+ item[SERVER_ADVICE_MARK] === true);
46
+ }
47
+ /** Protocol servers that already carry the wrapper, so reinstall is a no-op. */
48
+ const installedServers = new WeakSet();
49
+ function authoritativeFrom(getStatus) {
50
+ const status = getStatus?.();
51
+ if (status && status.known === true && typeof status.latestVersion === "string") {
52
+ return { known: true, latest: status.latestVersion };
53
+ }
54
+ return { known: false };
55
+ }
56
+ function emit(stderr) {
57
+ try {
58
+ stderr?.(TOOL_CALL_ADVISORY_INSTALL_FAILURE);
59
+ }
60
+ catch {
61
+ // A diagnostic sink failure must never affect startup.
62
+ }
63
+ }
64
+ /**
65
+ * Install the wrapper. Returns true when it is in place (including an earlier
66
+ * install on the same protocol server) and false after emitting the one fixed
67
+ * diagnostic. Never throws, and leaves the original handler in place on failure.
68
+ */
69
+ export function installToolCallAdvisory(options) {
70
+ const { protocolServer, callSchema, env, currentVersion, getStatus, stderr } = options;
71
+ try {
72
+ if (protocolServer && installedServers.has(protocolServer))
73
+ return true;
74
+ let method;
75
+ try {
76
+ method = getMethodLiteral(callSchema);
77
+ }
78
+ catch {
79
+ emit(stderr);
80
+ return false;
81
+ }
82
+ if (method !== "tools/call") {
83
+ emit(stderr);
84
+ return false;
85
+ }
86
+ const handlers = protocolServer?._requestHandlers;
87
+ if (!handlers || typeof handlers.get !== "function") {
88
+ emit(stderr);
89
+ return false;
90
+ }
91
+ const original = handlers.get(method);
92
+ if (typeof original !== "function") {
93
+ emit(stderr);
94
+ return false;
95
+ }
96
+ // Delivery state belongs to this installation, so it is isolated to one
97
+ // stdio session.
98
+ let delivered = false;
99
+ const decorate = (result) => {
100
+ if (delivered)
101
+ return result;
102
+ if (result === null || typeof result !== "object")
103
+ return result;
104
+ const content = result.content;
105
+ if (!Array.isArray(content))
106
+ return result;
107
+ const decision = getNoticeDecision(env, currentVersion, authoritativeFrom(getStatus));
108
+ if (!decision.show)
109
+ return result;
110
+ if (content.some(isServerUpgradeAdvice)) {
111
+ // The genuine ping advice already told the session.
112
+ delivered = true;
113
+ return result;
114
+ }
115
+ const notice = formatSessionUpgradeNotice(decision.current, decision.latest);
116
+ const decorated = {
117
+ ...result,
118
+ content: [...content, { type: "text", text: notice }],
119
+ };
120
+ delivered = true;
121
+ return decorated;
122
+ };
123
+ const wrapped = async (request, extra) => {
124
+ // Outside the boundary below: a tool-call error propagates unchanged.
125
+ const result = await original(request, extra);
126
+ try {
127
+ return decorate(result);
128
+ }
129
+ catch {
130
+ return result;
131
+ }
132
+ };
133
+ try {
134
+ // Replace in place; never remove the original first.
135
+ protocolServer.setRequestHandler(callSchema, wrapped);
136
+ }
137
+ catch {
138
+ // The replacement may have begun before the failure. Put the original back.
139
+ try {
140
+ if (handlers.get(method) !== original) {
141
+ try {
142
+ protocolServer.setRequestHandler(callSchema, original);
143
+ }
144
+ catch {
145
+ handlers.set(method, original);
146
+ }
147
+ }
148
+ }
149
+ catch {
150
+ // Nothing more can be done safely.
151
+ }
152
+ emit(stderr);
153
+ return false;
154
+ }
155
+ installedServers.add(protocolServer);
156
+ return true;
157
+ }
158
+ catch {
159
+ emit(stderr);
160
+ return false;
161
+ }
162
+ }
@@ -372,116 +372,55 @@ function setsEqual(a, b) {
372
372
  return true;
373
373
  }
374
374
  // ---------------------------------------------------------------------------
375
- // Update-staleness advisory decoration (BAPI-728)
375
+ // Asset-recovery advisory composition
376
376
  // ---------------------------------------------------------------------------
377
- /** The `_meta` key carrying the structured staleness advisory. */
378
- export const UPDATE_ADVISORY_META_KEY = "bridge-api/update-advisory";
379
- /** The tool whose description carries the model-visible advisory when stale. */
377
+ //
378
+ // BAPI-1446 removed the update-staleness decoration (BAPI-728) from `tools/list`:
379
+ // the `_meta` key and the text prepended to the `ping` description were an
380
+ // invisible channel, and the upgrade notice now reaches a session through the
381
+ // startup instructions, the first completed tool result, and the SessionStart
382
+ // hook. Only the asset-recovery advisory decorates the list now.
383
+ /** The tool whose description carries the model-visible recovery advisory. */
380
384
  const ADVISORY_HOST_TOOL = "ping";
381
- /**
382
- * Wrap a `tools/list` handler so a KNOWN-stale server tells the model so.
383
- *
384
- * Why decorate the list at all: the staleness value was already computed on every
385
- * server start and emitted to an MCP log sink nothing reads, so a session talking
386
- * to a twenty-release-old build looked completely normal — the visible symptom was
387
- * an agent that could not find `council`, with no signal explaining why. Tool
388
- * discovery is the one surface every session reads without being asked to.
389
- *
390
- * What is preserved, exactly: tool NAMES, their ORDER, their input/output SCHEMAS,
391
- * their enabled state, and every other response property. The advisory is added in
392
- * two places, both additive:
393
- *
394
- * - `_meta` — the protocol's sanctioned extension point, for clients that
395
- * surface structured metadata;
396
- * - the `ping` tool's DESCRIPTION — the surface a model actually reads. `ping`
397
- * is the topically correct host: its description already documents the
398
- * version/upgrade metadata it returns. Note this does not require anyone to
399
- * CALL `ping` — reading its description during ordinary discovery is enough,
400
- * which is the precise gap the ticket describes.
401
- *
402
- * The description text is appended at RUNTIME only, over the `ping` description
403
- * from the static tool catalog. That static catalog is untouched, so the
404
- * description prose pins that guard `mcp_server/src/index.ts` are unaffected —
405
- * but a LIVE `tools/list` capture DOES include this decoration whenever the
406
- * update status is stale, and so is NOT automatically safe for tools-budget
407
- * measurement. Budget measurements stay repository-owned only because their
408
- * child process explicitly disables the update check via
409
- * `BAPI_MCP_UPDATE_CHECK_ENABLED=false` (BAPI-917) — see
410
- * `mcp_server/src/integration/measure-tools.ts`.
411
- *
412
- * Fail-open: if the advisory cannot be produced, the original result is returned
413
- * byte-for-byte.
414
- */
415
- export function decorateListResultWithUpdateAdvisory(original, advisory) {
416
- if (!advisory)
417
- return original;
418
- try {
419
- const tools = original.tools.map((tool) => tool.name === ADVISORY_HOST_TOOL
420
- ? { ...tool, description: `${advisory}\n\n${tool.description ?? ""}`.trimEnd() }
421
- : tool);
422
- return {
423
- ...original,
424
- tools,
425
- _meta: { ...(original._meta ?? {}), [UPDATE_ADVISORY_META_KEY]: advisory },
426
- };
427
- }
428
- catch {
429
- return original;
430
- }
431
- }
432
- /**
433
- * Compose an advisory-decorating handler over `inner`.
434
- *
435
- * `inner` is the capability gate's handler when dynamic gating is enabled, and
436
- * the SDK's own captured handler when it is disabled — so turning gating off
437
- * never turns update visibility off with it.
438
- */
439
- export function createUpdateAdvisoryListHandler(inner, getAdvisory, onListServed) {
440
- return async (request, extra) => {
441
- const result = await inner(request, extra);
442
- try {
443
- onListServed?.();
444
- return decorateListResultWithUpdateAdvisory(result, getAdvisory());
445
- }
446
- catch {
447
- // Never let advisory decoration break tool discovery.
448
- return result;
449
- }
450
- };
451
- }
452
- /** Compose once over undecorated SDK results, preserving both metadata keys.
385
+ /** Compose once over undecorated SDK results, preserving the recovery metadata.
453
386
  * Weak keys prevent retained results from accumulating session-wide state. */
454
387
  const undecoratedResults = new WeakMap();
455
- export function composeListAdvisories(original, update, recovery) {
388
+ export function composeListAdvisories(original, recovery) {
456
389
  const base = undecoratedResults.get(original) ?? original;
457
- const updated = decorateListResultWithUpdateAdvisory(base, update);
458
390
  if (!recovery)
459
- return updated;
391
+ return base;
460
392
  try {
461
- const anchor = base.tools.find(t => t.name === "ping") ?? base.tools[0];
462
- const result = { ...updated,
463
- tools: updated.tools.map(tool => tool.name === anchor?.name
464
- ? { ...tool, description: `${tool.name === "ping" && update ? `${update}\n\n` : ""}${recovery.text}\n\n${anchor?.description ?? ""}`.trimEnd() } : tool),
465
- _meta: { ...(updated._meta ?? {}), "bridge-api/asset-recovery": { host: recovery.host,
393
+ const anchor = base.tools.find(t => t.name === ADVISORY_HOST_TOOL) ?? base.tools[0];
394
+ const result = { ...base,
395
+ tools: base.tools.map(tool => tool.name === anchor?.name
396
+ ? { ...tool, description: `${recovery.text}\n\n${anchor?.description ?? ""}`.trimEnd() } : tool),
397
+ _meta: { ...(base._meta ?? {}), "bridge-api/asset-recovery": { host: recovery.host,
466
398
  missingCount: recovery.missingCount, paths: recovery.paths, capabilities: recovery.capabilities, command: recovery.command } },
467
399
  };
468
400
  undecoratedResults.set(result, base);
469
401
  return result;
470
402
  }
471
403
  catch {
472
- return updated;
404
+ return base;
473
405
  }
474
406
  }
475
- export function createComposedAdvisoryListHandler(inner, getUpdate, getRecovery, onListServed) {
407
+ /**
408
+ * Compose the recovery advisory over `inner`.
409
+ *
410
+ * `inner` is the capability gate's handler when dynamic gating is enabled, and
411
+ * the SDK's own captured handler when it is disabled — so turning gating off
412
+ * never turns recovery visibility off with it. A failure to retrieve or apply the
413
+ * advisory returns the undecorated original result.
414
+ */
415
+ export function createComposedAdvisoryListHandler(inner, getRecovery) {
476
416
  return async (request, extra) => {
477
417
  const original = await inner(request, extra);
478
418
  try {
479
419
  const recovery = await getRecovery();
480
- onListServed?.();
481
- return composeListAdvisories(original, getUpdate(), recovery);
420
+ return composeListAdvisories(original, recovery);
482
421
  }
483
422
  catch {
484
- return decorateListResultWithUpdateAdvisory(original, getUpdate());
423
+ return original;
485
424
  }
486
425
  };
487
426
  }
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Update check logic — extracted for testability.
3
- * The main index.ts imports and calls checkForUpdate().
3
+ * `update-status.ts` (the background manager) and `doctor.ts` call checkForUpdate();
4
+ * the synchronous notice paths use `readCachedLatestVersion` and never touch the
5
+ * network.
4
6
  */
7
+ import { readFileSync } from "fs";
5
8
  import { writeFile, mkdir, readFile } from "fs/promises";
6
9
  import path from "path";
7
10
  import os from "os";
@@ -10,13 +13,51 @@ import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
10
13
  const CACHE_TTL = 86400000; // 24 hours
11
14
  const FETCH_TIMEOUT = 3000;
12
15
  const REGISTRY_URL = `https://registry.npmjs.org/${MCP_PACKAGE_NAME}/latest`;
16
+ /** Manual-only override of the latest version, for exercising the notice paths. */
17
+ export const FAKE_LATEST_ENV = "BAPI_MCP_UPDATE_CHECK_FAKE_LATEST";
18
+ const PLAIN_RELEASE_RE = /^\d+\.\d+\.\d+$/;
13
19
  export function getCachePath() {
14
20
  return path.join(os.homedir(), ".config", ...MCP_PACKAGE_NAME.split("/"), "update-check.json");
15
21
  }
22
+ /** True only for a plain `MAJOR.MINOR.PATCH` release string. */
23
+ export function isPlainReleaseVersion(value) {
24
+ return typeof value === "string" && PLAIN_RELEASE_RE.test(value);
25
+ }
26
+ /**
27
+ * Read the manual fake-latest override. Returns the value only when it is a plain
28
+ * release string; anything else (absent, blank, a prerelease, shell text) is
29
+ * ignored and never echoed.
30
+ */
31
+ export function readFakeLatestOverride(env = process.env) {
32
+ const value = env[FAKE_LATEST_ENV];
33
+ return isPlainReleaseVersion(value) ? value : null;
34
+ }
35
+ /** Compare two digit strings numerically without converting them to Numbers. */
36
+ function compareDigitStrings(a, b) {
37
+ const x = a.replace(/^0+(?=\d)/, "");
38
+ const y = b.replace(/^0+(?=\d)/, "");
39
+ if (x.length !== y.length)
40
+ return x.length > y.length ? 1 : -1;
41
+ if (x === y)
42
+ return 0;
43
+ return x > y ? 1 : -1;
44
+ }
16
45
  /**
17
46
  * Compare two semver strings. Returns true if `latest` is strictly greater.
47
+ * Valid release strings are compared segment by segment as digit strings, so a
48
+ * segment beyond the safe-integer range keeps its ordering.
18
49
  */
19
50
  export function isNewerVersion(current, latest) {
51
+ if (isPlainReleaseVersion(current) && isPlainReleaseVersion(latest)) {
52
+ const c = current.split(".");
53
+ const l = latest.split(".");
54
+ for (let i = 0; i < 3; i++) {
55
+ const order = compareDigitStrings(l[i], c[i]);
56
+ if (order !== 0)
57
+ return order > 0;
58
+ }
59
+ return false;
60
+ }
20
61
  const c = current.split(".").map(Number);
21
62
  const l = latest.split(".").map(Number);
22
63
  for (let i = 0; i < 3; i++) {
@@ -27,28 +68,66 @@ export function isNewerVersion(current, latest) {
27
68
  }
28
69
  return false;
29
70
  }
71
+ /**
72
+ * Read the latest version the server last cached, synchronously and offline.
73
+ *
74
+ * A valid override wins before the cache is opened. The cache's age is ignored on
75
+ * purpose: a cached latest can only be older than the real latest, so it is a
76
+ * lower bound. It may under-claim an update, but it never claims one that does
77
+ * not exist. Any missing, corrupt, malformed, or unreadable cache returns null.
78
+ */
79
+ export function readCachedLatestVersion(env = process.env) {
80
+ try {
81
+ const override = readFakeLatestOverride(env);
82
+ if (override)
83
+ return override;
84
+ const cache = JSON.parse(readFileSync(getCachePath(), "utf-8"));
85
+ if (cache === null || typeof cache !== "object" || Array.isArray(cache))
86
+ return null;
87
+ const { lastCheck, latestVersion } = cache;
88
+ if (typeof lastCheck !== "number" || !Number.isFinite(lastCheck))
89
+ return null;
90
+ return isPlainReleaseVersion(latestVersion) ? latestVersion : null;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
30
96
  /**
31
97
  * Checks the npm registry for a newer version of @bridge_gpt/mcp-server.
32
98
  * Results are cached for 24 hours. Returns null if the check fails.
33
99
  */
34
- export async function checkForUpdate() {
100
+ export async function checkForUpdate(options = {}) {
35
101
  try {
102
+ const override = readFakeLatestOverride(options.env ?? process.env);
103
+ if (override) {
104
+ // An override is never cached, so it cannot outlive the manual test.
105
+ return {
106
+ updateAvailable: isNewerVersion(VERSION, override),
107
+ currentVersion: VERSION,
108
+ latestVersion: override,
109
+ };
110
+ }
111
+ const useCache = options.readCache !== false;
112
+ const saveCache = options.writeCache !== false;
36
113
  const cachePath = getCachePath();
37
114
  const cacheDir = path.dirname(cachePath);
38
115
  let latestVersion = null;
39
116
  // Try reading cache
40
- try {
41
- const raw = await readFile(cachePath, "utf-8");
42
- const cache = JSON.parse(raw);
43
- if (cache &&
44
- typeof cache.lastCheck === "number" &&
45
- typeof cache.latestVersion === "string" &&
46
- Date.now() - cache.lastCheck < CACHE_TTL) {
47
- latestVersion = cache.latestVersion;
117
+ if (useCache) {
118
+ try {
119
+ const raw = await readFile(cachePath, "utf-8");
120
+ const cache = JSON.parse(raw);
121
+ if (cache &&
122
+ typeof cache.lastCheck === "number" &&
123
+ isPlainReleaseVersion(cache.latestVersion) &&
124
+ Date.now() - cache.lastCheck < CACHE_TTL) {
125
+ latestVersion = cache.latestVersion;
126
+ }
127
+ }
128
+ catch {
129
+ // Cache missing, corrupt, or stale — will fetch fresh
48
130
  }
49
- }
50
- catch {
51
- // Cache missing, corrupt, or stale — will fetch fresh
52
131
  }
53
132
  // Fetch from registry if no valid cache
54
133
  if (!latestVersion) {
@@ -57,10 +136,12 @@ export async function checkForUpdate() {
57
136
  try {
58
137
  const res = await fetch(REGISTRY_URL, { signal: controller.signal });
59
138
  const data = await res.json();
60
- if (data.version) {
139
+ if (isPlainReleaseVersion(data.version)) {
61
140
  latestVersion = data.version;
62
- await mkdir(cacheDir, { recursive: true });
63
- await writeFile(cachePath, JSON.stringify({ lastCheck: Date.now(), latestVersion }), "utf-8");
141
+ if (saveCache) {
142
+ await mkdir(cacheDir, { recursive: true });
143
+ await writeFile(cachePath, JSON.stringify({ lastCheck: Date.now(), latestVersion }), "utf-8");
144
+ }
64
145
  }
65
146
  }
66
147
  finally {