maxpool 1.0.3 → 1.0.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Multi-account Claude Code proxy with adaptive, rate-aware load balancing across Claude accounts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -14,7 +14,8 @@
14
14
  "scripts": {
15
15
  "start": "node src/index.js",
16
16
  "test": "node --test",
17
- "lint": "eslint src/ test/"
17
+ "lint": "eslint src/ test/",
18
+ "release": "bash scripts/release.sh"
18
19
  },
19
20
  "keywords": [
20
21
  "claude",
package/src/config.js CHANGED
@@ -36,6 +36,11 @@ export function createDefaultConfig() {
36
36
  apiKey: 'mp-' + randomBytes(24).toString('base64url'),
37
37
  },
38
38
  upstream: 'https://api.anthropic.com',
39
+ // On startup, check npm for a newer maxpool and notify. Set false to disable.
40
+ updateCheck: true,
41
+ // When true, a newer version is installed automatically (npm i -g maxpool@latest)
42
+ // and applied on the NEXT restart — running sessions are never interrupted.
43
+ autoUpdate: false,
39
44
  // Per-account "stop using this account" gate, applied to BOTH the 5h
40
45
  // session window and the 7d weekly window (whichever utilization is
41
46
  // higher). 0.90 = stop routing to an account once it crosses 90% of a
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isToke
10
10
  import { TUI } from './tui.js';
11
11
  import { RestartController } from './restart-controller.js';
12
12
  import { resolveAccounts } from './account-config.js';
13
+ import { maybeCheckForUpdate } from './updater.js';
13
14
 
14
15
  const args = process.argv.slice(2);
15
16
  const command = args[0];
@@ -47,6 +48,10 @@ switch (command) {
47
48
  await removeCommand();
48
49
  process.exit(0);
49
50
  break;
51
+ case 'rename':
52
+ await renameCommand();
53
+ process.exit(0);
54
+ break;
50
55
  case 'api':
51
56
  await apiCommand();
52
57
  process.exit(0);
@@ -361,6 +366,11 @@ async function serverWorkerCommand() {
361
366
  } else {
362
367
  logPlainServerStart({ host, port, accounts, threshold, config });
363
368
  }
369
+
370
+ // Non-blocking update check. Notifies (or self-updates if config.autoUpdate);
371
+ // never interrupts the running proxy. Failures are swallowed.
372
+ const notify = msg => (tui?._addLog ? tui._addLog(msg) : console.log(`[Maxpool] ${msg}`));
373
+ maybeCheckForUpdate(config, notify).catch(() => {});
364
374
  });
365
375
 
366
376
  process.on('SIGINT', () => shutdownGracefully('SIGINT'));
@@ -792,6 +802,42 @@ async function removeCommand() {
792
802
  console.log(`Removed account "${name}"`);
793
803
  }
794
804
 
805
+ // Resolve an account by exact name, else by 1-based index. Returns -1 if none.
806
+ function resolveAccountIndex(accounts, target) {
807
+ let idx = accounts.findIndex(a => a.name === target);
808
+ if (idx < 0 && /^\d+$/.test(String(target))) idx = Number(target) - 1;
809
+ return idx >= 0 && idx < accounts.length ? idx : -1;
810
+ }
811
+
812
+ async function renameCommand() {
813
+ const config = await loadOrCreateConfig();
814
+ const target = args[1];
815
+ const newName = args[2];
816
+
817
+ if (!target || !newName) {
818
+ console.error('Usage: maxpool rename <account-name|number> <new-name>');
819
+ process.exit(1);
820
+ }
821
+
822
+ const idx = resolveAccountIndex(config.accounts, target);
823
+ if (idx < 0) {
824
+ console.error(`Account "${target}" not found`);
825
+ process.exit(1);
826
+ }
827
+ if (config.accounts.some((a, i) => i !== idx && a.name === newName)) {
828
+ console.error(`An account named "${newName}" already exists`);
829
+ process.exit(1);
830
+ }
831
+
832
+ const old = config.accounts[idx].name;
833
+ config.accounts[idx].name = newName;
834
+ // Keep manual-preference routing pointing at the renamed account.
835
+ if (config.routing?.preferredAccount === old) config.routing.preferredAccount = newName;
836
+ await saveConfig(config);
837
+ console.log(`Renamed "${old}" → "${newName}"`);
838
+ console.log('Restart maxpool to apply this to a running proxy (or rename live from the TUI: a → n).');
839
+ }
840
+
795
841
  // ── help ────────────────────────────────────────────────────
