mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
fix(codex-auth): fail fast on missing active profile
This commit is contained in:
@@ -62,8 +62,12 @@ export async function main(argv: string[]): Promise<number> {
|
||||
}
|
||||
}
|
||||
} catch (resolverErr) {
|
||||
// Resolver module threw unexpectedly — degrade silently to legacy mode
|
||||
const msg = resolverErr instanceof Error ? resolverErr.message : String(resolverErr);
|
||||
if (resolverErr instanceof Error && resolverErr.name === 'CodexAuthProfileResolutionError') {
|
||||
process.stderr.write(`[X] codex-auth: ${msg}\n`);
|
||||
return 1;
|
||||
}
|
||||
// Resolver module threw unexpectedly — degrade to legacy mode.
|
||||
process.stderr.write(`[!] codex-auth: profile resolution skipped (${msg})\n`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Synchronous hot-path resolver for the active codex auth profile. <5ms typical.
|
||||
* Precedence: CCS_CODEX_PROFILE env → registry.default → null (legacy ~/.codex).
|
||||
* Errors degrade gracefully — never throw.
|
||||
* Legacy fallback is allowed only when no explicit CCS_CODEX_PROFILE was requested.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
@@ -14,6 +14,13 @@ export interface ResolvedProfile {
|
||||
source: 'env' | 'default';
|
||||
}
|
||||
|
||||
export class CodexAuthProfileResolutionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'CodexAuthProfileResolutionError';
|
||||
}
|
||||
}
|
||||
|
||||
interface RegistryShape {
|
||||
version?: string;
|
||||
default?: string | null;
|
||||
@@ -23,23 +30,41 @@ interface RegistryShape {
|
||||
/** @param env - Process env map; defaults to process.env. Injectable for tests. */
|
||||
export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): ResolvedProfile | null {
|
||||
const registryPath = getCodexAuthRegistryPath();
|
||||
const envName = (env.CCS_CODEX_PROFILE ?? '').trim();
|
||||
|
||||
// F4: silent fallback — no registry means no profiles, legacy mode
|
||||
if (!fs.existsSync(registryPath)) return null;
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
if (envName) {
|
||||
throw new CodexAuthProfileResolutionError(
|
||||
`CCS_CODEX_PROFILE='${envName}' is set but ${registryPath} does not exist. Refusing to fall back to ~/.codex.`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let registry: RegistryShape;
|
||||
try {
|
||||
const raw = fs.readFileSync(registryPath, 'utf8');
|
||||
const parsed = yaml.load(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
process.stderr.write(
|
||||
`[!] codex-auth: registry at ${registryPath} is not a valid YAML object, falling back to ~/.codex\n`
|
||||
);
|
||||
const msg = `registry at ${registryPath} is not a valid YAML object`;
|
||||
if (envName) {
|
||||
throw new CodexAuthProfileResolutionError(
|
||||
`CCS_CODEX_PROFILE='${envName}' is set but ${msg}. Refusing to fall back to ~/.codex.`
|
||||
);
|
||||
}
|
||||
process.stderr.write(`[!] codex-auth: ${msg}, falling back to ~/.codex\n`);
|
||||
return null;
|
||||
}
|
||||
registry = parsed as RegistryShape;
|
||||
} catch (err) {
|
||||
if (err instanceof CodexAuthProfileResolutionError) throw err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (envName) {
|
||||
throw new CodexAuthProfileResolutionError(
|
||||
`CCS_CODEX_PROFILE='${envName}' is set but registry YAML is corrupt at ${registryPath} (${msg}). Refusing to fall back to ~/.codex.`
|
||||
);
|
||||
}
|
||||
process.stderr.write(
|
||||
`[!] codex-auth: registry YAML corrupt at ${registryPath} (${msg}), falling back to ~/.codex\n`
|
||||
);
|
||||
@@ -49,13 +74,11 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso
|
||||
const profiles = registry.profiles ?? {};
|
||||
|
||||
// F2: explicit env override
|
||||
const envName = (env.CCS_CODEX_PROFILE ?? '').trim();
|
||||
if (envName) {
|
||||
if (!Object.prototype.hasOwnProperty.call(profiles, envName)) {
|
||||
process.stderr.write(
|
||||
`[!] codex-auth: CCS_CODEX_PROFILE='${envName}' not found in registry, falling back to ~/.codex\n`
|
||||
throw new CodexAuthProfileResolutionError(
|
||||
`CCS_CODEX_PROFILE='${envName}' not found in registry. Refusing to fall back to ~/.codex.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: envName,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Cases:
|
||||
* - Empty registry → resolveActiveProfile returns null (legacy mode)
|
||||
* - Missing registry file → returns null (no registry = legacy mode)
|
||||
* - CCS_CODEX_PROFILE set but registry missing → returns null + stderr warning
|
||||
* - CCS_CODEX_PROFILE set but registry missing or unmatched → throws to avoid unsafe fallback
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
@@ -74,7 +74,7 @@ describe('legacy fallback — empty registry', () => {
|
||||
});
|
||||
|
||||
describe('legacy fallback — CCS_CODEX_PROFILE set but no matching profile', () => {
|
||||
it('returns null and emits warning when env points to non-existent profile', async () => {
|
||||
it('throws when env points to non-existent profile', async () => {
|
||||
// Create registry with no profiles
|
||||
const registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
|
||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||
@@ -82,29 +82,19 @@ describe('legacy fallback — CCS_CODEX_PROFILE set but no matching profile', ()
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
const stderrLines: string[] = [];
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
process.stderr.write = (chunk: any): boolean => {
|
||||
stderrLines.push(String(chunk));
|
||||
return true;
|
||||
};
|
||||
const { resolveActiveProfile } = await import('../../../src/codex-auth/resolve-active-profile');
|
||||
expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost-profile' })).toThrow(
|
||||
/ghost-profile/
|
||||
);
|
||||
});
|
||||
|
||||
let result;
|
||||
try {
|
||||
const { resolveActiveProfile } = await import(
|
||||
'../../../src/codex-auth/resolve-active-profile'
|
||||
);
|
||||
result = resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost-profile' });
|
||||
} finally {
|
||||
process.stderr.write = origWrite;
|
||||
}
|
||||
it('throws when env is set but registry file is missing', async () => {
|
||||
const registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
|
||||
expect(fs.existsSync(registryPath)).toBe(false);
|
||||
|
||||
// Should fall back to null (not throw)
|
||||
expect(result).toBeNull();
|
||||
|
||||
// Warning emitted to stderr about missing profile
|
||||
const allStderr = stderrLines.join('');
|
||||
expect(allStderr).toContain('ghost-profile');
|
||||
const { resolveActiveProfile } = await import('../../../src/codex-auth/resolve-active-profile');
|
||||
expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost-profile' })).toThrow(
|
||||
/does not exist/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,6 +143,37 @@ describe('codex-runtime router — non-auth profile resolution', () => {
|
||||
expect(code).toBe(-1); // CCS branch: entry must not call process.exit()
|
||||
});
|
||||
|
||||
it('fails fast when CCS_CODEX_PROFILE points to a missing registry profile', async () => {
|
||||
writeRegistry({
|
||||
version: '1.0',
|
||||
default: null,
|
||||
profiles: {},
|
||||
});
|
||||
process.env.CCS_CODEX_PROFILE = 'ghost';
|
||||
|
||||
const stderrMessages: string[] = [];
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (chunk: string | Uint8Array): boolean => {
|
||||
stderrMessages.push(typeof chunk === 'string' ? chunk : String(chunk));
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
|
||||
flushRouterCache();
|
||||
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
|
||||
|
||||
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
|
||||
const code = await main(['node', 'codex-runtime', 'chat']);
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(process.env.CODEX_HOME).toBeUndefined();
|
||||
expect(stderrMessages.join('')).toContain("CCS_CODEX_PROFILE='ghost'");
|
||||
} finally {
|
||||
process.stderr.write = origWrite;
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves an explicit CODEX_HOME already in env — does not overwrite', async () => {
|
||||
const explicitHome = path.join(tempDir, 'explicit-codex-home');
|
||||
fs.mkdirSync(explicitHome, { recursive: true });
|
||||
|
||||
@@ -78,6 +78,15 @@ describe('resolveActiveProfile', () => {
|
||||
expect(stderrMessages.some((m) => m.includes('codex-auth'))).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when CCS_CODEX_PROFILE is set and registry YAML is corrupt', () => {
|
||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||
fs.writeFileSync(registryPath, '{ invalid yaml: [[[', { mode: 0o600 });
|
||||
|
||||
expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'work' })).toThrow(
|
||||
/Refusing to fall back to ~\/\.codex/
|
||||
);
|
||||
});
|
||||
|
||||
it('returns source=env when CCS_CODEX_PROFILE matches a registry entry', () => {
|
||||
const profileDir = makeProfileDir('work');
|
||||
writeRegistry({
|
||||
@@ -132,28 +141,20 @@ describe('resolveActiveProfile', () => {
|
||||
expect(result?.source).toBe('env');
|
||||
});
|
||||
|
||||
it('returns null and warns when CCS_CODEX_PROFILE names a profile not in registry', () => {
|
||||
it('throws when CCS_CODEX_PROFILE names a profile not in registry', () => {
|
||||
writeRegistry({
|
||||
version: '1.0',
|
||||
default: null,
|
||||
profiles: {},
|
||||
});
|
||||
|
||||
const stderrMessages: string[] = [];
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
const spy = spyOn(process.stderr, 'write').mockImplementation(
|
||||
(msg: string | Uint8Array, ...rest: unknown[]) => {
|
||||
stderrMessages.push(typeof msg === 'string' ? msg : String(msg));
|
||||
return origWrite(msg as string, ...(rest as Parameters<typeof origWrite>).slice(1));
|
||||
}
|
||||
expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost' })).toThrow(
|
||||
/CCS_CODEX_PROFILE='ghost'/
|
||||
);
|
||||
});
|
||||
|
||||
const result = resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost' });
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(stderrMessages.some((m) => m.includes('ghost'))).toBe(true);
|
||||
it('throws when CCS_CODEX_PROFILE is set but registry file is missing', () => {
|
||||
expect(() => resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost' })).toThrow(/does not exist/);
|
||||
});
|
||||
|
||||
it('treats empty/whitespace-only CCS_CODEX_PROFILE as unset, falls back to default', () => {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
*/
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -44,20 +46,22 @@ function formatLastUsed(iso: string | null): string {
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(source: 'default' | 'env' | 'explicit-codex-home'): string {
|
||||
function sourceLabel(source: 'default' | 'env' | 'explicit-codex-home', t: TFunction): string {
|
||||
switch (source) {
|
||||
case 'default':
|
||||
return 'default';
|
||||
return t('codex.auth.sourceDefault');
|
||||
case 'env':
|
||||
return '$CCS_CODEX_PROFILE';
|
||||
return t('codex.auth.sourceEnv');
|
||||
case 'explicit-codex-home':
|
||||
return '$CODEX_HOME';
|
||||
return t('codex.auth.sourceExplicitCodexHome');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Disabled action button with terminal-redirect tooltip ───────────────────
|
||||
|
||||
function TerminalOnlyButton({ label }: { label: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -69,11 +73,7 @@ function TerminalOnlyButton({ label }: { label: string }) {
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{/* TODO i18n: missing key codex.auth.terminalOnlyTooltip */}
|
||||
Use <code>ccsx auth switch <name></code> or{' '}
|
||||
<code>ccsx auth remove <name></code> in terminal.
|
||||
</TooltipContent>
|
||||
<TooltipContent>{t('codex.auth.terminalOnlyTooltip')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
@@ -90,6 +90,8 @@ function ProfileRow({
|
||||
isActive: boolean;
|
||||
activeSource?: 'default' | 'env' | 'explicit-codex-home';
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TableRow className={isActive ? 'bg-muted/40' : undefined}>
|
||||
<TableCell className="font-medium">
|
||||
@@ -97,8 +99,7 @@ function ProfileRow({
|
||||
{entry.name}
|
||||
{isActive && activeSource && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{/* TODO i18n: missing key codex.auth.activeSourceBadge */}
|
||||
{sourceLabel(activeSource)}
|
||||
{t('codex.auth.activeSourceBadge', { source: sourceLabel(activeSource, t) })}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
@@ -109,20 +110,18 @@ function ProfileRow({
|
||||
<TableCell>
|
||||
{entry.authValid ? (
|
||||
<Badge variant="secondary" className="text-xs text-green-700 dark:text-green-400">
|
||||
{/* TODO i18n: missing key codex.auth.statusOk */}
|
||||
OK
|
||||
{t('codex.auth.statusOk')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{/* TODO i18n: missing key codex.auth.statusInvalid */}
|
||||
[!] auth invalid
|
||||
{t('codex.auth.statusInvalid')}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex gap-1">
|
||||
<TerminalOnlyButton label="Switch" />
|
||||
<TerminalOnlyButton label="Remove" />
|
||||
<TerminalOnlyButton label={t('codex.auth.switchAction')} />
|
||||
<TerminalOnlyButton label={t('codex.auth.removeAction')} />
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -132,14 +131,14 @@ function ProfileRow({
|
||||
// ── Main card ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CodexAuthProfilesCard() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, error } = useCodexAuthProfiles();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground p-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{/* TODO i18n: missing key codex.auth.loading */}
|
||||
Loading auth profiles...
|
||||
{t('codex.auth.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -147,8 +146,7 @@ export function CodexAuthProfilesCard() {
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{/* TODO i18n: missing key codex.auth.loadError */}
|
||||
[!] Failed to load codex-auth profiles.
|
||||
{t('codex.auth.loadError')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -157,13 +155,8 @@ export function CodexAuthProfilesCard() {
|
||||
if (data.profiles.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground space-y-1">
|
||||
<p>
|
||||
{/* TODO i18n: missing key codex.auth.emptyRegistry */}
|
||||
[i] No codex-auth profiles. Run{' '}
|
||||
<code className="rounded bg-muted px-1">ccsx auth create <name></code> to create
|
||||
one.
|
||||
</p>
|
||||
<p>Codex will use the default ~/.codex location.</p>
|
||||
<p>{t('codex.auth.emptyRegistry')}</p>
|
||||
<p>{t('codex.auth.legacyCodexHome')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -173,10 +166,7 @@ export function CodexAuthProfilesCard() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.auth.legacyMode */}
|
||||
[i] No active profile. Using ~/.codex (legacy). Run{' '}
|
||||
<code className="rounded bg-muted px-1">ccsx auth switch <name></code> in terminal
|
||||
to activate one.
|
||||
{t('codex.auth.legacyMode')}
|
||||
</div>
|
||||
<ProfileTable data={data} />
|
||||
</div>
|
||||
@@ -188,10 +178,7 @@ export function CodexAuthProfilesCard() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
{/* TODO i18n: missing key codex.auth.externalCodexHome */}
|
||||
[i] $CODEX_HOME set externally to{' '}
|
||||
<code className="rounded bg-muted px-1">{data.active.codexHome}</code>. Profile registry
|
||||
not in use for this session.
|
||||
{t('codex.auth.externalCodexHome', { path: data.active.codexHome })}
|
||||
</div>
|
||||
<ProfileTable data={data} />
|
||||
</div>
|
||||
@@ -217,16 +204,16 @@ function ActiveBanner({
|
||||
source: 'default' | 'env' | 'explicit-codex-home';
|
||||
profiles: CodexAuthProfileEntry[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const activeEntry = profiles.find((p) => p.name === name);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 px-4 py-3 text-sm space-y-1">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{/* TODO i18n: missing key codex.auth.activeProfile */}
|
||||
Active profile:
|
||||
<span>{name ?? '(unknown)'}</span>
|
||||
{t('codex.auth.activeProfile')}
|
||||
<span>{name ?? t('codex.auth.unknownProfile')}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{sourceLabel(source)}
|
||||
{sourceLabel(source, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
{activeEntry && (
|
||||
@@ -234,10 +221,12 @@ function ActiveBanner({
|
||||
{activeEntry.email && <span>{activeEntry.email}</span>}
|
||||
{activeEntry.plan && (
|
||||
<span>
|
||||
Plan: <strong>{activeEntry.plan}</strong>
|
||||
{t('codex.auth.planLabel')} <strong>{activeEntry.plan}</strong>
|
||||
</span>
|
||||
)}
|
||||
{!activeEntry.authValid && <span className="text-destructive">[!] auth invalid</span>}
|
||||
{!activeEntry.authValid && (
|
||||
<span className="text-destructive">{t('codex.auth.statusInvalid')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -254,18 +243,19 @@ function ProfileTable({
|
||||
profiles: CodexAuthProfileEntry[];
|
||||
};
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* TODO i18n: missing keys codex.auth.col.name/email/plan/lastUsed/status/actions */}
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead>Last used</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
<TableHead>{t('codex.auth.col.name')}</TableHead>
|
||||
<TableHead>{t('codex.auth.col.email')}</TableHead>
|
||||
<TableHead>{t('codex.auth.col.plan')}</TableHead>
|
||||
<TableHead>{t('codex.auth.col.lastUsed')}</TableHead>
|
||||
<TableHead>{t('codex.auth.col.status')}</TableHead>
|
||||
<TableHead>{t('codex.auth.col.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -2175,6 +2175,37 @@ const resources = {
|
||||
featureAppsDesc: 'Enable ChatGPT Apps and connectors support.',
|
||||
featureSmartApprovalsLabel: 'Smart approvals',
|
||||
featureSmartApprovalsDesc: 'Route eligible approvals through the guardian flow.',
|
||||
auth: {
|
||||
terminalOnlyTooltip:
|
||||
'Use ccsx auth switch <name> or ccsx auth remove <name> in terminal.',
|
||||
activeSourceBadge: '{{source}}',
|
||||
statusOk: 'OK',
|
||||
statusInvalid: '[!] auth invalid',
|
||||
loading: 'Loading auth profiles...',
|
||||
loadError: '[!] Failed to load codex-auth profiles.',
|
||||
emptyRegistry: '[i] No codex-auth profiles. Run ccsx auth create <name> to create one.',
|
||||
legacyCodexHome: 'Codex will use the default ~/.codex location.',
|
||||
legacyMode:
|
||||
'[i] No active profile. Using ~/.codex (legacy). Run ccsx auth switch <name> in terminal to activate one.',
|
||||
externalCodexHome:
|
||||
'[i] $CODEX_HOME set externally to {{path}}. Profile registry not in use for this session.',
|
||||
activeProfile: 'Active profile:',
|
||||
unknownProfile: '(unknown)',
|
||||
planLabel: 'Plan:',
|
||||
switchAction: 'Switch',
|
||||
removeAction: 'Remove',
|
||||
sourceDefault: 'default',
|
||||
sourceEnv: '$CCS_CODEX_PROFILE',
|
||||
sourceExplicitCodexHome: '$CODEX_HOME',
|
||||
col: {
|
||||
name: 'Name',
|
||||
email: 'Email',
|
||||
plan: 'Plan',
|
||||
lastUsed: 'Last used',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
},
|
||||
},
|
||||
},
|
||||
droidSettings: {
|
||||
quickControls: 'Quick Controls',
|
||||
@@ -4739,6 +4770,35 @@ const resources = {
|
||||
yes: '是',
|
||||
no: '否',
|
||||
warningsTitle: '警告',
|
||||
auth: {
|
||||
terminalOnlyTooltip: '在终端使用 ccsx auth switch <name> 或 ccsx auth remove <name>。',
|
||||
activeSourceBadge: '{{source}}',
|
||||
statusOk: '正常',
|
||||
statusInvalid: '[!] 认证无效',
|
||||
loading: '正在加载认证配置...',
|
||||
loadError: '[!] 加载 codex-auth 配置失败。',
|
||||
emptyRegistry: '[i] 没有 codex-auth 配置。运行 ccsx auth create <name> 创建一个。',
|
||||
legacyCodexHome: 'Codex 将使用默认 ~/.codex 位置。',
|
||||
legacyMode:
|
||||
'[i] 没有活动配置。正在使用 ~/.codex(旧模式)。在终端运行 ccsx auth switch <name> 激活一个。',
|
||||
externalCodexHome: '[i] $CODEX_HOME 外部设置为 {{path}}。本会话未使用配置注册表。',
|
||||
activeProfile: '活动配置:',
|
||||
unknownProfile: '(未知)',
|
||||
planLabel: '套餐:',
|
||||
switchAction: '切换',
|
||||
removeAction: '移除',
|
||||
sourceDefault: '默认',
|
||||
sourceEnv: '$CCS_CODEX_PROFILE',
|
||||
sourceExplicitCodexHome: '$CODEX_HOME',
|
||||
col: {
|
||||
name: '名称',
|
||||
email: '邮箱',
|
||||
plan: '套餐',
|
||||
lastUsed: '上次使用',
|
||||
status: '状态',
|
||||
actions: '操作',
|
||||
},
|
||||
},
|
||||
},
|
||||
droidSettings: {
|
||||
quickControls: '快捷控制',
|
||||
@@ -7389,6 +7449,37 @@ const resources = {
|
||||
yes: 'Có',
|
||||
no: 'Không',
|
||||
warningsTitle: 'Cảnh báo',
|
||||
auth: {
|
||||
terminalOnlyTooltip:
|
||||
'Dùng ccsx auth switch <name> hoặc ccsx auth remove <name> trong terminal.',
|
||||
activeSourceBadge: '{{source}}',
|
||||
statusOk: 'OK',
|
||||
statusInvalid: '[!] auth không hợp lệ',
|
||||
loading: 'Đang tải hồ sơ auth...',
|
||||
loadError: '[!] Không tải được hồ sơ codex-auth.',
|
||||
emptyRegistry: '[i] Chưa có hồ sơ codex-auth. Chạy ccsx auth create <name> để tạo.',
|
||||
legacyCodexHome: 'Codex sẽ dùng vị trí mặc định ~/.codex.',
|
||||
legacyMode:
|
||||
'[i] Chưa có hồ sơ active. Đang dùng ~/.codex (legacy). Chạy ccsx auth switch <name> trong terminal để kích hoạt.',
|
||||
externalCodexHome:
|
||||
'[i] $CODEX_HOME được đặt bên ngoài là {{path}}. Registry hồ sơ không dùng trong phiên này.',
|
||||
activeProfile: 'Hồ sơ active:',
|
||||
unknownProfile: '(không rõ)',
|
||||
planLabel: 'Gói:',
|
||||
switchAction: 'Chuyển',
|
||||
removeAction: 'Xóa',
|
||||
sourceDefault: 'mặc định',
|
||||
sourceEnv: '$CCS_CODEX_PROFILE',
|
||||
sourceExplicitCodexHome: '$CODEX_HOME',
|
||||
col: {
|
||||
name: 'Tên',
|
||||
email: 'Email',
|
||||
plan: 'Gói',
|
||||
lastUsed: 'Dùng lần cuối',
|
||||
status: 'Trạng thái',
|
||||
actions: 'Thao tác',
|
||||
},
|
||||
},
|
||||
},
|
||||
droidSettings: {
|
||||
quickControls: 'Điều khiển nhanh',
|
||||
@@ -9765,6 +9856,38 @@ const resources = {
|
||||
yes: 'はい',
|
||||
no: 'いいえ',
|
||||
warningsTitle: '警告',
|
||||
auth: {
|
||||
terminalOnlyTooltip:
|
||||
'ターミナルで ccsx auth switch <name> または ccsx auth remove <name> を使用します。',
|
||||
activeSourceBadge: '{{source}}',
|
||||
statusOk: 'OK',
|
||||
statusInvalid: '[!] 認証が無効',
|
||||
loading: '認証プロファイルを読み込み中...',
|
||||
loadError: '[!] codex-auth プロファイルの読み込みに失敗しました。',
|
||||
emptyRegistry:
|
||||
'[i] codex-auth プロファイルがありません。ccsx auth create <name> を実行して作成します。',
|
||||
legacyCodexHome: 'Codex はデフォルトの ~/.codex を使用します。',
|
||||
legacyMode:
|
||||
'[i] アクティブなプロファイルがありません。~/.codex(レガシー)を使用中です。ターミナルで ccsx auth switch <name> を実行して有効化します。',
|
||||
externalCodexHome:
|
||||
'[i] $CODEX_HOME は外部で {{path}} に設定されています。このセッションではプロファイル registry は使われません。',
|
||||
activeProfile: 'アクティブプロファイル:',
|
||||
unknownProfile: '(不明)',
|
||||
planLabel: 'プラン:',
|
||||
switchAction: '切り替え',
|
||||
removeAction: '削除',
|
||||
sourceDefault: 'デフォルト',
|
||||
sourceEnv: '$CCS_CODEX_PROFILE',
|
||||
sourceExplicitCodexHome: '$CODEX_HOME',
|
||||
col: {
|
||||
name: '名前',
|
||||
email: 'メール',
|
||||
plan: 'プラン',
|
||||
lastUsed: '最終使用',
|
||||
status: 'ステータス',
|
||||
actions: '操作',
|
||||
},
|
||||
},
|
||||
},
|
||||
codexPage: {
|
||||
title: 'Codex',
|
||||
@@ -12735,6 +12858,38 @@ const resources = {
|
||||
featureAppsDesc: 'ChatGPT 앱 및 커넥터 지원을 활성화합니다.',
|
||||
featureSmartApprovalsLabel: '스마트 승인',
|
||||
featureSmartApprovalsDesc: '가디언 흐름을 통해 적격 승인을 라우팅합니다.',
|
||||
auth: {
|
||||
terminalOnlyTooltip:
|
||||
'터미널에서 ccsx auth switch <name> 또는 ccsx auth remove <name>을 사용하세요.',
|
||||
activeSourceBadge: '{{source}}',
|
||||
statusOk: 'OK',
|
||||
statusInvalid: '[!] 인증이 유효하지 않음',
|
||||
loading: '인증 프로필 로드 중...',
|
||||
loadError: '[!] codex-auth 프로필을 로드하지 못했습니다.',
|
||||
emptyRegistry:
|
||||
'[i] codex-auth 프로필이 없습니다. ccsx auth create <name>을 실행해 생성하세요.',
|
||||
legacyCodexHome: 'Codex는 기본 ~/.codex 위치를 사용합니다.',
|
||||
legacyMode:
|
||||
'[i] 활성 프로필이 없습니다. ~/.codex(레거시)를 사용 중입니다. 터미널에서 ccsx auth switch <name>을 실행해 활성화하세요.',
|
||||
externalCodexHome:
|
||||
'[i] $CODEX_HOME이 외부에서 {{path}}로 설정되었습니다. 이 세션에서는 프로필 registry를 사용하지 않습니다.',
|
||||
activeProfile: '활성 프로필:',
|
||||
unknownProfile: '(알 수 없음)',
|
||||
planLabel: '플랜:',
|
||||
switchAction: '전환',
|
||||
removeAction: '제거',
|
||||
sourceDefault: '기본값',
|
||||
sourceEnv: '$CCS_CODEX_PROFILE',
|
||||
sourceExplicitCodexHome: '$CODEX_HOME',
|
||||
col: {
|
||||
name: '이름',
|
||||
email: '이메일',
|
||||
plan: '플랜',
|
||||
lastUsed: '마지막 사용',
|
||||
status: '상태',
|
||||
actions: '작업',
|
||||
},
|
||||
},
|
||||
},
|
||||
droidSettings: {
|
||||
quickControls: '빠른 제어',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import i18n from '@/lib/i18n';
|
||||
|
||||
const locales = ['en', 'zh-CN', 'vi', 'ja', 'ko'] as const;
|
||||
|
||||
const codexAuthKeys = [
|
||||
['codex.auth.terminalOnlyTooltip'],
|
||||
['codex.auth.loading'],
|
||||
['codex.auth.loadError'],
|
||||
['codex.auth.emptyRegistry'],
|
||||
['codex.auth.externalCodexHome', { path: '/tmp/codex-home' }],
|
||||
['codex.auth.activeProfile'],
|
||||
['codex.auth.switchAction'],
|
||||
['codex.auth.col.name'],
|
||||
['codex.auth.col.actions'],
|
||||
] as const;
|
||||
|
||||
const originalLanguage = i18n.language;
|
||||
|
||||
afterAll(async () => {
|
||||
await i18n.changeLanguage(originalLanguage);
|
||||
});
|
||||
|
||||
describe('codex auth i18n', () => {
|
||||
it.each(locales)('resolves codex auth dashboard keys for %s', async (locale) => {
|
||||
await i18n.changeLanguage(locale);
|
||||
|
||||
for (const [key, options] of codexAuthKeys) {
|
||||
const translated = i18n.t(key, options);
|
||||
|
||||
expect(translated).not.toBe(key);
|
||||
expect(translated).not.toContain('codex.auth.');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user