teamclaude-cloud 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,7 +35,9 @@ Sign up there, then use the *same* account with `teamclaude cloud login` on the
35
35
  teamclaude cloud login --email <you@example.com>
36
36
  teamclaude cloud push
37
37
 
38
- # Groups: auth keys belong to a group (default "main"); accounts are allowed per GROUP.
38
+ # Groups: an auth key belongs to ONE OR MORE groups (default "main"); accounts are
39
+ # allowed per GROUP, and a key's pull returns the UNION of all its groups' accounts.
40
+ # Assign a key to multiple groups from the dashboard (Auth Keys → group checkboxes).
39
41
  teamclaude cloud group list
40
42
  teamclaude cloud group create team-a
41
43
  teamclaude cloud group allow team-a --accounts <uuid1>,<uuid2> # order = priority (#0 first)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamclaude-cloud",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
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/cloud.js CHANGED
@@ -154,6 +154,19 @@ export async function cloudLogin(cloud, email, password) {
154
154
  };
155
155
  }
156
156
 
157
+ /**
158
+ * Verify a session token actually authorizes as a cloud owner (GET /me). Used by
159
+ * `cloud login --token` to reject a forged/expired token BEFORE overwriting an
160
+ * existing working login. Returns true only on a 2xx owner response.
161
+ */
162
+ export async function cloudValidateOwner(cloud) {
163
+ requireSession(cloud);
164
+ const res = await httpJson(`${fnBase(cloud)}/me`, {
165
+ headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
166
+ });
167
+ return res.ok;
168
+ }
169
+
157
170
  /** Refresh an expired owner session. */