796
842
 
797
843
  function showHelp() {
@@ -809,6 +855,7 @@ Commands:
809
855
  status Show proxy & account status (live)
810
856
  accounts List configured accounts
811
857
  remove <name> Remove an account
858
+ rename <name|#> <new> Rename an account (by name or list number)
812
859
  api <path> Call an API endpoint with account credentials
813
860
  help Show this help
814
861
 
package/src/tui.js CHANGED
@@ -1,4 +1,5 @@
1
- import { importCredentials, fetchProfile } from './oauth.js';
1
+ import { createInterface } from 'node:readline';
2
+ import { importCredentials, fetchProfile, loginOAuth } from './oauth.js';
2
3
 
3
4
  // ── ANSI helpers ─────────────────────────────────────────────
4
5
 
@@ -369,6 +370,14 @@ export class TUI {
369
370
  () => this._doAddKey(value),
370
371
  );
371
372
  };
373
+ } else if (k === 'l') {
374
+ this._confirm(
375
+ 'Log in via browser?',
376
+ 'Opens a browser to add any Claude account; you name it afterward.',
377
+ () => this._doLogin(),
378
+ );
379
+ } else if (k === 'n' && this.am.accounts.length > 0) {
380
+ this._startSelection('rename');
372
381
  } else if (k === 't' && this.am.accounts.length > 0) {
373
382
  this._startSelection('toggle');
374
383
  } else if (k === 'd' && this.am.accounts.length > 0) {
@@ -432,6 +441,14 @@ export class TUI {
432
441
  'Permanently remove it from Maxpool config. Deletion is blocked while it has active requests.',
433
442
  () => this._doDelete(this.selIdx),
434
443
  );
444
+ } else if (this.selAction === 'rename') {
445
+ const targetIdx = this.selIdx;
446
+ const current = account.name;
447
+ this.mode = 'input';
448
+ this.inputPrompt = `New name for "${current}"`;
449
+ this.inputBuf = '';
450
+ this.inputSensitive = false;
451
+ this.inputCb = value => this._doRename(targetIdx, String(value || '').trim());
435
452
  }
436
453
  }
437
454
  else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
