chore: merge dev - resolve conflict in account-manager.ts

This commit is contained in:
kaitranntt
2026-01-06 14:59:14 -05:00
6 changed files with 323 additions and 18 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.14.0",
"version": "7.14.0-dev.2",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+123 -11
View File
@@ -17,6 +17,12 @@ import { PROVIDER_TYPE_VALUES } from './auth/auth-types';
/** Account tier for quota management */
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
/**
* Providers that typically have empty email in OAuth token files.
* For these providers, nickname is used as accountId instead of email.
*/
export const PROVIDERS_WITHOUT_EMAIL: CLIProxyProvider[] = ['kiro', 'ghcp'];
/** Account information */
export interface AccountInfo {
/** Account identifier (email or custom name) */
@@ -98,7 +104,7 @@ export function generateNickname(email?: string): string {
/**
* Validate nickname
* Rules: 1-50 chars, any non-whitespace allowed (permissive)
* Rules: 1-50 chars, no whitespace, URL-safe, no reserved patterns
* @returns null if valid, error message if invalid
*/
export function validateNickname(nickname: string): string | null {
@@ -111,6 +117,14 @@ export function validateNickname(nickname: string): string | null {
if (/\s/.test(nickname)) {
return 'Nickname cannot contain whitespace';
}
// Block URL-unsafe chars that break routing
if (/[%\/&?#]/.test(nickname)) {
return 'Nickname cannot contain special URL characters (%, /, &, ?, #)';
}
// Block reserved patterns used by auto-discovery (kiro-1, ghcp-2, etc.)
if (/^(kiro|ghcp)-\d+$/i.test(nickname)) {
return 'Nickname cannot match reserved pattern (kiro-N, ghcp-N)';
}
return null;
}
@@ -266,6 +280,14 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A
/**
* Register a new account
* Called after successful OAuth to record the account
*
* For providers without email (kiro, ghcp):
* - nickname is REQUIRED and used as accountId
* - Uniqueness is enforced to prevent overwriting
*
* For providers with email:
* - email is used as accountId
* - nickname is auto-generated from email if not provided
*/
export function registerAccount(
provider: CLIProxyProvider,
@@ -288,12 +310,44 @@ export function registerAccount(
throw new Error('Failed to initialize provider accounts');
}
// Determine account ID - use email if available, otherwise extract from filename
const accountId = extractAccountIdFromTokenFile(tokenFile, email);
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
// Determine account ID based on provider type
let accountId: string;
let accountNickname: string;
// Generate nickname if not provided
const accountNickname = nickname || generateNickname(email);
if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
// For kiro/ghcp: nickname is REQUIRED and used as accountId
if (!nickname || nickname === 'default') {
throw new Error(
`Nickname is required when adding ${provider} accounts. ` +
`Use --nickname <name> or provide a nickname in the UI.`
);
}
// Validate nickname format
const validationError = validateNickname(nickname);
if (validationError) {
throw new Error(validationError);
}
// Check uniqueness
for (const [existingId, _account] of Object.entries(providerAccounts.accounts)) {
if (existingId.toLowerCase() === nickname.toLowerCase()) {
throw new Error(
`An account with nickname "${nickname}" already exists for ${provider}. ` +
`Choose a different nickname.`
);
}
}
accountId = nickname;
accountNickname = nickname;
} else {
// For other providers: use email as accountId, fallback to filename extraction
accountId = extractAccountIdFromTokenFile(tokenFile, email);
accountNickname = nickname || generateNickname(email);
}
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
// Create or update account
providerAccounts.accounts[accountId] = {
@@ -507,6 +561,10 @@ export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: stri
/**
* Auto-discover accounts from existing token files
* Called during migration or first run to populate accounts registry
*
* For kiro/ghcp providers without email, generates unique accountId from:
* 1. OAuth provider + profile ID from filename (e.g., github-ABC123)
* 2. Fallback: provider + index (e.g., kiro-1, kiro-2)
*/
export function discoverExistingAccounts(): void {
const authDir = getAuthDir();
@@ -557,13 +615,10 @@ export function discoverExistingAccounts(): void {
}
}
// Use unified ID extraction: email or filename-based unique ID
const accountId = extractAccountIdFromTokenFile(file, email);
// Initialize provider section if needed
if (!registry.providers[provider]) {
registry.providers[provider] = {
default: accountId,
default: 'default',
accounts: {},
};
}
@@ -571,11 +626,45 @@ export function discoverExistingAccounts(): void {
const providerAccounts = registry.providers[provider];
if (!providerAccounts) continue;
// Skip if token file already registered (under any accountId)
const existingTokenFiles = Object.values(providerAccounts.accounts).map((a) => a.tokenFile);
if (existingTokenFiles.includes(file)) {
continue;
}
// Determine accountId based on provider type
let accountId: string;
if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email) {
// For kiro/ghcp without email: extract from filename or generate unique
// Pattern: kiro-github-ABC123.json -> github-ABC123
const filenameId = extractAccountIdFromTokenFile(file, undefined);
if (filenameId !== 'default') {
accountId = filenameId;
} else {
// Generate unique ID: provider + incrementing index
let index = 1;
while (providerAccounts.accounts[`${provider}-${index}`]) {
index++;
}
accountId = `${provider}-${index}`;
}
} else {
// For providers with email: use email or filename extraction
accountId = extractAccountIdFromTokenFile(file, email);
}
// Skip if account already registered
if (providerAccounts.accounts[accountId]) {
continue;
}
// Set as default if first account
if (Object.keys(providerAccounts.accounts).length === 0) {
providerAccounts.default = accountId;
}
// Get file stats for creation time
const stats = fs.statSync(filePath);
@@ -595,7 +684,30 @@ export function discoverExistingAccounts(): void {
}
}
saveAccountsRegistry(registry);
// Reload-merge pattern: reduce race condition with concurrent OAuth registration
// Reload fresh registry and merge discovered accounts (fresh registry wins on conflicts)
const freshRegistry = loadAccountsRegistry();
for (const [providerName, discovered] of Object.entries(registry.providers)) {
if (!discovered) continue;
const prov = providerName as CLIProxyProvider;
if (!freshRegistry.providers[prov]) {
freshRegistry.providers[prov] = discovered;
} else {
// Merge accounts, preferring fresh registry's existing entries
const freshProviderAccounts = freshRegistry.providers[prov];
if (!freshProviderAccounts) continue;
for (const [id, meta] of Object.entries(discovered.accounts)) {
if (!freshProviderAccounts.accounts[id]) {
freshProviderAccounts.accounts[id] = meta;
// Set default if none exists
if (!freshProviderAccounts.default || freshProviderAccounts.default === 'default') {
freshProviderAccounts.default = id;
}
}
}
}
}
saveAccountsRegistry(freshRegistry);
}
/**
+85 -3
View File
@@ -20,6 +20,8 @@ import {
getProviderAccounts,
getDefaultAccount,
touchAccount,
PROVIDERS_WITHOUT_EMAIL,
validateNickname,
} from '../account-manager';
import {
enhancedPreflightOAuthCheck,
@@ -49,6 +51,74 @@ async function promptAddAccount(): Promise<boolean> {
});
}
/**
* Prompt user for account nickname (required for kiro/ghcp)
* Returns null if user cancels
*/
async function promptNickname(
provider: CLIProxyProvider,
existingAccounts: AccountInfo[]
): Promise<string | null> {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const existingNicknames = existingAccounts.map(
(a) => a.nickname?.toLowerCase() || a.id.toLowerCase()
);
console.log('');
console.log(info(`${provider} accounts require a unique nickname to distinguish them.`));
if (existingNicknames.length > 0) {
console.log(` Existing: ${existingNicknames.join(', ')}`);
}
return new Promise<string | null>((resolve) => {
let resolved = false;
// Handle Ctrl+C gracefully (only if not already resolved)
rl.on('close', () => {
if (!resolved) {
resolved = true;
resolve(null);
}
});
const askForNickname = () => {
rl.question('[?] Enter a nickname for this account: ', (answer) => {
const nickname = answer.trim();
if (!nickname) {
console.log(fail('Nickname cannot be empty'));
askForNickname();
return;
}
const validationError = validateNickname(nickname);
if (validationError) {
console.log(fail(validationError));
askForNickname();
return;
}
if (existingNicknames.includes(nickname.toLowerCase())) {
console.log(fail(`Nickname "${nickname}" is already in use. Choose a different one.`));
askForNickname();
return;
}
resolved = true;
rl.close();
resolve(nickname);
});
};
askForNickname();
});
}
/**
* Run pre-flight OAuth checks
*/
@@ -127,7 +197,21 @@ export async function triggerOAuth(
options: OAuthOptions = {}
): Promise<AccountInfo | null> {
const oauthConfig = getOAuthConfig(provider);
const { verbose = false, add = false, nickname, fromUI = false, noIncognito = true } = options;
const { verbose = false, add = false, fromUI = false, noIncognito = true } = options;
let { nickname } = options;
// Check for existing accounts
const existingAccounts = getProviderAccounts(provider);
// For kiro/ghcp: require nickname if not provided (CLI only, not fromUI)
if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !nickname && !fromUI) {
const promptedNickname = await promptNickname(provider, existingAccounts);
if (!promptedNickname) {
console.log(info('Cancelled'));
return null;
}
nickname = promptedNickname;
}
// Handle --import flag: skip OAuth and import from Kiro IDE directly
if (options.import && provider === 'kiro') {
@@ -144,8 +228,6 @@ export async function triggerOAuth(
const headless = options.headless ?? isHeadlessEnvironment();
const isDeviceCodeFlow = callbackPort === null;
// Check for existing accounts
const existingAccounts = getProviderAccounts(provider);
if (existingAccounts.length > 0 && !add) {
console.log('');
console.log(
+7
View File
@@ -462,6 +462,13 @@ export async function execClaudeWithCLIProxy(
if (tokenResult.refreshed) {
log('Token was refreshed proactively');
}
// 3a-1. Update lastUsedAt for the account being used
// This ensures dashboard shows accurate "Last used" timestamps
const usedAccount = getDefaultAccount(provider);
if (usedAccount) {
touchAccount(provider, usedAccount.id);
}
}
// 3b. Preflight quota check - auto-switch to account with quota before launch
+38 -1
View File
@@ -26,6 +26,8 @@ import {
pauseAccount as pauseAccountFn,
resumeAccount as resumeAccountFn,
touchAccount,
PROVIDERS_WITHOUT_EMAIL,
validateNickname,
} from '../../cliproxy/account-manager';
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
@@ -342,7 +344,9 @@ router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Respons
*/
router.post('/:provider/start', async (req: Request, res: Response): Promise<void> => {
const { provider } = req.params;
const { nickname, noIncognito: noIncognitoBody } = req.body;
const { nickname: nicknameRaw, noIncognito: noIncognitoBody } = req.body;
// Trim nickname for consistency with CLI (oauth-handler.ts trims input)
const nickname = typeof nicknameRaw === 'string' ? nicknameRaw.trim() : nicknameRaw;
// Validate provider
if (!validProviders.includes(provider as CLIProxyProvider)) {
@@ -350,6 +354,39 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
return;
}
// For kiro/ghcp: nickname is required
if (PROVIDERS_WITHOUT_EMAIL.includes(provider as CLIProxyProvider)) {
if (!nickname) {
res.status(400).json({
error: `Nickname is required for ${provider} accounts. Please provide a unique nickname.`,
code: 'NICKNAME_REQUIRED',
});
return;
}
const validationError = validateNickname(nickname);
if (validationError) {
res.status(400).json({
error: validationError,
code: 'INVALID_NICKNAME',
});
return;
}
// Check uniqueness
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const existingNicknames = existingAccounts.map(
(a) => a.nickname?.toLowerCase() || a.id.toLowerCase()
);
if (existingNicknames.includes(nickname.toLowerCase())) {
res.status(400).json({
error: `Nickname "${nickname}" is already in use. Choose a different one.`,
code: 'NICKNAME_EXISTS',
});
return;
}
}
// Check Kiro no-incognito setting from config (or request body)
// Default to true (use normal browser) for reliability - incognito often fails
let noIncognito = true;
@@ -164,7 +164,7 @@ describe('Account Manager - discoverExistingAccounts', () => {
assert.strictEqual(accountIds[0], 'actual@email.com', 'Should use data.email when available');
});
it('falls back to "default" when no email in file or filename', () => {
it('generates unique ID for kiro/ghcp when no email in file or filename', () => {
createAuthFile('kiro-nomail.json', {
type: 'kiro',
email: '',
@@ -174,7 +174,8 @@ describe('Account Manager - discoverExistingAccounts', () => {
const accounts = getAccountsFile();
const accountIds = Object.keys(accounts.providers.kiro.accounts);
assert.strictEqual(accountIds[0], 'default', 'Should fall back to "default"');
// For kiro/ghcp without email, generates unique ID like "kiro-1"
assert.strictEqual(accountIds[0], 'kiro-1', 'Should generate unique ID for kiro without email');
});
it('handles dots in email local part', () => {
@@ -274,6 +275,72 @@ describe('Account Manager - discoverExistingAccounts', () => {
'First account should be default'
);
});
it('generates sequential IDs for multiple kiro files without email', () => {
// Create multiple kiro files without email in filename
// Files like "kiro-nomail.json" don't match oauth pattern (need 2+ hyphens)
createAuthFile('kiro-account1.json', {
type: 'kiro',
email: '',
});
createAuthFile('kiro-account2.json', {
type: 'kiro',
email: '',
});
accountManager.discoverExistingAccounts();
const accounts = getAccountsFile();
const accountIds = Object.keys(accounts.providers.kiro.accounts);
assert.strictEqual(accountIds.length, 2, 'Should have 2 accounts');
// Both should have kiro-N format since filenames don't match oauth pattern (1 hyphen only)
assert(accountIds.includes('kiro-1'), 'Should have kiro-1');
assert(accountIds.includes('kiro-2'), 'Should have kiro-2');
});
it('skips to next ID when collision exists', () => {
// Pre-create accounts.json with kiro-1 already registered
const accountsPath = path.join(testDir, '.ccs', 'cliproxy', 'accounts.json');
const existingRegistry = {
version: 1,
providers: {
kiro: {
default: 'kiro-1',
accounts: {
'kiro-1': {
tokenFile: 'kiro-existing.json',
createdAt: new Date().toISOString(),
},
},
},
},
};
fs.writeFileSync(accountsPath, JSON.stringify(existingRegistry));
// Create the existing token file (matches registry)
createAuthFile('kiro-existing.json', {
type: 'kiro',
email: '',
});
// Create new file that will need auto-generated ID (single hyphen = no oauth pattern match)
createAuthFile('kiro-newaccount.json', {
type: 'kiro',
email: '',
});
// Reload module to pick up pre-existing accounts.json
delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')];
accountManager = require('../../../dist/cliproxy/account-manager');
accountManager.discoverExistingAccounts();
const accounts = getAccountsFile();
const accountIds = Object.keys(accounts.providers.kiro.accounts);
assert.strictEqual(accountIds.length, 2, 'Should have 2 accounts');
assert(accountIds.includes('kiro-1'), 'Should keep existing kiro-1');
assert(accountIds.includes('kiro-2'), 'New account should be kiro-2 (skipped kiro-1)');
});
});
// =========================================================================