mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
feat(cliproxy): support duplicate-email codex accounts
- keep Codex team and personal auth files as separate identities - resolve quota and live monitor stats by token file-backed account id - surface duplicate-aware account labels across the dashboard and variant UI
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
|
||||
const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set<string>(['codex']);
|
||||
|
||||
function normalizeProvider(provider: CLIProxyProvider | string): string {
|
||||
return provider.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function cleanVariantTokenPart(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, '')
|
||||
.replace(/[^a-z0-9._-]+/gi, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function baseNicknameFromEmail(email?: string): string {
|
||||
if (!email) return 'default';
|
||||
return email.split('@')[0].replace(/\s+/g, '').slice(0, 50) || 'default';
|
||||
}
|
||||
|
||||
function formatVariantPart(value: string): string {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch (normalized) {
|
||||
case 'team':
|
||||
return 'Team';
|
||||
case 'free':
|
||||
return 'Free';
|
||||
case 'plus':
|
||||
return 'Plus';
|
||||
case 'pro':
|
||||
return 'Pro';
|
||||
default:
|
||||
return /^[a-f0-9]{8}$/i.test(normalized)
|
||||
? normalized
|
||||
: normalized
|
||||
.split(/[._-]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
export function supportsDuplicateEmailAccounts(provider: CLIProxyProvider | string): boolean {
|
||||
return DUPLICATE_EMAIL_ACCOUNT_PROVIDERS.has(normalizeProvider(provider));
|
||||
}
|
||||
|
||||
export function extractCanonicalEmailFromAccountId(accountId: string): string | null {
|
||||
const canonical = accountId.split('#')[0]?.trim();
|
||||
return canonical && canonical.includes('@') ? canonical : null;
|
||||
}
|
||||
|
||||
export function extractEmailAccountVariantKey(
|
||||
provider: CLIProxyProvider | string,
|
||||
tokenFile: string,
|
||||
email?: string
|
||||
): string | null {
|
||||
if (!email || !supportsDuplicateEmailAccounts(provider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedProvider = normalizeProvider(provider);
|
||||
const baseName = tokenFile.replace(/\.json$/i, '');
|
||||
const providerPrefix = `${normalizedProvider}-`;
|
||||
const candidate = baseName.toLowerCase().startsWith(providerPrefix)
|
||||
? baseName.slice(providerPrefix.length)
|
||||
: baseName;
|
||||
const emailIndex = candidate.toLowerCase().indexOf(email.toLowerCase());
|
||||
|
||||
if (emailIndex === -1) {
|
||||
const fallback = cleanVariantTokenPart(candidate);
|
||||
return fallback && fallback !== cleanVariantTokenPart(email) ? fallback : null;
|
||||
}
|
||||
|
||||
const before = cleanVariantTokenPart(candidate.slice(0, emailIndex));
|
||||
const after = cleanVariantTokenPart(candidate.slice(emailIndex + email.length));
|
||||
const parts = [before, after].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join('-') : null;
|
||||
}
|
||||
|
||||
export function buildEmailBackedAccountId(
|
||||
provider: CLIProxyProvider | string,
|
||||
tokenFile: string,
|
||||
email?: string,
|
||||
duplicateEmailCount = 1
|
||||
): string {
|
||||
if (!email) {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
if (!supportsDuplicateEmailAccounts(provider) || duplicateEmailCount <= 1) {
|
||||
return email;
|
||||
}
|
||||
|
||||
const variantKey = extractEmailAccountVariantKey(provider, tokenFile, email);
|
||||
return variantKey ? `${email}#${variantKey}` : email;
|
||||
}
|
||||
|
||||
export function buildEmailBackedNickname(
|
||||
provider: CLIProxyProvider | string,
|
||||
tokenFile: string,
|
||||
email?: string,
|
||||
duplicateEmailCount = 1
|
||||
): string {
|
||||
const base = baseNicknameFromEmail(email);
|
||||
if (!supportsDuplicateEmailAccounts(provider) || duplicateEmailCount <= 1) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const variantKey = extractEmailAccountVariantKey(provider, tokenFile, email);
|
||||
if (!variantKey) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return `${base}-${variantKey}`.slice(0, 50);
|
||||
}
|
||||
|
||||
export function formatAccountVariantLabel(accountId: string, email?: string): string | null {
|
||||
const variantKey =
|
||||
extractCanonicalEmailFromAccountId(accountId) === email ? accountId.split('#')[1] : null;
|
||||
if (!variantKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = variantKey.split('-').filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suffix = parts[parts.length - 1]?.toLowerCase();
|
||||
if (suffix && ['team', 'free', 'plus', 'pro'].includes(suffix)) {
|
||||
return [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
return parts.map(formatVariantPart).filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
export function formatAccountDisplayName(account: { id: string; email?: string }): string {
|
||||
const base = account.email || account.id;
|
||||
const variantLabel = formatAccountVariantLabel(account.id, account.email);
|
||||
return variantLabel ? `${base} (${variantLabel})` : base;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||
import { AccountInfo } from './types';
|
||||
import { loadAccountsRegistry, syncRegistryWithTokenFiles } from './registry';
|
||||
import { hydrateRegistryFromTokenFiles, loadAccountsRegistry } from './registry';
|
||||
|
||||
/**
|
||||
* Get all accounts for a provider
|
||||
@@ -14,8 +14,8 @@ import { loadAccountsRegistry, syncRegistryWithTokenFiles } from './registry';
|
||||
export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] {
|
||||
const registry = loadAccountsRegistry();
|
||||
|
||||
// Sync in-memory view with actual token files without mutating disk on read.
|
||||
syncRegistryWithTokenFiles(registry);
|
||||
// Hydrate the in-memory view from token files without mutating disk on read.
|
||||
hydrateRegistryFromTokenFiles(registry);
|
||||
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
@@ -55,14 +55,16 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A
|
||||
const accounts = getProviderAccounts(provider);
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
// Exact match first (id, email, nickname)
|
||||
const exactMatch = accounts.find(
|
||||
(a) =>
|
||||
a.id === query ||
|
||||
a.email?.toLowerCase() === lowerQuery ||
|
||||
a.nickname?.toLowerCase() === lowerQuery
|
||||
);
|
||||
if (exactMatch) return exactMatch;
|
||||
const exactIdMatch = accounts.find((a) => a.id === query);
|
||||
if (exactIdMatch) return exactIdMatch;
|
||||
|
||||
const emailMatches = accounts.filter((a) => a.email?.toLowerCase() === lowerQuery);
|
||||
if (emailMatches.length === 1) return emailMatches[0];
|
||||
if (emailMatches.length > 1) return null;
|
||||
|
||||
const nicknameMatches = accounts.filter((a) => a.nickname?.toLowerCase() === lowerQuery);
|
||||
if (nicknameMatches.length === 1) return nicknameMatches[0];
|
||||
if (nicknameMatches.length > 1) return null;
|
||||
|
||||
// Partial match on nickname or email prefix
|
||||
const partialMatches = accounts.filter(
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
deleteTokenFile,
|
||||
listRecoverableTokenFiles,
|
||||
} from './token-file-ops';
|
||||
import { buildEmailBackedAccountId, buildEmailBackedNickname } from './email-account-identity';
|
||||
|
||||
/** Default registry structure */
|
||||
function createDefaultRegistry(): AccountsRegistry {
|
||||
@@ -90,6 +91,16 @@ interface RegistryPopulationIssue {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface ParsedRecoverableTokenFile {
|
||||
tokenFile: string;
|
||||
filePath: string;
|
||||
paused: boolean;
|
||||
provider: CLIProxyProvider;
|
||||
email?: string;
|
||||
projectId: string | null;
|
||||
stats: fs.Stats;
|
||||
}
|
||||
|
||||
function describeRegistryPopulationIssue(issue: RegistryPopulationIssue): string {
|
||||
const sourceDir = issue.paused ? 'auth-paused' : 'auth';
|
||||
return `${sourceDir}/${issue.tokenFile} (${issue.reason})`;
|
||||
@@ -105,10 +116,15 @@ function getRegistryPopulationIssueReason(error: unknown): string {
|
||||
return 'unreadable token file';
|
||||
}
|
||||
|
||||
function populateRegistryFromTokenFiles(
|
||||
registry: AccountsRegistry,
|
||||
options: { includePaused?: boolean } = {}
|
||||
): RegistryPopulationIssue[] {
|
||||
function buildProviderEmailCountKey(provider: CLIProxyProvider, email: string): string {
|
||||
return `${provider}:${email.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function readRecoverableTokenFiles(options: { includePaused?: boolean } = {}): {
|
||||
tokens: ParsedRecoverableTokenFile[];
|
||||
issues: RegistryPopulationIssue[];
|
||||
} {
|
||||
const tokens: ParsedRecoverableTokenFile[] = [];
|
||||
const issues: RegistryPopulationIssue[] = [];
|
||||
|
||||
for (const token of listRecoverableTokenFiles(options)) {
|
||||
@@ -133,69 +149,134 @@ function populateRegistryFromTokenFiles(
|
||||
continue;
|
||||
}
|
||||
|
||||
const providerAccounts = ensureProviderRegistry(registry, provider);
|
||||
const projectId =
|
||||
typeof data.project_id === 'string' && data.project_id.trim()
|
||||
? data.project_id.trim()
|
||||
: null;
|
||||
const email =
|
||||
typeof data.email === 'string' && data.email.trim()
|
||||
? data.email.trim()
|
||||
: inferEmailFromTokenFileName(token.tokenFile, provider);
|
||||
const projectId =
|
||||
typeof data.project_id === 'string' && data.project_id.trim()
|
||||
? data.project_id.trim()
|
||||
: null;
|
||||
|
||||
const existingEntry = Object.entries(providerAccounts.accounts).find(
|
||||
([, account]) => account.tokenFile === token.tokenFile
|
||||
);
|
||||
if (existingEntry) {
|
||||
existingEntry[1].paused = token.paused || undefined;
|
||||
if (!token.paused) {
|
||||
existingEntry[1].pausedAt = undefined;
|
||||
}
|
||||
if (provider === 'agy' && projectId) {
|
||||
existingEntry[1].projectId = projectId;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const accountId =
|
||||
PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email
|
||||
? deriveNoEmailProviderAccountId(provider, token.tokenFile, providerAccounts.accounts)
|
||||
: extractAccountIdFromTokenFile(token.tokenFile, email);
|
||||
|
||||
if (providerAccounts.accounts[accountId]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Object.keys(providerAccounts.accounts).length === 0) {
|
||||
providerAccounts.default = accountId;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(token.filePath);
|
||||
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
|
||||
email,
|
||||
nickname: email ? generateNickname(email) : accountId,
|
||||
tokens.push({
|
||||
tokenFile: token.tokenFile,
|
||||
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
|
||||
lastUsedAt: (stats.mtime || stats.birthtime || new Date()).toISOString(),
|
||||
};
|
||||
|
||||
if (token.paused) {
|
||||
accountMeta.paused = true;
|
||||
}
|
||||
|
||||
if (provider === 'agy' && projectId) {
|
||||
accountMeta.projectId = projectId;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId] = accountMeta;
|
||||
filePath: token.filePath,
|
||||
paused: token.paused,
|
||||
provider,
|
||||
email,
|
||||
projectId,
|
||||
stats: fs.statSync(token.filePath),
|
||||
});
|
||||
} catch (error) {
|
||||
issues.push({
|
||||
tokenFile: token.tokenFile,
|
||||
paused: token.paused,
|
||||
reason: getRegistryPopulationIssueReason(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { tokens, issues };
|
||||
}
|
||||
|
||||
function buildDuplicateEmailCounts(
|
||||
tokens: ParsedRecoverableTokenFile[]
|
||||
): ReadonlyMap<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
for (const token of tokens) {
|
||||
if (!token.email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = buildProviderEmailCountKey(token.provider, token.email);
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
function populateRegistryFromTokenFiles(
|
||||
registry: AccountsRegistry,
|
||||
options: { includePaused?: boolean } = {}
|
||||
): RegistryPopulationIssue[] {
|
||||
const { tokens, issues } = readRecoverableTokenFiles(options);
|
||||
const duplicateEmailCounts = buildDuplicateEmailCounts(tokens);
|
||||
|
||||
for (const token of tokens) {
|
||||
const providerAccounts = ensureProviderRegistry(registry, token.provider);
|
||||
const existingEntry = Object.entries(providerAccounts.accounts).find(
|
||||
([, account]) => account.tokenFile === token.tokenFile
|
||||
);
|
||||
const existingAccountId = existingEntry?.[0];
|
||||
const existingAccount = existingEntry?.[1];
|
||||
const resolvedEmail = token.email ?? existingAccount?.email;
|
||||
const duplicateEmailCount = resolvedEmail
|
||||
? (duplicateEmailCounts.get(buildProviderEmailCountKey(token.provider, resolvedEmail)) ?? 1)
|
||||
: 1;
|
||||
|
||||
const desiredAccountId =
|
||||
PROVIDERS_WITHOUT_EMAIL.includes(token.provider) && !resolvedEmail
|
||||
? deriveNoEmailProviderAccountId(token.provider, token.tokenFile, providerAccounts.accounts)
|
||||
: !token.email && existingAccountId
|
||||
? existingAccountId
|
||||
: buildEmailBackedAccountId(
|
||||
token.provider,
|
||||
token.tokenFile,
|
||||
resolvedEmail,
|
||||
duplicateEmailCount
|
||||
);
|
||||
|
||||
if (existingEntry && existingEntry[0] !== desiredAccountId) {
|
||||
if (!providerAccounts.accounts[desiredAccountId]) {
|
||||
providerAccounts.accounts[desiredAccountId] = existingEntry[1];
|
||||
}
|
||||
if (providerAccounts.default === existingEntry[0]) {
|
||||
providerAccounts.default = desiredAccountId;
|
||||
}
|
||||
delete providerAccounts.accounts[existingEntry[0]];
|
||||
}
|
||||
|
||||
if (Object.keys(providerAccounts.accounts).length === 0) {
|
||||
providerAccounts.default = desiredAccountId;
|
||||
}
|
||||
|
||||
const hydratedAccount = providerAccounts.accounts[desiredAccountId];
|
||||
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
|
||||
email: resolvedEmail,
|
||||
nickname:
|
||||
hydratedAccount?.nickname ||
|
||||
(resolvedEmail
|
||||
? buildEmailBackedNickname(
|
||||
token.provider,
|
||||
token.tokenFile,
|
||||
resolvedEmail,
|
||||
duplicateEmailCount
|
||||
)
|
||||
: desiredAccountId),
|
||||
tokenFile: token.tokenFile,
|
||||
createdAt:
|
||||
hydratedAccount?.createdAt ||
|
||||
token.stats.birthtime?.toISOString() ||
|
||||
new Date().toISOString(),
|
||||
lastUsedAt:
|
||||
hydratedAccount?.lastUsedAt ||
|
||||
(token.stats.mtime || token.stats.birthtime || new Date()).toISOString(),
|
||||
};
|
||||
|
||||
if (token.paused) {
|
||||
accountMeta.paused = true;
|
||||
accountMeta.pausedAt = hydratedAccount?.pausedAt || new Date().toISOString();
|
||||
} else {
|
||||
accountMeta.paused = undefined;
|
||||
accountMeta.pausedAt = undefined;
|
||||
}
|
||||
|
||||
if (token.provider === 'agy') {
|
||||
accountMeta.projectId = token.projectId || hydratedAccount?.projectId;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[desiredAccountId] = accountMeta;
|
||||
}
|
||||
|
||||
return issues;
|
||||
@@ -384,6 +465,17 @@ export function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean
|
||||
return modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an in-memory view that includes both stale-entry cleanup and any token
|
||||
* files not yet persisted into accounts.json. Used by read paths so duplicate
|
||||
* email accounts stay visible without forcing a disk write.
|
||||
*/
|
||||
export function hydrateRegistryFromTokenFiles(registry: AccountsRegistry): boolean {
|
||||
const removedStaleEntries = syncRegistryWithTokenFiles(registry);
|
||||
const populationIssues = populateRegistryFromTokenFiles(registry);
|
||||
return removedStaleEntries || populationIssues.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new account
|
||||
* Called after successful OAuth to record the account
|
||||
@@ -448,8 +540,60 @@ export function registerAccount(
|
||||
accountNickname =
|
||||
nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId);
|
||||
} else {
|
||||
accountId = extractAccountIdFromTokenFile(tokenFile, email);
|
||||
accountNickname = nickname || generateNickname(email);
|
||||
const sameEmailEntries = email
|
||||
? Object.entries(providerAccounts.accounts).filter(
|
||||
([, account]) => account.email?.toLowerCase() === email.toLowerCase()
|
||||
)
|
||||
: [];
|
||||
const duplicateEmailCount = email
|
||||
? new Set([...sameEmailEntries.map(([, account]) => account.tokenFile), tokenFile]).size
|
||||
: 1;
|
||||
|
||||
if (email && duplicateEmailCount > 1) {
|
||||
for (const [existingId, existingMeta] of sameEmailEntries) {
|
||||
const migratedId = buildEmailBackedAccountId(
|
||||
provider,
|
||||
existingMeta.tokenFile,
|
||||
email,
|
||||
duplicateEmailCount
|
||||
);
|
||||
if (migratedId === existingId || providerAccounts.accounts[migratedId]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[migratedId] = existingMeta;
|
||||
if (providerAccounts.default === existingId) {
|
||||
providerAccounts.default = migratedId;
|
||||
}
|
||||
delete providerAccounts.accounts[existingId];
|
||||
}
|
||||
}
|
||||
|
||||
accountId = buildEmailBackedAccountId(provider, tokenFile, email, duplicateEmailCount);
|
||||
const existingAccount = providerAccounts.accounts[accountId];
|
||||
|
||||
if (nickname) {
|
||||
const validationError = validateNickname(nickname);
|
||||
if (validationError) {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
|
||||
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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
accountNickname =
|
||||
nickname ||
|
||||
existingAccount?.nickname ||
|
||||
buildEmailBackedNickname(provider, tokenFile, email, duplicateEmailCount);
|
||||
}
|
||||
|
||||
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||
import { getProviderAuthDir } from '../config-generator';
|
||||
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
|
||||
import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops';
|
||||
import { buildEmailBackedAccountId } from '../accounts/email-account-identity';
|
||||
import {
|
||||
AuthStatus,
|
||||
PROVIDER_AUTH_PREFIXES,
|
||||
@@ -215,6 +216,7 @@ export function registerAccountFromToken(
|
||||
mtimeMs: number;
|
||||
alreadyRegistered: boolean;
|
||||
};
|
||||
type RawTokenCandidate = Omit<TokenCandidate, 'accountId'>;
|
||||
|
||||
const { registerAccount } = require('../account-manager');
|
||||
let selectedCandidate: Omit<TokenCandidate, 'mtimeMs'> | null = null;
|
||||
@@ -222,33 +224,71 @@ export function registerAccountFromToken(
|
||||
const files = fs.readdirSync(tokenDir);
|
||||
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
|
||||
const existingAccounts = getProviderAccounts(provider);
|
||||
const candidates: TokenCandidate[] = jsonFiles
|
||||
.map((file): TokenCandidate | null => {
|
||||
const filePath = path.join(tokenDir, file);
|
||||
if (!isTokenFileForProvider(filePath, provider)) return null;
|
||||
const rawCandidates: RawTokenCandidate[] = jsonFiles.flatMap((file) => {
|
||||
const filePath = path.join(tokenDir, file);
|
||||
if (!isTokenFileForProvider(filePath, provider)) return [];
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content) as { email?: string; project_id?: string };
|
||||
const email = data.email || undefined;
|
||||
const projectId = data.project_id || undefined;
|
||||
const accountId = extractAccountIdFromTokenFile(file, email);
|
||||
const stats = fs.statSync(filePath);
|
||||
return {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content) as { email?: string; project_id?: string };
|
||||
const email = data.email || undefined;
|
||||
const projectId = data.project_id || undefined;
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
return [
|
||||
{
|
||||
file,
|
||||
filePath,
|
||||
email,
|
||||
projectId,
|
||||
accountId,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
alreadyRegistered: existingAccounts.some((account) => account.tokenFile === file),
|
||||
},
|
||||
];
|
||||
});
|
||||
const duplicateEmailCounts = new Map<string, number>();
|
||||
const duplicateEmailTokenSets = new Map<string, Set<string>>();
|
||||
for (const account of existingAccounts) {
|
||||
if (!account.email) continue;
|
||||
const key = account.email.toLowerCase();
|
||||
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
|
||||
tokenSet.add(account.tokenFile);
|
||||
duplicateEmailTokenSets.set(key, tokenSet);
|
||||
}
|
||||
for (const candidate of rawCandidates) {
|
||||
if (!candidate.email) continue;
|
||||
const key = candidate.email.toLowerCase();
|
||||
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
|
||||
tokenSet.add(candidate.file);
|
||||
duplicateEmailTokenSets.set(key, tokenSet);
|
||||
}
|
||||
for (const [key, tokenSet] of duplicateEmailTokenSets) {
|
||||
duplicateEmailCounts.set(key, tokenSet.size);
|
||||
}
|
||||
const candidates: TokenCandidate[] = rawCandidates
|
||||
.map((rawCandidate) => {
|
||||
const duplicateEmailCount = rawCandidate.email
|
||||
? (duplicateEmailCounts.get(rawCandidate.email.toLowerCase()) ?? 1)
|
||||
: 1;
|
||||
const accountId = rawCandidate.email
|
||||
? buildEmailBackedAccountId(
|
||||
provider,
|
||||
rawCandidate.file,
|
||||
rawCandidate.email,
|
||||
duplicateEmailCount
|
||||
)
|
||||
: extractAccountIdFromTokenFile(rawCandidate.file, rawCandidate.email);
|
||||
|
||||
return {
|
||||
...rawCandidate,
|
||||
accountId,
|
||||
};
|
||||
})
|
||||
.filter((candidate): candidate is TokenCandidate => candidate !== null)
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
|
||||
if (expectedAccountId) {
|
||||
selectedCandidate =
|
||||
candidates.find((candidate) => candidate.accountId === expectedAccountId) ||
|
||||
candidates.find((candidate) => candidate.file === expectedAccountId) ||
|
||||
candidates.find((candidate) => {
|
||||
const existingAccount = existingAccounts.find(
|
||||
(account) => account.id === expectedAccountId
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
renameAccount,
|
||||
getDefaultAccount,
|
||||
} from '../account-manager';
|
||||
import { formatAccountDisplayName } from '../accounts/email-account-identity';
|
||||
import {
|
||||
ensureMcpWebSearch,
|
||||
installWebSearchHook,
|
||||
@@ -422,7 +423,7 @@ export async function execClaudeWithCLIProxy(
|
||||
for (const acct of accounts) {
|
||||
const defaultMark = acct.isDefault ? ' (default)' : '';
|
||||
const nickname = acct.nickname ? `[${acct.nickname}]` : '';
|
||||
console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`);
|
||||
console.log(` ${nickname.padEnd(12)} ${formatAccountDisplayName(acct)}${defaultMark}`);
|
||||
}
|
||||
console.log(`\n Use "ccs ${provider} --use <nickname-or-id>" to switch accounts`);
|
||||
}
|
||||
@@ -438,14 +439,19 @@ export async function execClaudeWithCLIProxy(
|
||||
if (accounts.length > 0) {
|
||||
console.error(` Available accounts:`);
|
||||
for (const acct of accounts) {
|
||||
console.error(` - ${acct.nickname || acct.id} (${acct.email || 'no email'})`);
|
||||
const displayName = formatAccountDisplayName(acct);
|
||||
const label = acct.nickname ? `${acct.nickname} (${displayName})` : displayName;
|
||||
console.error(` - ${label}`);
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
setDefaultAccount(provider, account.id);
|
||||
touchAccount(provider, account.id);
|
||||
console.log(ok(`Switched to account: ${account.nickname || account.email || account.id}`));
|
||||
const switchedLabel = account.nickname
|
||||
? `${account.nickname} (${formatAccountDisplayName(account)})`
|
||||
: formatAccountDisplayName(account);
|
||||
console.log(ok(`Switched to account: ${switchedLabel}`));
|
||||
}
|
||||
|
||||
// Handle --nickname (rename account)
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getAuthDir } from './config-generator';
|
||||
import { getProviderAccounts, getPausedDir } from './account-manager';
|
||||
import { getAccount, getProviderAccounts, getPausedDir } from './account-manager';
|
||||
import { sanitizeEmail, isTokenExpired } from './auth-utils';
|
||||
import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types';
|
||||
import { extractCanonicalEmailFromAccountId } from './accounts/email-account-identity';
|
||||
|
||||
/** ChatGPT backend API base URL */
|
||||
const CODEX_API_BASE = 'https://chatgpt.com/backend-api';
|
||||
@@ -174,9 +175,44 @@ export function buildCodexCoreUsageSummary(windows: CodexQuotaWindow[]): CodexCo
|
||||
/**
|
||||
* Read auth data from Codex auth file
|
||||
*/
|
||||
function readCodexAuthFile(filePath: string): CodexAuthData | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
if (!data.access_token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
accountId: data.account_id || data.accountId || '',
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readCodexAuthData(accountId: string): CodexAuthData | null {
|
||||
const authDirs = [getAuthDir(), getPausedDir()];
|
||||
const sanitizedId = sanitizeEmail(accountId);
|
||||
const registryAccount = getAccount('codex', accountId);
|
||||
if (registryAccount?.tokenFile) {
|
||||
for (const authDir of authDirs) {
|
||||
const filePath = path.join(authDir, registryAccount.tokenFile);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const authData = readCodexAuthFile(filePath);
|
||||
if (authData) {
|
||||
return authData;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const legacyEmail = extractCanonicalEmailFromAccountId(accountId) ?? accountId;
|
||||
const sanitizedId = sanitizeEmail(legacyEmail);
|
||||
const expectedFile = `codex-${sanitizedId}.json`;
|
||||
|
||||
for (const authDir of authDirs) {
|
||||
@@ -184,19 +220,9 @@ function readCodexAuthData(accountId: string): CodexAuthData | null {
|
||||
|
||||
const filePath = path.join(authDir, expectedFile);
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
if (!data.access_token) continue;
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
accountId: data.account_id || data.accountId || '',
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
const authData = readCodexAuthFile(filePath);
|
||||
if (authData) {
|
||||
return authData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +234,7 @@ function readCodexAuthData(accountId: string): CodexAuthData | null {
|
||||
try {
|
||||
const content = fs.readFileSync(candidatePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
if (data.email === accountId && data.access_token) {
|
||||
if (data.email === legacyEmail && data.access_token) {
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
accountId: data.account_id || data.accountId || '',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { buildQualifiedAccountStatsKey } from './account-stats-key';
|
||||
import { mapExternalProviderName } from './provider-capabilities';
|
||||
import { buildEmailBackedAccountId } from './accounts/email-account-identity';
|
||||
import type {
|
||||
AccountUsageStats,
|
||||
CliproxyManagementAuthFile,
|
||||
@@ -15,6 +16,9 @@ interface BuildCliproxyStatsOptions {
|
||||
interface ResolvedAuthFile {
|
||||
provider?: string;
|
||||
source?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
duplicateEmailCount?: number;
|
||||
}
|
||||
|
||||
function normalizeProvider(provider: string): string {
|
||||
@@ -30,6 +34,16 @@ function buildAuthIndexLookup(
|
||||
authFiles: CliproxyManagementAuthFile[] | undefined
|
||||
): ReadonlyMap<string, ResolvedAuthFile> {
|
||||
const lookup = new Map<string, ResolvedAuthFile>();
|
||||
const duplicateEmailCounts = new Map<string, number>();
|
||||
|
||||
for (const authFile of authFiles ?? []) {
|
||||
if (!authFile.provider || !authFile.email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${normalizeProvider(authFile.provider)}:${authFile.email.trim().toLowerCase()}`;
|
||||
duplicateEmailCounts.set(key, (duplicateEmailCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
for (const authFile of authFiles ?? []) {
|
||||
if (authFile.auth_index === undefined || authFile.auth_index === null) {
|
||||
@@ -37,6 +51,8 @@ function buildAuthIndexLookup(
|
||||
}
|
||||
|
||||
const provider = authFile.provider ? normalizeProvider(authFile.provider) : undefined;
|
||||
const email = authFile.email?.trim() || undefined;
|
||||
const name = authFile.name?.trim() || undefined;
|
||||
const source = authFile.email?.trim() || authFile.name?.trim() || undefined;
|
||||
if (!provider && !source) {
|
||||
continue;
|
||||
@@ -45,6 +61,12 @@ function buildAuthIndexLookup(
|
||||
lookup.set(String(authFile.auth_index), {
|
||||
provider,
|
||||
source,
|
||||
email,
|
||||
name,
|
||||
duplicateEmailCount:
|
||||
provider && email
|
||||
? (duplicateEmailCounts.get(`${provider}:${email.toLowerCase()}`) ?? 1)
|
||||
: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,15 +87,29 @@ function resolveProviderForDetail(
|
||||
}
|
||||
|
||||
function resolveSourceForDetail(
|
||||
resolvedProvider: string,
|
||||
detail: CliproxyRequestDetail,
|
||||
authIndexLookup: ReadonlyMap<string, ResolvedAuthFile>
|
||||
): string {
|
||||
const resolvedAuthFile = authIndexLookup.get(String(detail.auth_index));
|
||||
if (resolvedAuthFile?.email && resolvedAuthFile?.name) {
|
||||
const derivedSource = buildEmailBackedAccountId(
|
||||
resolvedProvider,
|
||||
resolvedAuthFile.name,
|
||||
resolvedAuthFile.email,
|
||||
resolvedAuthFile.duplicateEmailCount ?? 1
|
||||
);
|
||||
if (derivedSource) {
|
||||
return derivedSource;
|
||||
}
|
||||
}
|
||||
|
||||
const source = detail.source?.trim();
|
||||
if (source) {
|
||||
return source;
|
||||
}
|
||||
|
||||
return authIndexLookup.get(String(detail.auth_index))?.source ?? 'unknown';
|
||||
return resolvedAuthFile?.source ?? 'unknown';
|
||||
}
|
||||
|
||||
export function buildCliproxyStatsFromUsageResponse(
|
||||
@@ -110,8 +146,8 @@ export function buildCliproxyStatsFromUsageResponse(
|
||||
for (const detail of modelData.details) {
|
||||
sawAnyDetail = true;
|
||||
sawProviderDetail = true;
|
||||
const source = resolveSourceForDetail(detail, authIndexLookup);
|
||||
const resolvedProvider = resolveProviderForDetail(provider, detail, authIndexLookup);
|
||||
const source = resolveSourceForDetail(resolvedProvider, detail, authIndexLookup);
|
||||
const accountKey = buildQualifiedAccountStatsKey(resolvedProvider, source);
|
||||
requestsByProvider[resolvedProvider] = (requestsByProvider[resolvedProvider] ?? 0) + 1;
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
QUOTA_SUPPORTED_PROVIDER_IDS,
|
||||
type QuotaSupportedProvider,
|
||||
} from '../../cliproxy/provider-capabilities';
|
||||
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
|
||||
import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui';
|
||||
|
||||
interface CliproxyProfileArgs {
|
||||
@@ -97,6 +98,11 @@ function formatResetTimeISO(isoTime: string): string {
|
||||
return formatResetTime(seconds);
|
||||
}
|
||||
|
||||
function formatCliAccountLabel(account: { id: string; email?: string; nickname?: string }): string {
|
||||
const displayName = formatAccountDisplayName(account);
|
||||
return account.nickname ? `${account.nickname} (${displayName})` : displayName;
|
||||
}
|
||||
|
||||
interface QuotaFailureDisplayEntry {
|
||||
tone: 'error' | 'info' | 'dim';
|
||||
text: string;
|
||||
@@ -360,13 +366,7 @@ function displayAntigravityQuotaSection(
|
||||
const tier = account.tier || 'unknown';
|
||||
const status = statusParts.join(', ');
|
||||
|
||||
rows.push([
|
||||
defaultMark,
|
||||
account.nickname || account.email || account.id,
|
||||
tier,
|
||||
avgQuota,
|
||||
status,
|
||||
]);
|
||||
rows.push([defaultMark, formatCliAccountLabel(account), tier, avgQuota, status]);
|
||||
}
|
||||
|
||||
console.log(
|
||||
@@ -384,10 +384,11 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR
|
||||
|
||||
for (const { account, quota } of results) {
|
||||
const accountInfo = findAccountByQuery('codex', account);
|
||||
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(account)}${defaultMark}`);
|
||||
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||
displayQuotaFailure(quota);
|
||||
console.log('');
|
||||
continue;
|
||||
@@ -406,7 +407,7 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR
|
||||
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
|
||||
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
|
||||
|
||||
console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`);
|
||||
console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`);
|
||||
|
||||
const coreUsageSummary = quota.coreUsage ?? {
|
||||
fiveHour: fiveHourWindow
|
||||
@@ -539,10 +540,11 @@ function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuot
|
||||
|
||||
for (const { account, quota } of results) {
|
||||
const accountInfo = findAccountByQuery('claude', account);
|
||||
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(account)}${defaultMark}`);
|
||||
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||
displayQuotaFailure(quota);
|
||||
console.log('');
|
||||
continue;
|
||||
@@ -562,7 +564,7 @@ function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuot
|
||||
const statusIcon =
|
||||
minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
|
||||
|
||||
console.log(` ${statusIcon}${account}${defaultMark}`);
|
||||
console.log(` ${statusIcon}${accountLabel}${defaultMark}`);
|
||||
|
||||
const resetParts: string[] = [];
|
||||
if (fiveHourWindow?.resetAt)
|
||||
@@ -616,10 +618,11 @@ function displayGeminiCliQuotaSection(
|
||||
|
||||
for (const { account, quota } of results) {
|
||||
const accountInfo = findAccountByQuery('gemini', account);
|
||||
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(account)}${defaultMark}`);
|
||||
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||
displayQuotaFailure(quota);
|
||||
console.log('');
|
||||
continue;
|
||||
@@ -631,7 +634,7 @@ function displayGeminiCliQuotaSection(
|
||||
: 0;
|
||||
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
|
||||
|
||||
console.log(` ${statusIcon}${account}${defaultMark}`);
|
||||
console.log(` ${statusIcon}${accountLabel}${defaultMark}`);
|
||||
if (quota.projectId) {
|
||||
console.log(` Project: ${dim(quota.projectId)}`);
|
||||
}
|
||||
@@ -667,10 +670,11 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes
|
||||
|
||||
for (const { account, quota } of results) {
|
||||
const accountInfo = findAccountByQuery('ghcp', account);
|
||||
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(account)}${defaultMark}`);
|
||||
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||
displayQuotaFailure(quota);
|
||||
console.log('');
|
||||
continue;
|
||||
@@ -685,7 +689,7 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes
|
||||
const statusIcon = minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
|
||||
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
|
||||
|
||||
console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`);
|
||||
console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`);
|
||||
if (quota.quotaResetDate) {
|
||||
console.log(` ${dim(`Resets ${formatResetTimeISO(quota.quotaResetDate)}`)}`);
|
||||
}
|
||||
@@ -840,7 +844,7 @@ export async function handleDoctor(verbose = false): Promise<void> {
|
||||
const quotaResult = await fetchAllProviderQuotas(provider, verbose);
|
||||
|
||||
for (const { account, quota } of quotaResult.accounts) {
|
||||
const accountLabel = account.email || account.id || 'Unknown Account';
|
||||
const accountLabel = formatCliAccountLabel(account);
|
||||
const defaultBadge = account.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
@@ -933,7 +937,7 @@ export async function handleSetDefault(args: string[]): Promise<void> {
|
||||
console.log('Available accounts:');
|
||||
for (const acc of accounts) {
|
||||
const badge = acc.isDefault ? color(' (current default)', 'info') : '';
|
||||
console.log(` - ${acc.email || acc.id}${badge}`);
|
||||
console.log(` - ${formatCliAccountLabel(acc)}${badge}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`No accounts found for provider: ${provider}`);
|
||||
@@ -945,7 +949,7 @@ export async function handleSetDefault(args: string[]): Promise<void> {
|
||||
const success = setDefaultAccount(provider, account.id);
|
||||
|
||||
if (success) {
|
||||
console.log(ok(`Default account set to: ${account.email || account.id}`));
|
||||
console.log(ok(`Default account set to: ${formatCliAccountLabel(account)}`));
|
||||
console.log(info(`Provider: ${provider}`));
|
||||
} else {
|
||||
console.log(fail('Failed to set default account'));
|
||||
@@ -973,7 +977,7 @@ export async function handlePauseAccount(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
if (account.paused) {
|
||||
console.log(warn(`Account already paused: ${account.email || account.id}`));
|
||||
console.log(warn(`Account already paused: ${formatCliAccountLabel(account)}`));
|
||||
console.log(info(`Paused at: ${account.pausedAt || 'unknown'}`));
|
||||
return;
|
||||
}
|
||||
@@ -981,7 +985,7 @@ export async function handlePauseAccount(args: string[]): Promise<void> {
|
||||
const success = pauseAccount(provider, account.id);
|
||||
|
||||
if (success) {
|
||||
console.log(ok(`Account paused: ${account.email || account.id}`));
|
||||
console.log(ok(`Account paused: ${formatCliAccountLabel(account)}`));
|
||||
console.log(info('Account will be skipped in quota rotation'));
|
||||
} else {
|
||||
console.log(fail('Failed to pause account'));
|
||||
@@ -1009,14 +1013,14 @@ export async function handleResumeAccount(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
if (!account.paused) {
|
||||
console.log(warn(`Account is not paused: ${account.email || account.id}`));
|
||||
console.log(warn(`Account is not paused: ${formatCliAccountLabel(account)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const success = resumeAccount(provider, account.id);
|
||||
|
||||
if (success) {
|
||||
console.log(ok(`Account resumed: ${account.email || account.id}`));
|
||||
console.log(ok(`Account resumed: ${formatCliAccountLabel(account)}`));
|
||||
console.log(info('Account is now active in quota rotation'));
|
||||
} else {
|
||||
console.log(fail('Failed to resume account'));
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '../../cliproxy/services';
|
||||
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
|
||||
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
|
||||
|
||||
interface CliproxyProfileArgs {
|
||||
name?: string;
|
||||
@@ -118,6 +119,15 @@ function getBackendLabel(backend: CLIProxyBackend): string {
|
||||
return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
||||
}
|
||||
|
||||
function formatVariantAccountLabel(account: {
|
||||
id: string;
|
||||
email?: string;
|
||||
nickname?: string;
|
||||
}): string {
|
||||
const displayName = formatAccountDisplayName(account);
|
||||
return account.nickname ? `${account.nickname} (${displayName})` : displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive prompt to select provider + model for a single tier.
|
||||
* Returns a CompositeTierConfig, or null if user cancelled auth.
|
||||
@@ -158,7 +168,7 @@ async function selectTierConfig(
|
||||
console.log(fail('Authentication failed'));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
|
||||
console.log(ok(`Authenticated as ${formatVariantAccountLabel(newAccount)}`));
|
||||
}
|
||||
|
||||
// Select model
|
||||
@@ -364,7 +374,7 @@ export async function handleCreate(
|
||||
}
|
||||
account = newAccount.id;
|
||||
console.log('');
|
||||
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
|
||||
console.log(ok(`Authenticated as ${formatVariantAccountLabel(newAccount)}`));
|
||||
} else if (providerAccounts.length === 1) {
|
||||
account = providerAccounts[0].id;
|
||||
} else {
|
||||
@@ -372,7 +382,7 @@ export async function handleCreate(
|
||||
const accountOptions = [
|
||||
...providerAccounts.map((acc) => ({
|
||||
id: acc.id,
|
||||
label: `${acc.email || acc.id}${acc.isDefault ? ' (default)' : ''}`,
|
||||
label: `${formatVariantAccountLabel(acc)}${acc.isDefault ? ' (default)' : ''}`,
|
||||
})),
|
||||
{ id: ADD_NEW_ID, label: color('[+ Add new account...]', 'info') },
|
||||
];
|
||||
@@ -394,7 +404,7 @@ export async function handleCreate(
|
||||
}
|
||||
account = newAccount.id;
|
||||
console.log('');
|
||||
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
|
||||
console.log(ok(`Authenticated as ${formatVariantAccountLabel(newAccount)}`));
|
||||
} else {
|
||||
account = selectedAccount;
|
||||
}
|
||||
@@ -406,7 +416,7 @@ export async function handleCreate(
|
||||
console.log('');
|
||||
console.log('Available accounts:');
|
||||
providerAccounts.forEach((a) =>
|
||||
console.log(` - ${a.email || a.id}${a.isDefault ? ' (default)' : ''}`)
|
||||
console.log(` - ${formatVariantAccountLabel(a)}${a.isDefault ? ' (default)' : ''}`)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -450,9 +460,13 @@ export async function handleCreate(
|
||||
? '~/.ccs/config.yaml'
|
||||
: `~/.ccs/${path.basename(result.settingsPath || '')}`;
|
||||
const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : '';
|
||||
const selectedAccount =
|
||||
account && provider
|
||||
? getProviderAccounts(provider as CLIProxyProvider).find((acc) => acc.id === account)
|
||||
: null;
|
||||
console.log(
|
||||
infoBox(
|
||||
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\nTarget: ${resolvedTarget}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
|
||||
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\nTarget: ${resolvedTarget}\n${portInfo}${selectedAccount ? `Account: ${formatVariantAccountLabel(selectedAccount)}\n` : account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
|
||||
configType
|
||||
)
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
bulkResumeAccounts,
|
||||
soloAccount,
|
||||
} from '../../cliproxy/account-manager';
|
||||
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
|
||||
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
|
||||
import {
|
||||
DEFAULT_ACCOUNT_CONTINUITY_MODE,
|
||||
@@ -122,7 +123,7 @@ router.get('/', (_req: Request, res: Response): void => {
|
||||
continue;
|
||||
}
|
||||
// Use unique ID for key to prevent collisions between accounts with same nickname/email
|
||||
const displayName = acct.nickname || acct.email || acct.id;
|
||||
const displayName = acct.nickname || formatAccountDisplayName(acct);
|
||||
const rawKey = `${provider}:${acct.id}`;
|
||||
const key = buildCliproxyAccountKey(rawKey, merged);
|
||||
if (!key) {
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
pauseAccount as pauseAccountFn,
|
||||
resumeAccount as resumeAccountFn,
|
||||
touchAccount,
|
||||
extractAccountIdFromTokenFile,
|
||||
hasAccountNameConflict,
|
||||
PROVIDERS_WITHOUT_EMAIL,
|
||||
validateNickname,
|
||||
@@ -932,7 +931,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
|
||||
getProviderTokenDir(localProvider),
|
||||
pendingAuth.nickname,
|
||||
false,
|
||||
extractAccountIdFromTokenFile(tokenSnapshot.file, tokenSnapshot.email)
|
||||
tokenSnapshot.file
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
|
||||
@@ -116,4 +116,41 @@ describe('registerAccount optional nickname flow', () => {
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it('treats duplicate Codex emails as distinct accounts and rejects bare-email lookups', async () => {
|
||||
const result = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'codex-04a0f049-kaidu.kd@gmail.com-team.json');
|
||||
writeTokenFile(homeDir, 'codex-kaidu.kd@gmail.com-free.json');
|
||||
const { registerAccount, getProviderAccounts, findAccountByQuery } = await loadAccountManager();
|
||||
const team = registerAccount(
|
||||
'codex',
|
||||
'codex-04a0f049-kaidu.kd@gmail.com-team.json',
|
||||
'kaidu.kd@gmail.com'
|
||||
);
|
||||
const free = registerAccount(
|
||||
'codex',
|
||||
'codex-kaidu.kd@gmail.com-free.json',
|
||||
'kaidu.kd@gmail.com'
|
||||
);
|
||||
|
||||
return {
|
||||
team,
|
||||
free,
|
||||
accounts: getProviderAccounts('codex'),
|
||||
ambiguousLookup: findAccountByQuery('codex', 'kaidu.kd@gmail.com'),
|
||||
teamLookup: findAccountByQuery('codex', 'kaidu.kd@gmail.com#04a0f049-team'),
|
||||
freeLookup: findAccountByQuery('codex', free.id),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.team.id).toBe('kaidu.kd@gmail.com');
|
||||
expect(result.free.id).toBe('kaidu.kd@gmail.com#free');
|
||||
expect(result.accounts.map((account) => account.id).sort()).toEqual([
|
||||
'kaidu.kd@gmail.com#04a0f049-team',
|
||||
'kaidu.kd@gmail.com#free',
|
||||
]);
|
||||
expect(result.ambiguousLookup).toBeNull();
|
||||
expect(result.teamLookup?.id).toBe('kaidu.kd@gmail.com#04a0f049-team');
|
||||
expect(result.freeLookup?.id).toBe('kaidu.kd@gmail.com#free');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ let buildCodexQuotaWindows: typeof import('../../../src/cliproxy/quota-fetcher-c
|
||||
let buildCodexCoreUsageSummary: typeof import('../../../src/cliproxy/quota-fetcher-codex').buildCodexCoreUsageSummary;
|
||||
let fetchCodexQuota: typeof import('../../../src/cliproxy/quota-fetcher-codex').fetchCodexQuota;
|
||||
let getUnknownCodexWindowLabels: typeof import('../../../src/cliproxy/quota-fetcher-codex').getUnknownCodexWindowLabels;
|
||||
let registerAccount: typeof import('../../../src/cliproxy/account-manager').registerAccount;
|
||||
|
||||
function createCodexAccount(
|
||||
accountId: string,
|
||||
@@ -40,6 +41,7 @@ beforeEach(async () => {
|
||||
);
|
||||
mock.module('../../../src/cliproxy/config-generator', () => configGenerator);
|
||||
mock.module('../../../src/cliproxy/account-manager', () => accountManager);
|
||||
({ registerAccount } = accountManager);
|
||||
|
||||
({
|
||||
buildCodexQuotaWindows,
|
||||
@@ -409,6 +411,55 @@ describe('Codex Quota Fetcher', () => {
|
||||
expect(result.actionHint).toContain('ccs cliproxy auth codex');
|
||||
});
|
||||
|
||||
it('uses the registry token file for duplicate-email Codex accounts', async () => {
|
||||
createValidCodexAccount(
|
||||
'kaidu.kd@gmail.com',
|
||||
'workspace-team',
|
||||
'codex-04a0f049-kaidu.kd@gmail.com-team.json'
|
||||
);
|
||||
createValidCodexAccount(
|
||||
'kaidu.kd@gmail.com',
|
||||
'workspace-free',
|
||||
'codex-kaidu.kd@gmail.com-free.json'
|
||||
);
|
||||
|
||||
registerAccount(
|
||||
'codex',
|
||||
'codex-04a0f049-kaidu.kd@gmail.com-team.json',
|
||||
'kaidu.kd@gmail.com'
|
||||
);
|
||||
const freeAccount = registerAccount(
|
||||
'codex',
|
||||
'codex-kaidu.kd@gmail.com-free.json',
|
||||
'kaidu.kd@gmail.com'
|
||||
);
|
||||
|
||||
const fetchSpy = mock((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
plan_type: 'free',
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 10, reset_after_seconds: 3600 },
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
)
|
||||
) as typeof fetch;
|
||||
global.fetch = fetchSpy;
|
||||
|
||||
const result = await fetchCodexQuota(freeAccount.id);
|
||||
const requestInit = fetchSpy.mock.calls[0]?.[1] as RequestInit | undefined;
|
||||
const headers = new Headers(requestInit?.headers);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(headers.get('ChatGPT-Account-Id')).toBe('workspace-free');
|
||||
});
|
||||
|
||||
it('maps 403 responses to forbidden metadata', async () => {
|
||||
createValidCodexAccount('forbidden@example.com', 'workspace-forbidden');
|
||||
|
||||
|
||||
@@ -288,4 +288,45 @@ describe('buildCliproxyStatsFromUsageResponse', () => {
|
||||
});
|
||||
expect(stats.requestsByProvider).toEqual({ codex: 1, 'ccs-internal-managed': 2 });
|
||||
});
|
||||
|
||||
it('derives duplicate-email Codex account ids from auth file metadata', () => {
|
||||
const usage = createInternallyBucketedUsage([
|
||||
createDetail({ auth_index: 'codex-team', source: 'kaidu.kd@gmail.com' }),
|
||||
createDetail({
|
||||
auth_index: 'codex-free',
|
||||
source: 'kaidu.kd@gmail.com',
|
||||
timestamp: '2025-03-26T10:01:00.000Z',
|
||||
failed: true,
|
||||
}),
|
||||
]);
|
||||
const authFiles: CliproxyManagementAuthFile[] = [
|
||||
{
|
||||
auth_index: 'codex-team',
|
||||
provider: 'codex',
|
||||
email: 'kaidu.kd@gmail.com',
|
||||
name: 'codex-04a0f049-kaidu.kd@gmail.com-team.json',
|
||||
},
|
||||
{
|
||||
auth_index: 'codex-free',
|
||||
provider: 'codex',
|
||||
email: 'kaidu.kd@gmail.com',
|
||||
name: 'codex-kaidu.kd@gmail.com-free.json',
|
||||
},
|
||||
];
|
||||
|
||||
const stats = buildCliproxyStatsFromUsageResponse(usage, { authFiles });
|
||||
|
||||
expect(stats.accountStats['codex:kaidu.kd@gmail.com#04a0f049-team']).toMatchObject({
|
||||
provider: 'codex',
|
||||
source: 'kaidu.kd@gmail.com#04a0f049-team',
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
});
|
||||
expect(stats.accountStats['codex:kaidu.kd@gmail.com#free']).toMatchObject({
|
||||
provider: 'codex',
|
||||
source: 'kaidu.kd@gmail.com#free',
|
||||
successCount: 0,
|
||||
failureCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
isClaudeQuotaResult,
|
||||
isCodexQuotaResult,
|
||||
} from '@/lib/utils';
|
||||
import { formatAccountVariantLabel } from '@/lib/account-identity';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import {
|
||||
GripVertical,
|
||||
@@ -188,6 +189,7 @@ export function AccountCard({
|
||||
account.tier &&
|
||||
account.tier !== 'unknown' &&
|
||||
account.tier !== 'free';
|
||||
const variantLabel = formatAccountVariantLabel(account.id, account.email);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -226,6 +228,11 @@ export function AccountCard({
|
||||
>
|
||||
{cleanEmail(account.email)}
|
||||
</span>
|
||||
{variantLabel && (
|
||||
<span className="text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0 bg-muted text-muted-foreground">
|
||||
{variantLabel}
|
||||
</span>
|
||||
)}
|
||||
{showTierBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -18,6 +18,7 @@ import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { usePrivacy } from '@/contexts/privacy-context';
|
||||
import { formatAccountDisplayName } from '@/lib/account-identity';
|
||||
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
||||
import { isDeniedAgyModelId } from '@/lib/utils';
|
||||
|
||||
@@ -220,7 +221,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||
<option value="">{t('cliproxyDialog.useDefaultAccount')}</option>
|
||||
{providerAccounts.map((acc) => (
|
||||
<option key={acc.id} value={acc.id}>
|
||||
{privacyMode ? '••••••' : acc.email || acc.id}
|
||||
{privacyMode ? '••••••' : formatAccountDisplayName(acc.id, acc.email)}
|
||||
{acc.isDefault ? ` ${t('cliproxyDialog.defaultSuffix')}` : ''}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
isClaudeQuotaResult,
|
||||
isCodexQuotaResult,
|
||||
} from '@/lib/utils';
|
||||
import { formatAccountVariantLabel } from '@/lib/account-identity';
|
||||
import { getAccountStats } from '@/lib/cliproxy-account-stats';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
@@ -187,6 +188,7 @@ export function AccountItem({
|
||||
: failureInfo?.tone === 'destructive'
|
||||
? 'border-destructive/50 text-destructive'
|
||||
: 'border-muted-foreground/50 text-muted-foreground';
|
||||
const variantLabel = formatAccountVariantLabel(account.id, account.email);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -274,6 +276,11 @@ export function AccountItem({
|
||||
>
|
||||
{account.email || account.id}
|
||||
</span>
|
||||
{variantLabel && (
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
|
||||
{variantLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{account.isDefault && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { ChevronRight, AlertTriangle } from 'lucide-react';
|
||||
import { formatAccountDisplayName } from '@/lib/account-identity';
|
||||
import { cn, STATUS_COLORS } from '@/lib/utils';
|
||||
import { PROVIDER_COLORS } from '@/lib/provider-config';
|
||||
import { ProviderIcon } from '@/components/shared/provider-icon';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { ProviderStats } from '../types';
|
||||
import { getSuccessRate, cleanEmail } from '../utils';
|
||||
import { getSuccessRate } from '../utils';
|
||||
import { InlineStatsBadge } from './inline-stats-badge';
|
||||
|
||||
interface ProviderCardProps {
|
||||
@@ -112,7 +113,7 @@ export function ProviderCard({
|
||||
<div
|
||||
className={cn('w-2 h-2 rounded-full', acc.paused && 'opacity-50')}
|
||||
style={{ backgroundColor: acc.color }}
|
||||
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
|
||||
title={privacyMode ? '••••••' : formatAccountDisplayName(acc.id, acc.email)}
|
||||
/>
|
||||
{isMissingProjectId && (
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react';
|
||||
import { formatAccountDisplayName } from '@/lib/account-identity';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import type { AccountStepProps } from '../types';
|
||||
@@ -37,7 +38,7 @@ export function AccountStep({
|
||||
</div>
|
||||
<div>
|
||||
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{acc.email || acc.id}
|
||||
{formatAccountDisplayName(acc.id, acc.email)}
|
||||
</div>
|
||||
{acc.isDefault && (
|
||||
<div className="text-xs text-muted-foreground">Default account</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { formatAccountDisplayName } from '@/lib/account-identity';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -63,7 +64,7 @@ export function VariantStep({
|
||||
<span>
|
||||
{t('setupVariant.using')}{' '}
|
||||
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{selectedAccount.email || selectedAccount.id}
|
||||
{formatAccountDisplayName(selectedAccount.id, selectedAccount.email)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
function formatVariantPart(part: string): string {
|
||||
const normalized = part.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch (normalized) {
|
||||
case 'team':
|
||||
return 'Team';
|
||||
case 'free':
|
||||
return 'Free';
|
||||
case 'plus':
|
||||
return 'Plus';
|
||||
case 'pro':
|
||||
return 'Pro';
|
||||
default:
|
||||
return /^[a-f0-9]{8}$/i.test(normalized)
|
||||
? normalized
|
||||
: normalized
|
||||
.split(/[._-]+/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment[0]?.toUpperCase() + segment.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
export function extractAccountVariantKey(accountId: string, email?: string): string | null {
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefix = `${email}#`;
|
||||
return accountId.startsWith(prefix) ? accountId.slice(prefix.length) : null;
|
||||
}
|
||||
|
||||
export function formatAccountVariantLabel(accountId: string, email?: string): string | null {
|
||||
const variantKey = extractAccountVariantKey(accountId, email);
|
||||
if (!variantKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = variantKey.split('-').filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const genericSuffix = parts[parts.length - 1];
|
||||
if (['team', 'free', 'plus', 'pro'].includes(genericSuffix)) {
|
||||
return [formatVariantPart(genericSuffix), ...parts.slice(0, -1).map(formatVariantPart)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
return parts.map(formatVariantPart).filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
export function formatAccountDisplayName(accountId: string, email?: string): string {
|
||||
const base = email || accountId;
|
||||
const variantLabel = formatAccountVariantLabel(accountId, email);
|
||||
return variantLabel ? `${base} (${variantLabel})` : base;
|
||||
}
|
||||
@@ -9,8 +9,17 @@ export function getAccountStats(
|
||||
stats: Pick<CliproxyStats, 'accountStats'> | null | undefined,
|
||||
account: Pick<OAuthAccount, 'provider' | 'email' | 'id'>
|
||||
): AccountUsageStats | undefined {
|
||||
const source = account.email || account.id;
|
||||
const qualifiedKey = buildQualifiedAccountStatsKey(account.provider, source);
|
||||
const sources = Array.from(
|
||||
new Set([account.id, account.email].filter((value): value is string => Boolean(value?.trim())))
|
||||
);
|
||||
|
||||
return stats?.accountStats?.[qualifiedKey] ?? stats?.accountStats?.[source];
|
||||
for (const source of sources) {
|
||||
const qualifiedKey = buildQualifiedAccountStatsKey(account.provider, source);
|
||||
const match = stats?.accountStats?.[qualifiedKey] ?? stats?.accountStats?.[source];
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ function mockRandom(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function makeAccount(overrides: Partial<AccountData> & Pick<AccountData, 'id' | 'email'>): AccountData {
|
||||
function makeAccount(
|
||||
overrides: Partial<AccountData> & Pick<AccountData, 'id' | 'email'>
|
||||
): AccountData {
|
||||
return {
|
||||
provider: 'agy',
|
||||
successCount: 0,
|
||||
@@ -170,9 +172,7 @@ describe('generateConnectionEvents()', () => {
|
||||
|
||||
it('all generated timestamps are not in the future', () => {
|
||||
const now = new Date();
|
||||
const accounts: AccountData[] = [
|
||||
makeAccount({ id: 'a1', email: 'a@x.com', successCount: 10 }),
|
||||
];
|
||||
const accounts: AccountData[] = [makeAccount({ id: 'a1', email: 'a@x.com', successCount: 10 })];
|
||||
|
||||
const events = generateConnectionEvents(accounts);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user