teamclaude-cloud 1.4.1
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/README.md +320 -0
- package/package.json +38 -0
- package/src/account-manager.js +1271 -0
- package/src/cloud.js +618 -0
- package/src/config.js +148 -0
- package/src/index.js +1726 -0
- package/src/oauth.js +347 -0
- package/src/server.js +1267 -0
- package/src/tui.js +843 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,1726 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { unlinkSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, getServerStatePath, writeServerState, readServerState, clearServerState, readQuotaCache, writeQuotaCacheSync } from './config.js';
|
|
7
|
+
import { AccountManager } from './account-manager.js';
|
|
8
|
+
import { createProxyServer } from './server.js';
|
|
9
|
+
import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
|
|
10
|
+
import { cloudLogin, cloudRefreshSession, cloudPush, cloudPushUsage, cloudReportUsage, cloudPull, cloudRefreshToken, cloudKeyCreate, cloudKeyList, cloudKeyRevoke, cloudKeyMove, cloudGroupList, cloudGroupCreate, cloudGroupRename, cloudGroupDelete, cloudGroupSetAccounts, cloudAccountSetEnabled, buildUsageFromQuota, mergePulledAccounts, resolveCloud, DEFAULT_CLOUD, keySource, reconcileRemovedAccounts } from './cloud.js';
|
|
11
|
+
import { TUI } from './tui.js';
|
|
12
|
+
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
const command = args[0];
|
|
15
|
+
|
|
16
|
+
// This package's npm name (used by the self-update path). Declared at module top so
|
|
17
|
+
// the hoisted self-update functions can reference it even when the top-level command
|
|
18
|
+
// switch runs before their later definitions (const is NOT hoisted → avoid a TDZ error).
|
|
19
|
+
const PKG_NAME = 'teamclaude-cloud';
|
|
20
|
+
|
|
21
|
+
switch (command) {
|
|
22
|
+
case 'server':
|
|
23
|
+
await serverCommand();
|
|
24
|
+
break;
|
|
25
|
+
case 'stop':
|
|
26
|
+
await stopCommand();
|
|
27
|
+
process.exit(0);
|
|
28
|
+
break;
|
|
29
|
+
case 'restart':
|
|
30
|
+
await restartCommand();
|
|
31
|
+
break;
|
|
32
|
+
case 'run':
|
|
33
|
+
await runCommand();
|
|
34
|
+
break;
|
|
35
|
+
case 'import':
|
|
36
|
+
await importCommand();
|
|
37
|
+
process.exit(0);
|
|
38
|
+
break;
|
|
39
|
+
case 'login':
|
|
40
|
+
await loginCommand();
|
|
41
|
+
process.exit(0);
|
|
42
|
+
break;
|
|
43
|
+
case 'env':
|
|
44
|
+
await envCommand();
|
|
45
|
+
process.exit(0);
|
|
46
|
+
break;
|
|
47
|
+
case 'status':
|
|
48
|
+
await statusCommand();
|
|
49
|
+
process.exit(0);
|
|
50
|
+
break;
|
|
51
|
+
case 'accounts':
|
|
52
|
+
await accountsCommand();
|
|
53
|
+
process.exit(0);
|
|
54
|
+
break;
|
|
55
|
+
case 'remove':
|
|
56
|
+
await removeCommand();
|
|
57
|
+
process.exit(0);
|
|
58
|
+
break;
|
|
59
|
+
case 'disable':
|
|
60
|
+
await setEnabledCommand(false);
|
|
61
|
+
process.exit(0);
|
|
62
|
+
break;
|
|
63
|
+
case 'enable':
|
|
64
|
+
await setEnabledCommand(true);
|
|
65
|
+
process.exit(0);
|
|
66
|
+
break;
|
|
67
|
+
case 'priority':
|
|
68
|
+
await setPriorityCommand();
|
|
69
|
+
process.exit(0);
|
|
70
|
+
break;
|
|
71
|
+
case 'api':
|
|
72
|
+
await apiCommand();
|
|
73
|
+
process.exit(0);
|
|
74
|
+
break;
|
|
75
|
+
case 'cloud':
|
|
76
|
+
await cloudCommand();
|
|
77
|
+
process.exit(0);
|
|
78
|
+
break;
|
|
79
|
+
case 'update':
|
|
80
|
+
await updateCommand();
|
|
81
|
+
process.exit(0);
|
|
82
|
+
break;
|
|
83
|
+
case 'version':
|
|
84
|
+
case '--version':
|
|
85
|
+
case '-v':
|
|
86
|
+
console.log(currentVersion() || 'unknown');
|
|
87
|
+
process.exit(0);
|
|
88
|
+
break;
|
|
89
|
+
case 'help':
|
|
90
|
+
case '--help':
|
|
91
|
+
case '-h':
|
|
92
|
+
showHelp();
|
|
93
|
+
break;
|
|
94
|
+
default:
|
|
95
|
+
// No command or unknown command → start server
|
|
96
|
+
if (command && !command.startsWith('-')) {
|
|
97
|
+
console.error(`Unknown command: ${command}\n`);
|
|
98
|
+
showHelp();
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
await serverCommand();
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── server ──────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
async function serverCommand() {
|
|
108
|
+
const config = await loadOrCreateConfig();
|
|
109
|
+
updateNotifier(config); // non-blocking: prints a one-line notice if a newer version is on npm
|
|
110
|
+
|
|
111
|
+
// --log-to <dir>
|
|
112
|
+
const logTo = argValue('--log-to');
|
|
113
|
+
if (logTo) config.logDir = logTo;
|
|
114
|
+
|
|
115
|
+
if (config.accounts.length === 0) {
|
|
116
|
+
console.error('No accounts configured.\n');
|
|
117
|
+
console.error('Add an account first:');
|
|
118
|
+
console.error(' teamclaude import Import from Claude Code');
|
|
119
|
+
console.error(' teamclaude login OAuth login via browser');
|
|
120
|
+
console.error(' teamclaude login --api Add an API key');
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const accounts = await resolveAccounts(config);
|
|
125
|
+
if (accounts.length === 0) {
|
|
126
|
+
console.error('No valid accounts after initialization');
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const threshold = config.switchThreshold || 0.98;
|
|
131
|
+
// An explicit numeric `reevalIntervalMs: 0` (or any number <= 0) disables the
|
|
132
|
+
// 5-minute periodic account re-switching. Require a finite number so a
|
|
133
|
+
// malformed value (false, "", "abc", null, ...) falls back to the default
|
|
134
|
+
// rather than silently disabling switching.
|
|
135
|
+
const reevalIntervalMs = Number.isFinite(config.reevalIntervalMs)
|
|
136
|
+
? config.reevalIntervalMs
|
|
137
|
+
: 5 * 60 * 1000;
|
|
138
|
+
// Default per-account concurrency cap (max simultaneous in-flight requests an
|
|
139
|
+
// account handles before load spreads to the next account). A per-account
|
|
140
|
+
// `maxConcurrent` overrides this. Must be a positive number, else default 3.
|
|
141
|
+
const maxConcurrentDefault = Number.isFinite(config.maxConcurrentPerAccount) && config.maxConcurrentPerAccount >= 1
|
|
142
|
+
? config.maxConcurrentPerAccount
|
|
143
|
+
: 3;
|
|
144
|
+
// Hard cap on the overflow wait-queue (requests waiting for a free slot when
|
|
145
|
+
// every account is at its cap). Bounds memory/FDs under a request flood.
|
|
146
|
+
const overflowQueueMaxDepth = Number.isFinite(config.overflowQueueMaxDepth) && config.overflowQueueMaxDepth >= 0
|
|
147
|
+
? config.overflowQueueMaxDepth
|
|
148
|
+
: 256;
|
|
149
|
+
const accountManager = new AccountManager(accounts, threshold, reevalIntervalMs, maxConcurrentDefault, overflowQueueMaxDepth);
|
|
150
|
+
|
|
151
|
+
// Restore the last run's quota snapshot so a restart doesn't blank the
|
|
152
|
+
// dashboard (quota otherwise lives only in memory and is re-learned from
|
|
153
|
+
// traffic). Stale-safe: the proxy takes no traffic while down, expired
|
|
154
|
+
// windows are lazily swept, and a still-future throttle is re-applied.
|
|
155
|
+
const quotaCache = await readQuotaCache();
|
|
156
|
+
if (quotaCache?.accounts) {
|
|
157
|
+
accountManager.importQuotaState(quotaCache.accounts);
|
|
158
|
+
// Restore the active-account marker too (identity by name) so the sticky
|
|
159
|
+
// primary — and its warm prompt cache — carries across the restart.
|
|
160
|
+
const cur = quotaCache.currentAccount
|
|
161
|
+
&& accountManager.accounts.find(a => a.name === quotaCache.currentAccount);
|
|
162
|
+
if (cur) accountManager.currentIndex = cur.index;
|
|
163
|
+
console.log(`[TeamClaude] Restored quota snapshot for ${quotaCache.accounts.length} account(s)`);
|
|
164
|
+
}
|
|
165
|
+
const saveQuotaSnapshot = () => writeQuotaCacheSync({
|
|
166
|
+
savedAt: new Date().toISOString(),
|
|
167
|
+
currentAccount: accountManager.accounts[accountManager.currentIndex]?.name || null,
|
|
168
|
+
accounts: accountManager.exportQuotaState(),
|
|
169
|
+
// The committed warm-up probe template — persisting it lets forced
|
|
170
|
+
// re-measure (TUI R) and warm-up probes work immediately after a restart,
|
|
171
|
+
// before any traffic re-seeds a known-accepted request shape. (`server` is
|
|
172
|
+
// initialized before this ever runs: the snapshot writers are registered
|
|
173
|
+
// inside the listen callback.)
|
|
174
|
+
probeTemplate: server.exportProbeTemplate?.() ?? null,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Persist refreshed tokens back to config (re-read from disk to avoid clobbering
|
|
178
|
+
// accounts added externally, e.g. by `teamclaude import` while server is running)
|
|
179
|
+
accountManager.onTokenRefresh((idx, newTokens) => {
|
|
180
|
+
const account = accountManager.accounts[idx];
|
|
181
|
+
if (!account) return;
|
|
182
|
+
// Keep the in-memory config.accounts in sync so a TUI saveConfig doesn't
|
|
183
|
+
// clobber fresh tokens. Match by IDENTITY (UUID first, then name) — not by the
|
|
184
|
+
// AccountManager index `idx`, which can point at a different config entry when
|
|
185
|
+
// a tokenless config account was skipped at load.
|
|
186
|
+
const memIdx = findConfigAccount(config, account);
|
|
187
|
+
if (memIdx >= 0) {
|
|
188
|
+
config.accounts[memIdx].accessToken = newTokens.accessToken;
|
|
189
|
+
config.accounts[memIdx].refreshToken = newTokens.refreshToken;
|
|
190
|
+
config.accounts[memIdx].expiresAt = newTokens.expiresAt;
|
|
191
|
+
}
|
|
192
|
+
atomicConfigUpdate(diskConfig => {
|
|
193
|
+
// Persist ONLY the refreshed account's tokens. We deliberately do NOT ingest
|
|
194
|
+
// disk-only accounts here: that loop existed to keep the old INDEX-based
|
|
195
|
+
// matching aligned, but matching is identity-based now (findConfigAccount),
|
|
196
|
+
// so it's unnecessary — and re-adding every disk account would resurrect one
|
|
197
|
+
// the TUI just deleted (whose save may not have committed yet). External
|
|
198
|
+
// account discovery stays in syncAccountsFromDisk (TUI R / restart).
|
|
199
|
+
// Match by UUID first, then by name — index may have shifted.
|
|
200
|
+
const cfgIdx = findConfigAccount(diskConfig, account);
|
|
201
|
+
if (cfgIdx >= 0) {
|
|
202
|
+
diskConfig.accounts[cfgIdx].accessToken = newTokens.accessToken;
|
|
203
|
+
diskConfig.accounts[cfgIdx].refreshToken = newTokens.refreshToken;
|
|
204
|
+
diskConfig.accounts[cfgIdx].expiresAt = newTokens.expiresAt;
|
|
205
|
+
}
|
|
206
|
+
}).catch(err => console.error(`[TeamClaude] Failed to save refreshed token: ${err.message}`));
|
|
207
|
+
// Admin auto-push: if this machine can push (has a cloud login session), upload
|
|
208
|
+
// the freshly-ROTATED token to the cloud immediately. Anthropic invalidates the
|
|
209
|
+
// previous refresh token on every refresh, so a cloud snapshot from an earlier
|
|
210
|
+
// push holds a now-dead refresh token — a consumer that (re-)pulls it gets an
|
|
211
|
+
// account it can't refresh ("error"). Pushing on every rotation keeps the cloud
|
|
212
|
+
// token valid, so remove/re-add from a group restores a WORKING token, not a
|
|
213
|
+
// stale one. Best-effort: cloud problems never affect the proxy.
|
|
214
|
+
maybePushRefreshedToken(config, account, newTokens);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Cloud-centralized refresh: on a consumer (has a pull key), route token
|
|
218
|
+
// refreshes through the cloud so the SAME account used from multiple PCs is not
|
|
219
|
+
// rotated by each PC independently (which invalidates the others). The cloud does
|
|
220
|
+
// the single rotation; this PC only receives the fresh access token. Falls back
|
|
221
|
+
// to a local refresh if the cloud is unreachable (availability over strict
|
|
222
|
+
// single-authority — a brief local rotation is the accepted tradeoff).
|
|
223
|
+
if (config.cloud?.pullKey || process.env.TEAMCLAUDE_AUTH_KEY) {
|
|
224
|
+
accountManager.setRefreshHandler(async (account) => {
|
|
225
|
+
const cloud = resolveCloud(config);
|
|
226
|
+
const key = config.cloud?.pullKey || process.env.TEAMCLAUDE_AUTH_KEY;
|
|
227
|
+
if (key && account.accountUuid) {
|
|
228
|
+
try {
|
|
229
|
+
const t = await cloudRefreshToken(cloud, key, account.accountUuid);
|
|
230
|
+
return {
|
|
231
|
+
accessToken: t.accessToken,
|
|
232
|
+
refreshToken: t.refreshToken || account.refreshToken,
|
|
233
|
+
expiresAt: t.expiresAt,
|
|
234
|
+
};
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.error(`[TeamClaude] Cloud refresh failed for "${account.name}", falling back to local: ${err.message}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return refreshAccessToken(account.refreshToken); // local fallback
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const port = config.proxy.port;
|
|
244
|
+
const useTUI = process.stdout.isTTY && process.stdin.isTTY;
|
|
245
|
+
|
|
246
|
+
let tui = null;
|
|
247
|
+
let hooks = {};
|
|
248
|
+
|
|
249
|
+
if (useTUI) {
|
|
250
|
+
tui = new TUI({
|
|
251
|
+
accountManager, config,
|
|
252
|
+
saveConfig: () => atomicConfigUpdate(async diskConfig => {
|
|
253
|
+
// Write in-memory accounts as the authoritative state, preserving
|
|
254
|
+
// extra disk-only fields (e.g. importFrom) where the account still exists.
|
|
255
|
+
// Use live tokens from AccountManager (not the stale config.accounts copy).
|
|
256
|
+
const mapped = config.accounts.map(a => {
|
|
257
|
+
// Match the live account by IDENTITY — never by array index:
|
|
258
|
+
// resolveAccounts() can skip a tokenless/bad config entry, so
|
|
259
|
+
// config.accounts and accountManager.accounts are not index-aligned, and
|
|
260
|
+
// an index map would overlay the wrong account's credentials. Two-phase
|
|
261
|
+
// (UUID first, then name): a single `uuid===x || name===x` find could
|
|
262
|
+
// return an earlier same-name account before reaching the real UUID match.
|
|
263
|
+
const am = (a.accountUuid && accountManager.accounts.find(x => x.accountUuid === a.accountUuid))
|
|
264
|
+
|| accountManager.accounts.find(x => x.name === a.name);
|
|
265
|
+
const live = am ? {
|
|
266
|
+
...a,
|
|
267
|
+
accessToken: am.credential,
|
|
268
|
+
refreshToken: am.refreshToken,
|
|
269
|
+
expiresAt: am.expiresAt,
|
|
270
|
+
} : a;
|
|
271
|
+
const diskAcct = (a.accountUuid && diskConfig.accounts.find(d => d.accountUuid === a.accountUuid))
|
|
272
|
+
|| diskConfig.accounts.find(d => d.name === a.name);
|
|
273
|
+
return diskAcct ? { ...diskAcct, ...live } : live;
|
|
274
|
+
});
|
|
275
|
+
// The TUI's in-memory config is authoritative for the account SET (an
|
|
276
|
+
// account it deleted must stay deleted, not be resurrected from the disk
|
|
277
|
+
// copy this atomic update re-read). An account added to disk by an external
|
|
278
|
+
// `teamclaude import/login` while the TUI runs is reconciled on the next
|
|
279
|
+
// reload (R) / restart via syncAccountsFromDisk — not merged here, since we
|
|
280
|
+
// can't distinguish "added externally" from "deleted locally" at save time.
|
|
281
|
+
diskConfig.accounts = mapped;
|
|
282
|
+
}),
|
|
283
|
+
syncAccounts: async () => {
|
|
284
|
+
const diskConfig = await loadConfig();
|
|
285
|
+
if (!diskConfig) return 0;
|
|
286
|
+
return syncAccountsFromDisk(diskConfig, config, accountManager);
|
|
287
|
+
},
|
|
288
|
+
// R also forces a fleet-wide quota re-measure. `server` is assigned below
|
|
289
|
+
// (before listen), and the TUI only starts inside the listen callback, so
|
|
290
|
+
// this closure never runs before the binding is initialized.
|
|
291
|
+
refreshQuota: () => server.refreshQuotaAll(),
|
|
292
|
+
onQuit: () => { server.close(() => process.exit(0)); },
|
|
293
|
+
});
|
|
294
|
+
hooks = {
|
|
295
|
+
onRequestStart: (id, info) => tui.onRequestStart(id, info),
|
|
296
|
+
onRequestRouted: (id, info) => tui.onRequestRouted(id, info),
|
|
297
|
+
onRequestEnd: (id, info) => tui.onRequestEnd(id, info),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// If a TeamClaude server is already running on this config's port, don't try to
|
|
302
|
+
// bind on top of it — point the user at stop/restart instead of a raw EADDRINUSE.
|
|
303
|
+
const existing = await findRunningServer(config);
|
|
304
|
+
if (existing && existing.port === port) {
|
|
305
|
+
console.error(`[TeamClaude] A server is already running on port ${port}${existing.pid ? ` (pid ${existing.pid})` : ''}.`);
|
|
306
|
+
console.error(' See it: teamclaude status');
|
|
307
|
+
console.error(' Stop it: teamclaude stop');
|
|
308
|
+
console.error(' Restart it: teamclaude restart');
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const server = createProxyServer(accountManager, config, hooks);
|
|
313
|
+
// Restore the last run's committed probe template alongside the quota
|
|
314
|
+
// snapshot, so forced re-measure (TUI R) and warm-up probes work on a
|
|
315
|
+
// freshly restarted idle proxy — without this the template is memory-only
|
|
316
|
+
// and R reports "no request has flowed" until real traffic re-seeds it.
|
|
317
|
+
if (quotaCache?.probeTemplate && server.importProbeTemplate?.(quotaCache.probeTemplate)) {
|
|
318
|
+
console.log('[TeamClaude] Restored warm-up probe template');
|
|
319
|
+
}
|
|
320
|
+
// Catch bind-time errors (e.g. EADDRINUSE) only. Once the socket is bound we
|
|
321
|
+
// remove this handler so a later runtime 'error' isn't misreported as a
|
|
322
|
+
// listen failure and exit the whole proxy.
|
|
323
|
+
const onListenError = err => handleServerListenError(err, port);
|
|
324
|
+
server.once('error', onListenError);
|
|
325
|
+
|
|
326
|
+
server.listen(port, () => {
|
|
327
|
+
server.removeListener('error', onListenError);
|
|
328
|
+
// Record runtime state so `teamclaude status/stop/restart` can find us, and
|
|
329
|
+
// remove it on process exit (covers SIGINT/SIGTERM/TUI quit/normal exit). A
|
|
330
|
+
// SIGKILL leaves a stale file, which stop/server detect as dead and clean up.
|
|
331
|
+
writeServerState({ pid: process.pid, port, startedAt: new Date().toISOString(), config: getConfigPath() }).catch(() => {});
|
|
332
|
+
const stateP = getServerStatePath();
|
|
333
|
+
process.on('exit', () => { try { unlinkSync(stateP); } catch { /* already gone */ } });
|
|
334
|
+
// Persist the quota snapshot on every exit path (TUI quit, SIGINT/SIGTERM
|
|
335
|
+
// → server.close → process.exit) and every minute as a crash backstop
|
|
336
|
+
// (a SIGKILL loses at most the last interval). The 'exit' write is sync.
|
|
337
|
+
process.on('exit', saveQuotaSnapshot);
|
|
338
|
+
setInterval(saveQuotaSnapshot, 60_000).unref();
|
|
339
|
+
|
|
340
|
+
// Live cloud sync: if this proxy was populated from the cloud (a stored pull
|
|
341
|
+
// key), keep it in sync with the cloud while running — token/group changes and
|
|
342
|
+
// key revocation apply automatically, no restart. Polling (zero-dep); cloud
|
|
343
|
+
// outage is non-blocking (transient errors keep the last-known accounts).
|
|
344
|
+
const cloudKey = config.cloud?.pullKey || process.env.TEAMCLAUDE_AUTH_KEY;
|
|
345
|
+
const cloudSyncIntervalMs = Number.isFinite(config.cloudSyncIntervalMs)
|
|
346
|
+
? Math.max(0, config.cloudSyncIntervalMs)
|
|
347
|
+
: 30_000; // 0 disables
|
|
348
|
+
if (cloudKey && cloudSyncIntervalMs > 0) {
|
|
349
|
+
const stopCloudSync = startCloudSyncLoop(config, accountManager, resolveCloud(config), cloudKey, cloudSyncIntervalMs);
|
|
350
|
+
process.on('exit', () => { try { stopCloudSync(); } catch { /* nothing to do on exit */ } });
|
|
351
|
+
console.log(`[TeamClaude] Cloud live-sync every ${Math.round(cloudSyncIntervalMs / 1000)}s (token/group changes + key revocation apply automatically)`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Live usage sync: the proxies that actually USE the accounts report the live
|
|
355
|
+
// 5h/7d/Fable numbers they observe, so the dashboard reflects real-time usage —
|
|
356
|
+
// not only when `cloud push` is run by hand. PRIMARY path: the consumer's pull
|
|
357
|
+
// key IS the credential (same as pull/refresh) — no owner session needed.
|
|
358
|
+
// Fallback: an owner cloud session (admin machine without a key). Usage-only:
|
|
359
|
+
// never touches tokens; unknown/other-group accounts are skipped server-side.
|
|
360
|
+
// Best-effort — a cloud outage never affects the running proxy. Skips a tick
|
|
361
|
+
// when the measured usage is unchanged since the last report (idle fleet).
|
|
362
|
+
if (cloudKey || config.cloud?.session?.access_token) {
|
|
363
|
+
const usageIntervalMs = Number.isFinite(config.cloudUsagePushIntervalMs)
|
|
364
|
+
? Math.max(0, config.cloudUsagePushIntervalMs)
|
|
365
|
+
: 30_000; // 0 disables
|
|
366
|
+
if (usageIntervalMs > 0) {
|
|
367
|
+
let lastUsageJson = '';
|
|
368
|
+
const pushUsage = async () => {
|
|
369
|
+
try {
|
|
370
|
+
const usageByUuid = {};
|
|
371
|
+
for (const entry of accountManager.exportQuotaState()) {
|
|
372
|
+
if (!entry.accountUuid) continue;
|
|
373
|
+
const u = buildUsageFromQuota(entry);
|
|
374
|
+
if (u) usageByUuid[entry.accountUuid] = u;
|
|
375
|
+
}
|
|
376
|
+
if (!Object.keys(usageByUuid).length) return; // nothing measured yet
|
|
377
|
+
const snapshot = JSON.stringify(usageByUuid);
|
|
378
|
+
if (snapshot === lastUsageJson) return; // unchanged since last report — skip
|
|
379
|
+
if (cloudKey) {
|
|
380
|
+
await cloudReportUsage(resolveCloud(config), cloudKey, usageByUuid);
|
|
381
|
+
} else {
|
|
382
|
+
const cloud = await ensureCloudSession(config).catch(() => config.cloud);
|
|
383
|
+
await cloudPushUsage(cloud, usageByUuid);
|
|
384
|
+
}
|
|
385
|
+
lastUsageJson = snapshot;
|
|
386
|
+
} catch (err) {
|
|
387
|
+
console.error(`[TeamClaude] Cloud usage sync failed: ${err.message}`);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
const usageTimer = setInterval(pushUsage, usageIntervalMs);
|
|
391
|
+
usageTimer.unref();
|
|
392
|
+
process.on('exit', () => { try { clearInterval(usageTimer); } catch { /* nothing to do on exit */ } });
|
|
393
|
+
setImmediate(pushUsage); // seed once at startup so the dashboard is fresh immediately
|
|
394
|
+
console.log(`[TeamClaude] Cloud usage sync every ${Math.round(usageIntervalMs / 1000)}s via ${cloudKey ? 'auth key' : 'owner session'} (real-time usage, tokens untouched)`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (tui) {
|
|
399
|
+
tui.start();
|
|
400
|
+
console.log(`Listening on port ${port} with ${accounts.length} account(s)`);
|
|
401
|
+
} else {
|
|
402
|
+
const sep = '='.repeat(60);
|
|
403
|
+
console.log('');
|
|
404
|
+
console.log(sep);
|
|
405
|
+
console.log(' TeamClaude Proxy');
|
|
406
|
+
console.log(sep);
|
|
407
|
+
console.log(` Port: ${port}`);
|
|
408
|
+
console.log(` Accounts: ${accounts.length}`);
|
|
409
|
+
console.log(` Threshold: ${(threshold * 100).toFixed(0)}%`);
|
|
410
|
+
console.log(` Upstream: ${config.upstream || 'https://api.anthropic.com'}`);
|
|
411
|
+
console.log('');
|
|
412
|
+
accounts.forEach((a, i) => {
|
|
413
|
+
console.log(` [${i + 1}] ${a.name} (${a.type})`);
|
|
414
|
+
});
|
|
415
|
+
console.log('');
|
|
416
|
+
console.log(' Run Claude through proxy: teamclaude run');
|
|
417
|
+
console.log(' Show env vars: teamclaude env');
|
|
418
|
+
console.log(sep);
|
|
419
|
+
console.log('');
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
if (!tui) {
|
|
424
|
+
process.on('SIGINT', () => {
|
|
425
|
+
console.log('\n[TeamClaude] Shutting down...');
|
|
426
|
+
server.close(() => process.exit(0));
|
|
427
|
+
});
|
|
428
|
+
process.on('SIGTERM', () => {
|
|
429
|
+
console.log('\n[TeamClaude] Shutting down...');
|
|
430
|
+
server.close(() => process.exit(0));
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── server lifecycle: discover / stop / restart ─────────────
|
|
436
|
+
|
|
437
|
+
// Function declaration (not a const arrow) so it is hoisted — these helpers run
|
|
438
|
+
// from the top-level command switch, which executes before later `const` lines
|
|
439
|
+
// in this module are initialized (temporal dead zone).
|
|
440
|
+
function delay(ms) {
|
|
441
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Is a pid alive? EPERM = alive but not ours; ESRCH = gone. */
|
|
445
|
+
function isPidAlive(pid) {
|
|
446
|
+
if (!pid) return false;
|
|
447
|
+
try { process.kill(pid, 0); return true; }
|
|
448
|
+
catch (e) { return e.code === 'EPERM'; }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function waitForExit(pid, timeoutMs) {
|
|
452
|
+
const deadline = Date.now() + timeoutMs;
|
|
453
|
+
while (Date.now() < deadline) {
|
|
454
|
+
if (!isPidAlive(pid)) return true;
|
|
455
|
+
await delay(150);
|
|
456
|
+
}
|
|
457
|
+
return !isPidAlive(pid);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Does a *TeamClaude* proxy answer on this port? Verifies the status endpoint
|
|
462
|
+
* returns our JSON shape, not just any 200 — so a foreign process occupying the
|
|
463
|
+
* port is NOT mistaken for our server (it falls through to the EADDRINUSE path).
|
|
464
|
+
*/
|
|
465
|
+
async function probeServer(port, timeoutMs = 1500) {
|
|
466
|
+
if (!port) return false;
|
|
467
|
+
const ctrl = new AbortController();
|
|
468
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
469
|
+
try {
|
|
470
|
+
const res = await fetch(`http://127.0.0.1:${port}/teamclaude/status`, { signal: ctrl.signal });
|
|
471
|
+
if (!res.ok) return false;
|
|
472
|
+
const data = await res.json();
|
|
473
|
+
return Array.isArray(data?.accounts) && typeof data?.switchThreshold === 'number';
|
|
474
|
+
} catch { return false; }
|
|
475
|
+
finally { clearTimeout(timer); }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Best-effort: the pid listening on a TCP port (macOS/Linux via lsof). */
|
|
479
|
+
function lsofPid(port) {
|
|
480
|
+
if (process.platform === 'win32') return null;
|
|
481
|
+
try {
|
|
482
|
+
const r = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { encoding: 'utf8' });
|
|
483
|
+
const pid = parseInt((r.stdout || '').trim().split('\n')[0], 10);
|
|
484
|
+
return Number.isInteger(pid) ? pid : null;
|
|
485
|
+
} catch { return null; }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Locate a running TeamClaude server for this config's port, returning the pid
|
|
490
|
+
* that ACTUALLY owns the listening socket — never a pid taken on faith from the
|
|
491
|
+
* state file. That matters because a state file can be stale (the recorded pid
|
|
492
|
+
* died and the OS recycled it for an unrelated process) or hand-written; trusting
|
|
493
|
+
* it would let `stop` signal the wrong pid. So: confirm a TeamClaude-shaped server
|
|
494
|
+
* answers on the port, then resolve the owner via `lsof`. The state file is only a
|
|
495
|
+
* fallback for the pid when lsof can't determine it (and only if it's alive and
|
|
496
|
+
* for this same port). Returns { pid, port } (pid may be null if undeterminable),
|
|
497
|
+
* or null when nothing is listening.
|
|
498
|
+
*/
|
|
499
|
+
async function findRunningServer(config) {
|
|
500
|
+
const configPort = config?.proxy?.port;
|
|
501
|
+
const state = await readServerState();
|
|
502
|
+
|
|
503
|
+
// Try the port the server ACTUALLY bound (recorded in the state file) first —
|
|
504
|
+
// it may differ from the current config port after the config was edited, and
|
|
505
|
+
// probing only the config port would miss (and then orphan) the live server.
|
|
506
|
+
const candidates = [];
|
|
507
|
+
if (state?.port) candidates.push(state.port);
|
|
508
|
+
if (configPort && configPort !== state?.port) candidates.push(configPort);
|
|
509
|
+
|
|
510
|
+
for (const port of candidates) {
|
|
511
|
+
if (!(await probeServer(port))) continue;
|
|
512
|
+
const ownerPid = lsofPid(port); // authoritative: who actually holds the socket
|
|
513
|
+
if (ownerPid) return { pid: ownerPid, port };
|
|
514
|
+
// lsof unavailable: trust the recorded pid only if alive AND recorded for THIS port.
|
|
515
|
+
if (state?.pid && state.port === port && isPidAlive(state.pid)) return { pid: state.pid, port };
|
|
516
|
+
return { pid: null, port };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Nothing TeamClaude-shaped answers on any candidate port. Only drop the state
|
|
520
|
+
// file if its recorded pid is also gone — don't delete the discovery record for
|
|
521
|
+
// a server that's merely unreachable for a moment.
|
|
522
|
+
if (state && !(state.pid && isPidAlive(state.pid))) await clearServerState();
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Stop the running server: SIGTERM, wait for graceful exit, escalate to SIGKILL.
|
|
528
|
+
* Returns { stopped, reason?, pid?, port? }.
|
|
529
|
+
*/
|
|
530
|
+
async function stopRunningServer() {
|
|
531
|
+
const config = await loadConfig();
|
|
532
|
+
if (!config) return { stopped: false, reason: 'not-running' };
|
|
533
|
+
|
|
534
|
+
const found = await findRunningServer(config);
|
|
535
|
+
if (!found) { await clearServerState(); return { stopped: false, reason: 'not-running' }; }
|
|
536
|
+
|
|
537
|
+
const { pid, port } = found;
|
|
538
|
+
if (!pid) return { stopped: false, reason: 'no-pid', port };
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
process.kill(pid, 'SIGTERM');
|
|
542
|
+
} catch (e) {
|
|
543
|
+
if (e.code === 'ESRCH') { await clearServerState(); return { stopped: true, pid, port }; }
|
|
544
|
+
if (e.code === 'EPERM') return { stopped: false, reason: 'eperm', pid, port };
|
|
545
|
+
throw e;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (!(await waitForExit(pid, 6000))) {
|
|
549
|
+
try { process.kill(pid, 'SIGKILL'); } catch { /* may have just exited */ }
|
|
550
|
+
await waitForExit(pid, 2000);
|
|
551
|
+
}
|
|
552
|
+
if (isPidAlive(pid)) return { stopped: false, reason: 'failed', pid, port };
|
|
553
|
+
|
|
554
|
+
await clearServerState();
|
|
555
|
+
return { stopped: true, pid, port };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function stopCommand() {
|
|
559
|
+
const r = await stopRunningServer();
|
|
560
|
+
if (r.stopped) {
|
|
561
|
+
console.log(`Stopped TeamClaude server (pid ${r.pid}, port ${r.port}).`);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
switch (r.reason) {
|
|
565
|
+
case 'not-running':
|
|
566
|
+
console.log('No TeamClaude server is running.');
|
|
567
|
+
return;
|
|
568
|
+
case 'no-pid':
|
|
569
|
+
console.error(`A server is responding on port ${r.port} but its PID is unknown (lsof unavailable).`);
|
|
570
|
+
console.error(`Stop it once with: kill $(lsof -nP -iTCP:${r.port} -sTCP:LISTEN -t)`);
|
|
571
|
+
process.exit(1);
|
|
572
|
+
break;
|
|
573
|
+
case 'eperm':
|
|
574
|
+
console.error(`No permission to signal pid ${r.pid}.`);
|
|
575
|
+
process.exit(1);
|
|
576
|
+
break;
|
|
577
|
+
default:
|
|
578
|
+
console.error(`Failed to stop pid ${r.pid} on port ${r.port}.`);
|
|
579
|
+
process.exit(1);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function restartCommand() {
|
|
584
|
+
const r = await stopRunningServer();
|
|
585
|
+
if (r.stopped) {
|
|
586
|
+
console.log(`Stopped previous server (pid ${r.pid}).`);
|
|
587
|
+
} else if (r.reason !== 'not-running') {
|
|
588
|
+
console.error(`Could not stop the existing server (${r.reason}); aborting restart.`);
|
|
589
|
+
if (r.reason === 'no-pid') {
|
|
590
|
+
console.error(`Stop it manually first: kill $(lsof -nP -iTCP:${r.port} -sTCP:LISTEN -t)`);
|
|
591
|
+
}
|
|
592
|
+
process.exit(1);
|
|
593
|
+
}
|
|
594
|
+
// Wait for the port to be released before re-binding.
|
|
595
|
+
const port = (await loadConfig())?.proxy?.port;
|
|
596
|
+
for (let i = 0; i < 20 && await probeServer(port, 500); i++) await delay(150);
|
|
597
|
+
await serverCommand();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ── import ──────────────────────────────────────────────────
|
|
601
|
+
|
|
602
|
+
async function importCommand() {
|
|
603
|
+
const config = await loadOrCreateConfig();
|
|
604
|
+
|
|
605
|
+
let name = argValue('--name');
|
|
606
|
+
const jsonStr = argValue('--json');
|
|
607
|
+
|
|
608
|
+
let creds;
|
|
609
|
+
if (jsonStr) {
|
|
610
|
+
// Accept raw JSON: --json '{"claudeAiOauth":{"accessToken":"...","refreshToken":"...","expiresAt":...}}'
|
|
611
|
+
// or flat: --json '{"accessToken":"...","refreshToken":"...","expiresAt":...}'
|
|
612
|
+
try {
|
|
613
|
+
const raw = JSON.parse(jsonStr);
|
|
614
|
+
const data = raw.claudeAiOauth || raw;
|
|
615
|
+
if (!data.accessToken) {
|
|
616
|
+
console.error('JSON must contain "accessToken" (directly or under "claudeAiOauth")');
|
|
617
|
+
process.exit(1);
|
|
618
|
+
}
|
|
619
|
+
creds = {
|
|
620
|
+
accessToken: data.accessToken,
|
|
621
|
+
refreshToken: data.refreshToken,
|
|
622
|
+
expiresAt: data.expiresAt,
|
|
623
|
+
};
|
|
624
|
+
} catch (err) {
|
|
625
|
+
console.error(`Failed to parse --json: ${err.message}`);
|
|
626
|
+
process.exit(1);
|
|
627
|
+
}
|
|
628
|
+
} else {
|
|
629
|
+
const fromPath = argValue('--from') || '~/.claude/.credentials.json';
|
|
630
|
+
try {
|
|
631
|
+
creds = await importCredentials(fromPath);
|
|
632
|
+
} catch (err) {
|
|
633
|
+
console.error(`Failed to import from ${fromPath}: ${err.message}`);
|
|
634
|
+
process.exit(1);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
await upsertOAuthAccount(config, name, creds, 'import');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ── login ───────────────────────────────────────────────────
|
|
642
|
+
|
|
643
|
+
async function loginCommand() {
|
|
644
|
+
if (args.includes('--api')) {
|
|
645
|
+
await loginApiCommand();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (args.includes('--oauth')) {
|
|
649
|
+
await loginOAuthCommand();
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Default to OAuth if not a TTY
|
|
654
|
+
if (!process.stdout.isTTY) {
|
|
655
|
+
await loginOAuthCommand();
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Interactive menu
|
|
660
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
661
|
+
console.log('Select login method:\n');
|
|
662
|
+
console.log(' 1. Claude subscription (Pro, Max, Team, Enterprise)');
|
|
663
|
+
console.log(' 2. Anthropic API key (Console API billing)');
|
|
664
|
+
console.log('');
|
|
665
|
+
const choice = await new Promise(resolve => rl.question('Choice [1]: ', resolve));
|
|
666
|
+
rl.close();
|
|
667
|
+
|
|
668
|
+
switch (choice.trim() || '1') {
|
|
669
|
+
case '1': await loginOAuthCommand(); break;
|
|
670
|
+
case '2': await loginApiCommand(); break;
|
|
671
|
+
default:
|
|
672
|
+
console.error(`Invalid choice: ${choice.trim()}`);
|
|
673
|
+
process.exit(1);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function loginApiCommand() {
|
|
678
|
+
const config = await loadOrCreateConfig();
|
|
679
|
+
let name = argValue('--name');
|
|
680
|
+
|
|
681
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
682
|
+
const apiKey = await new Promise(resolve => rl.question('Anthropic API key: ', resolve));
|
|
683
|
+
rl.close();
|
|
684
|
+
|
|
685
|
+
if (!apiKey.trim()) {
|
|
686
|
+
console.error('No API key provided');
|
|
687
|
+
process.exit(1);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (!name) {
|
|
691
|
+
// First FREE api-N (not `count + 1`, which collides after a delete) — a unique
|
|
692
|
+
// name is the identity key for credential-less API-key accounts.
|
|
693
|
+
let n = 1;
|
|
694
|
+
do { name = `api-${n++}`; } while (config.accounts.some(a => a.name === name));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
config.accounts.push({ name, type: 'apikey', apiKey: apiKey.trim() });
|
|
698
|
+
await saveConfig(config);
|
|
699
|
+
console.log(`Added API key account "${name}"`);
|
|
700
|
+
console.log(`Saved to ${getConfigPath()}`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function loginOAuthCommand() {
|
|
704
|
+
const config = await loadOrCreateConfig();
|
|
705
|
+
let name = argValue('--name');
|
|
706
|
+
|
|
707
|
+
console.log('Starting OAuth login...');
|
|
708
|
+
let creds;
|
|
709
|
+
try {
|
|
710
|
+
creds = await loginOAuth();
|
|
711
|
+
} catch (err) {
|
|
712
|
+
console.error(`OAuth login failed: ${err.message}`);
|
|
713
|
+
console.error('');
|
|
714
|
+
console.error('Alternatives:');
|
|
715
|
+
console.error(' teamclaude import Import from existing Claude Code credentials');
|
|
716
|
+
console.error(' teamclaude login --api Add an API key instead');
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
await upsertOAuthAccount(config, name, creds, 'login');
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ── env ─────────────────────────────────────────────────────
|
|
724
|
+
|
|
725
|
+
async function envCommand() {
|
|
726
|
+
const config = await loadOrCreateConfig();
|
|
727
|
+
console.log(`export ANTHROPIC_BASE_URL=http://localhost:${config.proxy.port}`);
|
|
728
|
+
console.log(`export ANTHROPIC_API_KEY=${config.proxy.apiKey}`);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ── run ─────────────────────────────────────────────────────
|
|
732
|
+
|
|
733
|
+
async function runCommand() {
|
|
734
|
+
const config = await loadOrCreateConfig();
|
|
735
|
+
|
|
736
|
+
// Everything after 'run' (skip -- separator if present)
|
|
737
|
+
const claudeArgs = args.slice(1);
|
|
738
|
+
if (claudeArgs[0] === '--') claudeArgs.shift();
|
|
739
|
+
|
|
740
|
+
// Only set ANTHROPIC_BASE_URL — Claude Code keeps its own OAuth token
|
|
741
|
+
// which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
|
|
742
|
+
// lets Claude Code stay in subscription mode (full model access).
|
|
743
|
+
// Use spawnSync so the Node process blocks entirely — behaves like execvp.
|
|
744
|
+
const result = spawnSync('claude', claudeArgs, {
|
|
745
|
+
stdio: 'inherit',
|
|
746
|
+
env: {
|
|
747
|
+
...process.env,
|
|
748
|
+
ANTHROPIC_BASE_URL: `http://localhost:${config.proxy.port}`,
|
|
749
|
+
},
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
if (result.error) {
|
|
753
|
+
if (result.error.code === 'ENOENT') {
|
|
754
|
+
console.error('Claude Code not found in PATH. Install it first.');
|
|
755
|
+
} else {
|
|
756
|
+
console.error(`Failed to start claude: ${result.error.message}`);
|
|
757
|
+
}
|
|
758
|
+
process.exit(1);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
process.exit(result.status ?? 1);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ── status ──────────────────────────────────────────────────
|
|
765
|
+
|
|
766
|
+
async function statusCommand() {
|
|
767
|
+
const config = await loadOrCreateConfig();
|
|
768
|
+
// Locate the actual running server (its bound port may differ from the current
|
|
769
|
+
// config port after an edit). findRunningServer handles stale-state cleanup; do
|
|
770
|
+
// NOT clear state here, or a momentary blip would orphan a live server.
|
|
771
|
+
const running = await findRunningServer(config);
|
|
772
|
+
if (!running) {
|
|
773
|
+
console.log(`Server: not running (no proxy on port ${config.proxy.port})`);
|
|
774
|
+
console.log('Start it with: teamclaude server');
|
|
775
|
+
process.exit(1);
|
|
776
|
+
}
|
|
777
|
+
const url = `http://127.0.0.1:${running.port}/teamclaude/status`;
|
|
778
|
+
|
|
779
|
+
try {
|
|
780
|
+
const res = await fetch(url, { headers: { 'x-api-key': config.proxy.apiKey } });
|
|
781
|
+
const data = await res.json();
|
|
782
|
+
|
|
783
|
+
const pidStr = running.pid ? `pid ${running.pid}, ` : '';
|
|
784
|
+
console.log(`Server: running (${pidStr}port ${running.port})`);
|
|
785
|
+
console.log(`Active account: ${data.currentAccount}`);
|
|
786
|
+
console.log(`Switch at: ${(data.switchThreshold * 100).toFixed(0)}% usage\n`);
|
|
787
|
+
|
|
788
|
+
for (const acct of data.accounts) {
|
|
789
|
+
const q = acct.quota;
|
|
790
|
+
const current = acct.name === data.currentAccount ? ' *' : '';
|
|
791
|
+
|
|
792
|
+
const disabledTag = acct.enabled === false ? ' [disabled]' : '';
|
|
793
|
+
console.log(` ${acct.name} (${acct.type})${current}${disabledTag}`);
|
|
794
|
+
console.log(` Status: ${acct.status}${acct.enabled === false ? ' (disabled — out of rotation)' : ''}`);
|
|
795
|
+
if (acct.priority != null) console.log(` Priority: ${acct.priority} (lower = preferred)`);
|
|
796
|
+
if (acct.maxConcurrent != null) {
|
|
797
|
+
console.log(` In flight: ${acct.inflight ?? 0}/${acct.maxConcurrent} concurrent`);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (q.unified5h != null || q.unified7d != null || Object.keys(q.modelWeekly ?? {}).length > 0) {
|
|
801
|
+
const ses = q.unified5h != null ? (q.unified5h * 100).toFixed(1) + '%' : '-';
|
|
802
|
+
const wk = q.unified7d != null ? (q.unified7d * 100).toFixed(1) + '%' : '-';
|
|
803
|
+
let line = ` Session: ${ses} used Weekly: ${wk} used`;
|
|
804
|
+
// Model-scoped weekly windows (7d_oi = the Fable weekly limit). Guarded
|
|
805
|
+
// with ?. so a status from an older running server (no modelWeekly
|
|
806
|
+
// field) still prints. Unknown labels print as-is.
|
|
807
|
+
for (const [label, w] of Object.entries(q.modelWeekly ?? {})) {
|
|
808
|
+
if (w?.utilization == null) continue;
|
|
809
|
+
const name = label === '7d_oi' ? 'Fable wk' : label;
|
|
810
|
+
line += ` ${name}: ${(w.utilization * 100).toFixed(1)}% used`;
|
|
811
|
+
}
|
|
812
|
+
console.log(line);
|
|
813
|
+
} else {
|
|
814
|
+
const tok = q.tokensLimit ? ((1 - q.tokensRemaining / q.tokensLimit) * 100).toFixed(1) + '%' : '-';
|
|
815
|
+
const req = q.requestsLimit ? ((1 - q.requestsRemaining / q.requestsLimit) * 100).toFixed(1) + '%' : '-';
|
|
816
|
+
console.log(` Tokens: ${tok} used Requests: ${req} used`);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
console.log(` Total: ${acct.usage.totalInputTokens + acct.usage.totalOutputTokens} tokens, ${acct.usage.totalRequests} requests`);
|
|
820
|
+
if (acct.rateLimitedUntil) console.log(` Throttled until: ${acct.rateLimitedUntil}`);
|
|
821
|
+
console.log('');
|
|
822
|
+
}
|
|
823
|
+
} catch {
|
|
824
|
+
// findRunningServer just confirmed a server answered; a failure here is a
|
|
825
|
+
// transient blip, not a reason to delete the discovery record.
|
|
826
|
+
console.log(`Server: unreachable (port ${running.port}) — try again`);
|
|
827
|
+
process.exit(1);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// ── accounts ────────────────────────────────────────────────
|
|
832
|
+
|
|
833
|
+
async function accountsCommand() {
|
|
834
|
+
const config = await loadOrCreateConfig();
|
|
835
|
+
const verbose = args.includes('-v') || args.includes('--verbose');
|
|
836
|
+
|
|
837
|
+
if (config.accounts.length === 0) {
|
|
838
|
+
console.log('No accounts configured.');
|
|
839
|
+
console.log('Add one with: teamclaude import, teamclaude login, or teamclaude login --api');
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Refresh expired tokens before fetching profiles
|
|
844
|
+
let configDirty = false;
|
|
845
|
+
await Promise.all(config.accounts.map(async (a) => {
|
|
846
|
+
if (a.type !== 'oauth' || !a.refreshToken) return;
|
|
847
|
+
if (!isTokenExpiringSoon(a.expiresAt)) return;
|
|
848
|
+
try {
|
|
849
|
+
const newTokens = await refreshAccessToken(a.refreshToken);
|
|
850
|
+
a.accessToken = newTokens.accessToken;
|
|
851
|
+
a.refreshToken = newTokens.refreshToken;
|
|
852
|
+
a.expiresAt = newTokens.expiresAt;
|
|
853
|
+
configDirty = true;
|
|
854
|
+
} catch (err) {
|
|
855
|
+
// refresh failed — fetchProfile will report the specific error
|
|
856
|
+
}
|
|
857
|
+
}));
|
|
858
|
+
if (configDirty) await saveConfig(config);
|
|
859
|
+
|
|
860
|
+
// Fetch profiles in parallel for all OAuth accounts
|
|
861
|
+
const profiles = await Promise.all(
|
|
862
|
+
config.accounts.map(a =>
|
|
863
|
+
a.type === 'oauth' && a.accessToken ? fetchProfile(a.accessToken) : null
|
|
864
|
+
)
|
|
865
|
+
);
|
|
866
|
+
|
|
867
|
+
// Deduplicate by accountUuid — keep the last (most recently added) entry
|
|
868
|
+
const seen = new Map();
|
|
869
|
+
let removed = 0;
|
|
870
|
+
for (let i = config.accounts.length - 1; i >= 0; i--) {
|
|
871
|
+
const a = config.accounts[i];
|
|
872
|
+
const uuid = profiles[i]?.accountUuid || a.accountUuid;
|
|
873
|
+
if (uuid) {
|
|
874
|
+
if (seen.has(uuid)) {
|
|
875
|
+
config.accounts.splice(i, 1);
|
|
876
|
+
profiles.splice(i, 1);
|
|
877
|
+
removed++;
|
|
878
|
+
} else {
|
|
879
|
+
seen.set(uuid, i);
|
|
880
|
+
// Update stored UUID and name from profile
|
|
881
|
+
if (profiles[i] && !profiles[i].error) {
|
|
882
|
+
a.accountUuid = profiles[i].accountUuid;
|
|
883
|
+
if (profiles[i].email) a.name = profiles[i].email;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (removed > 0) {
|
|
889
|
+
await saveConfig(config);
|
|
890
|
+
console.log(`Removed ${removed} duplicate account(s)\n`);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
for (const [i, a] of config.accounts.entries()) {
|
|
894
|
+
const p = profiles[i];
|
|
895
|
+
|
|
896
|
+
if (a.type === 'apikey') {
|
|
897
|
+
console.log(` [${i + 1}] ${a.name} (apikey) ${a.apiKey?.slice(0, 15)}...`);
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// OAuth account
|
|
902
|
+
const hasProfile = p && !p.error;
|
|
903
|
+
const tier = hasProfile ? (p.hasClaudeMax ? 'Max' : p.hasClaudePro ? 'Pro' : 'subscription') : null;
|
|
904
|
+
const status = hasProfile ? `Claude ${tier}` : `unknown (${p?.error || 'no token'})`;
|
|
905
|
+
const src = a.source ? `, ${a.source}` : '';
|
|
906
|
+
console.log(` [${i + 1}] ${a.name} (${status}${src})`);
|
|
907
|
+
if (hasProfile && p.email && p.email !== a.name) console.log(` Email: ${p.email}`);
|
|
908
|
+
if (hasProfile && p.orgName) console.log(` Org: ${p.orgName}`);
|
|
909
|
+
if (verbose && a.expiresAt) {
|
|
910
|
+
const remaining = a.expiresAt - Date.now();
|
|
911
|
+
if (remaining <= 0) {
|
|
912
|
+
console.log(` Token: expired`);
|
|
913
|
+
} else {
|
|
914
|
+
const mins = Math.floor(remaining / 60000);
|
|
915
|
+
const hrs = Math.floor(mins / 60);
|
|
916
|
+
const expiry = hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
|
917
|
+
console.log(` Token: expires in ${expiry}`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// ── api ─────────────────────────────────────────────────────
|
|
924
|
+
|
|
925
|
+
async function apiCommand() {
|
|
926
|
+
const config = await loadOrCreateConfig();
|
|
927
|
+
const path = args[1];
|
|
928
|
+
|
|
929
|
+
if (!path) {
|
|
930
|
+
console.error('Usage: teamclaude api <path> [--account NAME] [--method POST] [--data JSON]');
|
|
931
|
+
console.error('Example: teamclaude api /api/oauth/claude_cli/roles');
|
|
932
|
+
process.exit(1);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Find account to use
|
|
936
|
+
const accountName = argValue('--account');
|
|
937
|
+
const method = (argValue('--method') || 'GET').toUpperCase();
|
|
938
|
+
const data = argValue('--data');
|
|
939
|
+
|
|
940
|
+
const accounts = await resolveAccounts(config);
|
|
941
|
+
let account;
|
|
942
|
+
if (accountName) {
|
|
943
|
+
account = accounts.find(a => a.name === accountName);
|
|
944
|
+
if (!account) { console.error(`Account "${accountName}" not found`); process.exit(1); }
|
|
945
|
+
} else {
|
|
946
|
+
account = accounts.find(a => a.type === 'oauth') || accounts[0];
|
|
947
|
+
if (!account) { console.error('No accounts configured'); process.exit(1); }
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const credential = account.accessToken || account.apiKey;
|
|
951
|
+
const isOAuth = account.type === 'oauth';
|
|
952
|
+
const upstream = config.upstream || 'https://api.anthropic.com';
|
|
953
|
+
const url = path.startsWith('http') ? path : `${upstream}${path}`;
|
|
954
|
+
|
|
955
|
+
const headers = isOAuth
|
|
956
|
+
? { 'Authorization': `Bearer ${credential}` }
|
|
957
|
+
: { 'x-api-key': credential };
|
|
958
|
+
|
|
959
|
+
const fetchOpts = { method, headers };
|
|
960
|
+
if (data) {
|
|
961
|
+
headers['Content-Type'] = 'application/json';
|
|
962
|
+
fetchOpts.body = data;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const res = await fetch(url, fetchOpts);
|
|
966
|
+
|
|
967
|
+
// Print response headers to stderr
|
|
968
|
+
console.error(`${res.status} ${res.statusText}`);
|
|
969
|
+
for (const [k, v] of res.headers.entries()) {
|
|
970
|
+
console.error(` ${k}: ${v}`);
|
|
971
|
+
}
|
|
972
|
+
console.error('');
|
|
973
|
+
|
|
974
|
+
// Print body to stdout
|
|
975
|
+
const body = await res.text();
|
|
976
|
+
try {
|
|
977
|
+
console.log(JSON.stringify(JSON.parse(body), null, 2));
|
|
978
|
+
} catch {
|
|
979
|
+
console.log(body);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ── remove ──────────────────────────────────────────────────
|
|
984
|
+
|
|
985
|
+
async function removeCommand() {
|
|
986
|
+
const config = await loadOrCreateConfig();
|
|
987
|
+
const name = args[1];
|
|
988
|
+
|
|
989
|
+
if (!name) {
|
|
990
|
+
console.error('Usage: teamclaude remove <account-name>');
|
|
991
|
+
process.exit(1);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const idx = config.accounts.findIndex(a => a.name === name);
|
|
995
|
+
if (idx < 0) {
|
|
996
|
+
console.error(`Account "${name}" not found`);
|
|
997
|
+
process.exit(1);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
config.accounts.splice(idx, 1);
|
|
1001
|
+
await saveConfig(config);
|
|
1002
|
+
console.log(`Removed account "${name}"`);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// ── enable / disable / priority ─────────────────────────────
|
|
1006
|
+
|
|
1007
|
+
/** Note that changes apply to a running server only after a reload/restart. */
|
|
1008
|
+
function noteRunningServerReload(config) {
|
|
1009
|
+
return findRunningServer(config).then(running => {
|
|
1010
|
+
if (running) {
|
|
1011
|
+
console.log('A server is running — apply now with: teamclaude restart');
|
|
1012
|
+
console.log(' (or press "R" in the TUI to reload from config).');
|
|
1013
|
+
}
|
|
1014
|
+
}).catch(() => {});
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
async function setEnabledCommand(enabled) {
|
|
1018
|
+
const name = args[1];
|
|
1019
|
+
if (!name) {
|
|
1020
|
+
console.error(`Usage: teamclaude ${enabled ? 'enable' : 'disable'} <account-name>`);
|
|
1021
|
+
process.exit(1);
|
|
1022
|
+
}
|
|
1023
|
+
// atomicConfigUpdate re-reads disk before writing, so a concurrent token
|
|
1024
|
+
// refresh from the running server (or another CLI edit) isn't clobbered by a
|
|
1025
|
+
// stale snapshot. Match by name (what the user typed) within the fresh copy.
|
|
1026
|
+
let found = false;
|
|
1027
|
+
const config = await atomicConfigUpdate(cfg => {
|
|
1028
|
+
const acct = cfg.accounts.find(a => a.name === name);
|
|
1029
|
+
if (acct) { acct.enabled = enabled; found = true; }
|
|
1030
|
+
});
|
|
1031
|
+
if (!found) { console.error(`Account "${name}" not found`); process.exit(1); }
|
|
1032
|
+
console.log(`${enabled ? 'Enabled' : 'Disabled'} account "${name}"`);
|
|
1033
|
+
if (!enabled) console.log(' (excluded from active rotation; in-flight requests still finish)');
|
|
1034
|
+
await noteRunningServerReload(config);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
async function setPriorityCommand() {
|
|
1038
|
+
const name = args[1];
|
|
1039
|
+
const raw = args[2];
|
|
1040
|
+
if (!name || raw === undefined) {
|
|
1041
|
+
console.error('Usage: teamclaude priority <account-name> <number|auto>');
|
|
1042
|
+
console.error(' Lower number = preferred first. Use "auto" (or "clear") to return the');
|
|
1043
|
+
console.error(' account to automatic ordering: weekly reset soonest is drained first.');
|
|
1044
|
+
process.exit(1);
|
|
1045
|
+
}
|
|
1046
|
+
const clearing = raw === 'auto' || raw === 'clear' || raw === 'none' || raw === 'null';
|
|
1047
|
+
let value = null;
|
|
1048
|
+
if (!clearing) {
|
|
1049
|
+
const n = Number(raw);
|
|
1050
|
+
if (!Number.isFinite(n)) { console.error(`Invalid priority "${raw}" — expected a number or "auto"`); process.exit(1); }
|
|
1051
|
+
value = Math.floor(n);
|
|
1052
|
+
}
|
|
1053
|
+
let found = false;
|
|
1054
|
+
const config = await atomicConfigUpdate(cfg => {
|
|
1055
|
+
const acct = cfg.accounts.find(a => a.name === name);
|
|
1056
|
+
if (acct) { found = true; if (clearing) delete acct.priority; else acct.priority = value; }
|
|
1057
|
+
});
|
|
1058
|
+
if (!found) { console.error(`Account "${name}" not found`); process.exit(1); }
|
|
1059
|
+
console.log(clearing
|
|
1060
|
+
? `Set "${name}" to auto (use-or-lose ordering: weekly reset soonest first)`
|
|
1061
|
+
: `Set priority of "${name}" to ${value} (lower = preferred first)`);
|
|
1062
|
+
await noteRunningServerReload(config);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// ── help ────────────────────────────────────────────────────
|
|
1066
|
+
|
|
1067
|
+
function showHelp() {
|
|
1068
|
+
console.log(`TeamClaude - Multi-account Claude proxy
|
|
1069
|
+
|
|
1070
|
+
Usage: teamclaude [command] [options]
|
|
1071
|
+
|
|
1072
|
+
Commands:
|
|
1073
|
+
server Start the proxy server (default)
|
|
1074
|
+
stop Stop the running proxy server
|
|
1075
|
+
restart Stop the running server (if any) and start a fresh one
|
|
1076
|
+
import Import credentials from Claude Code
|
|
1077
|
+
login OAuth login via browser
|
|
1078
|
+
login --api Add an API key account
|
|
1079
|
+
env Print env vars to use with Claude
|
|
1080
|
+
run [-- args...] Run Claude Code through the proxy
|
|
1081
|
+
status Show proxy & account status (live)
|
|
1082
|
+
accounts List configured accounts
|
|
1083
|
+
remove <name> Remove an account
|
|
1084
|
+
disable <name> Disable an account (excluded from rotation)
|
|
1085
|
+
enable <name> Re-enable a disabled account
|
|
1086
|
+
priority <name> <n> Set selection priority (lower = preferred; "auto" to return
|
|
1087
|
+
to automatic ordering — weekly reset soonest drained first)
|
|
1088
|
+
api <path> Call an API endpoint with account credentials
|
|
1089
|
+
cloud <sub> Cloud SaaS: sync tokens + group/auth-key based token control
|
|
1090
|
+
cloud login --url <u> --anon-key <k> --email <e> [--password <p>]
|
|
1091
|
+
cloud push Push local tokens (+usage snapshot) to the cloud
|
|
1092
|
+
cloud pull --key <auth-key> Pull the key's group's tokens into local config
|
|
1093
|
+
cloud key create [--group g] | list | revoke <id> | move <id> --group <g>
|
|
1094
|
+
cloud group list | create <n> | rename <n> --to <n2> | delete <n>
|
|
1095
|
+
| allow <n> --accounts uuid1,uuid2 (order = priority)
|
|
1096
|
+
cloud account enable|disable <accountUuid|localName> (global)
|
|
1097
|
+
cloud status
|
|
1098
|
+
update Update to the latest published version (npm)
|
|
1099
|
+
version Print the installed version
|
|
1100
|
+
help Show this help
|
|
1101
|
+
|
|
1102
|
+
Options:
|
|
1103
|
+
--name NAME Set account name (import/login)
|
|
1104
|
+
--from PATH Credentials path (import, default: ~/.claude/.credentials.json)
|
|
1105
|
+
--json JSON Import from inline JSON (import), e.g.:
|
|
1106
|
+
--json '{"accessToken":"...","refreshToken":"...","expiresAt":1234}'
|
|
1107
|
+
--log-to DIR Log full requests/responses to DIR (server, one file per request)
|
|
1108
|
+
|
|
1109
|
+
Config: ${getConfigPath()}
|
|
1110
|
+
`);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ── shared account upsert ────────────────────────────────────
|
|
1114
|
+
|
|
1115
|
+
async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
|
|
1116
|
+
// Fetch profile to auto-name and deduplicate by account UUID
|
|
1117
|
+
const profile = await fetchProfile(creds.accessToken);
|
|
1118
|
+
const profileOk = profile && !profile.error;
|
|
1119
|
+
|
|
1120
|
+
if (!profileOk) {
|
|
1121
|
+
console.error(`Warning: could not fetch account profile — ${profile?.error || 'no token'}`);
|
|
1122
|
+
}
|
|
1123
|
+
if (!name && profile?.email) {
|
|
1124
|
+
name = profile.email;
|
|
1125
|
+
const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
|
|
1126
|
+
if (tier) console.log(`Detected Claude ${tier} account: ${profile.email}`);
|
|
1127
|
+
}
|
|
1128
|
+
if (!name) {
|
|
1129
|
+
// First FREE account-N (not `count + 1`, which collides after a delete) so the
|
|
1130
|
+
// generated name stays a unique identity key.
|
|
1131
|
+
let n = 1;
|
|
1132
|
+
do { name = `account-${n++}`; } while (config.accounts.some(a => a.name === name));
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const account = {
|
|
1136
|
+
name,
|
|
1137
|
+
type: 'oauth',
|
|
1138
|
+
source,
|
|
1139
|
+
accountUuid: profile?.accountUuid || null,
|
|
1140
|
+
accessToken: creds.accessToken,
|
|
1141
|
+
refreshToken: creds.refreshToken,
|
|
1142
|
+
expiresAt: creds.expiresAt,
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
// Deduplicate: match by UUID first, then by name
|
|
1146
|
+
let idx = profile?.accountUuid
|
|
1147
|
+
? config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
|
|
1148
|
+
: -1;
|
|
1149
|
+
if (idx < 0) idx = config.accounts.findIndex(a => a.name === name);
|
|
1150
|
+
|
|
1151
|
+
if (idx >= 0) {
|
|
1152
|
+
// Re-credentialing an existing account must not wipe its manual routing
|
|
1153
|
+
// settings — carry enabled/priority over from the entry being replaced.
|
|
1154
|
+
const prev = config.accounts[idx];
|
|
1155
|
+
if (prev.enabled !== undefined) account.enabled = prev.enabled;
|
|
1156
|
+
if (prev.priority !== undefined) account.priority = prev.priority;
|
|
1157
|
+
config.accounts[idx] = account;
|
|
1158
|
+
console.log(`Updated account "${name}"`);
|
|
1159
|
+
} else {
|
|
1160
|
+
config.accounts.push(account);
|
|
1161
|
+
console.log(`Added account "${name}"`);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
await saveConfig(config);
|
|
1165
|
+
console.log(`Saved to ${getConfigPath()}`);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// ── config sync helpers ─────────────────────────────────────
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Find a config account entry matching an in-memory account (by UUID, then name).
|
|
1172
|
+
*/
|
|
1173
|
+
function findConfigAccount(diskConfig, account) {
|
|
1174
|
+
if (account.accountUuid) {
|
|
1175
|
+
const idx = diskConfig.accounts.findIndex(a => a.accountUuid === account.accountUuid);
|
|
1176
|
+
if (idx >= 0) return idx;
|
|
1177
|
+
}
|
|
1178
|
+
return diskConfig.accounts.findIndex(a => a.name === account.name);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Sync accounts from disk config: add new accounts and refresh credentials
|
|
1183
|
+
* for existing ones (handles re-imported OAuth tokens, rotated API keys, etc.).
|
|
1184
|
+
* Returns the number of new accounts added.
|
|
1185
|
+
*/
|
|
1186
|
+
async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
|
|
1187
|
+
let added = 0;
|
|
1188
|
+
for (const diskAcct of diskConfig.accounts) {
|
|
1189
|
+
const matchByUuid = diskAcct.accountUuid &&
|
|
1190
|
+
memConfig.accounts.findIndex(a => a.accountUuid === diskAcct.accountUuid);
|
|
1191
|
+
const matchByName = memConfig.accounts.findIndex(a => a.name === diskAcct.name);
|
|
1192
|
+
const memIdx = (matchByUuid >= 0 ? matchByUuid : null) ?? (matchByName >= 0 ? matchByName : -1);
|
|
1193
|
+
|
|
1194
|
+
if (memIdx < 0) {
|
|
1195
|
+
// New account discovered on disk — add to running server
|
|
1196
|
+
memConfig.accounts.push(diskAcct);
|
|
1197
|
+
accountManager.addAccount(diskAcct);
|
|
1198
|
+
added++;
|
|
1199
|
+
console.log(`[TeamClaude] Picked up new account "${diskAcct.name}" from config`);
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Find the corresponding AccountManager entry — UUID first, then name, so a
|
|
1204
|
+
// disk entry whose UUID and name resolve to *different* live accounts can't
|
|
1205
|
+
// misattribute the update to the name-match when a UUID-match exists.
|
|
1206
|
+
const mgr = (diskAcct.accountUuid && accountManager.accounts.find(a => a.accountUuid === diskAcct.accountUuid))
|
|
1207
|
+
|| accountManager.accounts.find(a => a.name === diskAcct.name);
|
|
1208
|
+
|
|
1209
|
+
// Apply enable/disable + priority from disk FIRST — independent of credential
|
|
1210
|
+
// re-resolution below. A failed re-import (freshCred null) must NOT strand a
|
|
1211
|
+
// `teamclaude disable`/`priority` set while the server runs. setEnabled drains
|
|
1212
|
+
// the overflow queue when re-enabling so a freed-up account is used at once.
|
|
1213
|
+
if (mgr) {
|
|
1214
|
+
const wantEnabled = diskAcct.enabled !== false;
|
|
1215
|
+
if (mgr.enabled !== wantEnabled) accountManager.setEnabled(mgr, wantEnabled);
|
|
1216
|
+
const diskPriority = Number.isFinite(diskAcct.priority) ? Math.floor(diskAcct.priority) : null;
|
|
1217
|
+
if (mgr.priority !== diskPriority) accountManager.setPriority(mgr, diskPriority);
|
|
1218
|
+
// Mirror the applied state into the in-memory config copy too. Otherwise a
|
|
1219
|
+
// later TUI saveConfig (for any unrelated op) would spread the pre-sync
|
|
1220
|
+
// enabled/priority over the disk value and silently revert a CLI change.
|
|
1221
|
+
const memAcct = memConfig.accounts[memIdx];
|
|
1222
|
+
if (memAcct) {
|
|
1223
|
+
if (wantEnabled) delete memAcct.enabled; else memAcct.enabled = false;
|
|
1224
|
+
if (diskPriority === null) delete memAcct.priority; else memAcct.priority = diskPriority;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// Existing account — resolve fresh credentials from disk
|
|
1229
|
+
let freshCred = null;
|
|
1230
|
+
if (diskAcct.type === 'oauth' && diskAcct.importFrom) {
|
|
1231
|
+
try {
|
|
1232
|
+
const creds = await importCredentials(diskAcct.importFrom);
|
|
1233
|
+
freshCred = { accessToken: creds.accessToken, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt };
|
|
1234
|
+
} catch (err) {
|
|
1235
|
+
console.error(`[TeamClaude] Re-import failed for "${diskAcct.name}": ${err.message}`);
|
|
1236
|
+
}
|
|
1237
|
+
} else if (diskAcct.type === 'oauth' && diskAcct.accessToken) {
|
|
1238
|
+
freshCred = { accessToken: diskAcct.accessToken, refreshToken: diskAcct.refreshToken, expiresAt: diskAcct.expiresAt };
|
|
1239
|
+
} else if (diskAcct.type === 'apikey' && diskAcct.apiKey) {
|
|
1240
|
+
freshCred = { apiKey: diskAcct.apiKey };
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
if (!freshCred || !mgr) continue;
|
|
1244
|
+
|
|
1245
|
+
if (freshCred.accessToken) {
|
|
1246
|
+
const changed = mgr.credential !== freshCred.accessToken ||
|
|
1247
|
+
mgr.refreshToken !== freshCred.refreshToken;
|
|
1248
|
+
// Don't overwrite in-memory credentials with staler ones from disk
|
|
1249
|
+
// (e.g. after a TUI import updated the AM before saveConfig wrote to disk)
|
|
1250
|
+
const diskIsStaler = freshCred.expiresAt && mgr.expiresAt &&
|
|
1251
|
+
freshCred.expiresAt < mgr.expiresAt;
|
|
1252
|
+
if (changed && !diskIsStaler) {
|
|
1253
|
+
accountManager.updateAccountTokens(mgr.index, freshCred);
|
|
1254
|
+
console.log(`[TeamClaude] Refreshed credentials for "${mgr.name}"`);
|
|
1255
|
+
}
|
|
1256
|
+
} else if (freshCred.apiKey && mgr.credential !== freshCred.apiKey) {
|
|
1257
|
+
mgr.credential = freshCred.apiKey;
|
|
1258
|
+
if (mgr.status === 'error') mgr.status = 'active';
|
|
1259
|
+
console.log(`[TeamClaude] Updated API key for "${mgr.name}"`);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return added;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// ── live cloud sync (running server ⇄ cloud) ────────────────
|
|
1266
|
+
|
|
1267
|
+
// One cloud-sync pass: re-pull with the stored key and reconcile the live fleet.
|
|
1268
|
+
// Returns { stop } — stop=true means the key is revoked and the loop must end.
|
|
1269
|
+
// Failure classification (irreversible-mutation 3-way): revoked(403) → drop the
|
|
1270
|
+
// key's accounts and stop; transient(5xx/network) → keep last-known accounts and
|
|
1271
|
+
// retry next interval (cloud outage must NOT block the local proxy).
|
|
1272
|
+
async function syncFromCloudOnce(config, accountManager, cloud, key) {
|
|
1273
|
+
let pulled, allowed, source;
|
|
1274
|
+
try {
|
|
1275
|
+
({ accounts: pulled, allowed, source } = await cloudPull(cloud, key));
|
|
1276
|
+
} catch (err) {
|
|
1277
|
+
if (err && err.revoked) {
|
|
1278
|
+
// Revoke: prune every account this key manages (mergePulledAccounts with an
|
|
1279
|
+
// empty allow-set drops this key's source and removes now-unmanaged accounts).
|
|
1280
|
+
const src = keySource(key);
|
|
1281
|
+
await atomicConfigUpdate(c => { mergePulledAccounts(c, [], src, []); });
|
|
1282
|
+
const disk = await loadConfig();
|
|
1283
|
+
const removed = disk ? reconcileRemovedAccounts(disk, config, accountManager) : 0;
|
|
1284
|
+
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.`);
|
|
1285
|
+
return { stop: true };
|
|
1286
|
+
}
|
|
1287
|
+
console.error(`[TeamClaude] Cloud sync skipped (transient — keeping current accounts): ${err.message}`);
|
|
1288
|
+
return { stop: false };
|
|
1289
|
+
}
|
|
1290
|
+
await atomicConfigUpdate(c => { mergePulledAccounts(c, pulled, source, allowed); });
|
|
1291
|
+
const disk = await loadConfig();
|
|
1292
|
+
if (disk) {
|
|
1293
|
+
await syncAccountsFromDisk(disk, config, accountManager); // add / token-refresh / enable / priority
|
|
1294
|
+
reconcileRemovedAccounts(disk, config, accountManager); // removal (group shrink)
|
|
1295
|
+
}
|
|
1296
|
+
return { stop: false };
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// Start the periodic live-sync loop. Returns a stop() function. The timer is
|
|
1300
|
+
// unref'd (never keeps the process alive) and non-overlapping (a slow pull won't
|
|
1301
|
+
// stack). On revoke it self-cancels.
|
|
1302
|
+
function startCloudSyncLoop(config, accountManager, cloud, key, intervalMs) {
|
|
1303
|
+
let timer = null;
|
|
1304
|
+
let running = false;
|
|
1305
|
+
const tick = async () => {
|
|
1306
|
+
if (running) return;
|
|
1307
|
+
running = true;
|
|
1308
|
+
try {
|
|
1309
|
+
const r = await syncFromCloudOnce(config, accountManager, cloud, key);
|
|
1310
|
+
if (r.stop && timer) { clearInterval(timer); timer = null; }
|
|
1311
|
+
} catch (e) {
|
|
1312
|
+
console.error(`[TeamClaude] Cloud sync error: ${e.message}`);
|
|
1313
|
+
} finally {
|
|
1314
|
+
running = false;
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
timer = setInterval(tick, intervalMs);
|
|
1318
|
+
timer.unref?.();
|
|
1319
|
+
return () => { if (timer) { clearInterval(timer); timer = null; } };
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// ── self-update (public npm) ────────────────────────────────
|
|
1323
|
+
|
|
1324
|
+
// This package's own version, read from its package.json (zero-dep, ESM-safe).
|
|
1325
|
+
function currentVersion() {
|
|
1326
|
+
try {
|
|
1327
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
1328
|
+
} catch {
|
|
1329
|
+
return null;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// a.b.c compare (ignores prerelease tags — fine for our versions). true if a > b.
|
|
1334
|
+
function semverGt(a, b) {
|
|
1335
|
+
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
|
|
1336
|
+
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
|
|
1337
|
+
for (let i = 0; i < 3; i++) {
|
|
1338
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return true;
|
|
1339
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return false;
|
|
1340
|
+
}
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function updateCachePath() {
|
|
1345
|
+
return getConfigPath() + '.update-check.json';
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// Non-blocking update notice: print immediately from cache if we already know a
|
|
1349
|
+
// newer version is published, then refresh the cache in the background (≤ once/day,
|
|
1350
|
+
// short timeout, all errors swallowed) for next time. Never delays or breaks the
|
|
1351
|
+
// proxy; opt out with config.updateCheck === false. Before the package is on npm
|
|
1352
|
+
// the registry returns 404 → no cache written → silent (correct pre-publish).
|
|
1353
|
+
function updateNotifier(config) {
|
|
1354
|
+
if (!config || config.updateCheck === false) return;
|
|
1355
|
+
const cur = currentVersion();
|
|
1356
|
+
if (!cur) return;
|
|
1357
|
+
const path = updateCachePath();
|
|
1358
|
+
let cache = null;
|
|
1359
|
+
try { cache = JSON.parse(readFileSync(path, 'utf8')); } catch { /* no cache yet */ }
|
|
1360
|
+
if (cache && cache.latest && semverGt(cache.latest, cur)) {
|
|
1361
|
+
console.log(`[TeamClaude] Update available: ${cur} → ${cache.latest}. Run: teamclaude update`);
|
|
1362
|
+
}
|
|
1363
|
+
const stale = !cache || !cache.checkedAt || (Date.now() - cache.checkedAt > 24 * 60 * 60 * 1000);
|
|
1364
|
+
if (!stale) return;
|
|
1365
|
+
const ctrl = new AbortController();
|
|
1366
|
+
const t = setTimeout(() => ctrl.abort(), 3000);
|
|
1367
|
+
t.unref?.();
|
|
1368
|
+
fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`, { signal: ctrl.signal })
|
|
1369
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
1370
|
+
.then((j) => { if (j && j.version) { try { writeFileSync(path, JSON.stringify({ checkedAt: Date.now(), latest: j.version }), { mode: 0o600 }); } catch { /* cache is best-effort */ } } })
|
|
1371
|
+
.catch(() => { /* offline / registry down — try again next run */ })
|
|
1372
|
+
.finally(() => clearTimeout(t));
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// `teamclaude update` — pull the latest published version globally.
|
|
1376
|
+
async function updateCommand() {
|
|
1377
|
+
const cur = currentVersion() || 'unknown';
|
|
1378
|
+
console.log(`Current version: ${cur}. Updating ${PKG_NAME} to the latest published release…`);
|
|
1379
|
+
const r = spawnSync('npm', ['install', '-g', `${PKG_NAME}@latest`], { stdio: 'inherit' });
|
|
1380
|
+
if (r.error || r.status !== 0) {
|
|
1381
|
+
console.error('\nUpdate failed. Run it manually:');
|
|
1382
|
+
console.error(` npm install -g ${PKG_NAME}@latest`);
|
|
1383
|
+
console.error('(If you get a permissions error, your global npm prefix may need sudo.)');
|
|
1384
|
+
process.exitCode = 1;
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
console.log(`\n✓ Updated. New version: ${spawnSync('teamclaude', ['version'], { encoding: 'utf8' }).stdout?.trim() || 'run `teamclaude version`'}`);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// ── helpers ─────────────────────────────────────────────────
|
|
1391
|
+
|
|
1392
|
+
async function resolveAccounts(config) {
|
|
1393
|
+
const accounts = [];
|
|
1394
|
+
for (const acct of config.accounts) {
|
|
1395
|
+
if (acct.type === 'oauth') {
|
|
1396
|
+
if (acct.importFrom) {
|
|
1397
|
+
try {
|
|
1398
|
+
const creds = await importCredentials(acct.importFrom);
|
|
1399
|
+
// Carry accountUuid through so the live account can be matched UUID-first
|
|
1400
|
+
// on sync (otherwise it stays null and a name change misroutes the update).
|
|
1401
|
+
accounts.push({ name: acct.name, type: 'oauth', accountUuid: acct.accountUuid, maxConcurrent: acct.maxConcurrent, enabled: acct.enabled, priority: acct.priority, ...creds });
|
|
1402
|
+
console.log(`Imported "${acct.name}" from ${acct.importFrom}`);
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
console.error(`Failed to import "${acct.name}": ${err.message}`);
|
|
1405
|
+
}
|
|
1406
|
+
} else if (acct.accessToken) {
|
|
1407
|
+
accounts.push(acct);
|
|
1408
|
+
} else {
|
|
1409
|
+
console.error(`No token for "${acct.name}", skipping`);
|
|
1410
|
+
}
|
|
1411
|
+
} else if (acct.type === 'apikey' && acct.apiKey) {
|
|
1412
|
+
accounts.push(acct);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
return accounts;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function argValue(flag) {
|
|
1419
|
+
const i = args.indexOf(flag);
|
|
1420
|
+
return (i >= 0 && args[i + 1]) ? args[i + 1] : null;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// Coalesce concurrent auto-pushes per account so a burst of refreshes doesn't fan
|
|
1424
|
+
// out duplicate cloud calls.
|
|
1425
|
+
const _autoPushInFlight = new Set();
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* Best-effort: push a single freshly-refreshed account's token to the cloud, so the
|
|
1429
|
+
* cloud never serves a rotated-away (dead) refresh token to consumers. No-op unless
|
|
1430
|
+
* this machine has a cloud login session (i.e. it's an admin/source, not a pure
|
|
1431
|
+
* consumer). Never throws — cloud problems must not affect the running proxy.
|
|
1432
|
+
*/
|
|
1433
|
+
async function maybePushRefreshedToken(config, account, newTokens) {
|
|
1434
|
+
const key = account.accountUuid || account.name;
|
|
1435
|
+
try {
|
|
1436
|
+
if (!config.cloud || !config.cloud.session || !config.cloud.session.access_token) return; // not an admin/pusher
|
|
1437
|
+
if (_autoPushInFlight.has(key)) return;
|
|
1438
|
+
_autoPushInFlight.add(key);
|
|
1439
|
+
const cloud = await ensureCloudSession(config).catch(() => config.cloud);
|
|
1440
|
+
await cloudPush(cloud, [{
|
|
1441
|
+
accountUuid: account.accountUuid,
|
|
1442
|
+
name: account.name,
|
|
1443
|
+
subscriptionType: account.subscriptionType,
|
|
1444
|
+
tier: account.tier,
|
|
1445
|
+
type: account.type,
|
|
1446
|
+
accessToken: newTokens.accessToken,
|
|
1447
|
+
refreshToken: newTokens.refreshToken,
|
|
1448
|
+
expiresAt: newTokens.expiresAt,
|
|
1449
|
+
}]);
|
|
1450
|
+
} catch (err) {
|
|
1451
|
+
console.error(`[TeamClaude] Cloud auto-push (token refresh) failed for ${account.name}: ${err.message}`);
|
|
1452
|
+
} finally {
|
|
1453
|
+
_autoPushInFlight.delete(key);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// ── cloud (SaaS token sync + auth-key control) ──────────────────
|
|
1458
|
+
|
|
1459
|
+
// Refresh the owner session if it is expiring; persist the new session.
|
|
1460
|
+
async function ensureCloudSession(config) {
|
|
1461
|
+
const cloud = config.cloud;
|
|
1462
|
+
if (!cloud || !cloud.session || !cloud.session.access_token) return cloud;
|
|
1463
|
+
const expMs = (Number(cloud.session.expires_at) || 0) * 1000; // Supabase expires_at is seconds
|
|
1464
|
+
if (expMs && Date.now() + 60_000 >= expMs && cloud.session.refresh_token) {
|
|
1465
|
+
const s = await cloudRefreshSession(cloud, cloud.session.refresh_token);
|
|
1466
|
+
cloud.session = s;
|
|
1467
|
+
await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), session: s }; });
|
|
1468
|
+
}
|
|
1469
|
+
return cloud;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
async function cloudCommand() {
|
|
1473
|
+
const sub = args[1];
|
|
1474
|
+
const config = await loadOrCreateConfig();
|
|
1475
|
+
switch (sub) {
|
|
1476
|
+
case 'login': await cloudLoginCommand(config); break;
|
|
1477
|
+
case 'push': await cloudPushCommand(config); break;
|
|
1478
|
+
case 'pull': await cloudPullCommand(config); break;
|
|
1479
|
+
case 'key': await cloudKeyCommand(config); break;
|
|
1480
|
+
case 'group': await cloudGroupCommand(config); break;
|
|
1481
|
+
case 'account': await cloudAccountCommand(config); break;
|
|
1482
|
+
case 'status': await cloudStatusCommand(config); break;
|
|
1483
|
+
default:
|
|
1484
|
+
console.log('Cloud commands (SaaS token sync + auth-key control):');
|
|
1485
|
+
console.log(' teamclaude cloud pull --key <auth-key> Pull allowed tokens (key-only — no login)');
|
|
1486
|
+
console.log(' teamclaude cloud login --email <e> [--password <p>] (admin, to push)');
|
|
1487
|
+
console.log(' teamclaude cloud push Push local account tokens to the cloud');
|
|
1488
|
+
console.log(' teamclaude cloud key create [--label <l>] [--accounts uuid1,uuid2]');
|
|
1489
|
+
console.log(' teamclaude cloud key list');
|
|
1490
|
+
console.log(' teamclaude cloud key revoke <id>');
|
|
1491
|
+
console.log(' teamclaude cloud key allow <id> --accounts uuid1,uuid2');
|
|
1492
|
+
console.log(' teamclaude cloud status');
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
async function cloudLoginCommand(config) {
|
|
1497
|
+
// url/anon-key default to the built-in public endpoint — only --email/--password
|
|
1498
|
+
// are required for the owner (admin) login.
|
|
1499
|
+
const url = argValue('--url') || config.cloud?.url || process.env.SUPABASE_URL || DEFAULT_CLOUD.url;
|
|
1500
|
+
const anonKey = argValue('--anon-key') || config.cloud?.anonKey || process.env.SUPABASE_ANON_KEY || DEFAULT_CLOUD.anonKey;
|
|
1501
|
+
const email = argValue('--email') || config.cloud?.email || process.env.TEAMCLAUDE_CLOUD_EMAIL;
|
|
1502
|
+
let password = argValue('--password') || process.env.TEAMCLAUDE_CLOUD_PASSWORD;
|
|
1503
|
+
if (!email) {
|
|
1504
|
+
console.error('Missing --email (or TEAMCLAUDE_CLOUD_EMAIL).');
|
|
1505
|
+
process.exit(1);
|
|
1506
|
+
}
|
|
1507
|
+
if (!password) {
|
|
1508
|
+
if (!process.stdin.isTTY) { console.error('Password required (--password or TEAMCLAUDE_CLOUD_PASSWORD).'); process.exit(1); }
|
|
1509
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
1510
|
+
password = await new Promise(resolve => rl.question('Cloud password: ', resolve));
|
|
1511
|
+
rl.close();
|
|
1512
|
+
}
|
|
1513
|
+
const session = await cloudLogin({ url, anonKey }, email, password);
|
|
1514
|
+
await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), url, anonKey, email, session }; });
|
|
1515
|
+
console.log(`Logged in to cloud as ${email}.`);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
async function cloudPushCommand(config) {
|
|
1519
|
+
const cloud = await ensureCloudSession(config);
|
|
1520
|
+
if (!cloud?.session?.access_token) { console.error('Not logged in. Run `teamclaude cloud login`.'); process.exit(1); }
|
|
1521
|
+
const accounts = (config.accounts || []).filter(a => a.type !== 'apikey');
|
|
1522
|
+
if (!accounts.length) { console.error('No OAuth accounts to push.'); process.exit(1); }
|
|
1523
|
+
// Ride the proxy's quota snapshot (<config>.quota.json) along so the cloud
|
|
1524
|
+
// dashboard can show 5h/7d/Fable utilization. Absence is fine — usage rows
|
|
1525
|
+
// are simply not written (the dashboard shows "no data", not a fake zero).
|
|
1526
|
+
const usageByUuid = new Map();
|
|
1527
|
+
const quotaCache = await readQuotaCache();
|
|
1528
|
+
for (const entry of quotaCache?.accounts || []) {
|
|
1529
|
+
if (!entry?.accountUuid) continue;
|
|
1530
|
+
const usage = buildUsageFromQuota(entry);
|
|
1531
|
+
if (usage) usageByUuid.set(entry.accountUuid, usage);
|
|
1532
|
+
}
|
|
1533
|
+
const r = await cloudPush(cloud, accounts, usageByUuid);
|
|
1534
|
+
const usageNote = usageByUuid.size ? `, usage ${r.usage ?? 0}/${usageByUuid.size}` : ' (no quota snapshot — start the server once to measure usage)';
|
|
1535
|
+
console.log(`Pushed ${r.pushed} new, updated ${r.updated}, skipped ${r.skipped} (stale/invalid)${usageNote}.`);
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
async function cloudPullCommand(config) {
|
|
1539
|
+
// Consumer side: the auth key IS the credential — no owner `cloud login` needed.
|
|
1540
|
+
// resolveCloud() supplies the built-in public endpoint, so this works right after install.
|
|
1541
|
+
const cloud = resolveCloud(config);
|
|
1542
|
+
const key = argValue('--key') || process.env.TEAMCLAUDE_AUTH_KEY;
|
|
1543
|
+
if (!key) { console.error('Auth key required (--key or TEAMCLAUDE_AUTH_KEY).'); process.exit(1); }
|
|
1544
|
+
const { accounts: pulled, allowed, source } = await cloudPull(cloud, key);
|
|
1545
|
+
const updated = await atomicConfigUpdate(c => {
|
|
1546
|
+
c._pullSummary = mergePulledAccounts(c, pulled, source, allowed);
|
|
1547
|
+
// Persist the endpoint + auth key so a running `server` can re-pull on its own
|
|
1548
|
+
// (live sync). The key is the consumer's own credential; config is written 0o600.
|
|
1549
|
+
c.cloud = { ...(c.cloud || {}), url: cloud.url, anonKey: cloud.anonKey, pullKey: key };
|
|
1550
|
+
});
|
|
1551
|
+
const s = updated._pullSummary || { added: 0, updated: 0, skipped: 0, removed: 0 };
|
|
1552
|
+
await atomicConfigUpdate(c => { delete c._pullSummary; });
|
|
1553
|
+
const removedNote = s.removed ? `, ${s.removed} removed (revoked from group)` : '';
|
|
1554
|
+
console.log(`Pulled ${pulled.length} allowed account(s): +${s.added} added, ${s.updated} updated, ${s.skipped} kept-local (fresher)${removedNote}.`);
|
|
1555
|
+
console.log('A running `teamclaude server` will keep this in sync automatically (token/group changes + key revocation apply live).');
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/** Resolve a group by name or id against the cloud's group list. */
|
|
1559
|
+
async function resolveGroup(cloud, nameOrId) {
|
|
1560
|
+
const groups = await cloudGroupList(cloud);
|
|
1561
|
+
const g = groups.find(x => x.name === nameOrId) || groups.find(x => x.id === nameOrId);
|
|
1562
|
+
if (!g) {
|
|
1563
|
+
console.error(`Group not found: ${nameOrId} (existing: ${groups.map(x => x.name).join(', ') || 'none'})`);
|
|
1564
|
+
process.exit(1);
|
|
1565
|
+
}
|
|
1566
|
+
return g;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
async function cloudKeyCommand(config) {
|
|
1570
|
+
const cloud = await ensureCloudSession(config);
|
|
1571
|
+
if (!cloud?.session?.access_token) { console.error('Not logged in. Run `teamclaude cloud login`.'); process.exit(1); }
|
|
1572
|
+
const action = args[2];
|
|
1573
|
+
switch (action) {
|
|
1574
|
+
case 'create': {
|
|
1575
|
+
const groupArg = argValue('--group');
|
|
1576
|
+
const groupId = groupArg ? (await resolveGroup(cloud, groupArg)).id : null; // null → main
|
|
1577
|
+
const r = await cloudKeyCreate(cloud, { label: argValue('--label'), groupId });
|
|
1578
|
+
console.log(`Created auth key (id ${r.id}, group ${groupArg || 'main'}):`);
|
|
1579
|
+
console.log(` ${r.key}`);
|
|
1580
|
+
console.log(' ^ shown once — store it now. Consumers pull with: teamclaude cloud pull --key <this>');
|
|
1581
|
+
break;
|
|
1582
|
+
}
|
|
1583
|
+
case 'list': {
|
|
1584
|
+
const keys = await cloudKeyList(cloud);
|
|
1585
|
+
if (!keys.length) { console.log('No auth keys.'); break; }
|
|
1586
|
+
for (const k of keys) {
|
|
1587
|
+
console.log(`${k.key_prefix}… ${k.status.padEnd(8)} ${(k.group || 'main').padEnd(12)} ${k.label || ''} id=${k.id}`);
|
|
1588
|
+
}
|
|
1589
|
+
console.log('(허용 계정은 키가 아니라 그룹에 매핑됩니다: teamclaude cloud group list)');
|
|
1590
|
+
break;
|
|
1591
|
+
}
|
|
1592
|
+
case 'revoke': {
|
|
1593
|
+
const id = args[3];
|
|
1594
|
+
if (!id) { console.error('Usage: teamclaude cloud key revoke <id>'); process.exit(1); }
|
|
1595
|
+
await cloudKeyRevoke(cloud, id);
|
|
1596
|
+
console.log(`Revoked key ${id}.`);
|
|
1597
|
+
break;
|
|
1598
|
+
}
|
|
1599
|
+
case 'move': {
|
|
1600
|
+
const id = args[3];
|
|
1601
|
+
const groupArg = argValue('--group');
|
|
1602
|
+
if (!id || !groupArg) { console.error('Usage: teamclaude cloud key move <id> --group <name>'); process.exit(1); }
|
|
1603
|
+
const g = await resolveGroup(cloud, groupArg);
|
|
1604
|
+
const r = await cloudKeyMove(cloud, id, g.id);
|
|
1605
|
+
console.log(`Key ${id} moved to group "${r.group}".`);
|
|
1606
|
+
break;
|
|
1607
|
+
}
|
|
1608
|
+
case 'allow':
|
|
1609
|
+
console.error('`key allow` is gone — accounts are allowed per GROUP now:');
|
|
1610
|
+
console.error(' teamclaude cloud group allow <group> --accounts uuid1,uuid2 (order = priority)');
|
|
1611
|
+
process.exit(1);
|
|
1612
|
+
break;
|
|
1613
|
+
default:
|
|
1614
|
+
console.error('Usage: teamclaude cloud key <create|list|revoke|move>');
|
|
1615
|
+
process.exit(1);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
async function cloudGroupCommand(config) {
|
|
1620
|
+
const cloud = await ensureCloudSession(config);
|
|
1621
|
+
if (!cloud?.session?.access_token) { console.error('Not logged in. Run `teamclaude cloud login`.'); process.exit(1); }
|
|
1622
|
+
const action = args[2];
|
|
1623
|
+
switch (action) {
|
|
1624
|
+
case 'list': {
|
|
1625
|
+
const groups = await cloudGroupList(cloud);
|
|
1626
|
+
for (const g of groups) {
|
|
1627
|
+
console.log(`${g.name} (keys: ${g.activeKeys}, id=${g.id})`);
|
|
1628
|
+
if (!g.accounts.length) { console.log(' (no accounts)'); continue; }
|
|
1629
|
+
for (const a of g.accounts) {
|
|
1630
|
+
const prio = Number.isInteger(a.priority) ? `#${a.priority}` : 'auto';
|
|
1631
|
+
const flags = `${a.enabled ? 'enabled' : 'disabled'}${a.accountEnabled === false ? ' (globally disabled)' : ''}`;
|
|
1632
|
+
console.log(` ${prio.padEnd(5)} ${a.name} ${flags} ${a.accountUuid}`);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
break;
|
|
1636
|
+
}
|
|
1637
|
+
case 'create': {
|
|
1638
|
+
const name = args[3];
|
|
1639
|
+
if (!name) { console.error('Usage: teamclaude cloud group create <name>'); process.exit(1); }
|
|
1640
|
+
const r = await cloudGroupCreate(cloud, name);
|
|
1641
|
+
console.log(`Created group "${r.name}" (id ${r.id}).`);
|
|
1642
|
+
break;
|
|
1643
|
+
}
|
|
1644
|
+
case 'rename': {
|
|
1645
|
+
const nameOrId = args[3];
|
|
1646
|
+
const to = argValue('--to');
|
|
1647
|
+
if (!nameOrId || !to) { console.error('Usage: teamclaude cloud group rename <name|id> --to <newName>'); process.exit(1); }
|
|
1648
|
+
const g = await resolveGroup(cloud, nameOrId);
|
|
1649
|
+
await cloudGroupRename(cloud, g.id, to);
|
|
1650
|
+
console.log(`Renamed group "${g.name}" → "${to}".`);
|
|
1651
|
+
break;
|
|
1652
|
+
}
|
|
1653
|
+
case 'delete': {
|
|
1654
|
+
const nameOrId = args[3];
|
|
1655
|
+
if (!nameOrId) { console.error('Usage: teamclaude cloud group delete <name|id>'); process.exit(1); }
|
|
1656
|
+
const g = await resolveGroup(cloud, nameOrId);
|
|
1657
|
+
await cloudGroupDelete(cloud, g.id);
|
|
1658
|
+
console.log(`Deleted group "${g.name}" (its keys moved to "main").`);
|
|
1659
|
+
break;
|
|
1660
|
+
}
|
|
1661
|
+
case 'allow': {
|
|
1662
|
+
// Order in --accounts IS the priority (first = 0 = preferred first).
|
|
1663
|
+
const nameOrId = args[3];
|
|
1664
|
+
const accountsArg = argValue('--accounts');
|
|
1665
|
+
if (!nameOrId || !accountsArg) {
|
|
1666
|
+
console.error('Usage: teamclaude cloud group allow <name|id> --accounts uuid1,uuid2 (order = priority)');
|
|
1667
|
+
console.error(' --auto-priority 로 priority 없이(use-or-lose 자동 순서) 허용만 설정');
|
|
1668
|
+
process.exit(1);
|
|
1669
|
+
}
|
|
1670
|
+
const g = await resolveGroup(cloud, nameOrId);
|
|
1671
|
+
const autoPrio = args.includes('--auto-priority');
|
|
1672
|
+
const accounts = accountsArg.split(',').map(s => s.trim()).filter(Boolean)
|
|
1673
|
+
.map((accountUuid, i) => ({ accountUuid, enabled: true, priority: autoPrio ? null : i }));
|
|
1674
|
+
// Pass the revision we just read so the write goes through optimistic-
|
|
1675
|
+
// concurrency (a concurrent edit is rejected 409 stale_revision).
|
|
1676
|
+
const r = await cloudGroupSetAccounts(cloud, g.id, accounts, g.revision);
|
|
1677
|
+
console.log(`Group "${g.name}" now allows ${r.accounts} account(s)${autoPrio ? ' (auto order)' : ' (priority = given order)'}.`);
|
|
1678
|
+
break;
|
|
1679
|
+
}
|
|
1680
|
+
default:
|
|
1681
|
+
console.error('Usage: teamclaude cloud group <list|create|rename|delete|allow>');
|
|
1682
|
+
process.exit(1);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
async function cloudAccountCommand(config) {
|
|
1687
|
+
const cloud = await ensureCloudSession(config);
|
|
1688
|
+
if (!cloud?.session?.access_token) { console.error('Not logged in. Run `teamclaude cloud login`.'); process.exit(1); }
|
|
1689
|
+
const action = args[2];
|
|
1690
|
+
const ref = args[3];
|
|
1691
|
+
if ((action !== 'enable' && action !== 'disable') || !ref) {
|
|
1692
|
+
console.error('Usage: teamclaude cloud account <enable|disable> <accountUuid|localName>');
|
|
1693
|
+
process.exit(1);
|
|
1694
|
+
}
|
|
1695
|
+
// Convenience: allow a local account NAME and resolve it to its uuid.
|
|
1696
|
+
const local = (config.accounts || []).find(a => a.accountUuid === ref || a.name === ref);
|
|
1697
|
+
const uuid = local?.accountUuid || ref;
|
|
1698
|
+
const r = await cloudAccountSetEnabled(cloud, uuid, action === 'enable');
|
|
1699
|
+
console.log(`Account ${r.accountUuid} is now ${r.enabled ? 'enabled' : 'disabled'} (globally${r.enabled ? '' : ' — excluded from every group\'s pull'}).`);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
async function cloudStatusCommand(config) {
|
|
1703
|
+
const cloud = config.cloud;
|
|
1704
|
+
if (!cloud?.url) { console.log('Cloud: not configured.'); return; }
|
|
1705
|
+
console.log(`Cloud URL: ${cloud.url}`);
|
|
1706
|
+
console.log(`Email: ${cloud.email || '(unknown)'}`);
|
|
1707
|
+
const expMs = (Number(cloud.session?.expires_at) || 0) * 1000;
|
|
1708
|
+
const loggedIn = cloud.session?.access_token && (!expMs || expMs > Date.now());
|
|
1709
|
+
console.log(`Session: ${loggedIn ? 'active' : 'expired/none — run `teamclaude cloud login`'}`);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
function handleServerListenError(err, port) {
|
|
1713
|
+
if (err.code === 'EADDRINUSE') {
|
|
1714
|
+
console.error(`[TeamClaude] Port ${port} is already in use.`);
|
|
1715
|
+
console.error('Another TeamClaude proxy may already be running.');
|
|
1716
|
+
console.error(' See it: teamclaude status');
|
|
1717
|
+
console.error(' Stop it: teamclaude stop');
|
|
1718
|
+
console.error(' Restart it: teamclaude restart');
|
|
1719
|
+
} else if (err.code === 'EACCES') {
|
|
1720
|
+
console.error(`[TeamClaude] Permission denied while listening on port ${port}.`);
|
|
1721
|
+
console.error('Choose a non-privileged port in the TeamClaude config.');
|
|
1722
|
+
} else {
|
|
1723
|
+
console.error(`[TeamClaude] Failed to listen on port ${port}: ${err.message}`);
|
|
1724
|
+
}
|
|
1725
|
+
process.exit(1);
|
|
1726
|
+
}
|