teamclaude-cloud 1.5.7 → 1.6.0
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/config.js +1 -0
- package/src/index.js +91 -26
- package/src/server.js +17 -1
package/package.json
CHANGED
package/src/config.js
CHANGED
package/src/index.js
CHANGED
|
@@ -358,6 +358,16 @@ async function serverCommand() {
|
|
|
358
358
|
};
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
// Live-reload hook (both TUI and headless): a local CLI edit POSTs /teamclaude/reload
|
|
362
|
+
// so its change (enable/disable/remove/priority) applies to THIS running server
|
|
363
|
+
// immediately, instead of waiting for the next cloud-sync tick. Re-reads disk and
|
|
364
|
+
// applies through the same path as TUI "R" / a sync tick.
|
|
365
|
+
// The /teamclaude/reload endpoint and the cloud-sync loop both reconcile disk→fleet;
|
|
366
|
+
// route BOTH through the one serialized applyDiskToFleet chain (below) so they can't
|
|
367
|
+
// interleave — an older apply must not reconcile a removal while a newer one re-adds
|
|
368
|
+
// from a stale snapshot (Codex HIGH). Each run re-reads disk fresh.
|
|
369
|
+
hooks.onReload = () => applyDiskToFleet(config, accountManager);
|
|
370
|
+
|
|
361
371
|
// If a TeamClaude server is already running on this config's port, don't try to
|
|
362
372
|
// bind on top of it — point the user at stop/restart instead of a raw EADDRINUSE.
|
|
363
373
|
const existing = await findRunningServer(config);
|
|
@@ -1067,7 +1077,6 @@ async function apiCommand() {
|
|
|
1067
1077
|
// ── remove ──────────────────────────────────────────────────
|
|
1068
1078
|
|
|
1069
1079
|
async function removeCommand() {
|
|
1070
|
-
const config = await loadOrCreateConfig();
|
|
1071
1080
|
const name = args[1];
|
|
1072
1081
|
|
|
1073
1082
|
if (!name) {
|
|
@@ -1075,27 +1084,42 @@ async function removeCommand() {
|
|
|
1075
1084
|
process.exit(1);
|
|
1076
1085
|
}
|
|
1077
1086
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1087
|
+
// atomicConfigUpdate re-reads disk before writing, so a concurrent token-refresh
|
|
1088
|
+
// write (from a running server) can't resurrect the just-removed account.
|
|
1089
|
+
let found = false;
|
|
1090
|
+
const config = await atomicConfigUpdate((c) => {
|
|
1091
|
+
const idx = (c.accounts || []).findIndex(a => a.name === name);
|
|
1092
|
+
if (idx < 0) return; // not present on the fresh disk copy — no write
|
|
1093
|
+
c.accounts.splice(idx, 1);
|
|
1094
|
+
found = true;
|
|
1095
|
+
});
|
|
1096
|
+
if (!found) {
|
|
1080
1097
|
console.error(`Account "${name}" not found`);
|
|
1081
1098
|
process.exit(1);
|
|
1082
1099
|
}
|
|
1083
|
-
|
|
1084
|
-
config.accounts.splice(idx, 1);
|
|
1085
|
-
await saveConfig(config);
|
|
1086
1100
|
console.log(`Removed account "${name}"`);
|
|
1101
|
+
await noteRunningServerReload(config); // drop it from the running server now (not next sync)
|
|
1087
1102
|
}
|
|
1088
1103
|
|
|
1089
1104
|
// ── enable / disable / priority ─────────────────────────────
|
|
1090
1105
|
|
|
1091
1106
|
/** Note that changes apply to a running server only after a reload/restart. */
|
|
1092
|
-
function noteRunningServerReload(config) {
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1107
|
+
async function noteRunningServerReload(config) {
|
|
1108
|
+
const running = await findRunningServer(config).catch(() => null);
|
|
1109
|
+
if (!running) return;
|
|
1110
|
+
// Tell the running server to apply the just-written config change RIGHT NOW
|
|
1111
|
+
// (localhost-only endpoint), instead of waiting for its next cloud-sync tick.
|
|
1112
|
+
try {
|
|
1113
|
+
const res = await fetch(`http://127.0.0.1:${running.port}/teamclaude/reload`, {
|
|
1114
|
+
method: 'POST', headers: { 'x-api-key': config.proxy.apiKey },
|
|
1115
|
+
});
|
|
1116
|
+
if (res.ok) {
|
|
1117
|
+
await res.json().catch(() => ({}));
|
|
1118
|
+
console.log(' Applied to the running server immediately.');
|
|
1119
|
+
return;
|
|
1097
1120
|
}
|
|
1098
|
-
}
|
|
1121
|
+
} catch { /* server unreachable mid-shutdown — fall back to the manual hint */ }
|
|
1122
|
+
console.log(' A server is running — apply with: teamclaude restart (or "R" in the TUI).');
|
|
1099
1123
|
}
|
|
1100
1124
|
|
|
1101
1125
|
async function setEnabledCommand(enabled) {
|
|
@@ -1368,6 +1392,24 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
|
|
|
1368
1392
|
return added;
|
|
1369
1393
|
}
|
|
1370
1394
|
|
|
1395
|
+
// Serialized disk→fleet reconcile. The /teamclaude/reload endpoint (onReload) AND the
|
|
1396
|
+
// cloud-sync loop both apply disk to the live fleet; routing both through this one chain
|
|
1397
|
+
// stops them interleaving — an older apply reconciling a removal while a newer one re-adds
|
|
1398
|
+
// from a stale snapshot (Codex HIGH). Each run re-reads disk fresh, so the last-executed
|
|
1399
|
+
// run reflects the freshest config. Failure of one run doesn't stall the chain.
|
|
1400
|
+
let _fleetApplyChain = Promise.resolve();
|
|
1401
|
+
function applyDiskToFleet(config, accountManager) {
|
|
1402
|
+
const run = _fleetApplyChain.then(async () => {
|
|
1403
|
+
const disk = await loadConfig();
|
|
1404
|
+
if (!disk) return { reloaded: 0, removed: 0 };
|
|
1405
|
+
const reloaded = await syncAccountsFromDisk(disk, config, accountManager); // add / token / enable / priority
|
|
1406
|
+
const removed = reconcileRemovedAccounts(disk, config, accountManager); // removal (remove / group shrink)
|
|
1407
|
+
return { reloaded, removed };
|
|
1408
|
+
}, () => ({ reloaded: 0, removed: 0 }));
|
|
1409
|
+
_fleetApplyChain = run.then(() => {}, () => {}); // keep the chain non-rejecting
|
|
1410
|
+
return run;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1371
1413
|
// ── live cloud sync (running server ⇄ cloud) ────────────────
|
|
1372
1414
|
|
|
1373
1415
|
// One cloud-sync pass: re-pull with the stored key and reconcile the live fleet.
|
|
@@ -1385,8 +1427,7 @@ async function syncFromCloudOnce(config, accountManager, cloud, key) {
|
|
|
1385
1427
|
// empty allow-set drops this key's source and removes now-unmanaged accounts).
|
|
1386
1428
|
const src = keySource(key);
|
|
1387
1429
|
await atomicConfigUpdate(c => { mergePulledAccounts(c, [], src, []); });
|
|
1388
|
-
const
|
|
1389
|
-
const removed = disk ? reconcileRemovedAccounts(disk, config, accountManager) : 0;
|
|
1430
|
+
const { removed } = await applyDiskToFleet(config, accountManager); // via the shared chain
|
|
1390
1431
|
console.error(`[TeamClaude] Cloud auth key revoked — removed ${removed} account(s). The proxy will refuse new requests until you re-pull with a valid key.`);
|
|
1391
1432
|
return { stop: true };
|
|
1392
1433
|
}
|
|
@@ -1394,11 +1435,8 @@ async function syncFromCloudOnce(config, accountManager, cloud, key) {
|
|
|
1394
1435
|
return { stop: false };
|
|
1395
1436
|
}
|
|
1396
1437
|
await atomicConfigUpdate(c => { mergePulledAccounts(c, pulled, source, allowed); });
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
await syncAccountsFromDisk(disk, config, accountManager); // add / token-refresh / enable / priority
|
|
1400
|
-
reconcileRemovedAccounts(disk, config, accountManager); // removal (group shrink)
|
|
1401
|
-
}
|
|
1438
|
+
// Reconcile through the shared serialized chain so a concurrent /reload can't interleave.
|
|
1439
|
+
await applyDiskToFleet(config, accountManager);
|
|
1402
1440
|
return { stop: false };
|
|
1403
1441
|
}
|
|
1404
1442
|
|
|
@@ -1603,22 +1641,49 @@ async function ensureCloudSession(config) {
|
|
|
1603
1641
|
// detection and loses its push (Codex HIGH). Serialize: all concurrent callers
|
|
1604
1642
|
// share the SAME in-flight refresh instead of each rotating independently.
|
|
1605
1643
|
if (!_cloudSessionRefresh) {
|
|
1606
|
-
const rt = cloud.session.refresh_token;
|
|
1607
1644
|
_cloudSessionRefresh = (async () => {
|
|
1608
1645
|
try {
|
|
1609
|
-
|
|
1646
|
+
// #24: the owner session has ONE single-use rotating Supabase refresh token.
|
|
1647
|
+
// A running server + a concurrent CLI can both try to rotate it — one wins,
|
|
1648
|
+
// the other trips reuse detection. Rather than a cross-process FILE LOCK
|
|
1649
|
+
// (whose steal/release edge-cases proved riskier than the rare race — Codex
|
|
1650
|
+
// re-confirmed), RECOVER via freshest-wins: only when THE REFRESH ITSELF
|
|
1651
|
+
// fails (a concurrent process may have already rotated), re-read disk and, if
|
|
1652
|
+
// it holds a fresh DIFFERENT session for the SAME cloud, adopt it. Net: one
|
|
1653
|
+
// rotation lands, the loser adopts it — no cascade, no lock.
|
|
1654
|
+
let s;
|
|
1655
|
+
try {
|
|
1656
|
+
s = await cloudRefreshSession(cloud, cloud.session.refresh_token);
|
|
1657
|
+
} catch (err) {
|
|
1658
|
+
// The refresh failed. Did a concurrent process already rotate + persist a
|
|
1659
|
+
// fresh session? (Scope the recovery to the REFRESH failure only — a
|
|
1660
|
+
// successful refresh whose disk-write later fails must NOT fall in here and
|
|
1661
|
+
// re-adopt the now-consumed old session.)
|
|
1662
|
+
const disk = await loadConfig();
|
|
1663
|
+
const ds = disk?.cloud?.session;
|
|
1664
|
+
const dsExp = (Number(ds?.expires_at) || 0) * 1000;
|
|
1665
|
+
if (disk?.cloud?.url === cloud.url && ds?.access_token
|
|
1666
|
+
&& ds.refresh_token && ds.refresh_token !== cloud.session.refresh_token
|
|
1667
|
+
&& dsExp && Date.now() + 30_000 < dsExp) {
|
|
1668
|
+
cloud.session = ds; // a concurrent process already refreshed — adopt theirs
|
|
1669
|
+
return ds;
|
|
1670
|
+
}
|
|
1671
|
+
throw err; // genuinely couldn't refresh (and nobody else did)
|
|
1672
|
+
}
|
|
1673
|
+
// Refresh succeeded → the new session is authoritative. Persisting it is
|
|
1674
|
+
// best-effort: a disk-write failure must not discard the valid in-memory
|
|
1675
|
+
// session (returning it keeps this process working; it re-persists next time).
|
|
1610
1676
|
cloud.session = s;
|
|
1611
|
-
await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), session: s }; })
|
|
1677
|
+
await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), session: s }; })
|
|
1678
|
+
.catch((e) => console.error(`[TeamClaude] Failed to persist refreshed cloud session: ${e.message}`));
|
|
1612
1679
|
return s;
|
|
1613
1680
|
} finally {
|
|
1614
1681
|
_cloudSessionRefresh = null;
|
|
1615
1682
|
}
|
|
1616
1683
|
})();
|
|
1617
1684
|
}
|
|
1618
|
-
//
|
|
1619
|
-
//
|
|
1620
|
-
// done here — the codebase assumes a single proxy per config (see
|
|
1621
|
-
// atomicConfigUpdate's documented cross-process limitation).
|
|
1685
|
+
// Concurrent refreshes WITHIN this process share the single in-flight promise;
|
|
1686
|
+
// the freshest-wins re-read above absorbs a cross-PROCESS rotation race.
|
|
1622
1687
|
await _cloudSessionRefresh;
|
|
1623
1688
|
}
|
|
1624
1689
|
// If the session is (still) expired and cannot be refreshed — a Google/SSO token
|
package/src/server.js
CHANGED
|
@@ -345,6 +345,22 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
345
345
|
return;
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
// Reload endpoint — a local CLI edit (enable/disable/remove/priority) POSTs here
|
|
349
|
+
// so the change applies to the running server IMMEDIATELY instead of waiting for
|
|
350
|
+
// the next cloud-sync tick. Localhost-only (it mutates live rotation state).
|
|
351
|
+
if (req.method === 'POST' && req.url === '/teamclaude/reload') {
|
|
352
|
+
if (!isLocal) { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end('{"error":"localhost only"}'); return; }
|
|
353
|
+
try {
|
|
354
|
+
const r = (await hooks.onReload?.()) || { reloaded: 0 };
|
|
355
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
356
|
+
res.end(JSON.stringify({ ok: true, ...r }));
|
|
357
|
+
} catch (e) {
|
|
358
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
359
|
+
res.end(JSON.stringify({ error: String(e?.message || e) }));
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
348
364
|
// Everything below buffers a request body (the OAuth relay AND the proxied
|
|
349
365
|
// path) → global admission control to bound proxy memory: inFlightProxied
|
|
350
366
|
// may not exceed the fleet's useful capacity (sum of per-account caps +
|
|
@@ -1240,7 +1256,7 @@ function computeRetryAfter(accounts, threshold = 0.98) {
|
|
|
1240
1256
|
if (acct.enabled === false || acct.status === 'error') continue;
|
|
1241
1257
|
// freeAt = max(throttle, every over-threshold quota reset). The account is
|
|
1242
1258
|
// blocked until the LAST of these clears; taking the min across accounts
|
|
1243
|
-
// then gives the soonest the fleet
|
|
1259
|
+
// then gives the soonest the fleet will have capacity to serve.
|
|
1244
1260
|
let freeAt = 0;
|
|
1245
1261
|
if (acct.rateLimitedUntil) freeAt = Math.max(freeAt, new Date(acct.rateLimitedUntil).getTime());
|
|
1246
1262
|
const q = acct.quota || {};
|