158
171
  export async function cloudRefreshSession(cloud, refreshToken) {
159
172
  assertSecureUrl(cloud.url);
@@ -403,6 +416,18 @@ export async function cloudKeyMove(cloud, id, groupId) {
403
416
  return res.data; // { ok, id, groupId, group }
404
417
  }
405
418
 
419
+ /** Set the FULL set of groups a key belongs to (multi-group). Replaces the set. */
420
+ export async function cloudKeySetGroups(cloud, id, groupIds) {
421
+ requireSession(cloud);
422
+ const res = await httpJson(`${fnBase(cloud)}/auth-keys/${encodeURIComponent(id)}/groups`, {
423
+ method: 'PUT',
424
+ headers: fnHeaders(cloud, { Authorization: `Bearer ${cloud.session.access_token}` }),
425
+ body: { groupIds },
426
+ });
427
+ if (!res.ok) throw new Error(`Key set-groups failed (${res.status}): ${describe(res.data)}`);
428
+ return res.data; // { ok, id, groupIds }
429
+ }
430
+
406
431
  export async function cloudKeyList(cloud) {
407
432
  requireSession(cloud);
408
433
  const res = await httpJson(`${fnBase(cloud)}/auth-keys`, {
@@ -544,6 +569,22 @@ export async function cloudAccountSetEnabled(cloud, accountUuid, enabled) {
544
569
  export function mergePulledAccounts(config, pulled, source = null, allowed = null) {
545
570
  const summary = { added: 0, updated: 0, skipped: 0, removed: 0 };
546
571
  if (!Array.isArray(config.accounts)) config.accounts = [];
572
+ // Client-only local-disable overlay: a consumer may disable a pulled account OFFLINE.
573
+ // It is stored by accountUuid in config.cloud.localDisabled (persistent so the choice
574
+ // survives a prune+re-add) and is NEVER pushed or synced up — effective enabled =
575
+ // cloud_enabled (present in the pull's allow-set) AND NOT local_disabled. Re-enabling
576
+ // an admin-disabled account is impossible: it's absent from the pull → pruned below.
577
+ // Seed the set from any legacy enabled===false accounts so existing disables migrate.
578
+ const cloudCfg = config.cloud || (config.cloud = {});
579
+ const localDisabled = new Set(Array.isArray(cloudCfg.localDisabled) ? cloudCfg.localDisabled : []);
580
+ for (const x of config.accounts) {
581
+ if (x.enabled === false && x.accountUuid) localDisabled.add(x.accountUuid);
582
+ }
583
+ const applyOverlay = (acct) => {
584
+ // Only uuid-keyed accounts participate in the overlay; legacy no-uuid accounts
585
+ // keep their own `enabled` (they are never cloud-pulled anyway).
586
+ if (acct && acct.accountUuid) acct.enabled = !localDisabled.has(acct.accountUuid);
587
+ };
547
588
  const applyPriority = (target, incoming) => {
548
589
  if (!('priority' in incoming)) return; // pre-group cloud response — leave local as-is
549
590
  if (Number.isInteger(incoming.priority)) target.priority = incoming.priority;
@@ -577,6 +618,7 @@ export function mergePulledAccounts(config, pulled, source = null, allowed = nul
577
618
  subscriptionType: a.tier || undefined,
578
619
  enabled: true,
579
620
  };
621
+ applyOverlay(fresh); // honor an offline local-disable that survived a prune+re-add
580
622
  addSource(fresh);
581
623
  applyPriority(fresh, a);
582
624
  config.accounts.push(fresh);
@@ -584,6 +626,12 @@ export function mergePulledAccounts(config, pulled, source = null, allowed = nul
584
626
  continue;
585
627
  }
586
628
  const cur = config.accounts[idx];
629
+ // Carry a legacy local-disable forward: a locally-disabled row that had no uuid
630
+ // (so the seed above skipped it) is matched here by NAME and assigned the pull's
631
+ // uuid — record that uuid in the overlay BEFORE applyOverlay, or it would be
632
+ // re-enabled and put back into rotation (Codex HIGH).
633
+ if (cur.enabled === false) { const u = cur.accountUuid || a.accountUuid; if (u) localDisabled.add(u); }
634
+ applyOverlay(cur); // local-disable overlay is authoritative for `enabled`
587
635
  addSource(cur); // now managed by this key (so this key can prune it later)
588
636
  // A payload entry with no token at all (allowed-but-tokenless) must not clobber
589
637
  // a valid local token — only apply priority/metadata, keep the local token.
@@ -601,6 +649,7 @@ export function mergePulledAccounts(config, pulled, source = null, allowed = nul
601
649
  expiresAt: incomingExp,
602
650
  subscriptionType: a.tier || cur.subscriptionType,
603
651
  };
652
+ applyOverlay(config.accounts[idx]); // re-assert overlay on the rebuilt row
604
653
  applyPriority(config.accounts[idx], a);
605
654
  summary.updated++;
606
655
  }
@@ -628,6 +677,11 @@ export function mergePulledAccounts(config, pulled, source = null, allowed = nul
628
677
  });
629
678
  summary.removed = before - config.accounts.length;
630
679
  }
680
+ // Persist the (possibly seed-migrated) overlay set. Entries are kept even when the
681
+ // account is temporarily absent (admin-disabled → pruned) so the consumer's choice
682
+ // survives an admin disable→re-enable round-trip. The set only shrinks on an explicit
683
+ // `enable` (setEnabledCommand); it is bounded in practice by the user's own disables.
684
+ cloudCfg.localDisabled = [...localDisabled];
631
685
  return summary;
632
686
  }
633
687
 
package/src/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
1
+ import { readFile, writeFile, mkdir, rm, rename } from 'node:fs/promises';
2
2
  import { writeFileSync, mkdirSync } from 'node:fs';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
@@ -113,9 +113,16 @@ export async function loadOrCreateConfig() {
113
113
  export async function saveConfig(config) {
114
114
  const path = getConfigPath();
115
115
  await mkdir(dirname(path), { recursive: true });
116
- await writeFile(path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
116
+ // Atomic write: a reader (loadConfig from another process which does NOT take the
117
+ // config lock) must never observe a half-written file. Write a temp then rename
118
+ // (atomic on the same filesystem) so a read sees either the whole old or whole new.
119
+ const tmp = path + '.' + process.pid + '.' + randomBytes(4).toString('hex') + '.tmp';
120
+ await writeFile(tmp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
121
+ try { await rename(tmp, path); }
122
+ catch (err) { try { await rm(tmp, { force: true }); } catch { /* best-effort */ } throw err; }
117
123
  }
118
124
 
125
+
119
126
  /**
120
127
  * Atomically update the config: re-reads from disk, calls updater(config),
121
128
  * then saves. Returns the updated config. This prevents overwriting changes
@@ -126,9 +133,12 @@ export async function saveConfig(config) {
126
133
  * — e.g. a background token refresh and a TUI save/delete — would each read the same
127
134
  * snapshot and the later write would clobber the earlier one's change (resurrecting a
128
135
  * just-deleted account, or dropping a freshly-refreshed token). Chaining makes each
129
- * cycle observe the previous cycle's write. Cross-PROCESS races remain (the
130
- * single-proxy design assumption); a CLI write while the server runs is reconciled on
131
- * the next reload.
136
+ * cycle observe the previous cycle's write. saveConfig writes atomically (temp +
137
+ * rename) so a concurrent reader never sees a half-written file. Cross-PROCESS races
138
+ * (a running server's live-pull merge vs. a concurrent CLI write) remain the
139
+ * single-proxy design assumption — a CLI write while the server runs is reconciled on
140
+ * the next reload; a hand-rolled lockfile was evaluated and rejected (its steal/
141
+ * release edge-cases are riskier than the rare, recoverable race it would remove).
132
142
  */
133
143
  let _configWriteChain = Promise.resolve();
134
144
 
package/src/index.js CHANGED
@@ -7,7 +7,7 @@ import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConf
7
7
  import { AccountManager } from './account-manager.js';
8
8
  import { createProxyServer } from './server.js';
9
9
  import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
10
- import { cloudLogin, cloudRefreshSession, cloudPush, cloudPushUsage, cloudReportUsage, cloudPull, cloudRefreshToken, cloudReportToken, cloudKeyCreate, cloudKeyList, cloudKeyRevoke, cloudKeyMove, cloudGroupList, cloudGroupCreate, cloudGroupRename, cloudGroupDelete, cloudGroupSetAccounts, cloudAccountSetEnabled, buildUsageFromQuota, mergePulledAccounts, resolveCloud, DEFAULT_CLOUD, keySource, reconcileRemovedAccounts } from './cloud.js';
10
+ import { cloudLogin, cloudValidateOwner, cloudRefreshSession, cloudPush, cloudPushUsage, cloudReportUsage, cloudPull, cloudRefreshToken, cloudReportToken, cloudKeyCreate, cloudKeyList, cloudKeyRevoke, cloudKeyMove, cloudGroupList, cloudGroupCreate, cloudGroupRename, cloudGroupDelete, cloudGroupSetAccounts, cloudAccountSetEnabled, buildUsageFromQuota, mergePulledAccounts, resolveCloud, DEFAULT_CLOUD, keySource, reconcileRemovedAccounts } from './cloud.js';
11
11
  import { TUI } from './tui.js';
12
12
 
13
13
  const args = process.argv.slice(2);
@@ -76,6 +76,12 @@ switch (command) {
76
76
  await cloudCommand();
77
77
  process.exit(0);
78
78
  break;
79
+ case 'push':
80
+ // Shortcut for `teamclaude cloud push` — upload this machine's OAuth account
81
+ // tokens (+ usage snapshot) to the cloud under the logged-in user's own tenant.
82
+ await cloudPushCommand(await loadOrCreateConfig());
83
+ process.exit(0);
84
+ break;
79
85
  case 'update':
80
86
  await updateCommand();
81
87
  process.exit(0);
@@ -279,6 +285,10 @@ async function serverCommand() {
279
285
  // reload (R) / restart via syncAccountsFromDisk — not merged here, since we
280
286
  // can't distinguish "added externally" from "deleted locally" at save time.
281
287
  diskConfig.accounts = mapped;
288
+ // NOTE: cloud.localDisabled is maintained at the point of an EXPLICIT toggle
289
+ // (TUI _doToggleEnabled / CLI enable-disable), NOT reconciled from the account
290
+ // set here — a blanket "enabled!==false → clear overlay" would wrongly drop a
291
+ // disable when a pruned account is re-imported with its default enabled=true.
282
292
  }),
283
293
  syncAccounts: async () => {
284
294
  const diskConfig = await loadConfig();
@@ -290,6 +300,15 @@ async function serverCommand() {
290
300
  // this closure never runs before the binding is initialized.
291
301
  refreshQuota: () => server.refreshQuotaAll(),
292
302
  onQuit: () => { server.close(() => process.exit(0)); },
303
+ // Persist an offline local-disable toggle (by uuid) to disk atomically, re-reading
304
+ // the config so it merges with any concurrent CLI/pull change to cloud.localDisabled
305
+ // instead of overwriting it from the TUI's in-memory copy. Never pushed to the cloud.
306
+ setLocalDisabled: (uuid, disabled) => atomicConfigUpdate(c => {
307
+ const cloud = c.cloud || (c.cloud = {});
308
+ const set = new Set(Array.isArray(cloud.localDisabled) ? cloud.localDisabled : []);
309
+ if (disabled) set.add(uuid); else set.delete(uuid);
310
+ cloud.localDisabled = [...set];
311
+ }),
293
312
  });
294
313
  hooks = {
295
314
  onRequestStart: (id, info) => tui.onRequestStart(id, info),
@@ -1026,11 +1045,29 @@ async function setEnabledCommand(enabled) {
1026
1045
  let found = false;
1027
1046
  const config = await atomicConfigUpdate(cfg => {
1028
1047
  const acct = cfg.accounts.find(a => a.name === name);
1029
- if (acct) { acct.enabled = enabled; found = true; }
1048
+ if (acct) {
1049
+ acct.enabled = enabled;
1050
+ found = true;
1051
+ // Maintain the persistent, client-only local-disable overlay (by uuid). This is
1052
+ // an OFFLINE choice — never pushed or synced to the cloud — and it survives a pull
1053
+ // prune+re-add. A cloud-disabled account is pruned (absent here), so `enable` can't
1054
+ // find it → the admin's disable cannot be overridden by the client.
1055
+ if (acct.accountUuid) {
1056
+ const cloud = cfg.cloud || (cfg.cloud = {});
1057
+ const set = new Set(Array.isArray(cloud.localDisabled) ? cloud.localDisabled : []);
1058
+ if (enabled) set.delete(acct.accountUuid); else set.add(acct.accountUuid);
1059
+ cloud.localDisabled = [...set];
1060
+ }
1061
+ }
1030
1062
  });
1031
1063
  if (!found) { console.error(`Account "${name}" not found`); process.exit(1); }
1032
1064
  console.log(`${enabled ? 'Enabled' : 'Disabled'} account "${name}"`);
1033
- if (!enabled) console.log(' (excluded from active rotation; in-flight requests still finish)');
1065
+ if (!enabled) console.log(' (excluded from active rotation, offline-only — not pushed or synced to the cloud; in-flight requests still finish)');
1066
+ // Honesty about the authority model (HIGH): `enable` clears only the LOCAL override.
1067
+ // The cloud allow-set still governs — an account the admin disabled in the cloud is
1068
+ // pruned on the next pull (a running server re-applies this every sync interval), so
1069
+ // this re-enable is effective only while the account is still cloud-allowed.
1070
+ else console.log(' (local override cleared; the cloud allow-set still governs — a pull/live-sync re-applies any admin cloud-disable)');
1034
1071
  await noteRunningServerReload(config);
1035
1072
  }
1036
1073
 
@@ -1081,10 +1118,12 @@ Commands:
1081
1118
  status Show proxy & account status (live)
1082
1119
  accounts List configured accounts
1083
1120
  remove <name> Remove an account
1084
- disable <name> Disable an account (excluded from rotation)
1085
- enable <name> Re-enable a disabled account
1121
+ disable <name> Disable an account (offline-only; never pushed/synced to cloud)
1122
+ enable <name> Re-enable a locally-disabled account (cannot re-enable one the
1123
+ admin disabled in the cloud — that stays excluded)
1086
1124
  priority <name> <n> Set selection priority (lower = preferred; "auto" to return
1087
1125
  to automatic ordering — weekly reset soonest drained first)
1126
+ push Shortcut for "cloud push" — upload local tokens to the cloud
1088
1127
  api <path> Call an API endpoint with account credentials
1089
1128
  cloud <sub> Cloud SaaS: sync tokens + group/auth-key based token control
1090
1129
  cloud login --url <u> --anon-key <k> --email <e> [--password <p>]
@@ -1432,30 +1471,44 @@ const _autoPushInFlight = new Set();
1432
1471
  */
1433
1472
  async function maybePushRefreshedToken(config, account, newTokens) {
1434
1473
  const key = account.accountUuid || account.name;
1435
- const hasOwner = !!(config.cloud && config.cloud.session && config.cloud.session.access_token);
1474
+ const session = config.cloud && config.cloud.session;
1475
+ // An owner session is USABLE if its access token is still valid OR it carries a
1476
+ // refresh token (ensureCloudSession can renew it). A token-only login (Google/SSO)
1477
+ // has NO refresh token, so once it expires it's unusable — treating it as usable
1478
+ // would 401 the push and skip the key fallback, leaving the cloud with a dead
1479
+ // refresh token (Codex HIGH). expires_at is seconds.
1480
+ const ownerUsable = !!(session && session.access_token && (
1481
+ (Number(session.expires_at || 0) * 1000) > Date.now() + 30_000 ||
1482
+ (session.refresh_token && String(session.refresh_token).length > 0)
1483
+ ));
1436
1484
  const pullKey = config.cloud?.pullKey || process.env.TEAMCLAUDE_AUTH_KEY;
1437
- // Nothing to sync back to unless we're an admin (owner session) OR a consumer
1438
- // with a pull key. A consumer that rotated a token LOCALLY (cloud refresh was
1439
- // unreachable) MUST report it back, or the cloud keeps a now-dead refresh token
1440
- // and every consumer — plus the cloud's own /sync/refresh — fails invalid_grant.
1441
- if (!hasOwner && !(pullKey && account.accountUuid)) return;
1485
+ const canKey = !!(pullKey && account.accountUuid);
1486
+ if (!ownerUsable && !canKey) return; // nowhere to sync back to
1442
1487
  try {
1443
1488
  if (_autoPushInFlight.has(key)) return;
1444
1489
  _autoPushInFlight.add(key);
1445
- if (hasOwner) {
1446
- const cloud = await ensureCloudSession(config).catch(() => config.cloud);
1447
- await cloudPush(cloud, [{
1448
- accountUuid: account.accountUuid,
1449
- name: account.name,
1450
- subscriptionType: account.subscriptionType,
1451
- tier: account.tier,
1452
- type: account.type,
1453
- accessToken: newTokens.accessToken,
1454
- refreshToken: newTokens.refreshToken,
1455
- expiresAt: newTokens.expiresAt,
1456
- }]);
1457
- } else {
1458
- // Consumer path: key-authenticated write-back (freshest-wins on the server).
1490
+ if (ownerUsable) {
1491
+ try {
1492
+ const cloud = await ensureCloudSession(config).catch(() => config.cloud);
1493
+ await cloudPush(cloud, [{
1494
+ accountUuid: account.accountUuid,
1495
+ name: account.name,
1496
+ subscriptionType: account.subscriptionType,
1497
+ tier: account.tier,
1498
+ type: account.type,
1499
+ accessToken: newTokens.accessToken,
1500
+ refreshToken: newTokens.refreshToken,
1501
+ expiresAt: newTokens.expiresAt,
1502
+ }]);
1503
+ return;
1504
+ } catch (e) {
1505
+ // Owner push failed (e.g. the short-lived token-login expired) — fall back
1506
+ // to the key path so the rotation still reaches the cloud.
1507
+ if (!canKey) throw e;
1508
+ }
1509
+ }
1510
+ if (canKey) {
1511
+ // Consumer / expired-owner path: key-authenticated write-back (freshest-wins).
1459
1512
  await cloudReportToken(resolveCloud(config), pullKey, account.accountUuid, {
1460
1513
  accessToken: newTokens.accessToken,
1461
1514
  refreshToken: newTokens.refreshToken,
@@ -1472,14 +1525,34 @@ async function maybePushRefreshedToken(config, account, newTokens) {
1472
1525
  // ── cloud (SaaS token sync + auth-key control) ──────────────────
1473
1526
 
1474
1527
  // Refresh the owner session if it is expiring; persist the new session.
1528
+ let _cloudSessionRefresh = null; // coalesce concurrent owner-session refreshes
1475
1529
  async function ensureCloudSession(config) {
1476
1530
  const cloud = config.cloud;
1477
1531
  if (!cloud || !cloud.session || !cloud.session.access_token) return cloud;
1478
1532
  const expMs = (Number(cloud.session.expires_at) || 0) * 1000; // Supabase expires_at is seconds
1479
1533
  if (expMs && Date.now() + 60_000 >= expMs && cloud.session.refresh_token) {
1480
- const s = await cloudRefreshSession(cloud, cloud.session.refresh_token);
1481
- cloud.session = s;
1482
- await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), session: s }; });
1534
+ // The owner session has ONE single-use rotating Supabase refresh token. If two
1535
+ // account rotations refresh it concurrently, one wins and the other trips reuse
1536
+ // detection and loses its push (Codex HIGH). Serialize: all concurrent callers
1537
+ // share the SAME in-flight refresh instead of each rotating independently.
1538
+ if (!_cloudSessionRefresh) {
1539
+ const rt = cloud.session.refresh_token;
1540
+ _cloudSessionRefresh = (async () => {
1541
+ try {
1542
+ const s = await cloudRefreshSession(cloud, rt);
1543
+ cloud.session = s;
1544
+ await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), session: s }; });
1545
+ return s;
1546
+ } finally {
1547
+ _cloudSessionRefresh = null;
1548
+ }
1549
+ })();
1550
+ }
1551
+ // NOTE: concurrent refreshes WITHIN this process share the single in-flight
1552
+ // promise above (one rotation). Cross-PROCESS coordination is intentionally not
1553
+ // done here — the codebase assumes a single proxy per config (see
1554
+ // atomicConfigUpdate's documented cross-process limitation).
1555
+ await _cloudSessionRefresh;
1483
1556
  }
1484
1557
  return cloud;
1485
1558
  }
@@ -1499,6 +1572,7 @@ async function cloudCommand() {
1499
1572
  console.log('Cloud commands (SaaS token sync + auth-key control):');
1500
1573
  console.log(' teamclaude cloud pull --key <auth-key> Pull allowed tokens (key-only — no login)');
1501
1574
  console.log(' teamclaude cloud login --email <e> [--password <p>] (admin, to push)');
1575
+ console.log(' teamclaude cloud login --token (Google/SSO — paste the token from the dashboard at the prompt)');
1502
1576
  console.log(' teamclaude cloud push Push local account tokens to the cloud');
1503
1577
  console.log(' teamclaude cloud key create [--label <l>] [--accounts uuid1,uuid2]');
1504
1578
  console.log(' teamclaude cloud key list');
@@ -1508,11 +1582,88 @@ async function cloudCommand() {
1508
1582
  }
1509
1583
  }
1510
1584
 
1585
+ // Decode a JWT payload (base64url) without verifying — we only read exp/email to
1586
+ // stamp the stored session; the server verifies the token on every request.
1587
+ function decodeJwtPayload(token) {
1588
+ try {
1589
+ const part = String(token).split('.')[1] || '';
1590
+ const b64 = part.replace(/-/g, '+').replace(/_/g, '/');
1591
+ return JSON.parse(Buffer.from(b64, 'base64').toString('utf8')) || {};
1592
+ } catch {
1593
+ return {};
1594
+ }
1595
+ }
1596
+
1511
1597
  async function cloudLoginCommand(config) {
1512
1598
  // url/anon-key default to the built-in public endpoint — only --email/--password
1513
1599
  // are required for the owner (admin) login.
1514
1600
  const url = argValue('--url') || config.cloud?.url || process.env.SUPABASE_URL || DEFAULT_CLOUD.url;
1515
1601
  const anonKey = argValue('--anon-key') || config.cloud?.anonKey || process.env.SUPABASE_ANON_KEY || DEFAULT_CLOUD.anonKey;
1602
+
1603
+ // Token login: for Google / SSO owners who have NO password. The dashboard's
1604
+ // "Copy CLI token" button copies just the access token; run `teamclaude cloud
1605
+ // login --token` and PASTE it at the prompt. Reading from stdin keeps the owner
1606
+ // token out of argv / shell history (Codex HIGH). A `--token <value>` (or env)
1607
+ // is still accepted for scripting.
1608
+ const tokenFlag = args.includes('--token');
1609
+ const tokenArg = argValue('--token');
1610
+ let token = (tokenArg && tokenArg !== '-' && !tokenArg.startsWith('--')) ? tokenArg : process.env.TEAMCLAUDE_CLOUD_TOKEN || '';
1611
+ if (!token && tokenFlag) {
1612
+ if (process.stdin.isTTY) {
1613
+ // Read WITHOUT echo via raw mode — the owner token must never land in the
1614
+ // terminal's visible scrollback or a screen recording (Codex HIGH). Raw mode
1615
+ // disables the TTY driver's echo; we buffer chars ourselves and print nothing.
1616
+ token = (await new Promise((resolve) => {
1617
+ process.stderr.write('Paste cloud access token (input hidden): ');
1618
+ const stdin = process.stdin;
1619
+ stdin.setRawMode?.(true);
1620
+ stdin.resume();
1621
+ stdin.setEncoding('utf8');
1622
+ let buf = '';
1623
+ const onData = (chunk) => {
1624
+ for (const ch of chunk) {
1625
+ if (ch === '\n' || ch === '\r' || ch === '\u0004') { // Enter / EOF -> done
1626
+ stdin.removeListener('data', onData);
1627
+ stdin.setRawMode?.(false); stdin.pause();
1628
+ process.stderr.write('\n');
1629
+ return resolve(buf);
1630
+ }
1631
+ if (ch === '\u0003') { stdin.setRawMode?.(false); process.stderr.write('\n'); process.exit(1); } // Ctrl-C
1632
+ else if (ch === '\u007f' || ch === '\b') buf = buf.slice(0, -1); // backspace
1633
+ else buf += ch;
1634
+ }
1635
+ };
1636
+ stdin.on('data', onData);
1637
+ })).trim();
1638
+ } else {
1639
+ // Piped (e.g. pbpaste | teamclaude cloud login --token): read from stdin.
1640
+ token = (await new Promise((resolve) => {
1641
+ let d = ''; process.stdin.on('data', c => (d += c)); process.stdin.on('end', () => resolve(d));
1642
+ })).trim();
1643
+ }
1644
+ }
1645
+ if (token) {
1646
+ const payload = decodeJwtPayload(token);
1647
+ const expSec = Number(payload.exp) || Math.floor(Date.now() / 1000) + 3600; // JWT exp (seconds)
1648
+ const email = payload.email || payload?.user_metadata?.email || config.cloud?.email || null;
1649
+ // Access token ONLY — never store the browser's refresh token. Supabase refresh
1650
+ // tokens are single-use rotating; sharing one between the browser and the CLI
1651
+ // would make them rotate-conflict and revoke each other (reuse detection). So
1652
+ // this is a short-lived (~1h) login — enough to push; re-copy when it expires.
1653
+ const session = { access_token: token, refresh_token: '', expires_at: expSec };
1654
+ // Validate the token actually authorizes as an owner BEFORE overwriting any
1655
+ // existing login — a forged/expired token (or a bogus future `exp`) must not
1656
+ // silently replace a working session.
1657
+ const ok = await cloudValidateOwner({ url, anonKey, session }).catch(() => false);
1658
+ if (!ok) {
1659
+ console.error('Token login failed — the token is invalid or expired. Re-copy "CLI login" from the web dashboard.');
1660
+ process.exit(1);
1661
+ }
1662
+ await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), url, anonKey, email, session }; });
1663
+ console.log(`Logged in to cloud${email ? ` as ${email}` : ''} (token, short-lived ~1h — re-copy from the dashboard when it expires).`);
1664
+ return;
1665
+ }
1666
+
1516
1667
  const email = argValue('--email') || config.cloud?.email || process.env.TEAMCLAUDE_CLOUD_EMAIL;
1517
1668
  let password = argValue('--password') || process.env.TEAMCLAUDE_CLOUD_PASSWORD;
1518
1669
  if (!email) {
package/src/tui.js CHANGED
@@ -114,13 +114,14 @@ function timestamp() {
114
114
  // ── TUI class ────────────────────────────────────────────────
115
115
 
116
116
  export class TUI {
117
- constructor({ accountManager, config, saveConfig, syncAccounts, refreshQuota, onQuit }) {
117
+ constructor({ accountManager, config, saveConfig, syncAccounts, refreshQuota, onQuit, setLocalDisabled }) {
118
118
  this.am = accountManager;
119
119
  this.config = config;
120
120
  this.saveConfig = saveConfig;
121
121
  this.syncAccounts = syncAccounts;
122
122
  this.refreshQuota = refreshQuota; // optional: forced fleet quota re-measure (R)
123
123
  this.onQuit = onQuit;
124
+ this.setLocalDisabled = setLocalDisabled; // optional: persist offline local-disable overlay (by uuid) to disk (fresh-read merge)
124
125
 
125
126
  this.log = []; // completed activity entries
126
127
  this.active = new Map(); // in-flight requests
@@ -518,6 +519,14 @@ export class TUI {
518
519
  || this.config.accounts.find(a => a.name === amAcct.name);
519
520
  if (cfg) cfg.enabled = newEnabled;
520
521
  await this.saveConfig(this.config);
522
+ // Maintain the offline local-disable overlay (cloud.localDisabled, by uuid) at the
523
+ // point of this EXPLICIT toggle. Do it through setLocalDisabled, which re-reads the
524
+ // config from DISK and flips only this uuid — so it merges with a CLI/pull change
525
+ // instead of overwriting the disk set from the TUI's (possibly stale) in-memory
526
+ // copy, and it actually persists (the account-only saveConfig callback does not
527
+ // write cloud.localDisabled). Never pushed or synced to the cloud.
528
+ const uuid = amAcct.accountUuid || cfg?.accountUuid;
529
+ if (uuid && this.setLocalDisabled) await this.setLocalDisabled(uuid, !newEnabled);
521
530
  this._addLog(`${newEnabled ? 'Enabled' : 'Disabled'} "${amAcct.name}"`);
522
531
  }
523
532