maxpool 1.5.38 → 1.5.40
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/package.json +1 -1
- package/src/index.js +45 -3
- package/src/restart-controller.js +4 -1
- package/src/tui.js +58 -22
- package/src/updater.js +84 -28
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon, toke
|
|
|
12
12
|
import { TUI } from './tui.js';
|
|
13
13
|
import { RestartController } from './restart-controller.js';
|
|
14
14
|
import { resolveAccounts } from './account-config.js';
|
|
15
|
-
import { maybeCheckForUpdate, getCurrentVersion } from './updater.js';
|
|
15
|
+
import { maybeCheckForUpdate, getCurrentVersion, markApplied } from './updater.js';
|
|
16
16
|
import {
|
|
17
17
|
runReloadBaton,
|
|
18
18
|
RELOAD_SWAPPED, RELOAD_ROLLED_BACK,
|
|
@@ -702,6 +702,7 @@ async function serverWorkerCommand() {
|
|
|
702
702
|
let tui = null;
|
|
703
703
|
let server = null;
|
|
704
704
|
let syncTimer = null;
|
|
705
|
+
let updateTimer = null;
|
|
705
706
|
let draining = false;
|
|
706
707
|
let restartController = null;
|
|
707
708
|
// Set once this worker hands the terminal to a new worker during a seamless TUI
|
|
@@ -805,6 +806,7 @@ async function serverWorkerCommand() {
|
|
|
805
806
|
if (draining) return;
|
|
806
807
|
draining = true;
|
|
807
808
|
if (syncTimer) clearInterval(syncTimer);
|
|
809
|
+
if (updateTimer) clearInterval(updateTimer);
|
|
808
810
|
if (tui?.running) { tui.stop(); }
|
|
809
811
|
console.log('\n[Maxpool] Restarting server now; queued requests will reconnect automatically.');
|
|
810
812
|
server.closeAllConnections?.();
|
|
@@ -893,6 +895,7 @@ async function serverWorkerCommand() {
|
|
|
893
895
|
|
|
894
896
|
draining = true;
|
|
895
897
|
if (syncTimer) clearInterval(syncTimer);
|
|
898
|
+
if (updateTimer) clearInterval(updateTimer);
|
|
896
899
|
if (tui?.running) tui.stop();
|
|
897
900
|
// Best-effort final flush + settle writes (fire-and-forget; the drain timeout
|
|
898
901
|
// below bounds total quit time, and write barriers protect the on-disk state).
|
|
@@ -1015,6 +1018,41 @@ async function serverWorkerCommand() {
|
|
|
1015
1018
|
}, syncIntervalMs);
|
|
1016
1019
|
syncTimer.unref();
|
|
1017
1020
|
|
|
1021
|
+
// Periodic update re-check. The startup probe (in becomePrimary) is one-shot, but a
|
|
1022
|
+
// long-lived session (days) must still be reminded of versions published AFTER it
|
|
1023
|
+
// started. This timer is UNCONDITIONAL (NOT reload-guarded) so it survives a seamless
|
|
1024
|
+
// `r`-reload — otherwise taking an update (which the banner tells users to do via `r`)
|
|
1025
|
+
// would permanently disable all future update detection. It only refreshes
|
|
1026
|
+
// versionInfo (the persistent TUI banner is the reminder, so no repeated log spam);
|
|
1027
|
+
// autoUpdate progress lines still surface. unref so it never blocks a clean exit.
|
|
1028
|
+
const notifyUpdate = msg => (tui?._addLog ? tui._addLog(msg) : console.log(`[Maxpool] ${msg}`));
|
|
1029
|
+
// Fully-automatic apply (opt-in autoApply): mark the version attempted, THEN seamlessly
|
|
1030
|
+
// reload into the freshly-installed code (sessions survive). Marking here — at the
|
|
1031
|
+
// moment we act — is what makes the quarantine truthful: a boot-broken release is
|
|
1032
|
+
// attempted once, then blocked until an even newer version appears (no reload loop, no
|
|
1033
|
+
// stranded download). Shared by the startup one-shot AND the periodic timer.
|
|
1034
|
+
const applyUpdateIfReady = r => {
|
|
1035
|
+
if (r?.applicable && config?.autoApply && restartController) {
|
|
1036
|
+
markApplied(r.installedVersion);
|
|
1037
|
+
notifyUpdate('Applying update — seamless reload…');
|
|
1038
|
+
restartController.requestRestart();
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
const updateIntervalMs = Math.max(60_000, Number(process.env.MAXPOOL_UPDATE_CHECK_INTERVAL_MS) || 6 * 60 * 60 * 1000);
|
|
1042
|
+
updateTimer = setInterval(() => {
|
|
1043
|
+
// ONLY the lease-holding primary self-installs — the timer is unconditional across
|
|
1044
|
+
// EVERY worker (incl. a headless reload worker), so gate on hasLease here to avoid
|
|
1045
|
+
// two `npm i -g` racing during a reload overlap (global-package corruption). A
|
|
1046
|
+
// headless reload worker never holds the lease, so it never installs/auto-applies.
|
|
1047
|
+
if (config?.updateCheck === false || !hasLease) return;
|
|
1048
|
+
// announce:false — the persistent TUI banner is the passive reminder; only real
|
|
1049
|
+
// actions (installing / applying) log here, so a pending update never churns the log.
|
|
1050
|
+
maybeCheckForUpdate(config, notifyUpdate, info => { accountManager.versionInfo = info; }, { announce: false })
|
|
1051
|
+
.then(applyUpdateIfReady)
|
|
1052
|
+
.catch(() => {});
|
|
1053
|
+
}, updateIntervalMs);
|
|
1054
|
+
updateTimer.unref();
|
|
1055
|
+
|
|
1018
1056
|
// Become the live primary: start serving UI/logs, take the writer lease, run
|
|
1019
1057
|
// the update check. `viaTakeover` true means we acquired the socket through the
|
|
1020
1058
|
// baton (a reload) — freeze the update check so a reload doesn't re-probe npm
|
|
@@ -1061,8 +1099,11 @@ async function serverWorkerCommand() {
|
|
|
1061
1099
|
// A reload-spawned/takeover worker must NEVER re-probe (1x not 2x traffic).
|
|
1062
1100
|
if (!viaTakeover && !isReloadWorker) {
|
|
1063
1101
|
if (process.env.MAXPOOL_TEST_LOG_UPDATE_CHECK === '1') console.log('[Maxpool] UPDATE_CHECK_FIRED');
|
|
1064
|
-
|
|
1065
|
-
|
|
1102
|
+
// Cold-start-behind: download AND (autoApply) self-apply via the SAME helper as the
|
|
1103
|
+
// periodic path — so the version is applied, not marked-attempted-then-stranded.
|
|
1104
|
+
maybeCheckForUpdate(config, notifyUpdate, info => { accountManager.versionInfo = info; })
|
|
1105
|
+
.then(applyUpdateIfReady)
|
|
1106
|
+
.catch(() => {});
|
|
1066
1107
|
} else if (process.env.MAXPOOL_TEST_LOG_UPDATE_CHECK === '1') {
|
|
1067
1108
|
console.log('[Maxpool] UPDATE_CHECK_SKIPPED (reload)');
|
|
1068
1109
|
}
|
|
@@ -1092,6 +1133,7 @@ async function serverWorkerCommand() {
|
|
|
1092
1133
|
// proceeding (not a rollback). Disarm the rollback self-heal.
|
|
1093
1134
|
if (reloadWatchdog) { clearTimeout(reloadWatchdog); reloadWatchdog = null; }
|
|
1094
1135
|
if (syncTimer) clearInterval(syncTimer);
|
|
1136
|
+
if (updateTimer) clearInterval(updateTimer);
|
|
1095
1137
|
// Stop accepting NEW connections; KEEP in-flight requests alive.
|
|
1096
1138
|
server.maxpoolBeginDrain?.();
|
|
1097
1139
|
server.close(() => {});
|
|
@@ -67,7 +67,10 @@ export class RestartController {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
requestRestart() {
|
|
70
|
-
|
|
70
|
+
// Idempotent against a restart ALREADY in progress — restarting OR pending (drain).
|
|
71
|
+
// Without the `pending` guard a second call (e.g. auto-apply firing while a manual
|
|
72
|
+
// restart is draining) would orphan the first _drainTimer and re-log.
|
|
73
|
+
if (this.restarting || this.pending) return;
|
|
71
74
|
this.pauseAdmission();
|
|
72
75
|
if (this.upstreamRequests.size === 0) {
|
|
73
76
|
this._restart();
|
package/src/tui.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
2
|
import { fetchProfile, loginOAuth, tokenFingerprint } from './oauth.js';
|
|
3
|
-
import { appendEventLog } from './event-log.js';
|
|
3
|
+
import { appendEventLog, setConsoleStdoutSuppressed } from './event-log.js';
|
|
4
4
|
|
|
5
5
|
// ── ANSI helpers ─────────────────────────────────────────────
|
|
6
6
|
|
|
@@ -27,9 +27,9 @@ const vw = s => strip(s).length;
|
|
|
27
27
|
// ── Accounts-table columns ───────────────────────────────────
|
|
28
28
|
// Fixed column widths shared by the header row (acctHeader) AND every data row, so
|
|
29
29
|
// the header labels stay aligned with the columns they name. The Account/Provider/
|
|
30
|
-
// Status/Quota start offsets (4/
|
|
30
|
+
// Status/Quota start offsets (4/21/31/45) are pure functions of these widths + the
|
|
31
31
|
// 4-col row prefix, independent of the quota-bar width.
|
|
32
|
-
const NAME_W =
|
|
32
|
+
const NAME_W = 16; // a.name.slice(0, NAME_W).padEnd(NAME_W) — wide enough for a full email
|
|
33
33
|
const PROVIDER_W = 9; // providerLabel(a).padEnd(PROVIDER_W) — fits "Anthropic"
|
|
34
34
|
const STATUS_W = 13; // rpad(status, STATUS_W) — fits "throttled 59s"
|
|
35
35
|
const ROW_PREFIX = ' '; // ' ' + sel(1) + cur(1) + ' ' — 4 cols before the name
|
|
@@ -435,11 +435,9 @@ export class TUI {
|
|
|
435
435
|
'Reload account credentials and newly added accounts from the config file.',
|
|
436
436
|
() => this._doSync(),
|
|
437
437
|
);
|
|
438
|
-
} else if (k === 't' && this.am.accounts.length > 0) {
|
|
439
|
-
// Manual on/off toggle straight from the top level (also under a → Accounts).
|
|
440
|
-
// select → confirm → returns to 'normal', so this never strands the user.
|
|
441
|
-
this._startSelection('toggle');
|
|
442
438
|
}
|
|
439
|
+
// Enable/disable lives ONLY under [a] Accounts now (with rename/delete/login) —
|
|
440
|
+
// one home for every account mutation, instead of a duplicate top-level toggle.
|
|
443
441
|
}
|
|
444
442
|
|
|
445
443
|
_keyAccounts(k) {
|
|
@@ -482,9 +480,9 @@ export class TUI {
|
|
|
482
480
|
);
|
|
483
481
|
} else if (k === 'p' && this.am.accounts.some(account => account.type !== 'provider')) {
|
|
484
482
|
this._startSelection('prefer');
|
|
485
|
-
} else if (k === 'f') {
|
|
483
|
+
} else if (k === 'f' || k === 'F') {
|
|
486
484
|
// Cycle the cross-provider fallback policy in place — reversible + non-destructive,
|
|
487
|
-
// so no confirm dialog (unlike restart/delete).
|
|
485
|
+
// so no confirm dialog (unlike restart/delete). Accept F too (Shift-f muscle memory).
|
|
488
486
|
this._cycleCrossProviderPolicy();
|
|
489
487
|
} else if (k === 'esc' || k === 'q') {
|
|
490
488
|
this.mode = 'normal';
|
|
@@ -656,6 +654,12 @@ export class TUI {
|
|
|
656
654
|
async _doLogin() {
|
|
657
655
|
const wasRunning = this.running;
|
|
658
656
|
if (wasRunning) this.stop();
|
|
657
|
+
// Mute the console-log MIRROR to stdout during the interactive prompt so routing
|
|
658
|
+
// logs (console.log, e.g. the glm-fallback 429 churn) don't flood over the readline
|
|
659
|
+
// "Name this account" prompt. The prompts + errors use process.stdout.write, which
|
|
660
|
+
// BYPASSES the mirror, so they stay visible. Login runs only on the terminal-owning
|
|
661
|
+
// primary (mirror baseline false), so the finally restores it to false.
|
|
662
|
+
setConsoleStdoutSuppressed(true);
|
|
659
663
|
try {
|
|
660
664
|
process.stdout.write('\nOpening browser to log into Claude…\n');
|
|
661
665
|
const creds = await loginOAuth();
|
|
@@ -673,6 +677,9 @@ export class TUI {
|
|
|
673
677
|
rl.close();
|
|
674
678
|
name = String(answer || '').trim() || suggested;
|
|
675
679
|
}
|
|
680
|
+
// Lift the mute BEFORE the upsert so its persist-confirmation (a console.log,
|
|
681
|
+
// deliberately visible during the stopped-TUI login) reaches the user.
|
|
682
|
+
setConsoleStdoutSuppressed(false);
|
|
676
683
|
// Message honors the ACTUAL result: if a manually-typed name collided with a
|
|
677
684
|
// different existing account, upsert updates in place → "Re-authenticated", never
|
|
678
685
|
// a false "Added new account".
|
|
@@ -683,6 +690,7 @@ export class TUI {
|
|
|
683
690
|
} catch (e) {
|
|
684
691
|
process.stdout.write(`\nLogin failed: ${e.message}\n`);
|
|
685
692
|
} finally {
|
|
693
|
+
setConsoleStdoutSuppressed(false); // restore on every path (incl. the catch)
|
|
686
694
|
if (wasRunning) this.start();
|
|
687
695
|
}
|
|
688
696
|
}
|
|
@@ -962,14 +970,34 @@ export class TUI {
|
|
|
962
970
|
// ── Header
|
|
963
971
|
const v = this.am.versionInfo;
|
|
964
972
|
const verStr = v?.current ? ` ${dim('v' + v.current)}` : '';
|
|
965
|
-
|
|
966
|
-
// the check hasn't resolved yet (so the header never falsely implies up-to-date).
|
|
967
|
-
const updStr = (v?.hasUpdate && v?.latest) ? ` ${yellow('↑ v' + v.latest)}` : '';
|
|
968
|
-
const left = bold(' Maxpool') + verStr + updStr;
|
|
973
|
+
const left = bold(' Maxpool') + verStr;
|
|
969
974
|
const port = this.config.proxy?.port || 3456;
|
|
970
975
|
const right = `Port ${port} ${green('▲')} `;
|
|
971
976
|
lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
|
|
972
977
|
lines.push(' ' + dim('─'.repeat(W - 2)));
|
|
978
|
+
// Restart/reload in progress: admission is paused while active requests drain, then
|
|
979
|
+
// the process swaps. Without live feedback the screen looks FROZEN (identical
|
|
980
|
+
// dashboard, nothing moving) from R→Yes until the swap — the reported bug. Show an
|
|
981
|
+
// animated draining indicator. It clears when the swap completes (fresh worker
|
|
982
|
+
// renders) or a rollback resumes admission (admissionPaused → false).
|
|
983
|
+
if (this.am.admissionPaused) {
|
|
984
|
+
// Total TUI in-flight — an honest, user-meaningful "your work is finishing" count.
|
|
985
|
+
// (The reload gates its drain on UPSTREAM requests only, so this can read slightly
|
|
986
|
+
// higher than the controller's gating count; queued/idle ones reconnect, not lost.)
|
|
987
|
+
const inflight = this.active.size;
|
|
988
|
+
const detail = inflight > 0
|
|
989
|
+
? `draining ${inflight} active request${inflight === 1 ? '' : 's'}…`
|
|
990
|
+
: 'finishing up…';
|
|
991
|
+
lines.push(` ${cyan(SPINNER[this.frame])} ${yellow('Restarting')} ${dim(detail)}`);
|
|
992
|
+
}
|
|
993
|
+
// Update reminder: a prominent, actionable banner when a newer npm version is
|
|
994
|
+
// published — nothing when on latest or the check hasn't resolved (no permanent
|
|
995
|
+
// blank line). The persistent banner IS the reminder; a long-lived session's
|
|
996
|
+
// periodic re-check keeps it current (index.js updateTimer refreshes versionInfo).
|
|
997
|
+
if (v?.hasUpdate && v?.latest) {
|
|
998
|
+
lines.push(' ' + yellow(`↑ Update available: v${v.current} → v${v.latest}`)
|
|
999
|
+
+ dim(" · run 'npm i -g maxpool', then press r"));
|
|
1000
|
+
}
|
|
973
1001
|
const routing = this.am.routingMode === 'preferred'
|
|
974
1002
|
? `Manual preference: ${this.am.preferredAccountName} (automatic failover)`
|
|
975
1003
|
: 'Automatic load balancing';
|
|
@@ -1020,11 +1048,11 @@ export class TUI {
|
|
|
1020
1048
|
// misaligned second header).
|
|
1021
1049
|
lines.push(dimUnderline(acctHeader(W)));
|
|
1022
1050
|
const showBoth = W >= 70;
|
|
1023
|
-
//
|
|
1024
|
-
// gaps); grew by
|
|
1051
|
+
// 61/50 = the fixed pre-bar column span (prefix + Account + Provider + Status +
|
|
1052
|
+
// gaps); grew by +4 with the wider 16-col Account column (was 57/46 at NAME_W 12).
|
|
1025
1053
|
const bw = showBoth
|
|
1026
|
-
? Math.max(5, Math.min(20, Math.floor((W -
|
|
1027
|
-
: Math.max(5, Math.min(20, W -
|
|
1054
|
+
? Math.max(5, Math.min(20, Math.floor((W - 61) / 2)))
|
|
1055
|
+
: Math.max(5, Math.min(20, W - 50));
|
|
1028
1056
|
|
|
1029
1057
|
// Claude/OAuth accounts first, providers (GLM/Kimi fallback) last — a stable
|
|
1030
1058
|
// display order over the canonical am.accounts array (which stays untouched so
|
|
@@ -1109,7 +1137,10 @@ export class TUI {
|
|
|
1109
1137
|
|
|
1110
1138
|
// Name (bold if selected)
|
|
1111
1139
|
const rawName = a.name.slice(0, NAME_W).padEnd(NAME_W);
|
|
1112
|
-
|
|
1140
|
+
// A disabled account reads as "off" — dim the whole name. Applied as a SINGLE SGR
|
|
1141
|
+
// (never dim(bold(...)) — bold()'s trailing RESET would cancel the dim mid-string),
|
|
1142
|
+
// so a disabled row is never also bold-selected.
|
|
1143
|
+
const name = a.enabled === false ? dim(rawName) : (isSel ? bold(rawName) : rawName);
|
|
1113
1144
|
|
|
1114
1145
|
// Provider column — the real vendor (Anthropic / z.ai / Moonshot), padded to
|
|
1115
1146
|
// PROVIDER_W so "Anthropic" (9 chars) never overflows and shifts the row (incl.
|
|
@@ -1147,7 +1178,7 @@ export class TUI {
|
|
|
1147
1178
|
case 'probing': status = green('probing'); break;
|
|
1148
1179
|
case 'waiting': status = yellow('waiting'); break;
|
|
1149
1180
|
case 'paused': status = yellow('paused'); break;
|
|
1150
|
-
case 'disabled': status =
|
|
1181
|
+
case 'disabled': status = red('✕ disabled'); break;
|
|
1151
1182
|
case 'throttled': {
|
|
1152
1183
|
// A transient auto-recovering cooldown — show the remaining time (from
|
|
1153
1184
|
// rateLimitedUntil) so it reads as "recovering in Ns", not stuck.
|
|
@@ -1310,11 +1341,16 @@ export class TUI {
|
|
|
1310
1341
|
_renderFooter() {
|
|
1311
1342
|
switch (this.mode) {
|
|
1312
1343
|
case 'normal':
|
|
1313
|
-
return ` ${bold('a')} Accounts ${bold('
|
|
1344
|
+
return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('s')} Sync ${bold('r')} Restart ${bold('q')} Stop`;
|
|
1314
1345
|
case 'accounts':
|
|
1315
1346
|
return ` ${bold('l')} Login/re-auth (browser) ${bold('k')} API key ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
|
|
1316
|
-
case 'routing':
|
|
1317
|
-
|
|
1347
|
+
case 'routing': {
|
|
1348
|
+
// Show the CURRENT cross-provider policy inline so pressing f visibly changes it
|
|
1349
|
+
// right here at the footer (the policy also renders in the header, far from the
|
|
1350
|
+
// keypress — the "f does nothing" report).
|
|
1351
|
+
const xp = this.am._crossProviderFallbackPolicy?.() || 'when-exhausted';
|
|
1352
|
+
return ` ${bold('a')} Automatic ${bold('p')} Manual preference ${bold('f')} Cross-provider: ${cyan(xp)} ↻ ${bold('Esc')} Back`;
|
|
1353
|
+
}
|
|
1318
1354
|
case 'select': {
|
|
1319
1355
|
const act = this.selAction === 'prefer'
|
|
1320
1356
|
? 'prefer'
|
package/src/updater.js
CHANGED
|
@@ -68,43 +68,99 @@ export async function selfUpdate({ timeoutMs = 120_000 } = {}) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// The version of the CODE currently EXECUTING, captured ONCE before any self-install.
|
|
72
|
+
// getCurrentVersion() reads package.json FROM DISK, which `npm i -g` rewrites — so
|
|
73
|
+
// after a background self-install the disk version != the running version. Every
|
|
74
|
+
// "am I behind?" and loop-guard decision keys on THIS fixed value, not the disk read.
|
|
75
|
+
let _bootVersion;
|
|
76
|
+
// The newest version this process has already ATTEMPTED to auto-apply. The loop guard:
|
|
77
|
+
// a version that installs fine but fails to BOOT rolls back to the old worker, which is
|
|
78
|
+
// still running _bootVersion with its timer armed — without this it would re-detect the
|
|
79
|
+
// on-disk version every check and re-apply forever (a boot-broken release → infinite
|
|
80
|
+
// reload loop, ~30s of refused admission each cycle). We apply only versions strictly
|
|
81
|
+
// newer than max(_bootVersion, _lastAttemptedTarget), so a rolled-back target is
|
|
82
|
+
// quarantined until an even newer release appears.
|
|
83
|
+
let _lastAttemptedTarget = null;
|
|
84
|
+
|
|
85
|
+
/** Test-only: reset the module's version-tracking state between cases. */
|
|
86
|
+
export function __resetUpdaterState() { _bootVersion = undefined; _lastAttemptedTarget = null; }
|
|
87
|
+
|
|
88
|
+
/** Mark a version as ATTEMPTED-to-apply. The caller calls this at the moment it triggers
|
|
89
|
+
* the reload — BEFORE the reload — so a rolled-back target is quarantined (advance-only).
|
|
90
|
+
* Kept as the caller's action (not a side effect of maybeCheckForUpdate) so a caller that
|
|
91
|
+
* ignores `applicable` can never strand a version or poison the quarantine floor. */
|
|
92
|
+
export function markApplied(version) {
|
|
93
|
+
if (version && (!_lastAttemptedTarget || compareVersions(version, _lastAttemptedTarget) > 0)) {
|
|
94
|
+
_lastAttemptedTarget = version;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
71
98
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
99
|
+
* Check for an update; with `config.autoUpdate` also self-install; with
|
|
100
|
+
* `config.autoApply` SIGNAL that the caller should seamlessly reload to APPLY it
|
|
101
|
+
* (returns `applicable:true` — the CALLER then markApplied()+reloads). Failures swallowed.
|
|
102
|
+
* Deps are injectable for tests.
|
|
103
|
+
*
|
|
104
|
+
* `onVersionInfo` is ALWAYS invoked with { current, latest, hasUpdate, checkedAt } —
|
|
105
|
+
* current + hasUpdate keyed on the RUNNING version (so the banner stays accurate after a
|
|
106
|
+
* background download, until the reload). `deps.announce===false` suppresses the passive
|
|
107
|
+
* "Update available" line (the periodic path — the persistent banner already shows it).
|
|
76
108
|
*
|
|
77
|
-
*
|
|
78
|
-
* checkedAt } — even when up-to-date, offline, or updateCheck is off — so the TUI
|
|
79
|
-
* header / status can show the running version + whether an update is available.
|
|
109
|
+
* Returns { hasUpdate, applicable, installedVersion? }.
|
|
80
110
|
*/
|
|
81
|
-
export async function maybeCheckForUpdate(config, notify, onVersionInfo) {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
111
|
+
export async function maybeCheckForUpdate(config, notify, onVersionInfo, deps = {}) {
|
|
112
|
+
const _get = deps.getCurrentVersion || getCurrentVersion;
|
|
113
|
+
const _check = deps.checkForUpdate || checkForUpdate;
|
|
114
|
+
const _self = deps.selfUpdate || selfUpdate;
|
|
115
|
+
const announce = deps.announce !== false;
|
|
116
|
+
|
|
117
|
+
if (_bootVersion === undefined) _bootVersion = await _get();
|
|
118
|
+
const current = _bootVersion;
|
|
119
|
+
const result = config?.updateCheck === false ? null : await _check(current);
|
|
120
|
+
const hasUpdate = Boolean(result?.hasUpdate);
|
|
86
121
|
|
|
87
122
|
if (onVersionInfo) {
|
|
88
123
|
try {
|
|
89
|
-
onVersionInfo({
|
|
90
|
-
|
|
91
|
-
latest: result?.latest ?? null,
|
|
92
|
-
hasUpdate: Boolean(result?.hasUpdate),
|
|
93
|
-
checkedAt: Date.now(),
|
|
94
|
-
});
|
|
95
|
-
} catch { /* the indicator is best-effort; never break startup */ }
|
|
124
|
+
onVersionInfo({ current, latest: result?.latest ?? null, hasUpdate, checkedAt: Date.now() });
|
|
125
|
+
} catch { /* indicator is best-effort; never break startup */ }
|
|
96
126
|
}
|
|
97
127
|
|
|
98
|
-
if (!
|
|
128
|
+
if (!hasUpdate) return { hasUpdate: false, applicable: false };
|
|
129
|
+
if (announce) notify(`Update available: ${result.current} → ${result.latest}`);
|
|
99
130
|
|
|
100
|
-
|
|
101
|
-
|
|
131
|
+
if (!config?.autoUpdate) {
|
|
132
|
+
if (announce) notify(`Run 'npm i -g ${PACKAGE}' to update, or set "autoUpdate": true in your config.`);
|
|
133
|
+
return { hasUpdate: true, applicable: false };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Skip the reinstall if the latest is ALREADY on disk (a prior check downloaded it) —
|
|
137
|
+
// otherwise every periodic check re-runs `npm i -g` + re-logs until a manual restart.
|
|
138
|
+
const onDisk = await _get();
|
|
139
|
+
if (!onDisk || compareVersions(onDisk, result.latest) < 0) {
|
|
102
140
|
notify(`Auto-updating to ${result.latest}…`);
|
|
103
|
-
const r = await
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
141
|
+
const r = await _self();
|
|
142
|
+
if (!r.ok) {
|
|
143
|
+
notify(`Auto-update failed: ${r.error}. Run: npm i -g ${PACKAGE}`);
|
|
144
|
+
return { hasUpdate: true, applicable: false };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const installed = await _get();
|
|
149
|
+
// Loop/quarantine guard — a version is "applicable" only if it is strictly newer than
|
|
150
|
+
// the newest we've already booted-as OR already ATTEMPTED to apply (markApplied). This
|
|
151
|
+
// function has NO side effect on that floor: the CALLER marks it when it triggers the
|
|
152
|
+
// reload, so a caller that ignores `applicable` can't strand a version or poison state.
|
|
153
|
+
const floor = (_lastAttemptedTarget && compareVersions(_lastAttemptedTarget, current) > 0)
|
|
154
|
+
? _lastAttemptedTarget : current;
|
|
155
|
+
const advanced = Boolean(installed) && compareVersions(installed, current) > 0;
|
|
156
|
+
const applicable = advanced && compareVersions(installed, floor) > 0;
|
|
157
|
+
|
|
158
|
+
// Passive notices only. The "Applying now…" line + the reload are the caller's (it
|
|
159
|
+
// logs them exactly when it acts), so nothing here can claim an apply that didn't happen.
|
|
160
|
+
if (advanced && !applicable) {
|
|
161
|
+
if (announce) notify(`Update ${installed} already attempted — staying on ${current}; will retry only a newer release.`);
|
|
162
|
+
} else if (advanced && !config?.autoApply) {
|
|
163
|
+
if (announce) notify(`Updated to ${installed}. Restart maxpool to apply (sessions are not interrupted).`);
|
|
109
164
|
}
|
|
165
|
+
return { hasUpdate: true, installedVersion: installed, applicable };
|
|
110
166
|
}
|