@juspay/neurolink 9.88.10 → 9.88.12

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.
@@ -20,7 +20,9 @@ import { formatUptime, isProcessRunning, StateFileManager, } from "../utils/serv
20
20
  import { configureProxyKeepAliveDispatcher } from "../../lib/proxy/proxyDispatcher.js";
21
21
  import { anthropicAccountKeysEqual, createAccountAllowlist, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, shouldLoadFallbackCredential, } from "../../lib/proxy/accountSelection.js";
22
22
  import { beginProxyRequest, getProxyActivitySnapshot, trackProxyResponse, } from "../../lib/proxy/proxyActivity.js";
23
- import { describeInstallFailure, getGlobalInstallArgs, resolveGlobalInstaller, } from "../../lib/proxy/globalInstaller.js";
23
+ import { describeInstallFailure, getGlobalInstallArgs, resolveGlobalInstaller, validateInstalledVersion, } from "../../lib/proxy/globalInstaller.js";
24
+ import { startUpdaterWorkerSupervisor } from "../../lib/proxy/updaterSupervisor.js";
25
+ import { abandonPendingUpdate, isVersionSuppressed, loadUpdateState, recordCheck, recordSuccessfulUpdate, recordUpdateFailure, recordUpdateInstalled, suppressVersion, } from "../../lib/proxy/updateState.js";
24
26
  import { loadProxyEnvFile, resolveProxyEnvFile, } from "../../lib/proxy/proxyEnv.js";
25
27
  import { createRequire } from "node:module";
26
28
  import { fileURLToPath } from "node:url";
@@ -552,6 +554,12 @@ function spawnProxyUpdater(host, port, parentPid) {
552
554
  stdio: ["ignore", logFd, logFd],
553
555
  env: process.env,
554
556
  });
557
+ child.once("error", (error) => {
558
+ logger.always(`[proxy] updater worker error pid=${child.pid ?? "unknown"}: ${error.message}`);
559
+ });
560
+ child.once("exit", (code, signal) => {
561
+ logger.always(`[proxy] updater worker exited pid=${child.pid ?? "unknown"} code=${code ?? "none"} signal=${signal ?? "none"}`);
562
+ });
555
563
  child.unref();
556
564
  return child.pid;
557
565
  }
