mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-04 00:17:02 +00:00
feat(cliproxy): allow optional provider nicknames
- stop requiring manual unique nicknames for no-email providers by default - preserve explicit nickname validation and same-account reauth semantics - persist optional nicknames through dashboard manual callback flows
This commit is contained in:
@@ -28,8 +28,11 @@ export {
|
||||
getPausedDir,
|
||||
getAccountTokenPath,
|
||||
extractAccountIdFromTokenFile,
|
||||
deriveNoEmailProviderAccountId,
|
||||
generateNickname,
|
||||
validateNickname,
|
||||
hasAccountNameConflict,
|
||||
findAccountNameMatch,
|
||||
tokenFileExists,
|
||||
loadAccountsRegistry,
|
||||
saveAccountsRegistry,
|
||||
|
||||
@@ -21,8 +21,11 @@ export {
|
||||
getPausedDir,
|
||||
getAccountTokenPath,
|
||||
extractAccountIdFromTokenFile,
|
||||
deriveNoEmailProviderAccountId,
|
||||
generateNickname,
|
||||
validateNickname,
|
||||
hasAccountNameConflict,
|
||||
findAccountNameMatch,
|
||||
tokenFileExists,
|
||||
} from './token-file-ops';
|
||||
|
||||
|
||||
@@ -67,12 +67,12 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
// Partial match on nickname or email prefix
|
||||
const partialMatch = accounts.find(
|
||||
const partialMatches = accounts.filter(
|
||||
(a) =>
|
||||
a.nickname?.toLowerCase().startsWith(lowerQuery) ||
|
||||
a.email?.toLowerCase().startsWith(lowerQuery)
|
||||
);
|
||||
return partialMatch || null;
|
||||
return partialMatches.length === 1 ? partialMatches[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
getAccountsRegistryPath,
|
||||
getPausedDir,
|
||||
extractAccountIdFromTokenFile,
|
||||
deriveNoEmailProviderAccountId,
|
||||
generateNickname,
|
||||
hasAccountNameConflict,
|
||||
validateNickname,
|
||||
moveTokenToPaused,
|
||||
moveTokenFromPaused,
|
||||
@@ -21,10 +23,12 @@ import {
|
||||
} from './token-file-ops';
|
||||
|
||||
/** Default registry structure */
|
||||
const DEFAULT_REGISTRY: AccountsRegistry = {
|
||||
version: 1,
|
||||
providers: {},
|
||||
};
|
||||
function createDefaultRegistry(): AccountsRegistry {
|
||||
return {
|
||||
version: 1,
|
||||
providers: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load accounts registry
|
||||
@@ -33,7 +37,7 @@ export function loadAccountsRegistry(): AccountsRegistry {
|
||||
const registryPath = getAccountsRegistryPath();
|
||||
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
return { ...DEFAULT_REGISTRY };
|
||||
return createDefaultRegistry();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -44,7 +48,7 @@ export function loadAccountsRegistry(): AccountsRegistry {
|
||||
providers: data.providers || {},
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_REGISTRY };
|
||||
return createDefaultRegistry();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,8 +119,8 @@ export function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean
|
||||
* 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
|
||||
* - internal accountId is derived from token metadata
|
||||
* - nickname is optional metadata
|
||||
*
|
||||
* For providers with email:
|
||||
* - email is used as accountId
|
||||
@@ -149,23 +153,22 @@ export function registerAccount(
|
||||
let accountNickname: string;
|
||||
|
||||
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.`
|
||||
);
|
||||
}
|
||||
accountId = email
|
||||
? extractAccountIdFromTokenFile(tokenFile, email)
|
||||
: deriveNoEmailProviderAccountId(provider, tokenFile, providerAccounts.accounts);
|
||||
const existingAccount = providerAccounts.accounts[accountId];
|
||||
|
||||
// Validate nickname format
|
||||
const validationError = validateNickname(nickname);
|
||||
if (validationError) {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
if (nickname) {
|
||||
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()) {
|
||||
const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
|
||||
id,
|
||||
nickname: account.nickname,
|
||||
}));
|
||||
if (hasAccountNameConflict(existingAccounts, nickname, accountId)) {
|
||||
throw new Error(
|
||||
`An account with nickname "${nickname}" already exists for ${provider}. ` +
|
||||
`Choose a different nickname.`
|
||||
@@ -173,8 +176,8 @@ export function registerAccount(
|
||||
}
|
||||
}
|
||||
|
||||
accountId = nickname;
|
||||
accountNickname = nickname;
|
||||
accountNickname =
|
||||
nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId);
|
||||
} else {
|
||||
// For other providers: use email as accountId, fallback to filename extraction
|
||||
accountId = extractAccountIdFromTokenFile(tokenFile, email);
|
||||
@@ -184,11 +187,12 @@ export function registerAccount(
|
||||
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
|
||||
|
||||
// Create or update account
|
||||
const existingAccount = providerAccounts.accounts[accountId];
|
||||
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
|
||||
email,
|
||||
nickname: accountNickname,
|
||||
tokenFile,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: existingAccount?.createdAt || new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -339,11 +343,12 @@ export function renameAccount(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if nickname is already used by another account
|
||||
for (const [id, account] of Object.entries(providerAccounts.accounts)) {
|
||||
if (id !== accountId && account.nickname?.toLowerCase() === newNickname.toLowerCase()) {
|
||||
throw new Error(`Nickname "${newNickname}" is already used by another account`);
|
||||
}
|
||||
const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
|
||||
id,
|
||||
nickname: account.nickname,
|
||||
}));
|
||||
if (hasAccountNameConflict(existingAccounts, newNickname, accountId)) {
|
||||
throw new Error(`Nickname "${newNickname}" is already used by another account`);
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId].nickname = newNickname;
|
||||
@@ -476,28 +481,10 @@ export function discoverExistingAccounts(): void {
|
||||
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);
|
||||
}
|
||||
const accountId =
|
||||
PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email
|
||||
? deriveNoEmailProviderAccountId(provider, file, providerAccounts.accounts)
|
||||
: extractAccountIdFromTokenFile(file, email);
|
||||
|
||||
// Skip if account already registered
|
||||
if (providerAccounts.accounts[accountId]) {
|
||||
@@ -517,7 +504,7 @@ export function discoverExistingAccounts(): void {
|
||||
const lastModified = stats.mtime || stats.birthtime || new Date();
|
||||
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
|
||||
email,
|
||||
nickname: generateNickname(email),
|
||||
nickname: email ? generateNickname(email) : accountId,
|
||||
tokenFile: file,
|
||||
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
|
||||
lastUsedAt: lastModified.toISOString(),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCliproxyDir, getAuthDir } from '../config-generator';
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
import { AccountInfo } from './types';
|
||||
|
||||
/**
|
||||
@@ -143,6 +144,50 @@ export function extractAccountIdFromTokenFile(filename: string, email?: string):
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a collision-safe internal account ID for providers that may not expose email.
|
||||
* Reuses the existing entry when the token file is already known, otherwise prefers the
|
||||
* filename-derived ID before falling back to provider-scoped sequential IDs.
|
||||
*/
|
||||
export function deriveNoEmailProviderAccountId(
|
||||
provider: CLIProxyProvider,
|
||||
tokenFile: string,
|
||||
existingAccounts: Record<string, Pick<AccountInfo, 'tokenFile' | 'nickname'>>
|
||||
): string {
|
||||
const existingEntries = Object.entries(existingAccounts);
|
||||
const existingEntry = existingEntries.find(([, account]) => account.tokenFile === tokenFile);
|
||||
if (existingEntry) {
|
||||
return existingEntry[0];
|
||||
}
|
||||
|
||||
const extractedId = extractAccountIdFromTokenFile(tokenFile);
|
||||
const lowerExtractedId = extractedId.toLowerCase();
|
||||
|
||||
if (
|
||||
extractedId !== 'default' &&
|
||||
!existingEntries.some(
|
||||
([existingId, account]) =>
|
||||
existingId.toLowerCase() === lowerExtractedId ||
|
||||
account.nickname?.toLowerCase() === lowerExtractedId
|
||||
)
|
||||
) {
|
||||
return extractedId;
|
||||
}
|
||||
|
||||
let index = 1;
|
||||
while (
|
||||
existingEntries.some(
|
||||
([existingId, account]) =>
|
||||
existingId.toLowerCase() === `${provider}-${index}` ||
|
||||
account.nickname?.toLowerCase() === `${provider}-${index}`
|
||||
)
|
||||
) {
|
||||
index++;
|
||||
}
|
||||
|
||||
return `${provider}-${index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate nickname from email
|
||||
* Takes prefix before @ symbol, sanitizes whitespace
|
||||
@@ -180,3 +225,44 @@ export function validateNickname(nickname: string): string | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a nickname would collide with any existing account ID or nickname.
|
||||
*/
|
||||
export function hasAccountNameConflict(
|
||||
accounts: Array<Pick<AccountInfo, 'id' | 'nickname'>>,
|
||||
candidateName: string,
|
||||
excludeAccountId?: string
|
||||
): boolean {
|
||||
const normalizedCandidate = candidateName.toLowerCase();
|
||||
const normalizedExcludedId = excludeAccountId?.toLowerCase();
|
||||
|
||||
return accounts.some((account) => {
|
||||
if (normalizedExcludedId && account.id.toLowerCase() === normalizedExcludedId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
account.id.toLowerCase() === normalizedCandidate ||
|
||||
account.nickname?.toLowerCase() === normalizedCandidate
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the existing account that already owns the supplied id/nickname.
|
||||
*/
|
||||
export function findAccountNameMatch(
|
||||
accounts: Array<Pick<AccountInfo, 'id' | 'nickname'>>,
|
||||
candidateName: string
|
||||
): Pick<AccountInfo, 'id' | 'nickname'> | null {
|
||||
const normalizedCandidate = candidateName.toLowerCase();
|
||||
|
||||
return (
|
||||
accounts.find(
|
||||
(account) =>
|
||||
account.id.toLowerCase() === normalizedCandidate ||
|
||||
account.nickname?.toLowerCase() === normalizedCandidate
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
getProviderAccounts,
|
||||
getDefaultAccount,
|
||||
touchAccount,
|
||||
hasAccountNameConflict,
|
||||
findAccountNameMatch,
|
||||
PROVIDERS_WITHOUT_EMAIL,
|
||||
validateNickname,
|
||||
} from '../account-manager';
|
||||
@@ -88,6 +90,28 @@ export async function requestPasteCallbackStart(
|
||||
return (await response.json()) as PasteCallbackStartData;
|
||||
}
|
||||
|
||||
export function getCliAuthNicknameError(
|
||||
provider: CLIProxyProvider,
|
||||
nickname: string | undefined,
|
||||
existingAccounts: Array<Pick<AccountInfo, 'id' | 'nickname'>>,
|
||||
allowExistingAccountId?: string
|
||||
): string | null {
|
||||
if (!nickname || !PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validationError = validateNickname(nickname);
|
||||
if (validationError) {
|
||||
return validationError;
|
||||
}
|
||||
|
||||
if (hasAccountNameConflict(existingAccounts, nickname, allowExistingAccountId)) {
|
||||
return `Nickname "${nickname}" is already in use. Choose a different one.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -206,74 +230,6 @@ async function promptOAuthModeChoice(callbackPort: number | null): Promise<'past
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -350,7 +306,8 @@ async function handlePasteCallbackMode(
|
||||
oauthConfig: ProviderOAuthConfig,
|
||||
verbose: boolean,
|
||||
tokenDir: string,
|
||||
nickname?: string
|
||||
nickname?: string,
|
||||
expectedAccountId?: string
|
||||
): Promise<AccountInfo | null> {
|
||||
// Resolve CLIProxyAPI target (local or remote based on config)
|
||||
const target = getProxyTarget();
|
||||
@@ -474,7 +431,13 @@ async function handlePasteCallbackMode(
|
||||
}
|
||||
|
||||
console.log(ok('Authentication successful!'));
|
||||
const account = registerAccountFromToken(provider, tokenDir, nickname);
|
||||
const account = registerAccountFromToken(
|
||||
provider,
|
||||
tokenDir,
|
||||
nickname,
|
||||
verbose,
|
||||
expectedAccountId
|
||||
);
|
||||
|
||||
// Account safety: check for cross-provider conflicts
|
||||
if (account?.email) {
|
||||
@@ -509,7 +472,7 @@ export async function triggerOAuth(
|
||||
warnOAuthBanRisk(provider);
|
||||
const { verbose = false, add = false, fromUI = false, noIncognito = true } = options;
|
||||
const acceptAgyRisk = options.acceptAgyRisk === true;
|
||||
let { nickname } = options;
|
||||
const { nickname } = options;
|
||||
const resolvedKiroMethod =
|
||||
provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD;
|
||||
|
||||
@@ -533,21 +496,26 @@ export async function triggerOAuth(
|
||||
|
||||
// Check for existing accounts
|
||||
const existingAccounts = getProviderAccounts(provider);
|
||||
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
|
||||
const nicknameError = !fromUI
|
||||
? getCliAuthNicknameError(provider, nickname, existingAccounts, existingNameMatch?.id)
|
||||
: null;
|
||||
if (nicknameError) {
|
||||
console.log(fail(nicknameError));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle paste-callback mode
|
||||
if (options.pasteCallback) {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname);
|
||||
}
|
||||
|
||||
// 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;
|
||||
return handlePasteCallbackMode(
|
||||
provider,
|
||||
oauthConfig,
|
||||
verbose,
|
||||
tokenDir,
|
||||
nickname,
|
||||
existingNameMatch?.id
|
||||
);
|
||||
}
|
||||
|
||||
// Handle --import flag: skip OAuth and import from Kiro IDE directly
|
||||
@@ -555,7 +523,7 @@ export async function triggerOAuth(
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
const success = await importKiroToken(verbose);
|
||||
if (success) {
|
||||
return registerAccountFromToken(provider, tokenDir, nickname);
|
||||
return registerAccountFromToken(provider, tokenDir, nickname, verbose, existingNameMatch?.id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -589,12 +557,26 @@ export async function triggerOAuth(
|
||||
// Non-interactive environment (piped input) - default to paste mode
|
||||
if (!process.stdin.isTTY) {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname);
|
||||
return handlePasteCallbackMode(
|
||||
provider,
|
||||
oauthConfig,
|
||||
verbose,
|
||||
tokenDir,
|
||||
nickname,
|
||||
existingNameMatch?.id
|
||||
);
|
||||
}
|
||||
const mode = await promptOAuthModeChoice(callbackPort);
|
||||
if (mode === 'paste') {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname);
|
||||
return handlePasteCallbackMode(
|
||||
provider,
|
||||
oauthConfig,
|
||||
verbose,
|
||||
tokenDir,
|
||||
nickname,
|
||||
existingNameMatch?.id
|
||||
);
|
||||
}
|
||||
// mode === 'forward' continues to existing port-forwarding flow below
|
||||
}
|
||||
@@ -678,6 +660,7 @@ export async function triggerOAuth(
|
||||
verbose,
|
||||
isCLI,
|
||||
nickname,
|
||||
expectedAccountId: existingNameMatch?.id,
|
||||
});
|
||||
|
||||
// Show hint for Kiro users about --no-incognito option (first-time auth only)
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface OAuthProcessOptions {
|
||||
verbose: boolean;
|
||||
isCLI: boolean;
|
||||
nickname?: string;
|
||||
expectedAccountId?: string;
|
||||
}
|
||||
|
||||
/** Internal state for OAuth process */
|
||||
@@ -276,6 +277,7 @@ async function handleTokenNotFound(
|
||||
callbackPort: number | null,
|
||||
tokenDir: string,
|
||||
nickname: string | undefined,
|
||||
expectedAccountId: string | undefined,
|
||||
verbose: boolean,
|
||||
failureReason?: string
|
||||
): Promise<AccountInfo | null> {
|
||||
@@ -289,7 +291,7 @@ async function handleTokenNotFound(
|
||||
if (result.success) {
|
||||
const providerInfo = result.provider ? ` (Provider: ${result.provider})` : '';
|
||||
console.log(ok(`Imported Kiro token from IDE${providerInfo}`));
|
||||
return registerAccountFromToken(provider, tokenDir, nickname);
|
||||
return registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId);
|
||||
}
|
||||
|
||||
console.log(fail(`Auto-import failed: ${result.error}`));
|
||||
@@ -376,6 +378,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
headless,
|
||||
verbose,
|
||||
nickname,
|
||||
expectedAccountId,
|
||||
} = options;
|
||||
|
||||
const log = (msg: string) => {
|
||||
@@ -538,7 +541,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
deviceCodeEvents.emit('deviceCode:completed', state.sessionId);
|
||||
}
|
||||
|
||||
resolve(registerAccountFromToken(provider, tokenDir, nickname));
|
||||
resolve(
|
||||
registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId)
|
||||
);
|
||||
} else {
|
||||
const failureReason = extractLikelyAuthFailureFromStderr(provider, state.stderrData);
|
||||
|
||||
@@ -556,6 +561,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
||||
callbackPort,
|
||||
tokenDir,
|
||||
nickname,
|
||||
expectedAccountId,
|
||||
verbose,
|
||||
failureReason || undefined
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CLIProxyProvider } from '../types';
|
||||
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||
import { getProviderAuthDir } from '../config-generator';
|
||||
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
|
||||
import { deleteTokenFile } from '../accounts/token-file-ops';
|
||||
import {
|
||||
AuthStatus,
|
||||
PROVIDER_AUTH_PREFIXES,
|
||||
@@ -202,14 +203,15 @@ export function registerAccountFromToken(
|
||||
provider: CLIProxyProvider,
|
||||
tokenDir: string,
|
||||
nickname?: string,
|
||||
verbose = false
|
||||
verbose = false,
|
||||
expectedAccountId?: string
|
||||
): import('../account-manager').AccountInfo | null {
|
||||
const { registerAccount, generateNickname } = require('../account-manager');
|
||||
const { registerAccount } = require('../account-manager');
|
||||
let newestFile: string | null = null;
|
||||
try {
|
||||
const files = fs.readdirSync(tokenDir);
|
||||
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
|
||||
|
||||
let newestFile: string | null = null;
|
||||
let newestMtime = 0;
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
@@ -233,19 +235,33 @@ export function registerAccountFromToken(
|
||||
const email = data.email || undefined;
|
||||
const projectId = data.project_id || undefined;
|
||||
|
||||
const account = registerAccount(
|
||||
provider,
|
||||
newestFile,
|
||||
email,
|
||||
nickname || generateNickname(email),
|
||||
projectId
|
||||
);
|
||||
const account = registerAccount(provider, newestFile, email, nickname, projectId);
|
||||
|
||||
// Upload token to remote server if configured (async, don't block)
|
||||
uploadTokenToRemoteAsync(tokenPath, verbose);
|
||||
|
||||
return account;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (verbose) {
|
||||
console.error(`[auth] Failed to register token-backed account: ${message}`);
|
||||
}
|
||||
|
||||
if (typeof newestFile === 'string') {
|
||||
const hasExistingRegistration = getProviderAccounts(provider).some(
|
||||
(account) => account.tokenFile === newestFile
|
||||
);
|
||||
if (!hasExistingRegistration) {
|
||||
deleteTokenFile(newestFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedAccountId && verbose) {
|
||||
console.error(
|
||||
`[auth] Reauthentication target ${expectedAccountId} did not resolve cleanly from the new token`
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +418,7 @@ export async function execClaudeWithCLIProxy(
|
||||
const nickname = acct.nickname ? `[${acct.nickname}]` : '';
|
||||
console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`);
|
||||
}
|
||||
console.log(`\n Use "ccs ${provider} --use <nickname>" to switch accounts`);
|
||||
console.log(`\n Use "ccs ${provider} --use <nickname-or-id>" to switch accounts`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
'Show auth URL and prompt for callback paste (cross-browser)',
|
||||
],
|
||||
['ccs <provider> --accounts', 'List all accounts'],
|
||||
['ccs <provider> --use <name>', 'Switch to account'],
|
||||
['ccs <provider> --use <nickname-or-id>', 'Switch to account'],
|
||||
['ccs <provider> --config', 'Change model (agy, gemini)'],
|
||||
[
|
||||
'ccs agy --accept-agr-risk',
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
pauseAccount as pauseAccountFn,
|
||||
resumeAccount as resumeAccountFn,
|
||||
touchAccount,
|
||||
hasAccountNameConflict,
|
||||
findAccountNameMatch,
|
||||
PROVIDERS_WITHOUT_EMAIL,
|
||||
validateNickname,
|
||||
} from '../../cliproxy/account-manager';
|
||||
@@ -33,7 +35,7 @@ import {
|
||||
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
|
||||
import { getProviderTokenDir } from '../../cliproxy/auth/token-manager';
|
||||
import { getProviderTokenDir, registerAccountFromToken } from '../../cliproxy/auth/token-manager';
|
||||
import {
|
||||
CLIPROXY_CALLBACK_PROVIDER_MAP,
|
||||
CLIPROXY_AUTH_URL_PROVIDER_MAP,
|
||||
@@ -53,12 +55,55 @@ import {
|
||||
import { createRouteErrorHelpers } from './route-helpers';
|
||||
|
||||
const router = Router();
|
||||
const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000;
|
||||
const pendingManualAuthState = new Map<
|
||||
string,
|
||||
{ nickname?: string; expectedAccountId?: string; createdAt: number }
|
||||
>();
|
||||
|
||||
// Valid providers list - derived from canonical CLIPROXY_PROFILES
|
||||
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
|
||||
|
||||
const { respondInternalError } = createRouteErrorHelpers('cliproxy-auth-routes');
|
||||
|
||||
function pruneExpiredManualAuthState(now = Date.now()): void {
|
||||
for (const [state, pending] of pendingManualAuthState.entries()) {
|
||||
if (now - pending.createdAt > MANUAL_AUTH_STATE_TTL_MS) {
|
||||
pendingManualAuthState.delete(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rememberManualAuthState(
|
||||
state: string,
|
||||
pending: { nickname?: string; expectedAccountId?: string }
|
||||
): void {
|
||||
pruneExpiredManualAuthState();
|
||||
pendingManualAuthState.set(state, {
|
||||
...pending,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function getManualAuthState(
|
||||
state: string | undefined
|
||||
): { nickname?: string; expectedAccountId?: string } | null {
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
pruneExpiredManualAuthState();
|
||||
const pending = pendingManualAuthState.get(state);
|
||||
if (!pending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
nickname: pending.nickname,
|
||||
expectedAccountId: pending.expectedAccountId,
|
||||
};
|
||||
}
|
||||
|
||||
function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } {
|
||||
if (raw === undefined || raw === null) {
|
||||
return { method: normalizeKiroAuthMethod(), invalid: false };
|
||||
@@ -104,6 +149,34 @@ export function getStartAuthFailureMessage(provider: CLIProxyProvider): string {
|
||||
return 'Authentication failed or was cancelled';
|
||||
}
|
||||
|
||||
export function getStartAuthNicknameError(
|
||||
provider: CLIProxyProvider,
|
||||
nickname: string | undefined,
|
||||
existingAccounts: Array<{ id: string; nickname?: string }>,
|
||||
allowExistingAccountId?: string
|
||||
): { error: string; code: 'INVALID_NICKNAME' | 'NICKNAME_EXISTS' } | null {
|
||||
if (!PROVIDERS_WITHOUT_EMAIL.includes(provider) || !nickname) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validationError = validateNickname(nickname);
|
||||
if (validationError) {
|
||||
return {
|
||||
error: validationError,
|
||||
code: 'INVALID_NICKNAME',
|
||||
};
|
||||
}
|
||||
|
||||
if (hasAccountNameConflict(existingAccounts, nickname, allowExistingAccountId)) {
|
||||
return {
|
||||
error: `Nickname "${nickname}" is already in use. Choose a different one.`,
|
||||
code: 'NICKNAME_EXISTS',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
|
||||
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
|
||||
@@ -430,37 +503,17 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
|
||||
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
|
||||
const nicknameError = getStartAuthNicknameError(
|
||||
provider as CLIProxyProvider,
|
||||
nickname,
|
||||
existingAccounts,
|
||||
existingNameMatch?.id
|
||||
);
|
||||
if (nicknameError) {
|
||||
res.status(400).json(nicknameError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check Kiro no-incognito setting from config (or request body)
|
||||
@@ -625,7 +678,12 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise<void>
|
||||
*/
|
||||
router.post('/:provider/start-url', async (req: Request, res: Response): Promise<void> => {
|
||||
const { provider } = req.params;
|
||||
const { kiroMethod: kiroMethodRaw, riskAcknowledgement } = req.body ?? {};
|
||||
const requestBody =
|
||||
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
|
||||
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
|
||||
const kiroMethodRaw = requestBody.kiroMethod;
|
||||
const riskAcknowledgement = requestBody.riskAcknowledgement;
|
||||
const nickname = nicknameRaw?.trim();
|
||||
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
|
||||
|
||||
// Check remote mode
|
||||
@@ -668,6 +726,19 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
return;
|
||||
}
|
||||
|
||||
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
|
||||
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
|
||||
const nicknameError = getStartAuthNicknameError(
|
||||
provider as CLIProxyProvider,
|
||||
nickname,
|
||||
existingAccounts,
|
||||
existingNameMatch?.id
|
||||
);
|
||||
if (nicknameError) {
|
||||
res.status(400).json(nicknameError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authUrlProvider =
|
||||
CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider;
|
||||
@@ -696,19 +767,27 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
method?: string;
|
||||
};
|
||||
const authUrl = data.url || data.auth_url;
|
||||
const oauthState = data.state || parseAuthUrlState(authUrl);
|
||||
|
||||
// Some upstream flows return state first and provide auth_url in subsequent status polling.
|
||||
if (!authUrl && !data.state) {
|
||||
if (!authUrl && !oauthState) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: 'No OAuth state or authorization URL received from CLIProxyAPI' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (oauthState) {
|
||||
rememberManualAuthState(oauthState, {
|
||||
nickname: nickname || undefined,
|
||||
expectedAccountId: existingNameMatch?.id,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
authUrl: authUrl || null,
|
||||
state: data.state || null,
|
||||
state: oauthState,
|
||||
method: data.method || null,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -768,6 +847,18 @@ function parseCallbackUrl(url: string): { code?: string; state?: string } {
|
||||
}
|
||||
}
|
||||
|
||||
function parseAuthUrlState(url: string | null | undefined): string | null {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(url).searchParams.get('state');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy/auth/:provider/submit-callback - Submit OAuth callback URL manually
|
||||
* For cross-browser OAuth flows where callback cannot redirect directly
|
||||
@@ -800,6 +891,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
|
||||
res.status(400).json({ error: 'Invalid callback URL: missing code parameter' });
|
||||
return;
|
||||
}
|
||||
const pendingAuth = getManualAuthState(parsed.state);
|
||||
|
||||
try {
|
||||
const callbackProvider =
|
||||
@@ -824,7 +916,29 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
const account = registerAccountFromToken(
|
||||
provider as CLIProxyProvider,
|
||||
getProviderTokenDir(provider as CLIProxyProvider),
|
||||
pendingAuth?.nickname,
|
||||
false,
|
||||
pendingAuth?.expectedAccountId
|
||||
);
|
||||
if (parsed.state) {
|
||||
pendingManualAuthState.delete(parsed.state);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
account: account
|
||||
? {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
nickname: account.nickname,
|
||||
provider: account.provider,
|
||||
isDefault: account.isDefault,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user