@@ -519,74 +536,123 @@ export class TUI {
519
536
  async _doImport() {
520
537
  try {
521
538
  this._addLog('Importing credentials...');
522
- const creds = await importCredentials('~/.claude/.credentials.json');
539
+ const creds = await importCredentials(); // file, then macOS Keychain fallback
523
540
  const profile = await fetchProfile(creds.accessToken);
524
- const profileOk = profile && !profile.error;
525
-
526
- if (!profileOk) {
541
+ if (!profile || profile.error) {
527
542
  this._addLog(`Warning: could not fetch profile — ${profile?.error || 'no token'}`);
528
543
  }
529
-
530
544
  let name;
531
545
  if (profile?.email) {
532
546
  name = profile.email;
533
547
  const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
534
548
  if (tier) this._addLog(`Detected Claude ${tier}: ${name}`);
535
- } else {
536
- const n = this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1;
537
- name = `account-${n}`;
538
549
  }
550
+ await this._upsertOAuthAccount({ creds, profile, name, source: 'import', verb: 'Imported' });
551
+ } catch (e) {
552
+ this._addLog(`Import failed: ${e.message}`);
553
+ }
554
+ }
539
555
 
540
- const entry = {
541
- name, type: 'oauth', source: 'import',
542
- accountUuid: profile?.accountUuid || null,
543
- accessToken: creds.accessToken,
544
- refreshToken: creds.refreshToken,
545
- expiresAt: creds.expiresAt,
546
- };
556
+ // Browser OAuth login: any Claude account, named afterward. Suspends the TUI
557
+ // around the interactive flow (browser + name prompt), then resumes.
558
+ async _doLogin() {
559
+ const wasRunning = this.running;
560
+ if (wasRunning) this.stop();
561
+ try {
562
+ process.stdout.write('\nOpening browser to log into Claude…\n');
563
+ const creds = await loginOAuth();
564
+ const profile = await fetchProfile(creds.accessToken);
565
+ const suggested = profile?.email
566
+ || `account-${this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1}`;
567
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
568
+ const answer = await new Promise(resolve => rl.question(`Name this account [${suggested}]: `, resolve));
569
+ rl.close();
570
+ const name = String(answer || '').trim() || suggested;
571
+ await this._upsertOAuthAccount({ creds, profile, name, source: 'login', verb: 'Added' });
572
+ process.stdout.write(`\nAdded account "${name}". Returning to maxpool…\n`);
573
+ } catch (e) {
574
+ process.stdout.write(`\nLogin failed: ${e.message}\n`);
575
+ } finally {
576
+ if (wasRunning) this.start();
577
+ }
578
+ }
547
579
 
548
- // Deduplicate: match by UUID first, then by name
549
- let idx = profile?.accountUuid
550
- ? this.config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
551
- : -1;
552
- if (idx < 0) idx = this.config.accounts.findIndex(a => a.name === name);
553
-
554
- if (idx >= 0) {
555
- const previous = this.config.accounts[idx];
556
- entry.enabled = this.config.accounts[idx].enabled;
557
- this.config.accounts[idx] = entry;
558
- try {
559
- await this.saveConfig(this.config);
560
- } catch (error) {
561
- this.config.accounts[idx] = previous;
562
- throw error;
563
- }
564
- // Update the running account manager entry
565
- const amAcct = this.am.accounts.find(account =>
566
- (entry.accountUuid && account.accountUuid === entry.accountUuid) || account.name === name
567
- );
568
- if (amAcct) {
569
- amAcct.credential = creds.accessToken;
570
- amAcct.refreshToken = creds.refreshToken;
571
- amAcct.expiresAt = creds.expiresAt;
572
- amAcct.accountUuid = entry.accountUuid;
573
- amAcct.name = name;
574
- if (amAcct.status === 'error') amAcct.status = 'active';
575
- }
576
- this._addLog(`Updated account "${name}"`);
577
- } else {
578
- this.config.accounts.push(entry);
579
- try {
580
- await this.saveConfig(this.config);
581
- } catch (error) {
582
- this.config.accounts.pop();
583
- throw error;
584
- }
585
- this.am.addAccount(entry);
586
- this._addLog(`Imported account "${name}"`);
580
+ // Rename an account in config and in the running manager.
581
+ async _doRename(idx, newName) {
582
+ const account = this.am.accounts[idx];
583
+ if (!account) { this._addLog('Account no longer exists'); return; }
584
+ if (!newName) { this._addLog('Rename cancelled (empty name)'); return; }
585
+ if (this.am.accounts.some((a, i) => i !== idx && a.name === newName)) {
586
+ this._addLog(`An account named "${newName}" already exists`); return;
587
+ }
588
+ const cfgIdx = this._configAccountIndex(account);
589
+ if (cfgIdx < 0) { this._addLog(`Cannot rename "${account.name}" (not in config)`); return; }
590
+ const old = account.name;
591
+ const prev = this.config.accounts[cfgIdx].name;
592
+ this.config.accounts[cfgIdx].name = newName;
593
+ if (this.config.routing?.preferredAccount === old) this.config.routing.preferredAccount = newName;
594
+ try {
595
+ await this.saveConfig(this.config);
596
+ } catch (error) {
597
+ this.config.accounts[cfgIdx].name = prev;
598
+ throw error;
599
+ }
600
+ account.name = newName; // update the running account manager
601
+ this._addLog(`Renamed "${old}" → "${newName}"`);
602
+ }
603
+
604
+ // Upsert an OAuth account into config + the running manager. Dedupes by
605
+ // accountUuid, then name. Shared by import and browser login.
606
+ async _upsertOAuthAccount({ creds, profile, name, source, verb = 'Added' }) {
607
+ if (!name) {
608
+ name = profile?.email
609
+ || `account-${this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1}`;
610
+ }
611
+ const entry = {
612
+ name, type: 'oauth', source,
613
+ accountUuid: profile?.accountUuid || null,
614
+ accessToken: creds.accessToken,
615
+ refreshToken: creds.refreshToken,
616
+ expiresAt: creds.expiresAt,
617
+ };
618
+
619
+ let idx = entry.accountUuid
620
+ ? this.config.accounts.findIndex(a => a.accountUuid === entry.accountUuid)
621
+ : -1;
622
+ if (idx < 0) idx = this.config.accounts.findIndex(a => a.name === name);
623
+
624
+ if (idx >= 0) {
625
+ const previous = this.config.accounts[idx];
626
+ entry.enabled = previous.enabled;
627
+ this.config.accounts[idx] = entry;
628
+ try {
629
+ await this.saveConfig(this.config);
630
+ } catch (error) {
631
+ this.config.accounts[idx] = previous;
632
+ throw error;
587
633
  }
588
- } catch (e) {
589
- this._addLog(`Import failed: ${e.message}`);
634
+ const amAcct = this.am.accounts.find(account =>
635
+ (entry.accountUuid && account.accountUuid === entry.accountUuid) || account.name === name
636
+ );
637
+ if (amAcct) {
638
+ amAcct.credential = creds.accessToken;
639
+ amAcct.refreshToken = creds.refreshToken;
640
+ amAcct.expiresAt = creds.expiresAt;
641
+ amAcct.accountUuid = entry.accountUuid;
642
+ amAcct.name = name;
643
+ if (amAcct.status === 'error') amAcct.status = 'active';
644
+ }
645
+ this._addLog(`Updated account "${name}"`);
646
+ } else {
647
+ this.config.accounts.push(entry);
648
+ try {
649
+ await this.saveConfig(this.config);
650
+ } catch (error) {
651
+ this.config.accounts.pop();
652
+ throw error;
653
+ }
654
+ this.am.addAccount(entry);
655
+ this._addLog(`${verb} account "${name}"`);
590
656
  }
591
657
  }
592
658
 
@@ -829,7 +895,13 @@ export class TUI {
829
895
 
830
896
  _renderAcct(idx, bw, showBoth) {
831
897
  const a = this.am.accounts[idx];
832
- const isCur = this.am.routingMode === 'preferred' && a.name === this.am.preferredAccountName;
898
+ // Highlight the currently-active account. In manual mode that's the
899
+ // preferred account; in automatic mode it's the one most recently routed
900
+ // to (currentIndex). Previously only manual mode highlighted anything, so
901
+ // in automatic load-balancing no row was ever marked current.
902
+ const isCur = this.am.routingMode === 'preferred'
903
+ ? a.name === this.am.preferredAccountName
904
+ : idx === this.am.currentIndex;
833
905
  const isSel = this.mode === 'select' && idx === this.selIdx;
834
906
 
835
907
  // Prefix: selection marker + current marker
@@ -936,7 +1008,7 @@ export class TUI {
936
1008
  case 'normal':
937
1009
  return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('s')} Sync ${bold('r')} Restart ${bold('q')} Stop`;
938
1010
  case 'accounts':
939
- return ` ${bold('i')} Import Claude login ${bold('k')} API key ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
1011
+ return ` ${bold('i')} Import ${bold('l')} Login (browser) ${bold('k')} API key ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
940
1012
  case 'routing':
941
1013
  return ` ${bold('a')} Automatic ${bold('p')} Manual preference ${bold('Esc')} Back`;
942
1014
  case 'select': {
@@ -944,7 +1016,9 @@ export class TUI {
944
1016
  ? 'prefer'
945
1017
  : this.selAction === 'toggle'
946
1018
  ? 'enable/disable'
947
- : 'delete';
1019
+ : this.selAction === 'rename'
1020
+ ? 'rename'
1021
+ : 'delete';
948
1022
  return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;
949
1023
  }
950
1024
  case 'input':
package/src/updater.js ADDED
@@ -0,0 +1,93 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const PACKAGE = 'maxpool';
9
+ const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
10
+
11
+ /** Read the running maxpool version from its own package.json. Null on failure. */
12
+ export async function getCurrentVersion() {
13
+ try {
14
+ const here = dirname(fileURLToPath(import.meta.url));
15
+ const pkg = JSON.parse(await readFile(join(here, '..', 'package.json'), 'utf-8'));
16
+ return pkg.version || null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /** Compare two dotted versions. Returns 1 if a>b, -1 if a<b, 0 if equal. */
23
+ export function compareVersions(a, b) {
24
+ const pa = String(a).split('.').map(n => Number(n) || 0);
25
+ const pb = String(b).split('.').map(n => Number(n) || 0);
26
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
27
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
28
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
29
+ }
30
+ return 0;
31
+ }
32
+
33
+ /**
34
+ * Check npm for a newer published version. Network-failure-safe: returns null
35
+ * on any error (offline, timeout, bad response) so a check never breaks startup.
36
+ */
37
+ export async function checkForUpdate(currentVersion, { timeoutMs = 4000, registry = DEFAULT_REGISTRY } = {}) {
38
+ try {
39
+ const ctrl = new AbortController();
40
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
41
+ let res;
42
+ try {
43
+ res = await fetch(`${registry}/${PACKAGE}/latest`, { signal: ctrl.signal });
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
47
+ if (!res.ok) return null;
48
+ const data = await res.json();
49
+ const latest = data.version;
50
+ if (!latest) return null;
51
+ return {
52
+ latest,
53
+ current: currentVersion,
54
+ hasUpdate: currentVersion ? compareVersions(latest, currentVersion) > 0 : false,
55
+ };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /** Run `npm install -g maxpool@latest`. Returns {ok, output|error}. */
62
+ export async function selfUpdate({ timeoutMs = 120_000 } = {}) {
63
+ try {
64
+ const { stdout, stderr } = await execFileAsync('npm', ['install', '-g', `${PACKAGE}@latest`], { timeout: timeoutMs });
65
+ return { ok: true, output: (stdout || stderr || '').trim() };
66
+ } catch (err) {
67
+ return { ok: false, error: err.message };
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Startup hook: check for an update and either notify (default) or self-install
73
+ * (config.autoUpdate). Never auto-restarts a running proxy — the new version
74
+ * applies on the next restart, so in-flight sessions are never interrupted.
75
+ * Fire-and-forget; all failures are swallowed.
76
+ */
77
+ export async function maybeCheckForUpdate(config, notify) {
78
+ if (config?.updateCheck === false) return;
79
+ const current = await getCurrentVersion();
80
+ const result = await checkForUpdate(current);
81
+ if (!result || !result.hasUpdate) return;
82
+
83
+ notify(`Update available: ${result.current} → ${result.latest}`);
84
+ if (config?.autoUpdate) {
85
+ notify(`Auto-updating to ${result.latest}…`);
86
+ const r = await selfUpdate();
87
+ notify(r.ok
88
+ ? `Updated to ${result.latest}. Restart maxpool to apply (running sessions are not interrupted).`
89
+ : `Auto-update failed: ${r.error}. Run: npm i -g ${PACKAGE}`);
90
+ } else {
91
+ notify(`Run 'npm i -g ${PACKAGE}' to update, or set "autoUpdate": true in your config.`);
92
+ }
93
+ }