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 +3 -1
- package/package.json +1 -1
- package/src/cloud.js +41 -0
- package/src/config.js +15 -5
- package/src/index.js +43 -4
- package/src/tui.js +10 -1
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
|
|
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
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
|
-
|
|
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.
|
|
130
|
-
*
|
|
131
|
-
*
|
|
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) {
|
|
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 (
|
|
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
|
|