teamclaude-cloud 1.4.3 → 1.5.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 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.3",
3
+ "version": "1.5.1",
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
@@ -416,6 +416,18 @@ export async function cloudKeyMove(cloud, id, groupId) {
416
416
  return res.data; // { ok, id, groupId, group }
417
417
  }
418
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
+
419
431
  export async function cloudKeyList(cloud) {
420
432
  requireSession(cloud);
421
433
  const res = await httpJson(`${fnBase(cloud)}/auth-keys`, {
@@ -557,6 +569,22 @@ export async function cloudAccountSetEnabled(cloud, accountUuid, enabled) {
557
569
  export function mergePulledAccounts(config, pulled, source = null, allowed = null) {
558
570
  const summary = { added: 0, updated: 0, skipped: 0, removed: 0 };
559
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
+ };
560
588
  const applyPriority = (target, incoming) => {
561
589
  if (!('priority' in incoming)) return; // pre-group cloud response — leave local as-is
562
590
  if (Number.isInteger(incoming.priority)) target.priority = incoming.priority;
@@ -590,6 +618,7 @@ export function mergePulledAccounts(config, pulled, source = null, allowed = nul
590
618
  subscriptionType: a.tier || undefined,
591
619
  enabled: true,
592
620
  };
621
+ applyOverlay(fresh); // honor an offline local-disable that survived a prune+re-add
593
622
  addSource(fresh);
594
623
  applyPriority(fresh, a);
595
624
  config.accounts.push(fresh);
@@ -597,6 +626,12 @@ export function mergePulledAccounts(config, pulled, source = null, allowed = nul
597
626
  continue;
598
627
  }
599
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`
600
635
  addSource(cur); // now managed by this key (so this key can prune it later)
601
636
  // A payload entry with no token at all (allowed-but-tokenless) must not clobber
602
637
  // a valid local token — only apply priority/metadata, keep the local token.
@@ -614,6 +649,7 @@ export function mergePulledAccounts(config, pulled, source = null, allowed = nul
614
649
  expiresAt: incomingExp,
615
650
  subscriptionType: a.tier || cur.subscriptionType,
616
651
  };
652
+ applyOverlay(config.accounts[idx]); // re-assert overlay on the rebuilt row
617
653
  applyPriority(config.accounts[idx], a);
618
654
  summary.updated++;
619
655
  }
@@ -641,6 +677,11 @@ export function mergePulledAccounts(config, pulled, source = null, allowed = nul
641
677
  });
642
678
  summary.removed = before - config.accounts.length;
643
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];
644
685
  return summary;
645
686
  }
646
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
@@ -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,39 @@ 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
+ }),
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
+ const fresh = (await loadConfig()) || config;
319
+ const cloud = await ensureCloudSession(fresh);
320
+ if (!cloud?.session?.access_token) throw new Error('Not logged in to cloud — run `teamclaude cloud login`');
321
+ const accounts = (config.accounts || []).filter(a => a.type !== 'apikey').map(a => {
322
+ const am = (a.accountUuid && accountManager.accounts.find(x => x.accountUuid === a.accountUuid))
323
+ || accountManager.accounts.find(x => x.name === a.name);
324
+ return am ? { ...a, accessToken: am.credential, refreshToken: am.refreshToken, expiresAt: am.expiresAt } : a;
325
+ });
326
+ if (!accounts.length) throw new Error('No OAuth accounts to push');
327
+ const usageByUuid = new Map();
328
+ const quotaCache = await readQuotaCache();
329
+ for (const entry of quotaCache?.accounts || []) {
330
+ if (!entry?.accountUuid) continue;
331
+ const usage = buildUsageFromQuota(entry);
332
+ if (usage) usageByUuid.set(entry.accountUuid, usage);
333
+ }
334
+ return await cloudPush(cloud, accounts, usageByUuid);
335
+ }) : undefined,
293
336
  });
294
337
  hooks = {
295
338
  onRequestStart: (id, info) => tui.onRequestStart(id, info),
@@ -1026,11 +1069,29 @@ async function setEnabledCommand(enabled) {
1026
1069
  let found = false;
1027
1070
  const config = await atomicConfigUpdate(cfg => {
1028
1071
  const acct = cfg.accounts.find(a => a.name === name);
1029
- if (acct) { acct.enabled = enabled; found = true; }
1072
+ if (acct) {
1073
+ acct.enabled = enabled;
1074
+ found = true;
1075
+ // Maintain the persistent, client-only local-disable overlay (by uuid). This is
1076
+ // an OFFLINE choice — never pushed or synced to the cloud — and it survives a pull
1077
+ // prune+re-add. A cloud-disabled account is pruned (absent here), so `enable` can't
1078
+ // find it → the admin's disable cannot be overridden by the client.
1079
+ if (acct.accountUuid) {
1080
+ const cloud = cfg.cloud || (cfg.cloud = {});
1081
+ const set = new Set(Array.isArray(cloud.localDisabled) ? cloud.localDisabled : []);
1082
+ if (enabled) set.delete(acct.accountUuid); else set.add(acct.accountUuid);
1083
+ cloud.localDisabled = [...set];
1084
+ }
1085
+ }
1030
1086
  });
1031
1087
  if (!found) { console.error(`Account "${name}" not found`); process.exit(1); }
1032
1088
  console.log(`${enabled ? 'Enabled' : 'Disabled'} account "${name}"`);
1033
- if (!enabled) console.log(' (excluded from active rotation; in-flight requests still finish)');
1089
+ if (!enabled) console.log(' (excluded from active rotation, offline-only — not pushed or synced to the cloud; in-flight requests still finish)');
1090
+ // Honesty about the authority model (HIGH): `enable` clears only the LOCAL override.
1091
+ // The cloud allow-set still governs — an account the admin disabled in the cloud is
1092
+ // pruned on the next pull (a running server re-applies this every sync interval), so
1093
+ // this re-enable is effective only while the account is still cloud-allowed.
1094
+ else console.log(' (local override cleared; the cloud allow-set still governs — a pull/live-sync re-applies any admin cloud-disable)');
1034
1095
  await noteRunningServerReload(config);
1035
1096
  }
1036
1097
 
@@ -1081,10 +1142,12 @@ Commands:
1081
1142
  status Show proxy & account status (live)
1082
1143
  accounts List configured accounts
1083
1144
  remove <name> Remove an account
1084
- disable <name> Disable an account (excluded from rotation)
1085
- enable <name> Re-enable a disabled account
1145
+ disable <name> Disable an account (offline-only; never pushed/synced to cloud)
1146
+ enable <name> Re-enable a locally-disabled account (cannot re-enable one the
1147
+ admin disabled in the cloud — that stays excluded)
1086
1148
  priority <name> <n> Set selection priority (lower = preferred; "auto" to return
1087
1149
  to automatic ordering — weekly reset soonest drained first)
1150
+ push Shortcut for "cloud push" — upload local tokens to the cloud
1088
1151
  api <path> Call an API endpoint with account credentials
1089
1152
  cloud <sub> Cloud SaaS: sync tokens + group/auth-key based token control
1090
1153
  cloud login --url <u> --anon-key <k> --email <e> [--password <p>]
package/src/tui.js CHANGED
@@ -114,13 +114,15 @@ 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, cloudPush }) {
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)
125
+ this.cloudPush = cloudPush; // optional: push account tokens to the cloud (`p`) — returns {pushed,updated,skipped,usage}
124
126
 
125
127
  this.log = []; // completed activity entries
126
128
  this.active = new Map(); // in-flight requests
@@ -262,6 +264,7 @@ export class TUI {
262
264
  if (k === 'q') { this.stop(); this.onQuit?.(); return; }
263
265
  if (k === 'a') { this.mode = 'add'; return; }
264
266
  if (k === 'R') { this._doSync(); return; }
267
+ if (k === 'p') { this._doPush(); return; }
265
268
 
266
269
  if (this.am.accounts.length === 0) return;
267
270
 
@@ -389,6 +392,20 @@ export class TUI {
389
392
  }
390
393
  }
391
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
+ const usageNote = (r && r.usage != null) ? `, usage ${r.usage}` : '';
403
+ this._addLog(`Cloud push: ${r?.pushed ?? 0} new, ${r?.updated ?? 0} updated, ${r?.skipped ?? 0} skipped${usageNote}`);
404
+ } catch (e) {
405
+ this._addLog(`Cloud push failed: ${e.message}`);
406
+ }
407
+ }
408
+
392
409
  async _doImport() {
393
410
  try {
394
411
  this._addLog('Importing credentials...');
@@ -518,6 +535,14 @@ export class TUI {
518
535
  || this.config.accounts.find(a => a.name === amAcct.name);
519
536
  if (cfg) cfg.enabled = newEnabled;
520
537
  await this.saveConfig(this.config);
538
+ // Maintain the offline local-disable overlay (cloud.localDisabled, by uuid) at the
539
+ // point of this EXPLICIT toggle. Do it through setLocalDisabled, which re-reads the
540
+ // config from DISK and flips only this uuid — so it merges with a CLI/pull change
541
+ // instead of overwriting the disk set from the TUI's (possibly stale) in-memory
542
+ // copy, and it actually persists (the account-only saveConfig callback does not
543
+ // write cloud.localDisabled). Never pushed or synced to the cloud.
544
+ const uuid = amAcct.accountUuid || cfg?.accountUuid;
545
+ if (uuid && this.setLocalDisabled) await this.setLocalDisabled(uuid, !newEnabled);
521
546
  this._addLog(`${newEnabled ? 'Enabled' : 'Disabled'} "${amAcct.name}"`);
522
547
  }
523
548
 
@@ -826,8 +851,11 @@ export class TUI {
826
851
 
827
852
  _renderFooter() {
828
853
  switch (this.mode) {
829
- case 'normal':
830
- 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`;
854
+ case 'normal': {
855
+ // `p`ush is only shown when a cloud config is present (the callback is wired).
856
+ const push = this.cloudPush ? ` ${bold('p')}ush` : '';
857
+ 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`;
858
+ }
831
859
  case 'select':
832
860
  return ` ${dim('↑↓')} select ${bold('Enter')} delete ${bold('Esc')} cancel`;
833
861
  case 'order':