@@ -1034,6 +1042,7 @@ export async function createProxyStartApp(params) {
1034
1042
  const { loadAccountCooldowns } = await import("../../lib/proxy/accountCooldown.js");
1035
1043
  const stats = getStats();
1036
1044
  const runtimeState = loadProxyState();
1045
+ const updateState = loadUpdateState();
1037
1046
  const cooldowns = await loadAccountCooldowns();
1038
1047
  const storedAccountKeys = new Set();
1039
1048
  try {
@@ -1066,10 +1075,13 @@ export async function createProxyStartApp(params) {
1066
1075
  health,
1067
1076
  stats: {
1068
1077
  totalAttempts: stats.totalAttempts,
1078
+ totalAttemptErrors: stats.totalAttemptErrors,
1069
1079
  totalRequests: stats.totalRequests,
1070
1080
  totalSuccess: stats.totalSuccess,
1071
1081
  totalErrors: stats.totalErrors,
1072
1082
  totalRateLimits: stats.totalRateLimits,
1083
+ totalTransientRateLimits: stats.totalTransientRateLimits,
1084
+ totalQuotaRateLimits: stats.totalQuotaRateLimits,
1073
1085
  accounts: Object.values(stats.accounts).map((account) => {
1074
1086
  const normalizedKey = normalizeAnthropicAccountKey(account.label);
1075
1087
  const accountKey = storedAccountKeys.has(normalizedKey)
@@ -1083,10 +1095,13 @@ export async function createProxyStartApp(params) {
1083
1095
  label: account.label,
1084
1096
  type: account.type,
1085
1097
  attempts: account.attemptCount,
1086
- requests: account.attemptCount,
1098
+ requests: account.successCount + account.errorCount,
1087
1099
  success: account.successCount,
1088
1100
  errors: account.errorCount,
1101
+ attemptErrors: account.attemptErrorCount,
1089
1102
  rateLimits: account.rateLimitCount,
1103
+ transientRateLimits: account.transientRateLimitCount,
1104
+ quotaRateLimits: account.quotaRateLimitCount,
1090
1105
  cooling: (cooldowns[accountKey]?.coolingUntil ?? 0) > now,
1091
1106
  };
1092
1107
  }),
@@ -1105,6 +1120,19 @@ export async function createProxyStartApp(params) {
1105
1120
  updaterRunning: runtimeState?.updaterPid
1106
1121
  ? isProcessRunning(runtimeState.updaterPid)
1107
1122
  : false,
1123
+ liveVersion: PROXY_VERSION,
1124
+ latestVersion: updateState?.lastCheckVersion || null,
1125
+ pendingRestartVersion: updateState?.pendingRestartVersion ?? null,
1126
+ lastCheckAt: updateState?.lastCheckAt ?? null,
1127
+ lastUpdateAt: updateState?.lastUpdateAt ?? null,
1128
+ lastUpdateVersion: updateState?.lastUpdateVersion ?? null,
1129
+ lastFailure: updateState?.lastFailure
1130
+ ? {
1131
+ at: updateState.lastFailure.at,
1132
+ version: updateState.lastFailure.version,
1133
+ stage: updateState.lastFailure.stage,
1134
+ }
1135
+ : null,
1108
1136
  },
1109
1137
  config: params.proxyConfig
1110
1138
  ? {
@@ -1356,6 +1384,7 @@ function registerProxyShutdownHandlers(params) {
1356
1384
  shutdownStarted = true;
1357
1385
  clearInterval(params.refreshInterval);
1358
1386
  clearInterval(params.logCleanupInterval);
1387
+ params.updaterSupervisor?.stop();
1359
1388
  logger.always(`\nShutting down proxy (${signal})...`);
1360
1389
  let exitCode = signal === "SIGINT" ? 0 : 1;
1361
1390
  try {
@@ -1429,9 +1458,33 @@ async function startProxyRuntime(params) {
1429
1458
  port: params.port,
1430
1459
  });
1431
1460
  markProxyReady(params.readiness);
1432
- const updaterPid = managedByLaunchd && !params.argv.dev
1433
- ? spawnProxyUpdater(readinessHost, params.port, process.pid)
1461
+ try {
1462
+ const { reconcileRunningUpdate } = await import("../../lib/proxy/updateState.js");
1463
+ if (reconcileRunningUpdate(PROXY_VERSION)) {
1464
+ logger.always(`[proxy] confirmed pending update is now running at v${PROXY_VERSION}`);
1465
+ }
1466
+ }
1467
+ catch (error) {
1468
+ logger.always(`[proxy] WARNING: failed to reconcile update state: ${error instanceof Error ? error.message : String(error)}`);
1469
+ }
1470
+ /** Mirror the supervised worker PID into the proxy state used by status. */
1471
+ const updatePersistedUpdaterPid = (updaterPid) => {
1472
+ const state = loadProxyState();
1473
+ if (!state || state.pid !== process.pid) {
1474
+ return;
1475
+ }
1476
+ saveProxyState({ ...state, updaterPid });
1477
+ };
1478
+ const updaterSupervisor = managedByLaunchd && !params.argv.dev
1479
+ ? startUpdaterWorkerSupervisor({
1480
+ spawnWorker: () => spawnProxyUpdater(readinessHost, params.port, process.pid),
1481
+ isProcessRunning,
1482
+ stopWorker: (pid) => process.kill(pid, "SIGTERM"),
1483
+ onPidChange: updatePersistedUpdaterPid,
1484
+ log: (message) => logger.always(message),
1485
+ })
1434
1486
  : undefined;
1487
+ const updaterPid = updaterSupervisor?.currentPid();
1435
1488
  const fallbackChain = params.proxyConfig?.routing?.fallbackChain?.map((entry) => ({
1436
1489
  provider: entry.provider,
1437
1490
  model: entry.model,
@@ -1507,6 +1560,7 @@ async function startProxyRuntime(params) {
1507
1560
  host: params.host,
1508
1561
  port: params.port,
1509
1562
  isDev,
1563
+ updaterSupervisor,
1510
1564
  ...maintenance,
1511
1565
  });
1512
1566
  }
@@ -1668,18 +1722,51 @@ function printStatusStats(stats) {
1668
1722
  console.info(` Attempts: ${stats.totalAttempts}`);
1669
1723
  }
1670
1724
  console.info(` Completed: ${stats.totalRequests} total, ${stats.totalSuccess} success, ${stats.totalErrors} errors`);
1671
- console.info(` Rate limits: ${stats.totalRateLimits}`);
1725
+ if (stats.totalAttemptErrors !== undefined) {
1726
+ console.info(` Failed attempts: ${stats.totalAttemptErrors}`);
1727
+ }
1728
+ console.info(` Rate-limited attempts: ${stats.totalRateLimits}` +
1729
+ (stats.totalTransientRateLimits !== undefined &&
1730
+ stats.totalQuotaRateLimits !== undefined
1731
+ ? ` (${stats.totalTransientRateLimits} transient, ${stats.totalQuotaRateLimits} quota)`
1732
+ : ""));
1672
1733
  if (stats.accounts?.length) {
1673
1734
  console.info(`\n Accounts:`);
1674
- for (const a of stats.accounts) {
1675
- const acctStatus = a.cooling
1676
- ? chalk.red("cooling")
1677
- : chalk.green("active");
1678
- const attempts = a.attempts ?? a.requests ?? 0;
1679
- const success = a.success ?? 0;
1680
- const errors = a.errors ?? 0;
1681
- const rateLimits = a.rateLimits ?? 0;
1682
- console.info(` ${a.label.padEnd(20)} ${a.type.padEnd(8)} ${String(attempts).padEnd(6)} attempts ${String(success).padEnd(6)} success ${String(errors).padEnd(6)} errors ${String(rateLimits).padEnd(6)} rl ${acctStatus}`);
1735
+ const headers = [
1736
+ "ACCOUNT",
1737
+ "AUTH",
1738
+ "ATTEMPTS",
1739
+ "SUCCESS",
1740
+ "ERRORS",
1741
+ "RL",
1742
+ "STATUS",
1743
+ ];
1744
+ const rows = stats.accounts.map((account) => [
1745
+ account.label,
1746
+ account.type,
1747
+ String(account.attempts ?? account.requests ?? 0),
1748
+ String(account.success ?? 0),
1749
+ String(account.errors ?? 0),
1750
+ String(account.rateLimits ?? 0),
1751
+ account.cooling ? "cooling" : "active",
1752
+ ]);
1753
+ const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index].length)));
1754
+ const numericColumns = new Set([2, 3, 4, 5]);
1755
+ /** Align text columns left and numeric account counters right. */
1756
+ const formatRow = (cells) => cells
1757
+ .map((cell, index) => numericColumns.has(index)
1758
+ ? cell.padStart(widths[index])
1759
+ : cell.padEnd(widths[index]))
1760
+ .join(" ");
1761
+ console.info(` ${chalk.gray(formatRow(headers))}`);
1762
+ console.info(` ${chalk.gray(widths.map((width) => "-".repeat(width)).join(" "))}`);
1763
+ for (const row of rows) {
1764
+ const status = row[6];
1765
+ const formatted = formatRow(row);
1766
+ const statusStart = formatted.length - widths[6];
1767
+ const prefix = formatted.slice(0, statusStart);
1768
+ const paddedStatus = formatted.slice(statusStart);
1769
+ console.info(` ${chalk.cyan(prefix.slice(0, widths[0]))}${prefix.slice(widths[0])}${status === "cooling" ? chalk.red(paddedStatus) : chalk.green(paddedStatus)}`);
1683
1770
  }
1684
1771
  }
1685
1772
  }
@@ -1709,6 +1796,7 @@ export const proxyStatusCommand = {
1709
1796
  handler: async (argv) => {
1710
1797
  try {
1711
1798
  const state = loadProxyState();
1799
+ const updateState = loadUpdateState();
1712
1800
  const status = {
1713
1801
  running: false,
1714
1802
  pid: null,
@@ -1725,6 +1813,9 @@ export const proxyStatusCommand = {
1725
1813
  autoUpdateEnabled: isProxyAutoUpdateEnabled(),
1726
1814
  updaterPid: null,
1727
1815
  updaterRunning: false,
1816
+ latestVersion: updateState?.lastCheckVersion || null,
1817
+ pendingRestartVersion: updateState?.pendingRestartVersion ?? null,
1818
+ lastUpdateFailure: updateState?.lastFailure ?? null,
1728
1819
  };
1729
1820
  if (state && isProcessRunning(state.pid)) {
1730
1821
  status.running = true;
@@ -1776,6 +1867,15 @@ export const proxyStatusCommand = {
1776
1867
  logger.always(` ${chalk.bold("Started:")} ${chalk.cyan(status.startTime)}`);
1777
1868
  logger.always(` ${chalk.bold("Uptime:")} ${chalk.cyan(formatUptime(status.uptime ?? 0))}`);
1778
1869
  logger.always(` ${chalk.bold("Auto-update:")} ${status.autoUpdateEnabled ? chalk.green(status.updaterRunning ? `enabled (PID ${status.updaterPid})` : "enabled (worker unavailable)") : chalk.yellow("disabled")}`);
1870
+ if (status.pendingRestartVersion) {
1871
+ logger.always(` ${chalk.bold("Pending:")} ${chalk.yellow(`v${status.pendingRestartVersion} installed; restart pending`)}`);
1872
+ }
1873
+ if (status.latestVersion) {
1874
+ logger.always(` ${chalk.bold("Latest:")} ${chalk.cyan(`v${status.latestVersion}`)}`);
1875
+ }
1876
+ if (status.lastUpdateFailure) {
1877
+ logger.always(` ${chalk.bold("Update error:")} ${chalk.red(`${status.lastUpdateFailure.stage}: ${status.lastUpdateFailure.message}`)}`);
1878
+ }
1779
1879
  if (status.envFile) {
1780
1880
  logger.always(` ${chalk.bold("Env File:")} ${chalk.cyan(status.envFile)}`);
1781
1881
  }
@@ -1985,6 +2085,15 @@ export const proxyGuardCommand = {
1985
2085
  updateCheckInterval = undefined;
1986
2086
  }
1987
2087
  };
2088
+ /** Keep state-write failures observable without terminating the updater. */
2089
+ const persistUpdaterState = (operation, action) => {
2090
+ try {
2091
+ action();
2092
+ }
2093
+ catch (error) {
2094
+ logger.always(`[updater] WARNING: failed to ${operation}: ${error instanceof Error ? error.message : String(error)}`);
2095
+ }
2096
+ };
1988
2097
  let updateInProgress = false;
1989
2098
  let updateRestartInProgress = false;
1990
2099
  const runUpdateCheck = async () => {
@@ -1992,17 +2101,19 @@ export const proxyGuardCommand = {
1992
2101
  return;
1993
2102
  }
1994
2103
  updateInProgress = true;
2104
+ let updateVersion = runningVersion;
1995
2105
  try {
1996
2106
  // Lazy-load update modules so they're only imported at check time
1997
2107
  const { checkForUpdate } = await import("../../lib/proxy/updateChecker.js");
1998
- const { recordCheck, isVersionSuppressed, suppressVersion, recordSuccessfulUpdate, } = await import("../../lib/proxy/updateState.js");
1999
2108
  // 1. Check for update
2000
2109
  const result = await checkForUpdate(runningVersion);
2001
- recordCheck(result.latestVersion);
2110
+ updateVersion = result.latestVersion;
2111
+ persistUpdaterState("record update check", () => recordCheck(result.latestVersion));
2002
2112
  if (!result.updateAvailable) {
2003
2113
  return;
2004
2114
  }
2005
- if (isVersionSuppressed(result.latestVersion)) {
2115
+ const pendingRestart = loadUpdateState()?.pendingRestartVersion === result.latestVersion;
2116
+ if (isVersionSuppressed(result.latestVersion) && !pendingRestart) {
2006
2117
  logger.debug(`[guard] version ${result.latestVersion} is suppressed, skipping`);
2007
2118
  return;
2008
2119
  }
@@ -2028,61 +2139,71 @@ export const proxyGuardCommand = {
2028
2139
  }
2029
2140
  // 3. Install update (validate version string before passing to shell)
2030
2141
  if (!/^\d+\.\d+\.\d+$/.test(result.latestVersion)) {
2142
+ const message = `invalid version format: ${result.latestVersion}`;
2031
2143
  logger.always(`[guard] WARNING: invalid version format "${result.latestVersion}", skipping`);
2032
- return;
2033
- }
2034
- const installerResolution = resolveGlobalInstaller({
2035
- entryScript: process.argv[1],
2036
- });
2037
- logger.always(`[updater] package-manager candidates: ${installerResolution.tried
2038
- .map((candidate) => `${candidate.kind}:${candidate.bin}(${candidate.installable ? `v${candidate.version}` : (candidate.reason ?? "unusable")})`)
2039
- .join(", ")}`);
2040
- const installer = installerResolution.installer;
2041
- if (!installer) {
2042
- logger.always(`[updater] WARNING: no package manager has a writable global root and executable directory; skipping this cycle`);
2043
- return;
2044
- }
2045
- logger.always(`[updater] installing @juspay/neurolink@${result.latestVersion} via ${installer.kind} ${installer.bin} (v${installer.version})`);
2046
- if (guardStopping || getProcessStatus(parentPid) === "not_running") {
2144
+ persistUpdaterState("record update failure", () => recordUpdateFailure(result.latestVersion, "check", message));
2047
2145
  return;
2048
2146
  }
2049
2147
  const { execFileSync } = await import("node:child_process");
2050
- try {
2051
- execFileSync(installer.bin, getGlobalInstallArgs(installer.kind, `@juspay/neurolink@${result.latestVersion}`), {
2052
- timeout: 120_000,
2053
- stdio: "pipe",
2148
+ if (!pendingRestart) {
2149
+ const installerResolution = resolveGlobalInstaller({
2150
+ entryScript: process.argv[1],
2054
2151
  });
2152
+ logger.always(`[updater] package-manager candidates: ${installerResolution.tried
2153
+ .map((candidate) => `${candidate.kind}:${candidate.bin}(${candidate.installable ? `v${candidate.version}` : (candidate.reason ?? "unusable")})`)
2154
+ .join(", ")}`);
2155
+ const installer = installerResolution.installer;
2156
+ if (!installer) {
2157
+ const message = "no package manager has a writable global root and executable directory";
2158
+ logger.always(`[updater] WARNING: ${message}; skipping this cycle`);
2159
+ persistUpdaterState("record update failure", () => recordUpdateFailure(result.latestVersion, "install", message));
2160
+ return;
2161
+ }
2162
+ logger.always(`[updater] installing @juspay/neurolink@${result.latestVersion} via ${installer.kind} ${installer.bin} (v${installer.version})`);
2163
+ if (guardStopping || getProcessStatus(parentPid) === "not_running") {
2164
+ return;
2165
+ }
2166
+ try {
2167
+ execFileSync(installer.bin, getGlobalInstallArgs(installer.kind, `@juspay/neurolink@${result.latestVersion}`), {
2168
+ timeout: 120_000,
2169
+ stdio: "pipe",
2170
+ });
2171
+ }
2172
+ catch (installErr) {
2173
+ const detail = describeInstallFailure(installErr);
2174
+ logger.always(`[updater] WARNING: global install failed:\n${detail}`);
2175
+ persistUpdaterState("record update failure", () => recordUpdateFailure(result.latestVersion, "install", detail));
2176
+ return;
2177
+ }
2055
2178
  }
2056
- catch (installErr) {
2057
- const detail = describeInstallFailure(installErr);
2058
- logger.always(`[updater] WARNING: global install failed:\n${detail}`);
2059
- return;
2179
+ else {
2180
+ logger.always(`[updater] resuming pending restart for already-installed v${result.latestVersion}`);
2060
2181
  }
2061
2182
  // 4. Refresh and validate the stable trampoline. The plist already
2062
2183
  // points at this path, so it must not be unloaded or rewritten here.
2063
2184
  try {
2064
2185
  writeTrampoline();
2065
- // Validate the trampoline actually resolves to the NEW version
2066
- // before asking launchd to restart. If the install somehow left
2067
- // PATH still pointing at the old version, don't kickstart.
2068
- const probed = probeBinVersion(TRAMPOLINE_PATH);
2069
- if (!probed) {
2070
- logger.always(`[updater] WARNING: trampoline does not resolve to a working neurolink after install; skipping restart`);
2071
- suppressVersion(result.latestVersion, `trampoline_broken_after_install: ${TRAMPOLINE_PATH} --version failed`);
2072
- return;
2073
- }
2074
- if (probed !== result.latestVersion) {
2075
- logger.always(`[updater] ABORT: trampoline resolves to v${probed} but installed v${result.latestVersion}`);
2076
- logger.always(`[updater] installer used: ${installer.kind} ${installer.bin} (v${installer.version})`);
2077
- suppressVersion(result.latestVersion, `version_mismatch: trampoline=${probed} expected=${result.latestVersion} installer=${installer.kind}:${installer.bin}(v${installer.version})`);
2186
+ const validation = await validateInstalledVersion({
2187
+ binPath: TRAMPOLINE_PATH,
2188
+ expectedVersion: result.latestVersion,
2189
+ });
2190
+ if (validation.version !== result.latestVersion) {
2191
+ const message = `trampoline validation failed after ${validation.attempts} attempts: ${validation.failure ?? "unknown failure"}`;
2192
+ logger.always(`[updater] WARNING: ${message}; restart deferred`);
2193
+ persistUpdaterState("record update failure", () => recordUpdateFailure(result.latestVersion, "validation", message));
2194
+ persistUpdaterState("abandon invalid pending update", () => abandonPendingUpdate(result.latestVersion));
2078
2195
  return;
2079
2196
  }
2080
- logger.always(`[updater] trampoline validated at v${probed}`);
2197
+ persistUpdaterState("record installed update", () => recordUpdateInstalled(result.latestVersion));
2198
+ logger.always(`[updater] trampoline validated at v${validation.version} after ${validation.attempts} attempt(s)`);
2081
2199
  }
2082
2200
  catch (trampolineError) {
2083
- logger.always(`[updater] WARNING: failed to refresh trampoline; refusing restart: ${trampolineError instanceof Error
2201
+ const message = trampolineError instanceof Error
2084
2202
  ? trampolineError.message
2085
- : String(trampolineError)}`);
2203
+ : String(trampolineError);
2204
+ logger.always(`[updater] WARNING: failed to refresh trampoline; refusing restart: ${message}`);
2205
+ persistUpdaterState("record update failure", () => recordUpdateFailure(result.latestVersion, "validation", message));
2206
+ persistUpdaterState("abandon invalid pending update", () => abandonPendingUpdate(result.latestVersion));
2086
2207
  return;
2087
2208
  }
2088
2209
  if (guardStopping || getProcessStatus(parentPid) === "not_running") {
@@ -2121,6 +2242,7 @@ export const proxyGuardCommand = {
2121
2242
  ? restartErr.message
2122
2243
  : String(restartErr);
2123
2244
  logger.always(`[updater] WARNING: launchctl kickstart failed: ${msg}`);
2245
+ persistUpdaterState("record update failure", () => recordUpdateFailure(result.latestVersion, "restart", msg));
2124
2246
  return;
2125
2247
  }
2126
2248
  // 5. Wait for healthy restart
@@ -2146,18 +2268,22 @@ export const proxyGuardCommand = {
2146
2268
  }
2147
2269
  if (healthy) {
2148
2270
  logger.always(`[updater] update successful: now running ${result.latestVersion}`);
2149
- recordSuccessfulUpdate(result.latestVersion);
2271
+ persistUpdaterState("record successful update", () => recordSuccessfulUpdate(result.latestVersion));
2150
2272
  // The replacement proxy starts a worker running the new version.
2151
2273
  process.exit(0);
2152
2274
  }
2153
2275
  else {
2154
2276
  logger.always(`[updater] WARNING: proxy unhealthy after update to ${result.latestVersion}`);
2155
- suppressVersion(result.latestVersion, "unhealthy_after_restart");
2277
+ persistUpdaterState("record update failure", () => recordUpdateFailure(result.latestVersion, "health", `proxy did not report v${result.latestVersion} healthy within ${UPDATE_TIMEOUT_MS}ms`));
2278
+ persistUpdaterState("abandon unhealthy pending update", () => abandonPendingUpdate(result.latestVersion));
2279
+ persistUpdaterState("suppress unhealthy update", () => suppressVersion(result.latestVersion, "unhealthy_after_restart"));
2156
2280
  updateRestartInProgress = false;
2157
2281
  }
2158
2282
  }
2159
2283
  catch (err) {
2160
- logger.always(`[updater] update check error: ${err instanceof Error ? err.message : String(err)}`);
2284
+ const message = err instanceof Error ? err.message : String(err);
2285
+ logger.always(`[updater] update check error: ${message}`);
2286
+ persistUpdaterState("record update failure", () => recordUpdateFailure(updateVersion, "check", message));
2161
2287
  }
2162
2288
  finally {
2163
2289
  updateInProgress = false;
@@ -2175,12 +2301,21 @@ export const proxyGuardCommand = {
2175
2301
  while (true) {
2176
2302
  const healthy = await isProxyHealthy(host, port, 1_500);
2177
2303
  if (healthy) {
2304
+ if (updaterOnly && consecutiveUnhealthy >= failureThreshold) {
2305
+ logger.always(`[updater] proxy health recovered after ${consecutiveUnhealthy} failed checks`);
2306
+ }
2178
2307
  consecutiveUnhealthy = 0;
2179
2308
  }
2180
2309
  else {
2181
2310
  consecutiveUnhealthy += 1;
2311
+ if (updaterOnly && consecutiveUnhealthy === failureThreshold) {
2312
+ logger.always(`[updater] proxy health unavailable after ${consecutiveUnhealthy} checks; worker remains active`);
2313
+ }
2182
2314
  }
2183
2315
  if (parentStatus === "not_running" && !updateRestartInProgress) {
2316
+ if (updaterOnly) {
2317
+ logger.always(`[updater] parent pid=${parentPid} exited; updater worker stopping`);
2318
+ }
2184
2319
  // Parent is gone (and we're not mid-update-restart).
2185
2320
  // If endpoint is still healthy, another proxy took over.
2186
2321
  if (healthy) {
@@ -2189,7 +2324,8 @@ export const proxyGuardCommand = {
2189
2324
  }
2190
2325
  break;
2191
2326
  }
2192
- if (!updateRestartInProgress &&
2327
+ if (!updaterOnly &&
2328
+ !updateRestartInProgress &&
2193
2329
  !healthy &&
2194
2330
  consecutiveUnhealthy >= failureThreshold) {
2195
2331
  // A detached guard cannot safely decide that a live process should be
@@ -1,5 +1,11 @@
1
- import type { GlobalInstallerKind, GlobalInstallerResolution, ResolveGlobalInstallerOptions } from "../types/index.js";
1
+ import type { GlobalInstallerKind, GlobalInstallerResolution, InstalledVersionValidation, ResolveGlobalInstallerOptions, ValidateInstalledVersionOptions } from "../types/index.js";
2
2
  /** Resolve a package manager that can update the installation currently running. */
3
3
  export declare function resolveGlobalInstaller(options?: ResolveGlobalInstallerOptions): GlobalInstallerResolution;
4
4
  export declare function getGlobalInstallArgs(kind: GlobalInstallerKind, packageSpec: string): string[];
5
+ /**
6
+ * Validate a freshly installed CLI with retries. Global package replacement
7
+ * can leave executable shims briefly unavailable while filesystem metadata and
8
+ * cold module loading settle, so a single short probe is not authoritative.
9
+ */
10
+ export declare function validateInstalledVersion(options: ValidateInstalledVersionOptions): Promise<InstalledVersionValidation>;
5
11
  export declare function describeInstallFailure(error: unknown): string;
@@ -1,7 +1,7 @@
1
1
  import { execFileSync as nodeExecFileSync } from "node:child_process";
2
2
  import { accessSync, constants, existsSync, realpathSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { basename, dirname, join, resolve } from "node:path";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
5
  function runText(execFileSync, bin, args) {
6
6
  return String(execFileSync(bin, args, {
7
7
  encoding: "utf8",
@@ -136,6 +136,54 @@ export function getGlobalInstallArgs(kind, packageSpec) {
136
136
  ? ["add", "-g", packageSpec]
137
137
  : ["install", "--global", "--no-audit", "--no-fund", packageSpec];
138
138
  }
139
+ /**
140
+ * Validate a freshly installed CLI with retries. Global package replacement
141
+ * can leave executable shims briefly unavailable while filesystem metadata and
142
+ * cold module loading settle, so a single short probe is not authoritative.
143
+ */
144
+ export async function validateInstalledVersion(options) {
145
+ if (!isAbsolute(options.binPath)) {
146
+ return {
147
+ attempts: 0,
148
+ failure: `binPath must be absolute: ${options.binPath}`,
149
+ };
150
+ }
151
+ const execFileSync = options.execFileSync ?? nodeExecFileSync;
152
+ const sleepFn = options.sleep ??
153
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
154
+ const maxAttempts = Math.max(1, options.maxAttempts ?? 5);
155
+ const delayMs = Math.max(0, options.delayMs ?? 2_000);
156
+ const timeoutMs = Math.max(1_000, options.timeoutMs ?? 10_000);
157
+ let lastVersion;
158
+ let lastFailure;
159
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
160
+ try {
161
+ const output = String(execFileSync(options.binPath, ["--version"], {
162
+ encoding: "utf8",
163
+ timeout: timeoutMs,
164
+ stdio: ["ignore", "pipe", "pipe"],
165
+ })).trim();
166
+ lastVersion = output || undefined;
167
+ if (lastVersion === options.expectedVersion) {
168
+ return { version: lastVersion, attempts: attempt };
169
+ }
170
+ lastFailure = lastVersion
171
+ ? `resolved to v${lastVersion}; expected v${options.expectedVersion}`
172
+ : "version command returned empty output";
173
+ }
174
+ catch (error) {
175
+ lastFailure = describeInstallFailure(error);
176
+ }
177
+ if (attempt < maxAttempts) {
178
+ await sleepFn(delayMs);
179
+ }
180
+ }
181
+ return {
182
+ version: lastVersion,
183
+ attempts: maxAttempts,
184
+ failure: lastFailure ?? "version validation failed",
185
+ };
186
+ }
139
187
  function capturedOutput(error, key) {
140
188
  if (!error || typeof error !== "object" || !(key in error)) {
141
189
  return "";
@@ -48,6 +48,14 @@ export declare function suppressVersion(version: string, reason: string, stateFi
48
48
  * @param stateFilePath - Override path for testing
49
49
  */
50
50
  export declare function recordSuccessfulUpdate(version: string, stateFilePath?: string): void;
51
+ /** Record that package installation completed but the live restart is pending. */
52
+ export declare function recordUpdateInstalled(version: string, stateFilePath?: string): void;
53
+ /** Abandon a matching installed version so the next cycle may reinstall it. */
54
+ export declare function abandonPendingUpdate(version: string, stateFilePath?: string): boolean;
55
+ /** Persist a stage-specific updater failure so status remains actionable. */
56
+ export declare function recordUpdateFailure(version: string, stage: NonNullable<UpdateState["lastFailure"]>["stage"], message: string, stateFilePath?: string): void;
57
+ /** Complete an updater-managed install after a manual or automatic restart. */
58
+ export declare function reconcileRunningUpdate(runningVersion: string, stateFilePath?: string): boolean;
51
59
  /**
52
60
  * Record an update check: set lastCheckAt and lastCheckVersion, then persist.
53
61
  *
@@ -15,6 +15,13 @@ import { randomUUID } from "node:crypto";
15
15
  // ============================================
16
16
  const STATE_FILENAME = "update-state.json";
17
17
  const SUPPRESSION_TTL_MS = 86_400_000; // 24 hours
18
+ const UPDATE_FAILURE_STAGES = new Set([
19
+ "check",
20
+ "install",
21
+ "validation",
22
+ "restart",
23
+ "health",
24
+ ]);
18
25
  // ============================================
19
26
  // Internal Helpers
20
27
  // ============================================
@@ -37,6 +44,18 @@ function ensureParentDir(filePath) {
37
44
  fs.mkdirSync(dir, { recursive: true });
38
45
  }
39
46
  }
47
+ /** Reject malformed persisted failure metadata before it reaches status output. */
48
+ function isValidLastFailure(value) {
49
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
50
+ return false;
51
+ }
52
+ const candidate = value;
53
+ return (typeof candidate.at === "string" &&
54
+ typeof candidate.version === "string" &&
55
+ typeof candidate.stage === "string" &&
56
+ UPDATE_FAILURE_STAGES.has(candidate.stage) &&
57
+ typeof candidate.message === "string");
58
+ }
40
59
  // ============================================
41
60
  // Exported Functions
42
61
  // ============================================
@@ -50,6 +69,8 @@ export function getDefaultUpdateState() {
50
69
  suppressedVersions: {},
51
70
  lastUpdateAt: null,
52
71
  lastUpdateVersion: null,
72
+ pendingRestartVersion: null,
73
+ lastFailure: null,
53
74
  };
54
75
  }
55
76
  /**
@@ -77,7 +98,18 @@ export function loadUpdateState(stateFilePath) {
77
98
  typeof parsed.lastCheckAt !== "string") {
78
99
  return getDefaultUpdateState();
79
100
  }
80
- return parsed;
101
+ const candidate = parsed;
102
+ return {
103
+ ...getDefaultUpdateState(),
104
+ ...candidate,
105
+ suppressedVersions: candidate.suppressedVersions ?? {},
106
+ pendingRestartVersion: typeof candidate.pendingRestartVersion === "string"
107
+ ? candidate.pendingRestartVersion
108
+ : null,
109
+ lastFailure: isValidLastFailure(candidate.lastFailure)
110
+ ? candidate.lastFailure
111
+ : null,
112
+ };
81
113
  }
82
114
  catch {
83
115
  // Corrupt or unreadable JSON — return default state
@@ -152,8 +184,48 @@ export function recordSuccessfulUpdate(version, stateFilePath) {
152
184
  const state = loadUpdateState(stateFilePath) ?? getDefaultUpdateState();
153
185
  state.lastUpdateAt = new Date().toISOString();
154
186
  state.lastUpdateVersion = version;
187
+ state.pendingRestartVersion = null;
188
+ state.lastFailure = null;
189
+ delete state.suppressedVersions[version];
190
+ saveUpdateState(state, stateFilePath);
191
+ }
192
+ /** Record that package installation completed but the live restart is pending. */
193
+ export function recordUpdateInstalled(version, stateFilePath) {
194
+ const state = loadUpdateState(stateFilePath) ?? getDefaultUpdateState();
195
+ state.pendingRestartVersion = version;
196
+ state.lastFailure = null;
155
197
  saveUpdateState(state, stateFilePath);
156
198
  }
199
+ /** Abandon a matching installed version so the next cycle may reinstall it. */
200
+ export function abandonPendingUpdate(version, stateFilePath) {
201
+ const state = loadUpdateState(stateFilePath);
202
+ if (!state || state.pendingRestartVersion !== version) {
203
+ return false;
204
+ }
205
+ state.pendingRestartVersion = null;
206
+ saveUpdateState(state, stateFilePath);
207
+ return true;
208
+ }
209
+ /** Persist a stage-specific updater failure so status remains actionable. */
210
+ export function recordUpdateFailure(version, stage, message, stateFilePath) {
211
+ const state = loadUpdateState(stateFilePath) ?? getDefaultUpdateState();
212
+ state.lastFailure = {
213
+ at: new Date().toISOString(),
214
+ version,
215
+ stage,
216
+ message: message.trim().slice(0, 1_000),
217
+ };
218
+ saveUpdateState(state, stateFilePath);
219
+ }
220
+ /** Complete an updater-managed install after a manual or automatic restart. */
221
+ export function reconcileRunningUpdate(runningVersion, stateFilePath) {
222
+ const state = loadUpdateState(stateFilePath);
223
+ if (!state || state.pendingRestartVersion !== runningVersion) {
224
+ return false;
225
+ }
226
+ recordSuccessfulUpdate(runningVersion, stateFilePath);
227
+ return true;
228
+ }
157
229
  /**
158
230
  * Record an update check: set lastCheckAt and lastCheckVersion, then persist.
159
231
  *
@@ -0,0 +1,3 @@
1
+ import type { UpdaterWorkerSupervisor, UpdaterWorkerSupervisorOptions } from "../types/index.js";
2
+ /** Keep the launchd updater worker alive while its owning proxy is running. */
3
+ export declare function startUpdaterWorkerSupervisor(options: UpdaterWorkerSupervisorOptions): UpdaterWorkerSupervisor;