@juspay/neurolink 9.88.9 → 9.88.11
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/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +246 -246
- package/dist/cli/commands/proxy.js +196 -60
- package/dist/lib/proxy/globalInstaller.d.ts +7 -1
- package/dist/lib/proxy/globalInstaller.js +49 -1
- package/dist/lib/proxy/updateState.d.ts +8 -0
- package/dist/lib/proxy/updateState.js +73 -1
- package/dist/lib/proxy/updaterSupervisor.d.ts +3 -0
- package/dist/lib/proxy/updaterSupervisor.js +58 -0
- package/dist/lib/proxy/usageStats.d.ts +1 -1
- package/dist/lib/proxy/usageStats.js +20 -2
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +2 -10
- package/dist/lib/server/routes/claudeProxyRoutes.js +68 -21
- package/dist/lib/types/proxy.d.ts +69 -1
- package/dist/lib/utils/messageBuilder.js +25 -9
- package/dist/proxy/globalInstaller.d.ts +7 -1
- package/dist/proxy/globalInstaller.js +49 -1
- package/dist/proxy/updateState.d.ts +8 -0
- package/dist/proxy/updateState.js +73 -1
- package/dist/proxy/updaterSupervisor.d.ts +3 -0
- package/dist/proxy/updaterSupervisor.js +57 -0
- package/dist/proxy/usageStats.d.ts +1 -1
- package/dist/proxy/usageStats.js +20 -2
- package/dist/server/routes/claudeProxyRoutes.d.ts +2 -10
- package/dist/server/routes/claudeProxyRoutes.js +68 -21
- package/dist/types/proxy.d.ts +69 -1
- package/dist/utils/messageBuilder.js +25 -9
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
-
|
|
1433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
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
|
-
|
|
2110
|
+
updateVersion = result.latestVersion;
|
|
2111
|
+
persistUpdaterState("record update check", () => recordCheck(result.latestVersion));
|
|
2002
2112
|
if (!result.updateAvailable) {
|
|
2003
2113
|
return;
|
|
2004
2114
|
}
|
|
2005
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
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
|
-
|
|
2057
|
-
|
|
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
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
if (
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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;
|