teamclaude-cloud 1.5.0 → 1.5.2

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +79 -5
  3. package/src/tui.js +21 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamclaude-cloud",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Multi-account Claude proxy with quota-based rotation + cloud token sync and auth-key control",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -309,6 +309,41 @@ async function serverCommand() {
309
309
  if (disabled) set.add(uuid); else set.delete(uuid);
310
310
  cloud.localDisabled = [...set];
311
311
  }),
312
+ // `p` in the dashboard = `teamclaude cloud push`, so the admin can register the
313
+ // server's live account tokens to the cloud without dropping to a shell. Only
314
+ // wired when a cloud config exists (else the footer hides the key). Uses the
315
+ // running AccountManager's LIVE tokens (freshest) and re-reads the cloud session
316
+ // from disk so a `cloud login` done after the server started is picked up.
317
+ cloudPush: config.cloud ? (async () => {
318
+ // Use the running server's LIVE tokens (freshest) for each account.
319
+ const accounts = (config.accounts || []).filter(a => a.type !== 'apikey').map(a => {
320
+ const am = (a.accountUuid && accountManager.accounts.find(x => x.accountUuid === a.accountUuid))
321
+ || accountManager.accounts.find(x => x.name === a.name);
322
+ return am ? { ...a, accessToken: am.credential, refreshToken: am.refreshToken, expiresAt: am.expiresAt } : a;
323
+ });
324
+ if (!accounts.length) throw new Error('No OAuth accounts to push');
325
+ // Prefer the AUTH KEY (push to your own tenant, no expiring owner login).
326
+ const fresh = (await loadConfig()) || config;
327
+ const key = fresh.cloud?.pullKey || config.cloud?.pullKey;
328
+ if (key) {
329
+ const r = await cloudPushViaKey(resolveCloud(fresh), key, accounts);
330
+ const na = r.notAllowed ? `, ${r.notAllowed} not in the key's group` : '';
331
+ const sk = r.skipped ? `, ${r.skipped} skipped` : '';
332
+ return { summary: `${r.updated} updated, ${r.unchanged} unchanged${na}${sk} (via auth key)` };
333
+ }
334
+ // No key — owner login (also creates new accounts) + usage snapshot.
335
+ const cloud = await ensureCloudSession(fresh);
336
+ if (!cloud?.session?.access_token) throw new Error('No auth key and not logged in — pull once with a key, or run `teamclaude cloud login`');
337
+ const usageByUuid = new Map();
338
+ const quotaCache = await readQuotaCache();
339
+ for (const entry of quotaCache?.accounts || []) {
340
+ if (!entry?.accountUuid) continue;
341
+ const usage = buildUsageFromQuota(entry);
342
+ if (usage) usageByUuid.set(entry.accountUuid, usage);
343
+ }
344
+ const r = await cloudPush(cloud, accounts, usageByUuid);
345
+ return { summary: `${r.pushed} new, ${r.updated} updated, ${r.skipped} skipped${usageByUuid.size ? `, usage ${r.usage ?? 0}` : ''}` };
346
+ }) : undefined,
312
347
  });
313
348
  hooks = {
314
349
  onRequestStart: (id, info) => tui.onRequestStart(id, info),
@@ -1554,6 +1589,17 @@ async function ensureCloudSession(config) {
1554
1589
  // atomicConfigUpdate's documented cross-process limitation).
1555
1590
  await _cloudSessionRefresh;
1556
1591
  }
1592
+ // If the session is (still) expired and cannot be refreshed — a Google/SSO token
1593
+ // login (`cloud login --token`) stores NO refresh token, so its ~1h token simply
1594
+ // lapses — fail with an actionable message instead of letting the caller hit a
1595
+ // bare 401 unauthorized.
1596
+ const curExpMs = (Number(cloud.session.expires_at) || 0) * 1000;
1597
+ if (curExpMs && Date.now() >= curExpMs) {
1598
+ const hint = cloud.session.refresh_token
1599
+ ? 'Run `teamclaude cloud login` to sign in again.'
1600
+ : 'Re-copy the CLI login token from the dashboard (the "관리자 로그인" box in the Auth Keys section) and run `teamclaude cloud login --token`.';
1601
+ throw new Error(`Cloud session expired. ${hint}`);
1602
+ }
1557
1603
  return cloud;
1558
1604
  }
1559
1605
 
@@ -1681,14 +1727,42 @@ async function cloudLoginCommand(config) {
1681
1727
  console.log(`Logged in to cloud as ${email}.`);
1682
1728
  }
1683
1729
 
