teamclaude-cloud 1.4.3 → 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.3",
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
@@ -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,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>]
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