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 +3 -1
- package/package.json +1 -1
- package/src/cloud.js +54 -0
- package/src/config.js +15 -5
- package/src/index.js +179 -28
- 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
|
@@ -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
|
-
|
|
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
|
@@ -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) {
|
|
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>]
|
|
@@ -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
|
|
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
|
-
|
|
1438
|
-
|
|
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 (
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
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
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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
|
|