1730
+ // Push account tokens to the cloud using an AUTH KEY — no owner login needed. Reuses
1731
+ // the key-authenticated, group-scoped, freshest-wins /sync/report-token path, so the
1732
+ // tokens land in the KEY'S OWNER tenant (your personal key → your own account). This
1733
+ // UPDATES accounts already registered + in the key's group; an account the key can't
1734
+ // pull is reported as "not in the key's group" (create it via an owner login once).
1735
+ async function cloudPushViaKey(cloud, key, accounts) {
1736
+ const r = { updated: 0, unchanged: 0, notAllowed: 0, skipped: 0 };
1737
+ for (const a of accounts) {
1738
+ if (!a.accountUuid || !a.accessToken || !a.refreshToken) { r.skipped++; continue; }
1739
+ try {
1740
+ const res = await cloudReportToken(cloud, key, a.accountUuid, { accessToken: a.accessToken, refreshToken: a.refreshToken, expiresAt: a.expiresAt });
1741
+ if (res.action === 'updated') r.updated++; else r.unchanged++;
1742
+ } catch (e) {
1743
+ if (/\(40[34]\)/.test(e.message)) r.notAllowed++; else r.skipped++; // 403 not in group / 404 unknown account
1744
+ }
1745
+ }
1746
+ return r;
1747
+ }
1748
+
1684
1749
  async function cloudPushCommand(config) {
1685
- const cloud = await ensureCloudSession(config);
1686
- if (!cloud?.session?.access_token) { console.error('Not logged in. Run `teamclaude cloud login`.'); process.exit(1); }
1687
1750
  const accounts = (config.accounts || []).filter(a => a.type !== 'apikey');
1688
1751
  if (!accounts.length) { console.error('No OAuth accounts to push.'); process.exit(1); }
1689
- // Ride the proxy's quota snapshot (<config>.quota.json) along so the cloud
1690
- // dashboard can show 5h/7d/Fable utilization. Absence is fine usage rows
1691
- // are simply not written (the dashboard shows "no data", not a fake zero).
1752
+ // Prefer the AUTH KEY: pushing to your own tenant is the key's job — no separate,
1753
+ // short-lived owner login required (the key is the credential, same as pull).
1754
+ const key = config.cloud?.pullKey || process.env.TEAMCLAUDE_AUTH_KEY;
1755
+ if (key) {
1756
+ const r = await cloudPushViaKey(resolveCloud(config), key, accounts);
1757
+ const na = r.notAllowed ? `, ${r.notAllowed} not in the key's group` : '';
1758
+ const sk = r.skipped ? `, ${r.skipped} skipped (no/expired token)` : '';
1759
+ console.log(`Pushed tokens via auth key: ${r.updated} updated, ${r.unchanged} unchanged${na}${sk}.`);
1760
+ return;
1761
+ }
1762
+ // No auth key — fall back to an owner login (which also CREATES new accounts) and
1763
+ // rides the quota snapshot so the dashboard can show utilization.
1764
+ const cloud = await ensureCloudSession(config);
1765
+ if (!cloud?.session?.access_token) { console.error('No auth key found and not logged in. Pull once with `teamclaude cloud pull --key <key>`, or `teamclaude cloud login`.'); process.exit(1); }
1692
1766
  const usageByUuid = new Map();
1693
1767
  const quotaCache = await readQuotaCache();
1694
1768
  for (const entry of quotaCache?.accounts || []) {
package/src/tui.js CHANGED
@@ -114,7 +114,7 @@ function timestamp() {
114
114
  // ── TUI class ────────────────────────────────────────────────
115
115
 
116
116
  export class TUI {
117
- constructor({ accountManager, config, saveConfig, syncAccounts, refreshQuota, onQuit, setLocalDisabled }) {
117
+ constructor({ accountManager, config, saveConfig, syncAccounts, refreshQuota, onQuit, setLocalDisabled, cloudPush }) {
118
118
  this.am = accountManager;
119
119
  this.config = config;
120
120
  this.saveConfig = saveConfig;
@@ -122,6 +122,7 @@ export class TUI {
122
122
  this.refreshQuota = refreshQuota; // optional: forced fleet quota re-measure (R)
123
123
  this.onQuit = onQuit;
124
124
  this.setLocalDisabled = setLocalDisabled; // optional: persist offline local-disable overlay (by uuid) to disk (fresh-read merge)
125
+ this.cloudPush = cloudPush; // optional: push account tokens to the cloud (`p`) — returns {pushed,updated,skipped,usage}
125
126
 
126
127
  this.log = []; // completed activity entries
127
128
  this.active = new Map(); // in-flight requests
@@ -263,6 +264,7 @@ export class TUI {
263
264
  if (k === 'q') { this.stop(); this.onQuit?.(); return; }
264
265
  if (k === 'a') { this.mode = 'add'; return; }
265
266
  if (k === 'R') { this._doSync(); return; }
267
+ if (k === 'p') { this._doPush(); return; }
266
268
 
267
269
  if (this.am.accounts.length === 0) return;
268
270
 
@@ -390,6 +392,19 @@ export class TUI {
390
392
  }
391
393
  }
392
394
 
395
+ // `p` — push this machine's account tokens (+ usage snapshot) to the cloud, the
396
+ // same as `teamclaude push` / `teamclaude cloud push`, without leaving the dashboard.
397
+ async _doPush() {
398
+ if (!this.cloudPush) { this._addLog('Cloud push unavailable (no cloud config)'); return; }
399
+ try {
400
+ this._addLog('Pushing account tokens to the cloud...');
401
+ const r = await this.cloudPush();
402
+ this._addLog(`Cloud push: ${r?.summary ?? 'done'}`);
403
+ } catch (e) {
404
+ this._addLog(`Cloud push failed: ${e.message}`);
405
+ }
406
+ }
407
+
393
408
  async _doImport() {
394
409
  try {
395
410
  this._addLog('Importing credentials...');
@@ -835,8 +850,11 @@ export class TUI {
835
850
 
836
851
  _renderFooter() {
837
852
  switch (this.mode) {
838
- case 'normal':
839
- return ` ${dim('↑↓')} select ${bold('s')}witch ${bold('e')}nable/disable ${bold('o')}rder ${bold('d')}elete ${bold('a')}dd ${bold('R')}eload ${bold('q')}uit`;
853
+ case 'normal': {
854
+ // `p`ush is only shown when a cloud config is present (the callback is wired).
855
+ const push = this.cloudPush ? ` ${bold('p')}ush` : '';
856
+ return ` ${dim('↑↓')} select ${bold('s')}witch ${bold('e')}nable/disable ${bold('o')}rder ${bold('d')}elete ${bold('a')}dd${push} ${bold('R')}eload ${bold('q')}uit`;
857
+ }
840
858
  case 'select':
841
859
  return ` ${dim('↑↓')} select ${bold('Enter')} delete ${bold('Esc')} cancel`;
842
860
  case 'order':