@karpeleslab/teamclaude 1.0.4 → 1.0.6
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 +29 -29
- package/package.json +1 -1
- package/src/account-manager.js +5 -1
- package/src/index.js +129 -32
- package/src/oauth.js +79 -9
- package/src/server.js +77 -68
- package/src/tui.js +48 -8
package/README.md
CHANGED
|
@@ -10,8 +10,9 @@ Sits transparently between Claude Code and the Anthropic API, managing multiple
|
|
|
10
10
|
|
|
11
11
|
- **Automatic account rotation** — switches to the next account when session (5h) or weekly (7d) quota reaches the configured threshold (default 98%)
|
|
12
12
|
- **Auto-retry on 429** — if an account is rate-limited, transparently retries with the next one
|
|
13
|
-
- **Interactive TUI** — real-time dashboard with quota bars, activity log, and keyboard controls
|
|
14
|
-
- **OAuth token refresh** — proactively refreshes expiring tokens and persists them to config
|
|
13
|
+
- **Interactive TUI** — real-time dashboard with color-coded quota bars showing reset countdowns, activity log, and keyboard controls
|
|
14
|
+
- **OAuth token refresh** — proactively refreshes expiring tokens, intercepts client token renewals, and persists them to config
|
|
15
|
+
- **Hot-reload accounts** — add accounts via `import` or `login` while the server is running, press **R** to pick them up
|
|
15
16
|
- **Request logging** — optional full request/response logging for debugging
|
|
16
17
|
- **Zero dependencies** — uses only Node.js built-in modules
|
|
17
18
|
|
|
@@ -23,12 +24,11 @@ Requires Node.js 18+.
|
|
|
23
24
|
# Install
|
|
24
25
|
npm install -g @karpeleslab/teamclaude
|
|
25
26
|
|
|
26
|
-
#
|
|
27
|
-
teamclaude
|
|
27
|
+
# Add your first account (opens browser for OAuth)
|
|
28
|
+
teamclaude login
|
|
28
29
|
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
# teamclaude import
|
|
30
|
+
# Add a second account
|
|
31
|
+
teamclaude login
|
|
32
32
|
|
|
33
33
|
# Start the proxy
|
|
34
34
|
teamclaude server
|
|
@@ -37,27 +37,37 @@ teamclaude server
|
|
|
37
37
|
teamclaude run
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
You can also import existing Claude Code credentials instead of logging in:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
claude /login # Log into an account in Claude Code
|
|
44
|
+
teamclaude import # Import its credentials
|
|
45
|
+
```
|
|
46
|
+
|
|
40
47
|
## Adding Accounts
|
|
41
48
|
|
|
42
|
-
###
|
|
49
|
+
### OAuth Login (recommended)
|
|
43
50
|
|
|
44
|
-
The easiest way to add accounts
|
|
51
|
+
The easiest way to add accounts — opens your browser for authentication:
|
|
45
52
|
|
|
46
53
|
```bash
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
teamclaude login
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Uses the same OAuth flow as Claude Code. Auto-detects the account email and subscription tier. Logging in with the same account again updates its credentials.
|
|
49
58
|
|
|
50
|
-
|
|
51
|
-
teamclaude import
|
|
59
|
+
You can add accounts while the server is running — press **R** in the TUI to reload.
|
|
52
60
|
|
|
53
|
-
|
|
54
|
-
claude /login
|
|
61
|
+
### Import from Claude Code
|
|
55
62
|
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
If you already have Claude Code set up, you can import its credentials directly:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
claude /login # Log into an account in Claude Code
|
|
67
|
+
teamclaude import # Import its credentials
|
|
58
68
|
```
|
|
59
69
|
|
|
60
|
-
|
|
70
|
+
Re-importing the same account updates its credentials.
|
|
61
71
|
|
|
62
72
|
### API Key
|
|
63
73
|
|
|
@@ -67,16 +77,6 @@ For Anthropic API key accounts (billed via Console):
|
|
|
67
77
|
teamclaude login --api
|
|
68
78
|
```
|
|
69
79
|
|
|
70
|
-
### OAuth Login (experimental)
|
|
71
|
-
|
|
72
|
-
Direct browser-based OAuth login without needing Claude Code:
|
|
73
|
-
|
|
74
|
-
```bash
|
|
75
|
-
teamclaude login
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
> **Note:** OAuth login is currently experimental. Tokens obtained this way may not work for proxying `/v1/messages` requests. Use `teamclaude import` as the reliable method.
|
|
79
|
-
|
|
80
80
|
## Usage
|
|
81
81
|
|
|
82
82
|
### Start the proxy server
|
|
@@ -88,7 +88,7 @@ teamclaude server
|
|
|
88
88
|
When running from a TTY, shows an interactive TUI with:
|
|
89
89
|
- Account table with session/weekly quota progress bars
|
|
90
90
|
- Real-time activity log with request tracking
|
|
91
|
-
- Keyboard shortcuts: **s**witch, **a**dd, **r**emove, **q**uit
|
|
91
|
+
- Keyboard shortcuts: **s**witch, **a**dd, **r**emove, **R**eload, **q**uit
|
|
92
92
|
|
|
93
93
|
Falls back to plain log output when not a TTY (e.g. running as a service).
|
|
94
94
|
|
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -252,7 +252,11 @@ export class AccountManager {
|
|
|
252
252
|
this._onTokenRefresh?.(accountIndex, newTokens);
|
|
253
253
|
} catch (err) {
|
|
254
254
|
console.error(`[TeamClaude] Token refresh failed for "${account.name}": ${err.message}`);
|
|
255
|
-
|
|
255
|
+
// Only mark as error if the access token is actually expired;
|
|
256
|
+
// a failed proactive refresh shouldn't kill a still-valid token
|
|
257
|
+
if (!account.expiresAt || Date.now() >= account.expiresAt) {
|
|
258
|
+
account.status = 'error';
|
|
259
|
+
}
|
|
256
260
|
} finally {
|
|
257
261
|
account._refreshPromise = null;
|
|
258
262
|
}
|
package/src/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import { createInterface } from 'node:readline';
|
|
5
5
|
import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath } from './config.js';
|
|
6
6
|
import { AccountManager } from './account-manager.js';
|
|
7
7
|
import { createProxyServer } from './server.js';
|
|
8
|
-
import { importCredentials, loginOAuth, fetchProfile } from './oauth.js';
|
|
8
|
+
import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
|
|
9
9
|
import { TUI } from './tui.js';
|
|
10
10
|
|
|
11
11
|
const args = process.argv.slice(2);
|
|
@@ -15,29 +15,36 @@ switch (command) {
|
|
|
15
15
|
case 'server':
|
|
16
16
|
await serverCommand();
|
|
17
17
|
break;
|
|
18
|
+
case 'run':
|
|
19
|
+
await runCommand();
|
|
20
|
+
break;
|
|
18
21
|
case 'import':
|
|
19
22
|
await importCommand();
|
|
23
|
+
process.exit(0);
|
|
20
24
|
break;
|
|
21
25
|
case 'login':
|
|
22
26
|
await loginCommand();
|
|
27
|
+
process.exit(0);
|
|
23
28
|
break;
|
|
24
29
|
case 'env':
|
|
25
30
|
await envCommand();
|
|
26
|
-
|
|
27
|
-
case 'run':
|
|
28
|
-
await runCommand();
|
|
31
|
+
process.exit(0);
|
|
29
32
|
break;
|
|
30
33
|
case 'status':
|
|
31
34
|
await statusCommand();
|
|
35
|
+
process.exit(0);
|
|
32
36
|
break;
|
|
33
37
|
case 'accounts':
|
|
34
38
|
await accountsCommand();
|
|
39
|
+
process.exit(0);
|
|
35
40
|
break;
|
|
36
41
|
case 'remove':
|
|
37
42
|
await removeCommand();
|
|
43
|
+
process.exit(0);
|
|
38
44
|
break;
|
|
39
45
|
case 'api':
|
|
40
46
|
await apiCommand();
|
|
47
|
+
process.exit(0);
|
|
41
48
|
break;
|
|
42
49
|
case 'help':
|
|
43
50
|
case '--help':
|
|
@@ -87,8 +94,23 @@ async function serverCommand() {
|
|
|
87
94
|
accountManager.onTokenRefresh((idx, newTokens) => {
|
|
88
95
|
const account = accountManager.accounts[idx];
|
|
89
96
|
if (!account) return;
|
|
97
|
+
// Keep config.accounts in sync so TUI saveConfig doesn't clobber fresh tokens
|
|
98
|
+
if (config.accounts[idx]) {
|
|
99
|
+
config.accounts[idx].accessToken = newTokens.accessToken;
|
|
100
|
+
config.accounts[idx].refreshToken = newTokens.refreshToken;
|
|
101
|
+
config.accounts[idx].expiresAt = newTokens.expiresAt;
|
|
102
|
+
}
|
|
90
103
|
atomicConfigUpdate(diskConfig => {
|
|
91
|
-
|
|
104
|
+
// Pick up any new accounts from disk so index matching stays correct
|
|
105
|
+
// (only add, don't refresh credentials — we're about to write the authoritative tokens)
|
|
106
|
+
for (const diskAcct of diskConfig.accounts) {
|
|
107
|
+
const known = (diskAcct.accountUuid && config.accounts.some(a => a.accountUuid === diskAcct.accountUuid))
|
|
108
|
+
|| config.accounts.some(a => a.name === diskAcct.name);
|
|
109
|
+
if (!known) {
|
|
110
|
+
config.accounts.push(diskAcct);
|
|
111
|
+
accountManager.addAccount(diskAcct);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
92
114
|
// Match by UUID first, then by name — index may have shifted
|
|
93
115
|
const cfgIdx = findConfigAccount(diskConfig, account);
|
|
94
116
|
if (cfgIdx >= 0) {
|
|
@@ -107,22 +129,28 @@ async function serverCommand() {
|
|
|
107
129
|
if (useTUI) {
|
|
108
130
|
tui = new TUI({
|
|
109
131
|
accountManager, config,
|
|
110
|
-
saveConfig: () => atomicConfigUpdate(diskConfig => {
|
|
111
|
-
|
|
112
|
-
//
|
|
113
|
-
|
|
132
|
+
saveConfig: () => atomicConfigUpdate(async diskConfig => {
|
|
133
|
+
// Write in-memory accounts as the authoritative state, preserving
|
|
134
|
+
// extra disk-only fields (e.g. importFrom) where the account still exists.
|
|
135
|
+
// Use live tokens from AccountManager (not the stale config.accounts copy).
|
|
136
|
+
diskConfig.accounts = config.accounts.map((a, i) => {
|
|
137
|
+
const am = accountManager.accounts[i];
|
|
138
|
+
const live = am ? {
|
|
139
|
+
...a,
|
|
140
|
+
accessToken: am.credential,
|
|
141
|
+
refreshToken: am.refreshToken,
|
|
142
|
+
expiresAt: am.expiresAt,
|
|
143
|
+
} : a;
|
|
114
144
|
const diskAcct = diskConfig.accounts.find(
|
|
115
145
|
d => (a.accountUuid && d.accountUuid === a.accountUuid) || d.name === a.name
|
|
116
146
|
);
|
|
117
|
-
return diskAcct ? { ...diskAcct, ...
|
|
147
|
+
return diskAcct ? { ...diskAcct, ...live } : live;
|
|
118
148
|
});
|
|
119
149
|
}),
|
|
120
150
|
syncAccounts: async () => {
|
|
121
151
|
const diskConfig = await loadConfig();
|
|
122
152
|
if (!diskConfig) return 0;
|
|
123
|
-
|
|
124
|
-
syncNewAccountsFromDisk(diskConfig, config, accountManager);
|
|
125
|
-
return accountManager.accounts.length - before;
|
|
153
|
+
return syncAccountsFromDisk(diskConfig, config, accountManager);
|
|
126
154
|
},
|
|
127
155
|
onQuit: () => { server.close(() => process.exit(0)); },
|
|
128
156
|
});
|
|
@@ -292,7 +320,8 @@ async function runCommand() {
|
|
|
292
320
|
// Only set ANTHROPIC_BASE_URL — Claude Code keeps its own OAuth token
|
|
293
321
|
// which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
|
|
294
322
|
// lets Claude Code stay in subscription mode (full model access).
|
|
295
|
-
|
|
323
|
+
// Use spawnSync so the Node process blocks entirely — behaves like execvp.
|
|
324
|
+
const result = spawnSync('claude', claudeArgs, {
|
|
296
325
|
stdio: 'inherit',
|
|
297
326
|
env: {
|
|
298
327
|
...process.env,
|
|
@@ -300,16 +329,16 @@ async function runCommand() {
|
|
|
300
329
|
},
|
|
301
330
|
});
|
|
302
331
|
|
|
303
|
-
|
|
304
|
-
if (
|
|
332
|
+
if (result.error) {
|
|
333
|
+
if (result.error.code === 'ENOENT') {
|
|
305
334
|
console.error('Claude Code not found in PATH. Install it first.');
|
|
306
335
|
} else {
|
|
307
|
-
console.error(`Failed to start claude: ${
|
|
336
|
+
console.error(`Failed to start claude: ${result.error.message}`);
|
|
308
337
|
}
|
|
309
338
|
process.exit(1);
|
|
310
|
-
}
|
|
339
|
+
}
|
|
311
340
|
|
|
312
|
-
|
|
341
|
+
process.exit(result.status ?? 1);
|
|
313
342
|
}
|
|
314
343
|
|
|
315
344
|
// ── status ──────────────────────────────────────────────────
|
|
@@ -365,6 +394,23 @@ async function accountsCommand() {
|
|
|
365
394
|
return;
|
|
366
395
|
}
|
|
367
396
|
|
|
397
|
+
// Refresh expired tokens before fetching profiles
|
|
398
|
+
let configDirty = false;
|
|
399
|
+
await Promise.all(config.accounts.map(async (a) => {
|
|
400
|
+
if (a.type !== 'oauth' || !a.refreshToken) return;
|
|
401
|
+
if (!isTokenExpiringSoon(a.expiresAt)) return;
|
|
402
|
+
try {
|
|
403
|
+
const newTokens = await refreshAccessToken(a.refreshToken);
|
|
404
|
+
a.accessToken = newTokens.accessToken;
|
|
405
|
+
a.refreshToken = newTokens.refreshToken;
|
|
406
|
+
a.expiresAt = newTokens.expiresAt;
|
|
407
|
+
configDirty = true;
|
|
408
|
+
} catch (err) {
|
|
409
|
+
// refresh failed — fetchProfile will report the specific error
|
|
410
|
+
}
|
|
411
|
+
}));
|
|
412
|
+
if (configDirty) await saveConfig(config);
|
|
413
|
+
|
|
368
414
|
// Fetch profiles in parallel for all OAuth accounts
|
|
369
415
|
const profiles = await Promise.all(
|
|
370
416
|
config.accounts.map(a =>
|
|
@@ -386,7 +432,7 @@ async function accountsCommand() {
|
|
|
386
432
|
} else {
|
|
387
433
|
seen.set(uuid, i);
|
|
388
434
|
// Update stored UUID and name from profile
|
|
389
|
-
if (profiles[i]) {
|
|
435
|
+
if (profiles[i] && !profiles[i].error) {
|
|
390
436
|
a.accountUuid = profiles[i].accountUuid;
|
|
391
437
|
if (profiles[i].email) a.name = profiles[i].email;
|
|
392
438
|
}
|
|
@@ -407,12 +453,13 @@ async function accountsCommand() {
|
|
|
407
453
|
}
|
|
408
454
|
|
|
409
455
|
// OAuth account
|
|
410
|
-
const
|
|
411
|
-
const
|
|
456
|
+
const hasProfile = p && !p.error;
|
|
457
|
+
const tier = hasProfile ? (p.hasClaudeMax ? 'Max' : p.hasClaudePro ? 'Pro' : 'subscription') : null;
|
|
458
|
+
const status = hasProfile ? `Claude ${tier}` : `unknown (${p?.error || 'no token'})`;
|
|
412
459
|
const src = a.source ? `, ${a.source}` : '';
|
|
413
460
|
console.log(` [${i + 1}] ${a.name} (${status}${src})`);
|
|
414
|
-
if (p
|
|
415
|
-
if (p
|
|
461
|
+
if (hasProfile && p.email && p.email !== a.name) console.log(` Email: ${p.email}`);
|
|
462
|
+
if (hasProfile && p.orgName) console.log(` Org: ${p.orgName}`);
|
|
416
463
|
if (verbose && a.expiresAt) {
|
|
417
464
|
const remaining = a.expiresAt - Date.now();
|
|
418
465
|
if (remaining <= 0) {
|
|
@@ -543,7 +590,11 @@ Config: ${getConfigPath()}
|
|
|
543
590
|
async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
|
|
544
591
|
// Fetch profile to auto-name and deduplicate by account UUID
|
|
545
592
|
const profile = await fetchProfile(creds.accessToken);
|
|
593
|
+
const profileOk = profile && !profile.error;
|
|
546
594
|
|
|
595
|
+
if (!profileOk) {
|
|
596
|
+
console.error(`Warning: could not fetch account profile — ${profile?.error || 'no token'}`);
|
|
597
|
+
}
|
|
547
598
|
if (!name && profile?.email) {
|
|
548
599
|
name = profile.email;
|
|
549
600
|
const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
|
|
@@ -596,22 +647,68 @@ function findConfigAccount(diskConfig, account) {
|
|
|
596
647
|
}
|
|
597
648
|
|
|
598
649
|
/**
|
|
599
|
-
*
|
|
600
|
-
*
|
|
650
|
+
* Sync accounts from disk config: add new accounts and refresh credentials
|
|
651
|
+
* for existing ones (handles re-imported OAuth tokens, rotated API keys, etc.).
|
|
652
|
+
* Returns the number of new accounts added.
|
|
601
653
|
*/
|
|
602
|
-
function
|
|
654
|
+
async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
|
|
655
|
+
let added = 0;
|
|
603
656
|
for (const diskAcct of diskConfig.accounts) {
|
|
604
|
-
const
|
|
605
|
-
memConfig.accounts.
|
|
606
|
-
const
|
|
657
|
+
const matchByUuid = diskAcct.accountUuid &&
|
|
658
|
+
memConfig.accounts.findIndex(a => a.accountUuid === diskAcct.accountUuid);
|
|
659
|
+
const matchByName = memConfig.accounts.findIndex(a => a.name === diskAcct.name);
|
|
660
|
+
const memIdx = (matchByUuid >= 0 ? matchByUuid : null) ?? (matchByName >= 0 ? matchByName : -1);
|
|
607
661
|
|
|
608
|
-
if (
|
|
662
|
+
if (memIdx < 0) {
|
|
609
663
|
// New account discovered on disk — add to running server
|
|
610
664
|
memConfig.accounts.push(diskAcct);
|
|
611
665
|
accountManager.addAccount(diskAcct);
|
|
666
|
+
added++;
|
|
612
667
|
console.log(`[TeamClaude] Picked up new account "${diskAcct.name}" from config`);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Existing account — resolve fresh credentials from disk
|
|
672
|
+
let freshCred = null;
|
|
673
|
+
if (diskAcct.type === 'oauth' && diskAcct.importFrom) {
|
|
674
|
+
try {
|
|
675
|
+
const creds = await importCredentials(diskAcct.importFrom);
|
|
676
|
+
freshCred = { accessToken: creds.accessToken, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt };
|
|
677
|
+
} catch (err) {
|
|
678
|
+
console.error(`[TeamClaude] Re-import failed for "${diskAcct.name}": ${err.message}`);
|
|
679
|
+
}
|
|
680
|
+
} else if (diskAcct.type === 'oauth' && diskAcct.accessToken) {
|
|
681
|
+
freshCred = { accessToken: diskAcct.accessToken, refreshToken: diskAcct.refreshToken, expiresAt: diskAcct.expiresAt };
|
|
682
|
+
} else if (diskAcct.type === 'apikey' && diskAcct.apiKey) {
|
|
683
|
+
freshCred = { apiKey: diskAcct.apiKey };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (!freshCred) continue;
|
|
687
|
+
|
|
688
|
+
// Find the corresponding AccountManager entry and update credentials
|
|
689
|
+
const mgr = accountManager.accounts.find(a =>
|
|
690
|
+
(diskAcct.accountUuid && a.accountUuid === diskAcct.accountUuid) || a.name === diskAcct.name
|
|
691
|
+
);
|
|
692
|
+
if (!mgr) continue;
|
|
693
|
+
|
|
694
|
+
if (freshCred.accessToken) {
|
|
695
|
+
const changed = mgr.credential !== freshCred.accessToken ||
|
|
696
|
+
mgr.refreshToken !== freshCred.refreshToken;
|
|
697
|
+
// Don't overwrite in-memory credentials with staler ones from disk
|
|
698
|
+
// (e.g. after a TUI import updated the AM before saveConfig wrote to disk)
|
|
699
|
+
const diskIsStaler = freshCred.expiresAt && mgr.expiresAt &&
|
|
700
|
+
freshCred.expiresAt < mgr.expiresAt;
|
|
701
|
+
if (changed && !diskIsStaler) {
|
|
702
|
+
accountManager.updateAccountTokens(mgr.index, freshCred);
|
|
703
|
+
console.log(`[TeamClaude] Refreshed credentials for "${mgr.name}"`);
|
|
704
|
+
}
|
|
705
|
+
} else if (freshCred.apiKey && mgr.credential !== freshCred.apiKey) {
|
|
706
|
+
mgr.credential = freshCred.apiKey;
|
|
707
|
+
if (mgr.status === 'error') mgr.status = 'active';
|
|
708
|
+
console.log(`[TeamClaude] Updated API key for "${mgr.name}"`);
|
|
613
709
|
}
|
|
614
710
|
}
|
|
711
|
+
return added;
|
|
615
712
|
}
|
|
616
713
|
|
|
617
714
|
// ── helpers ─────────────────────────────────────────────────
|
package/src/oauth.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { randomBytes, createHash } from 'node:crypto';
|
|
4
4
|
import { exec } from 'node:child_process';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
5
6
|
import http from 'node:http';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -68,7 +69,7 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
|
|
|
68
69
|
return {
|
|
69
70
|
accessToken: data.access_token,
|
|
70
71
|
refreshToken: data.refresh_token || refreshToken,
|
|
71
|
-
expiresAt: data.expires_at || (Date.now() + (data.expires_in || 3600) * 1000),
|
|
72
|
+
expiresAt: normalizeExpiresAt(data.expires_at) || (Date.now() + (data.expires_in || 3600) * 1000),
|
|
72
73
|
};
|
|
73
74
|
} catch (err) {
|
|
74
75
|
const isNetworkError = err instanceof Error &&
|
|
@@ -84,24 +85,45 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Normalize an expires_at value to milliseconds.
|
|
90
|
+
* OAuth endpoints may return seconds; Claude Code credentials use milliseconds.
|
|
91
|
+
*/
|
|
92
|
+
export function normalizeExpiresAt(expiresAt) {
|
|
93
|
+
if (!expiresAt) return expiresAt;
|
|
94
|
+
// If the value is plausibly in seconds (< 10^12 ≈ year 2001 in ms, year 33658 in s),
|
|
95
|
+
// convert to milliseconds
|
|
96
|
+
return expiresAt < 1e12 ? expiresAt * 1000 : expiresAt;
|
|
97
|
+
}
|
|
98
|
+
|
|
87
99
|
/**
|
|
88
100
|
* Check if an OAuth token is expiring within the given threshold.
|
|
89
101
|
*/
|
|
90
102
|
export function isTokenExpiringSoon(expiresAt, thresholdMs = 5 * 60 * 1000) {
|
|
91
103
|
if (!expiresAt) return false;
|
|
92
|
-
return Date.now() + thresholdMs >= expiresAt;
|
|
104
|
+
return Date.now() + thresholdMs >= normalizeExpiresAt(expiresAt);
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
/**
|
|
96
108
|
* Fetch account profile for an OAuth token.
|
|
97
|
-
* Returns { email, name, orgName, orgType }
|
|
109
|
+
* Returns { email, name, orgName, orgType, ... } on success,
|
|
110
|
+
* or { error: 'reason' } on failure.
|
|
98
111
|
*/
|
|
99
112
|
export async function fetchProfile(accessToken) {
|
|
100
113
|
try {
|
|
101
114
|
const res = await fetch(PROFILE_URL, {
|
|
102
115
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
103
116
|
});
|
|
104
|
-
if (!res.ok)
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
let detail = '';
|
|
119
|
+
try {
|
|
120
|
+
const body = await res.json();
|
|
121
|
+
detail = body?.error?.message || JSON.stringify(body).slice(0, 200);
|
|
122
|
+
} catch {
|
|
123
|
+
detail = await res.text().catch(() => '');
|
|
124
|
+
}
|
|
125
|
+
return { error: `HTTP ${res.status}${detail ? ': ' + detail : ''}` };
|
|
126
|
+
}
|
|
105
127
|
const data = await res.json();
|
|
106
128
|
return {
|
|
107
129
|
accountUuid: data.account?.uuid,
|
|
@@ -112,8 +134,8 @@ export async function fetchProfile(accessToken) {
|
|
|
112
134
|
hasClaudeMax: data.account?.has_claude_max,
|
|
113
135
|
hasClaudePro: data.account?.has_claude_pro,
|
|
114
136
|
};
|
|
115
|
-
} catch {
|
|
116
|
-
return
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return { error: err.message || String(err) };
|
|
117
139
|
}
|
|
118
140
|
}
|
|
119
141
|
|
|
@@ -153,10 +175,10 @@ export async function loginOAuth() {
|
|
|
153
175
|
console.log(`If it doesn't open, visit:\n ${authUrl.toString()}\n`);
|
|
154
176
|
openBrowser(authUrl.toString());
|
|
155
177
|
|
|
156
|
-
// Wait for the
|
|
178
|
+
// Wait for either the callback server or manual paste from stdin
|
|
157
179
|
let code;
|
|
158
180
|
try {
|
|
159
|
-
code = await codePromise;
|
|
181
|
+
code = await raceWithStdinCode(codePromise, state);
|
|
160
182
|
} finally {
|
|
161
183
|
server.close();
|
|
162
184
|
}
|
|
@@ -185,10 +207,58 @@ export async function loginOAuth() {
|
|
|
185
207
|
return {
|
|
186
208
|
accessToken: tokens.access_token,
|
|
187
209
|
refreshToken: tokens.refresh_token,
|
|
188
|
-
expiresAt: tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000),
|
|
210
|
+
expiresAt: normalizeExpiresAt(tokens.expires_at) || (Date.now() + (tokens.expires_in || 3600) * 1000),
|
|
189
211
|
};
|
|
190
212
|
}
|
|
191
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Race the callback server promise against manual code entry from stdin.
|
|
216
|
+
* The user can paste the full callback URL or just the authorization code.
|
|
217
|
+
*/
|
|
218
|
+
function raceWithStdinCode(callbackPromise, expectedState) {
|
|
219
|
+
if (!process.stdin.isTTY) return callbackPromise;
|
|
220
|
+
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
223
|
+
let settled = false;
|
|
224
|
+
|
|
225
|
+
const settle = (fn, val) => {
|
|
226
|
+
if (settled) return;
|
|
227
|
+
settled = true;
|
|
228
|
+
rl.close();
|
|
229
|
+
fn(val);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
rl.question('Paste authorization code here (or wait for browser callback): ', answer => {
|
|
233
|
+
const trimmed = answer.trim();
|
|
234
|
+
if (!trimmed) return; // empty input, keep waiting for callback
|
|
235
|
+
|
|
236
|
+
// Try to parse as a URL with ?code= parameter
|
|
237
|
+
try {
|
|
238
|
+
const url = new URL(trimmed);
|
|
239
|
+
const code = url.searchParams.get('code');
|
|
240
|
+
const state = url.searchParams.get('state');
|
|
241
|
+
if (code) {
|
|
242
|
+
if (expectedState && state && state !== expectedState) {
|
|
243
|
+
settle(reject, new Error('OAuth state mismatch'));
|
|
244
|
+
} else {
|
|
245
|
+
settle(resolve, code);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
} catch {}
|
|
250
|
+
|
|
251
|
+
// Treat raw input as the authorization code
|
|
252
|
+
settle(resolve, trimmed);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
callbackPromise.then(
|
|
256
|
+
code => settle(resolve, code),
|
|
257
|
+
err => settle(reject, err),
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
192
262
|
function startCallbackServer(expectedState) {
|
|
193
263
|
return new Promise((resolve, reject) => {
|
|
194
264
|
let resolveCode, rejectCode;
|
package/src/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import http from 'node:http';
|
|
|
2
2
|
import { writeFile, mkdir } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
|
|
5
|
+
|
|
5
6
|
const HOP_BY_HOP_HEADERS = new Set([
|
|
6
7
|
'host', 'connection', 'keep-alive', 'transfer-encoding',
|
|
7
8
|
'te', 'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate',
|
|
@@ -39,9 +40,11 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
39
40
|
return;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
//
|
|
43
|
+
// Let client token refresh requests pass through to upstream untouched.
|
|
44
|
+
// The proxy manages its own tokens via ensureTokenFresh(); intercepting
|
|
45
|
+
// or rewriting client refreshes would cause token rotation conflicts.
|
|
43
46
|
if (req.method === 'POST' && req.url === '/v1/oauth/token') {
|
|
44
|
-
await
|
|
47
|
+
await relayRaw(req, res, upstream);
|
|
45
48
|
return;
|
|
46
49
|
}
|
|
47
50
|
|
|
@@ -57,82 +60,52 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
57
60
|
const body = Buffer.concat(bodyChunks);
|
|
58
61
|
|
|
59
62
|
const ctx = { account: null, status: null };
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
try {
|
|
64
|
+
await forwardRequest(req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
ctx.status = ctx.status || 502;
|
|
67
|
+
console.error('[TeamClaude] Unhandled error:', err);
|
|
68
|
+
if (!res.headersSent) {
|
|
69
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
70
|
+
res.end(JSON.stringify({
|
|
71
|
+
type: 'error',
|
|
72
|
+
error: { type: 'proxy_error', message: 'Internal proxy error' },
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
hooks.onRequestEnd?.(reqId, {
|
|
77
|
+
method: req.method, path: req.url,
|
|
78
|
+
account: ctx.account, status: ctx.status,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
66
81
|
} catch (err) {
|
|
67
82
|
console.error('[TeamClaude] Unhandled error:', err);
|
|
68
|
-
if (!res.headersSent) {
|
|
69
|
-
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
70
|
-
res.end(JSON.stringify({
|
|
71
|
-
type: 'error',
|
|
72
|
-
error: { type: 'proxy_error', message: 'Internal proxy error' },
|
|
73
|
-
}));
|
|
74
|
-
}
|
|
75
83
|
}
|
|
76
84
|
});
|
|
77
85
|
|
|
78
86
|
return server;
|
|
79
87
|
}
|
|
80
88
|
|
|
81
|
-
const TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
|
|
82
|
-
|
|
83
89
|
/**
|
|
84
|
-
*
|
|
85
|
-
* and pass the response back to the client.
|
|
90
|
+
* Relay a request to upstream with no header rewriting — pure passthrough.
|
|
86
91
|
*/
|
|
87
|
-
async function
|
|
92
|
+
async function relayRaw(req, res, upstream) {
|
|
88
93
|
const bodyChunks = [];
|
|
89
|
-
for await (const chunk of req)
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
const rawBody = Buffer.concat(bodyChunks);
|
|
93
|
-
|
|
94
|
-
// Replace the refresh token in the request with the current account's refresh token
|
|
95
|
-
// so the renewal happens for the active account, not just the one the client knows about
|
|
96
|
-
let body = rawBody;
|
|
97
|
-
try {
|
|
98
|
-
const parsed = JSON.parse(rawBody.toString());
|
|
99
|
-
const currentAccount = accountManager.accounts[accountManager.currentIndex];
|
|
100
|
-
if (parsed.grant_type === 'refresh_token' && currentAccount?.type === 'oauth' && currentAccount.refreshToken) {
|
|
101
|
-
parsed.refresh_token = currentAccount.refreshToken;
|
|
102
|
-
body = JSON.stringify(parsed);
|
|
103
|
-
console.log(`[TeamClaude] Token refresh: substituted refresh token for account "${currentAccount.name}"`);
|
|
104
|
-
}
|
|
105
|
-
} catch {}
|
|
94
|
+
for await (const chunk of req) bodyChunks.push(chunk);
|
|
95
|
+
const body = Buffer.concat(bodyChunks);
|
|
106
96
|
|
|
107
97
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
method: 'POST',
|
|
98
|
+
const upstreamRes = await fetch(`${upstream}${req.url}`, {
|
|
99
|
+
method: req.method,
|
|
111
100
|
headers: {
|
|
112
|
-
'
|
|
113
|
-
'
|
|
114
|
-
'
|
|
101
|
+
'content-type': req.headers['content-type'] || 'application/json',
|
|
102
|
+
'accept': req.headers['accept'] || 'application/json',
|
|
103
|
+
'user-agent': req.headers['user-agent'] || 'node',
|
|
115
104
|
},
|
|
116
|
-
body,
|
|
105
|
+
body: body.length > 0 ? body : undefined,
|
|
117
106
|
});
|
|
118
107
|
|
|
119
108
|
const responseBody = await upstreamRes.text();
|
|
120
|
-
|
|
121
|
-
// Capture tokens from successful refresh — update the current account directly
|
|
122
|
-
if (upstreamRes.ok) {
|
|
123
|
-
try {
|
|
124
|
-
const tokens = JSON.parse(responseBody);
|
|
125
|
-
if (tokens.access_token) {
|
|
126
|
-
accountManager.updateAccountTokens(accountManager.currentIndex, {
|
|
127
|
-
accessToken: tokens.access_token,
|
|
128
|
-
refreshToken: tokens.refresh_token,
|
|
129
|
-
expiresAt: tokens.expires_at || (Date.now() + (tokens.expires_in || 3600) * 1000),
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
} catch {}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Forward response to client
|
|
136
109
|
const responseHeaders = {};
|
|
137
110
|
for (const [key, value] of upstreamRes.headers.entries()) {
|
|
138
111
|
if (key === 'transfer-encoding' || key === 'connection') continue;
|
|
@@ -141,17 +114,15 @@ async function handleTokenRefresh(req, res, accountManager, hooks) {
|
|
|
141
114
|
res.writeHead(upstreamRes.status, responseHeaders);
|
|
142
115
|
res.end(responseBody);
|
|
143
116
|
} catch (err) {
|
|
144
|
-
console.error('[TeamClaude]
|
|
117
|
+
console.error('[TeamClaude] Raw relay error:', err.message);
|
|
145
118
|
if (!res.headersSent) {
|
|
146
119
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
147
|
-
res.end(JSON.stringify({
|
|
148
|
-
type: 'error',
|
|
149
|
-
error: { type: 'proxy_error', message: `Token refresh failed: ${err.message}` },
|
|
150
|
-
}));
|
|
120
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'proxy_error', message: 'Upstream unreachable' } }));
|
|
151
121
|
}
|
|
152
122
|
}
|
|
153
123
|
}
|
|
154
124
|
|
|
125
|
+
|
|
155
126
|
function logTimestamp() {
|
|
156
127
|
const d = new Date();
|
|
157
128
|
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
@@ -272,6 +243,23 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
|
|
|
272
243
|
}
|
|
273
244
|
accountManager.updateQuota(account.index, rateLimitHeaders);
|
|
274
245
|
|
|
246
|
+
// On 429, wait the retry-after duration and retry on the same account
|
|
247
|
+
// (this is a transient rate limit, not quota exhaustion)
|
|
248
|
+
if (upstreamRes.status === 429) {
|
|
249
|
+
const retryAfter = parseInt(upstreamRes.headers.get('retry-after'), 10) || 60;
|
|
250
|
+
// Discard the 429 response body
|
|
251
|
+
await upstreamRes.body?.cancel();
|
|
252
|
+
|
|
253
|
+
if (logDir) {
|
|
254
|
+
logSections.push(`=== RESPONSE 429 — waiting ${retryAfter}s ===\n${formatHeaders(upstreamRes.headers)}`);
|
|
255
|
+
}
|
|
256
|
+
console.log(`[TeamClaude] 429 on "${account.name}" — waiting ${retryAfter}s before retry`);
|
|
257
|
+
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
|
|
258
|
+
// Client may have disconnected during the wait
|
|
259
|
+
if (res.destroyed) return;
|
|
260
|
+
return forwardRequest(req, res, body, accountManager, upstream, retryCount, hooks, reqId, ctx, logDir);
|
|
261
|
+
}
|
|
262
|
+
|
|
275
263
|
// Log response headers
|
|
276
264
|
if (logDir) {
|
|
277
265
|
logSections.push(`=== RESPONSE ${upstreamRes.status} ===\n${formatHeaders(upstreamRes.headers)}`);
|
|
@@ -329,6 +317,17 @@ async function forwardRequest(req, res, body, accountManager, upstream, retryCou
|
|
|
329
317
|
writeRequestLog(logDir, reqId, logSections);
|
|
330
318
|
}
|
|
331
319
|
|
|
320
|
+
const isTransient = err instanceof Error &&
|
|
321
|
+
(err.message.includes('fetch failed') ||
|
|
322
|
+
err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
|
|
323
|
+
err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT');
|
|
324
|
+
|
|
325
|
+
// Transient network errors: just close the connection and let the client retry
|
|
326
|
+
if (isTransient) {
|
|
327
|
+
res.destroy();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
332
331
|
if (retryCount < maxRetries && !res.headersSent) {
|
|
333
332
|
account.status = 'error';
|
|
334
333
|
return forwardRequest(req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir);
|
|
@@ -358,6 +357,9 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
|
|
|
358
357
|
const { done, value } = await reader.read();
|
|
359
358
|
if (done) break;
|
|
360
359
|
|
|
360
|
+
// Client disconnected — stop reading from upstream
|
|
361
|
+
if (res.destroyed) break;
|
|
362
|
+
|
|
361
363
|
// Forward chunk immediately
|
|
362
364
|
const ok = res.write(value);
|
|
363
365
|
|
|
@@ -375,9 +377,14 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
|
|
|
375
377
|
parseSSEUsage(event, accountIndex, accountManager);
|
|
376
378
|
}
|
|
377
379
|
|
|
378
|
-
// Handle backpressure
|
|
380
|
+
// Handle backpressure — also bail out if client disconnects,
|
|
381
|
+
// because 'drain' will never fire on a destroyed socket
|
|
379
382
|
if (!ok) {
|
|
380
|
-
await new Promise(resolve =>
|
|
383
|
+
await new Promise(resolve => {
|
|
384
|
+
res.once('drain', resolve);
|
|
385
|
+
res.once('close', resolve);
|
|
386
|
+
});
|
|
387
|
+
if (res.destroyed) break;
|
|
381
388
|
}
|
|
382
389
|
}
|
|
383
390
|
|
|
@@ -386,7 +393,9 @@ async function streamResponse(webStream, res, accountIndex, accountManager, stre
|
|
|
386
393
|
parseSSEUsage(sseBuffer, accountIndex, accountManager);
|
|
387
394
|
}
|
|
388
395
|
} finally {
|
|
389
|
-
|
|
396
|
+
// Cancel upstream reader to stop consuming data nobody needs
|
|
397
|
+
reader.cancel().catch(() => {});
|
|
398
|
+
if (!res.writableEnded) res.end();
|
|
390
399
|
}
|
|
391
400
|
}
|
|
392
401
|
|
package/src/tui.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { importCredentials } from './oauth.js';
|
|
1
|
+
import { importCredentials, fetchProfile } from './oauth.js';
|
|
2
2
|
|
|
3
3
|
// ── ANSI helpers ─────────────────────────────────────────────
|
|
4
4
|
|
|
@@ -283,7 +283,7 @@ export class TUI {
|
|
|
283
283
|
if (count > 0) {
|
|
284
284
|
this._addLog(`Synced ${count} new account(s) from config`);
|
|
285
285
|
} else {
|
|
286
|
-
this._addLog('Config reloaded,
|
|
286
|
+
this._addLog('Config reloaded, credentials refreshed');
|
|
287
287
|
}
|
|
288
288
|
} catch (e) {
|
|
289
289
|
this._addLog(`Sync failed: ${e.message}`);
|
|
@@ -292,19 +292,59 @@ export class TUI {
|
|
|
292
292
|
|
|
293
293
|
async _doImport() {
|
|
294
294
|
try {
|
|
295
|
+
this._addLog('Importing credentials...');
|
|
295
296
|
const creds = await importCredentials('~/.claude/.credentials.json');
|
|
296
|
-
const
|
|
297
|
-
const
|
|
297
|
+
const profile = await fetchProfile(creds.accessToken);
|
|
298
|
+
const profileOk = profile && !profile.error;
|
|
299
|
+
|
|
300
|
+
if (!profileOk) {
|
|
301
|
+
this._addLog(`Warning: could not fetch profile — ${profile?.error || 'no token'}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let name;
|
|
305
|
+
if (profile?.email) {
|
|
306
|
+
name = profile.email;
|
|
307
|
+
const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
|
|
308
|
+
if (tier) this._addLog(`Detected Claude ${tier}: ${name}`);
|
|
309
|
+
} else {
|
|
310
|
+
const n = this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1;
|
|
311
|
+
name = `account-${n}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
298
314
|
const entry = {
|
|
299
|
-
name, type: 'oauth',
|
|
315
|
+
name, type: 'oauth', source: 'import',
|
|
316
|
+
accountUuid: profile?.accountUuid || null,
|
|
300
317
|
accessToken: creds.accessToken,
|
|
301
318
|
refreshToken: creds.refreshToken,
|
|
302
319
|
expiresAt: creds.expiresAt,
|
|
303
320
|
};
|
|
304
|
-
|
|
305
|
-
|
|
321
|
+
|
|
322
|
+
// Deduplicate: match by UUID first, then by name
|
|
323
|
+
let idx = profile?.accountUuid
|
|
324
|
+
? this.config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
|
|
325
|
+
: -1;
|
|
326
|
+
if (idx < 0) idx = this.config.accounts.findIndex(a => a.name === name);
|
|
327
|
+
|
|
328
|
+
if (idx >= 0) {
|
|
329
|
+
this.config.accounts[idx] = entry;
|
|
330
|
+
// Update the running account manager entry
|
|
331
|
+
const amAcct = this.am.accounts[idx];
|
|
332
|
+
if (amAcct) {
|
|
333
|
+
amAcct.credential = creds.accessToken;
|
|
334
|
+
amAcct.refreshToken = creds.refreshToken;
|
|
335
|
+
amAcct.expiresAt = creds.expiresAt;
|
|
336
|
+
amAcct.accountUuid = entry.accountUuid;
|
|
337
|
+
amAcct.name = name;
|
|
338
|
+
if (amAcct.status === 'error') amAcct.status = 'active';
|
|
339
|
+
}
|
|
340
|
+
this._addLog(`Updated account "${name}"`);
|
|
341
|
+
} else {
|
|
342
|
+
this.config.accounts.push(entry);
|
|
343
|
+
this.am.addAccount(entry);
|
|
344
|
+
this._addLog(`Imported account "${name}"`);
|
|
345
|
+
}
|
|
346
|
+
|
|
306
347
|
await this.saveConfig(this.config);
|
|
307
|
-
this._addLog(`Imported account "${name}"`);
|
|
308
348
|
} catch (e) {
|
|
309
349
|
this._addLog(`Import failed: ${e.message}`);
|
|
310
350
|
}
|