mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
fix(codex-auth): close remaining review gaps
This commit is contained in:
@@ -17,6 +17,24 @@
|
||||
|
||||
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
|
||||
|
||||
function isCodexAuthProfileResolutionError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'name' in err &&
|
||||
(err as { name?: unknown }).name === 'CodexAuthProfileResolutionError'
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'object' && err !== null && 'message' in err) {
|
||||
const message = (err as { message?: unknown }).message;
|
||||
if (typeof message === 'string') return message;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry-point for the ccsx / codex-runtime binary.
|
||||
*
|
||||
@@ -62,8 +80,8 @@ export async function main(argv: string[]): Promise<number> {
|
||||
}
|
||||
}
|
||||
} catch (resolverErr) {
|
||||
const msg = resolverErr instanceof Error ? resolverErr.message : String(resolverErr);
|
||||
if (resolverErr instanceof Error && resolverErr.name === 'CodexAuthProfileResolutionError') {
|
||||
const msg = errorMessage(resolverErr);
|
||||
if (isCodexAuthProfileResolutionError(resolverErr)) {
|
||||
process.stderr.write(`[X] codex-auth: ${msg}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a running `codex` process via pgrep (best-effort, never throws).
|
||||
* Detect a running `codex` process via pgrep + ps validation (best-effort, never throws).
|
||||
* Returns the PID string if found, null otherwise.
|
||||
*/
|
||||
function detectCodexRunning(): string | null {
|
||||
@@ -47,15 +47,83 @@ function detectCodexRunning(): string | null {
|
||||
encoding: 'utf8',
|
||||
timeout: 2000,
|
||||
});
|
||||
if (result.status === 0 && result.stdout && result.stdout.trim().length > 0) {
|
||||
return result.stdout.trim().split('\n')[0];
|
||||
if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
|
||||
const pids = parsePgrepPids(result.stdout);
|
||||
if (pids.length === 0) return null;
|
||||
|
||||
const psResult = childProcess.spawnSync(
|
||||
'ps',
|
||||
['-p', pids.join(','), '-o', 'pid=', '-o', 'command='],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 2000,
|
||||
}
|
||||
);
|
||||
if (psResult.status !== 0 || !psResult.stdout) return null;
|
||||
return selectCodexPidFromPsOutput(psResult.stdout, pids);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePgrepPids(stdout: string): string[] {
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((pid) => /^\d+$/.test(pid) && pid !== String(process.pid));
|
||||
}
|
||||
|
||||
function selectCodexPidFromPsOutput(stdout: string, candidatePids: string[]): string | null {
|
||||
const candidates = new Set(candidatePids);
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
|
||||
if (!match) continue;
|
||||
const [, pid, command] = match;
|
||||
if (!pid || !command || !candidates.has(pid)) continue;
|
||||
if (isLikelyCodexProcessCommand(command)) return pid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLikelyCodexProcessCommand(command: string): boolean {
|
||||
const executable = firstCommandToken(command);
|
||||
if (isCodexExecutableToken(executable)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalized = command.replace(/\\/g, '/').toLowerCase();
|
||||
if (normalized.includes('/@openai/codex/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return command.split(/\s+/).some((token) => {
|
||||
const tokenName = executableTokenBasename(token);
|
||||
return tokenName === 'codex.js' || isCodexExecutableName(tokenName);
|
||||
});
|
||||
}
|
||||
|
||||
function firstCommandToken(command: string): string {
|
||||
const trimmed = command.trim();
|
||||
const quoted = trimmed.match(/^"([^"]+)"/);
|
||||
if (quoted?.[1]) return quoted[1];
|
||||
return trimmed.split(/\s+/)[0] ?? '';
|
||||
}
|
||||
|
||||
function isCodexExecutableToken(token: string): boolean {
|
||||
return isCodexExecutableName(executableTokenBasename(token));
|
||||
}
|
||||
|
||||
function executableTokenBasename(token: string): string {
|
||||
return path.basename(token.replace(/^["']|["']$/g, '').replace(/\\/g, '/')).toLowerCase();
|
||||
}
|
||||
|
||||
function isCodexExecutableName(name: string): boolean {
|
||||
return ['codex', 'codex.exe', 'codex.cmd', 'codex.ps1'].includes(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate auth.json with retry on torn-write (C3).
|
||||
* Returns parsed auth JSON or throws on persistent failure.
|
||||
|
||||
@@ -113,6 +113,9 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
|
||||
const stagedDeleteDir = `${profileDir}.deleting.${process.pid}.${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
const preservationDir = `${profileDir}.preserved.${process.pid}.${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
|
||||
try {
|
||||
fs.renameSync(profileDir, stagedDeleteDir);
|
||||
@@ -125,10 +128,25 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.cpSync(stagedDeleteDir, preservationDir, { recursive: true, errorOnExist: true });
|
||||
} catch (err) {
|
||||
const restored = _restoreProfileDir(stagedDeleteDir, profileDir);
|
||||
_removePathBestEffort(preservationDir);
|
||||
const preservedPath = restored ? profileDir : stagedDeleteDir;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
exitWithError(
|
||||
`Profile data delete preparation failed; profile data was preserved at ${preservedPath}.\n ${msg}`,
|
||||
ExitCode.GENERAL_ERROR
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
registry.removeProfile(profileName);
|
||||
} catch (err) {
|
||||
const restored = _restoreProfileDir(stagedDeleteDir, profileDir);
|
||||
_removePathBestEffort(preservationDir);
|
||||
const preservedPath = restored ? profileDir : stagedDeleteDir;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
exitWithError(
|
||||
@@ -140,9 +158,14 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
|
||||
|
||||
try {
|
||||
fs.rmSync(stagedDeleteDir, { recursive: true, force: true });
|
||||
fs.rmSync(preservationDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
const restoredDir = _restoreProfileDir(stagedDeleteDir, profileDir);
|
||||
const restoreSource = fs.existsSync(preservationDir) ? preservationDir : stagedDeleteDir;
|
||||
const restoredDir = _restoreProfileDir(restoreSource, profileDir);
|
||||
const restoredRegistry = _restoreRegistryEntry(registry, profileName, meta, originalDefault);
|
||||
if (restoredDir && restoreSource === preservationDir) {
|
||||
_removePathBestEffort(stagedDeleteDir);
|
||||
}
|
||||
const preservedPath = restoredDir ? profileDir : stagedDeleteDir;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const registryNote = restoredRegistry
|
||||
@@ -158,6 +181,14 @@ export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]
|
||||
console.log(ok(`Profile removed: ${profileName}`));
|
||||
}
|
||||
|
||||
function _removePathBestEffort(targetPath: string): void {
|
||||
try {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup after data has already been preserved elsewhere
|
||||
}
|
||||
}
|
||||
|
||||
function _restoreRegistryEntry(
|
||||
registry: CodexCommandContext['registry'],
|
||||
profileName: string,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { decodeAccountIdentity } from '../codex-account-identity';
|
||||
import { showProfileDetail } from './show-detail-view';
|
||||
import { parseArgs, rejectUnsupportedOptions, formatRelativeTime } from './types';
|
||||
import type { CodexCommandContext, CodexProfileOutput } from './types';
|
||||
import type { CodexAccountIdentity } from '../types';
|
||||
|
||||
export async function handleShowCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
@@ -44,6 +45,7 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
|
||||
name: string;
|
||||
email: string;
|
||||
plan: string;
|
||||
accountId: string | null;
|
||||
lastUsed: string;
|
||||
state: string;
|
||||
missing?: boolean;
|
||||
@@ -57,6 +59,7 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
|
||||
name: activeName ?? '',
|
||||
email: '<unknown>',
|
||||
plan: '-',
|
||||
accountId: null,
|
||||
lastUsed: 'never',
|
||||
state: 'active(missing)',
|
||||
missing: true,
|
||||
@@ -71,15 +74,16 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
|
||||
|
||||
const profileDir = resolveCodexProfileDir(name);
|
||||
const authJsonPath = path.join(profileDir, 'auth.json');
|
||||
let email = meta.email ?? '<unknown>';
|
||||
if (fs.existsSync(authJsonPath) && !meta.email) {
|
||||
const identity = decodeAccountIdentity(authJsonPath);
|
||||
email = identity.email ?? '<unknown>';
|
||||
}
|
||||
const identity: CodexAccountIdentity = fs.existsSync(authJsonPath)
|
||||
? decodeAccountIdentity(authJsonPath)
|
||||
: {};
|
||||
const email = meta.email ?? identity.email ?? '<unknown>';
|
||||
const plan = meta.plan_type ?? identity.plan_type ?? '-';
|
||||
const accountId = meta.account_id ?? identity.account_id ?? null;
|
||||
|
||||
const lastUsed = meta.last_used ? formatRelativeTime(new Date(meta.last_used)) : 'never';
|
||||
|
||||
rows.push({ name, email, plan: meta.plan_type ?? '-', lastUsed, state: states.join(',') });
|
||||
rows.push({ name, email, plan, accountId, lastUsed, state: states.join(',') });
|
||||
}
|
||||
|
||||
if (json) {
|
||||
@@ -94,7 +98,7 @@ function _showList(ctx: CodexCommandContext, json: boolean): void {
|
||||
last_used: meta?.last_used ?? null,
|
||||
email: r.email === '<unknown>' ? null : r.email,
|
||||
plan: r.plan === '-' ? null : r.plan,
|
||||
account_id: null,
|
||||
account_id: r.accountId,
|
||||
profile_dir: profileDir,
|
||||
auth_json_exists: r.missing ? false : fs.existsSync(path.join(profileDir, 'auth.json')),
|
||||
auth_json_mtime: null,
|
||||
|
||||
@@ -68,6 +68,7 @@ export function showProfileDetail(
|
||||
if (isDefault) states.push('default');
|
||||
if (isActive) states.push('active');
|
||||
const stateStr = states.join(',');
|
||||
const accountId = meta.account_id ?? identity.account_id ?? null;
|
||||
|
||||
if (json) {
|
||||
const out: CodexProfileOutput = {
|
||||
@@ -78,7 +79,7 @@ export function showProfileDetail(
|
||||
last_used: meta.last_used ?? null,
|
||||
email: identity.email ?? null,
|
||||
plan: meta.plan_type ?? null,
|
||||
account_id: identity.account_id ?? null,
|
||||
account_id: accountId,
|
||||
profile_dir: profileDir,
|
||||
auth_json_exists: authExists,
|
||||
auth_json_mtime: authMtime,
|
||||
@@ -99,7 +100,7 @@ export function showProfileDetail(
|
||||
['auth.json', authState],
|
||||
['Email', identity.email ?? (authExists ? '<invalid>' : '<unknown>')],
|
||||
['Plan', meta.plan_type ?? (authExists ? '<invalid>' : '<unknown>')],
|
||||
['Account ID', identity.account_id ?? '-'],
|
||||
['Account ID', accountId ?? '-'],
|
||||
['Created', new Date(meta.created).toLocaleString()],
|
||||
['Last used', meta.last_used ? new Date(meta.last_used).toLocaleString() : 'never'],
|
||||
['CODEX_HOME (env)', process.env.CODEX_HOME ?? 'unset'],
|
||||
|
||||
@@ -31,7 +31,7 @@ function base64urlDecode(str: string): string {
|
||||
}
|
||||
|
||||
function isBase64UrlSegment(str: string): boolean {
|
||||
return str.length > 0 && BASE64URL_SEGMENT_RE.test(str);
|
||||
return str.length > 0 && str.length % 4 !== 1 && BASE64URL_SEGMENT_RE.test(str);
|
||||
}
|
||||
|
||||
function decodeJsonSegment(str: string): unknown {
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { getCodexAuthRegistryPath, resolveCodexProfileDir } from './codex-profile-paths';
|
||||
import { getCcsDirSource } from '../utils/config-manager';
|
||||
|
||||
export interface ResolvedProfile {
|
||||
name: string;
|
||||
@@ -27,16 +28,41 @@ interface RegistryShape {
|
||||
profiles?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function quoteDiagnosticValue(value: string): string {
|
||||
const escaped = value
|
||||
.replace(/[\x00-\x1f\x7f]/g, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
||||
.replace(/'/g, "\\'");
|
||||
return `'${escaped.length > 96 ? `${escaped.slice(0, 96)}...` : escaped}'`;
|
||||
}
|
||||
|
||||
function registryDisplayPath(registryPath: string): string {
|
||||
const [source] = getCcsDirSource();
|
||||
if (source === 'default') {
|
||||
return process.platform === 'win32'
|
||||
? '%USERPROFILE%\\.ccs\\codex-profiles.yaml'
|
||||
: '~/.ccs/codex-profiles.yaml';
|
||||
}
|
||||
if (source === 'CCS_HOME' || source === 'scoped:CCS_HOME') {
|
||||
return '$CCS_HOME/.ccs/codex-profiles.yaml';
|
||||
}
|
||||
if (source === 'CCS_DIR' || source === 'scoped:CCS_DIR') {
|
||||
return '$CCS_DIR/codex-profiles.yaml';
|
||||
}
|
||||
return registryPath;
|
||||
}
|
||||
|
||||
/** @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();
|
||||
const displayEnvName = quoteDiagnosticValue(envName);
|
||||
const displayRegistryPath = registryDisplayPath(registryPath);
|
||||
|
||||
// F4: silent fallback — no registry means no profiles, legacy mode
|
||||
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.`
|
||||
`CCS_CODEX_PROFILE=${displayEnvName} is set but ${displayRegistryPath} does not exist. Refusing to fall back to ~/.codex.`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -47,10 +73,10 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso
|
||||
const raw = fs.readFileSync(registryPath, 'utf8');
|
||||
const parsed = yaml.load(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
const msg = `registry at ${registryPath} is not a valid YAML object`;
|
||||
const msg = `registry at ${displayRegistryPath} 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.`
|
||||
`CCS_CODEX_PROFILE=${displayEnvName} is set but ${msg}. Refusing to fall back to ~/.codex.`
|
||||
);
|
||||
}
|
||||
process.stderr.write(`[!] codex-auth: ${msg}, falling back to ~/.codex\n`);
|
||||
@@ -59,15 +85,13 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso
|
||||
registry = parsed as RegistryShape;
|
||||
} catch (err) {
|
||||
if (err instanceof CodexAuthProfileResolutionError) throw err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const msg = `registry YAML could not be parsed at ${displayRegistryPath}`;
|
||||
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.`
|
||||
`CCS_CODEX_PROFILE=${displayEnvName} is set but ${msg}. Refusing to fall back to ~/.codex.`
|
||||
);
|
||||
}
|
||||
process.stderr.write(
|
||||
`[!] codex-auth: registry YAML corrupt at ${registryPath} (${msg}), falling back to ~/.codex\n`
|
||||
);
|
||||
process.stderr.write(`[!] codex-auth: ${msg}, falling back to ~/.codex\n`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -77,7 +101,7 @@ export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): Reso
|
||||
if (envName) {
|
||||
if (!Object.prototype.hasOwnProperty.call(profiles, envName)) {
|
||||
throw new CodexAuthProfileResolutionError(
|
||||
`CCS_CODEX_PROFILE='${envName}' not found in registry. Refusing to fall back to ~/.codex.`
|
||||
`CCS_CODEX_PROFILE=${displayEnvName} not found in registry. Refusing to fall back to ~/.codex.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -3,19 +3,27 @@
|
||||
* Determines current shell to emit correct eval-safe export syntax.
|
||||
*/
|
||||
|
||||
import * as childProcess from 'child_process';
|
||||
|
||||
export type Shell = 'bash' | 'zsh' | 'fish' | 'pwsh' | 'cmd';
|
||||
|
||||
/**
|
||||
* Detect current shell from environment.
|
||||
* On Windows: PSModulePath presence → pwsh, else cmd.
|
||||
* On Windows: inspect explicit shell executable hints, else default to cmd.
|
||||
* On Unix: inspect $SHELL suffix.
|
||||
*/
|
||||
export function detectShell(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: string = process.platform
|
||||
platform: string = process.platform,
|
||||
parentProcessName?: string
|
||||
): Shell {
|
||||
if (platform === 'win32') {
|
||||
return env.PSModulePath ? 'pwsh' : 'cmd';
|
||||
return (
|
||||
shellFromExecutable(env.SHELL) ??
|
||||
shellFromExecutable(parentProcessName ?? detectParentProcessName(platform)) ??
|
||||
shellFromExecutable(env.ComSpec ?? env.COMSPEC) ??
|
||||
'cmd'
|
||||
);
|
||||
}
|
||||
const sh = (env.SHELL ?? '').toLowerCase();
|
||||
if (sh.endsWith('/fish')) return 'fish';
|
||||
@@ -23,6 +31,50 @@ export function detectShell(
|
||||
return 'bash'; // default for bash, sh, dash, ksh
|
||||
}
|
||||
|
||||
function detectParentProcessName(platform: string): string | undefined {
|
||||
if (platform !== 'win32' || !Number.isInteger(process.ppid) || process.ppid <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = childProcess.spawnSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
`(Get-Process -Id ${process.ppid} -ErrorAction Stop).ProcessName`,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 1500, windowsHide: true }
|
||||
);
|
||||
|
||||
if (result.status !== 0 || !result.stdout) return undefined;
|
||||
return result.stdout.trim().split(/\r?\n/).pop()?.trim();
|
||||
}
|
||||
|
||||
function shellFromExecutable(value: string | undefined): Shell | null {
|
||||
if (!value) return null;
|
||||
const base = value
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.pop()
|
||||
?.toLowerCase()
|
||||
.replace(/\.(exe|cmd|ps1|bat)$/i, '');
|
||||
|
||||
switch (base) {
|
||||
case 'fish':
|
||||
case 'zsh':
|
||||
case 'bash':
|
||||
case 'cmd':
|
||||
return base;
|
||||
case 'pwsh':
|
||||
case 'powershell':
|
||||
return 'pwsh';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-quote escape for POSIX shells (bash/zsh/fish).
|
||||
* Closes the single-quote, inserts escaped quote, reopens.
|
||||
@@ -33,11 +85,11 @@ function posixSingleQuote(value: string): string {
|
||||
|
||||
/**
|
||||
* Double-quote escape for PowerShell.
|
||||
* Wraps in double quotes; escapes internal double quotes by doubling them
|
||||
* and backtick-escapes $ to prevent variable interpolation.
|
||||
* Wraps in double quotes; escapes the PowerShell escape char first, doubles
|
||||
* internal double quotes, and backtick-escapes $ to prevent interpolation.
|
||||
*/
|
||||
function pwshDoubleQuote(value: string): string {
|
||||
return '"' + value.replace(/"/g, '""').replace(/\$/g, '`$') + '"';
|
||||
return '"' + value.replace(/`/g, '``').replace(/"/g, '""').replace(/\$/g, '`$') + '"';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +97,7 @@ function pwshDoubleQuote(value: string): string {
|
||||
* like &, |, <, and > inside the assignment instead of executing them.
|
||||
*/
|
||||
function cmdSetQuote(value: string): string {
|
||||
return value.replace(/\^/g, '^^').replace(/%/g, '%%').replace(/"/g, '^"');
|
||||
return value.replace(/\^/g, '^^').replace(/%/g, '%%').replace(/"/g, '^"').replace(/!/g, '^^!');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -45,6 +45,20 @@ function makeProfileDir(name: string): string {
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function withCapturedStderr<T>(fn: () => Promise<T>): Promise<{ result: T; stderr: string }> {
|
||||
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 {
|
||||
return { result: await fn(), stderr: stderrMessages.join('') };
|
||||
} finally {
|
||||
process.stderr.write = origWrite;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-router-test-'));
|
||||
ccsHome = path.join(tempDir, 'ccs');
|
||||
@@ -151,27 +165,95 @@ describe('codex-runtime router — non-auth profile resolution', () => {
|
||||
});
|
||||
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 {
|
||||
const { result: code, stderr } = await withCapturedStderr(async () => {
|
||||
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']);
|
||||
return 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;
|
||||
}
|
||||
expect(code).toBe(1);
|
||||
expect(process.env.CODEX_HOME).toBeUndefined();
|
||||
expect(stderr).toContain("CCS_CODEX_PROFILE='ghost'");
|
||||
});
|
||||
|
||||
it('fails fast when CCS_CODEX_PROFILE is set but registry is missing', async () => {
|
||||
process.env.CCS_CODEX_PROFILE = 'ghost';
|
||||
|
||||
const { result: code, stderr } = await withCapturedStderr(async () => {
|
||||
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> };
|
||||
return main(['node', 'codex-runtime', 'chat']);
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(process.env.CODEX_HOME).toBeUndefined();
|
||||
expect(stderr).toContain('does not exist');
|
||||
});
|
||||
|
||||
it('fails fast when CCS_CODEX_PROFILE is set and registry YAML is corrupt', async () => {
|
||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||
fs.writeFileSync(registryPath, 'profiles: [unterminated\n');
|
||||
process.env.CCS_CODEX_PROFILE = 'ghost';
|
||||
|
||||
const { result: code, stderr } = await withCapturedStderr(async () => {
|
||||
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> };
|
||||
return main(['node', 'codex-runtime', 'chat']);
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(process.env.CODEX_HOME).toBeUndefined();
|
||||
expect(stderr).toContain('registry YAML could not be parsed');
|
||||
});
|
||||
|
||||
it('fails fast when CCS_CODEX_PROFILE is set and registry is not an object', async () => {
|
||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||
fs.writeFileSync(registryPath, '- not\n- an\n- object\n');
|
||||
process.env.CCS_CODEX_PROFILE = 'ghost';
|
||||
|
||||
const { result: code, stderr } = await withCapturedStderr(async () => {
|
||||
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> };
|
||||
return main(['node', 'codex-runtime', 'chat']);
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(process.env.CODEX_HOME).toBeUndefined();
|
||||
expect(stderr).toContain('not a valid YAML object');
|
||||
});
|
||||
|
||||
it('fails fast for structural resolver errors with the expected name', async () => {
|
||||
const { result: code, stderr } = await withCapturedStderr(async () => {
|
||||
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
|
||||
flushRouterCache();
|
||||
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
|
||||
require.cache[resolveProfilePath] = {
|
||||
exports: {
|
||||
resolveActiveProfile: () => {
|
||||
throw { name: 'CodexAuthProfileResolutionError', message: 'boundary failure' };
|
||||
},
|
||||
},
|
||||
} as NodeJS.Module;
|
||||
|
||||
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
|
||||
return main(['node', 'codex-runtime', 'chat']);
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(process.env.CODEX_HOME).toBeUndefined();
|
||||
expect(stderr).toContain('boundary failure');
|
||||
});
|
||||
|
||||
it('preserves an explicit CODEX_HOME already in env — does not overwrite', async () => {
|
||||
|
||||
@@ -123,6 +123,45 @@ function captureOutput(): { stderr: string[]; restore: () => void } {
|
||||
};
|
||||
}
|
||||
|
||||
function mockProcessTable(pgrepStdout: string, psStdout: string) {
|
||||
spyOn(childProcess, 'spawnSync').mockImplementation(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(cmd: string, _args: string[]): any => {
|
||||
if (cmd === 'pgrep') {
|
||||
return {
|
||||
status: pgrepStdout.trim().length > 0 ? 0 : 1,
|
||||
stdout: pgrepStdout,
|
||||
stderr: '',
|
||||
pid: 0,
|
||||
output: [],
|
||||
signal: null,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
if (cmd === 'ps') {
|
||||
return {
|
||||
status: psStdout.trim().length > 0 ? 0 : 1,
|
||||
stdout: psStdout,
|
||||
stderr: '',
|
||||
pid: 0,
|
||||
output: [],
|
||||
signal: null,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 1,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
pid: 0,
|
||||
output: [],
|
||||
signal: null,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('import-default — missing legacy auth.json', () => {
|
||||
@@ -385,22 +424,43 @@ describe('import-default — torn-write retry', () => {
|
||||
expect(exitCalled).toBe(true);
|
||||
expect(ctx.registry.hasProfile('bad-base64url')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an id_token signature with impossible base64url length', async () => {
|
||||
const authPath = path.join(legacyCodexHome, 'auth.json');
|
||||
const [header, payload] = VALID_JWT.split('.');
|
||||
fs.writeFileSync(authPath, JSON.stringify({ tokens: { id_token: `${header}.${payload}.a` } }));
|
||||
|
||||
const { handleImportDefaultCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/import-default-command'
|
||||
);
|
||||
const ctx = await makeCtx();
|
||||
|
||||
let exitCalled = false;
|
||||
const origExit = process.exit;
|
||||
process.exit = () => {
|
||||
exitCalled = true;
|
||||
throw new Error('exit');
|
||||
};
|
||||
const restore = silenceConsole();
|
||||
try {
|
||||
await handleImportDefaultCodex(ctx, ['bad-signature']);
|
||||
} catch {
|
||||
/* expected */
|
||||
} finally {
|
||||
restore();
|
||||
process.exit = origExit;
|
||||
}
|
||||
|
||||
expect(exitCalled).toBe(true);
|
||||
expect(ctx.registry.hasProfile('bad-signature')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('import-default — Codex running detection', () => {
|
||||
it('warns and refuses when pgrep finds a codex PID', async () => {
|
||||
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
|
||||
|
||||
// Mock spawnSync to simulate pgrep finding codex
|
||||
spyOn(childProcess, 'spawnSync').mockReturnValue({
|
||||
status: 0,
|
||||
stdout: '12345\n',
|
||||
stderr: '',
|
||||
pid: 0,
|
||||
output: [],
|
||||
signal: null,
|
||||
error: undefined,
|
||||
});
|
||||
mockProcessTable('12345\n', '12345 /usr/local/bin/codex login\n');
|
||||
|
||||
const { handleImportDefaultCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/import-default-command'
|
||||
@@ -431,15 +491,7 @@ describe('import-default — Codex running detection', () => {
|
||||
it('proceeds with --force-while-running even when Codex is running', async () => {
|
||||
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
|
||||
|
||||
spyOn(childProcess, 'spawnSync').mockReturnValue({
|
||||
status: 0,
|
||||
stdout: '12345\n',
|
||||
stderr: '',
|
||||
pid: 0,
|
||||
output: [],
|
||||
signal: null,
|
||||
error: undefined,
|
||||
});
|
||||
mockProcessTable('12345\n', '12345 /usr/local/bin/codex login\n');
|
||||
|
||||
const { handleImportDefaultCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/import-default-command'
|
||||
@@ -456,6 +508,57 @@ describe('import-default — Codex running detection', () => {
|
||||
// Should have proceeded and created the profile
|
||||
expect(ctx.registry.hasProfile('forcerunning')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns and refuses when Codex is running through a node shim', async () => {
|
||||
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
|
||||
|
||||
mockProcessTable('12345\n', '12345 /usr/bin/node /usr/local/bin/codex login\n');
|
||||
|
||||
const { handleImportDefaultCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/import-default-command'
|
||||
);
|
||||
const ctx = await makeCtx();
|
||||
|
||||
let exitCalled = false;
|
||||
const origExit = process.exit;
|
||||
process.exit = () => {
|
||||
exitCalled = true;
|
||||
throw new Error('exit');
|
||||
};
|
||||
const captured = captureOutput();
|
||||
try {
|
||||
await handleImportDefaultCodex(ctx, ['nodeshim']);
|
||||
} catch {
|
||||
/* expected */
|
||||
} finally {
|
||||
captured.restore();
|
||||
process.exit = origExit;
|
||||
}
|
||||
|
||||
expect(exitCalled).toBe(true);
|
||||
expect(captured.stderr.join('')).toContain('12345');
|
||||
expect(ctx.registry.hasProfile('nodeshim')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores pgrep false positives that are not Codex executables', async () => {
|
||||
fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON);
|
||||
|
||||
mockProcessTable('11111\n', '11111 /usr/bin/node /tmp/codex-auth-helper.js\n');
|
||||
|
||||
const { handleImportDefaultCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/import-default-command'
|
||||
);
|
||||
const ctx = await makeCtx();
|
||||
|
||||
const restore = silenceConsole();
|
||||
try {
|
||||
await handleImportDefaultCodex(ctx, ['falsepositive']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(ctx.registry.hasProfile('falsepositive')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('import-default — --with-history', () => {
|
||||
|
||||
@@ -53,6 +53,12 @@ function spawnReturnsCode(code: number, writeAuth = false) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildToken(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
return `${header}.${body}.fakesig`;
|
||||
}
|
||||
|
||||
describe('handleLoginCodex — binary missing', () => {
|
||||
it('exits with BINARY_ERROR when codex not found', async () => {
|
||||
const detectorMod = await import('../../../../src/targets/codex-detector');
|
||||
@@ -149,4 +155,57 @@ describe('handleLoginCodex — clean exit updates registry', () => {
|
||||
// last_used should now be set
|
||||
expect(meta.last_used).toBeTruthy();
|
||||
});
|
||||
|
||||
it('persists account_id when login token has account_id only', async () => {
|
||||
const detectorMod = await import('../../../../src/targets/codex-detector');
|
||||
spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex');
|
||||
|
||||
spyOn(childProcess, 'spawn').mockImplementation(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(_cmd: string, _args: string[], opts: any) => {
|
||||
const dir = (opts?.env?.CODEX_HOME as string) ?? '';
|
||||
if (dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'auth.json'),
|
||||
JSON.stringify({
|
||||
tokens: {
|
||||
id_token: buildToken({
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct-login-only',
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
const ee = {
|
||||
on: (evt: string, cb: (code: number) => void) => {
|
||||
if (evt === 'exit') setTimeout(() => cb(0), 0);
|
||||
return ee;
|
||||
},
|
||||
};
|
||||
return ee as ReturnType<typeof childProcess.spawn>;
|
||||
}
|
||||
);
|
||||
|
||||
const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command');
|
||||
const ctx = await makeCtx();
|
||||
ctx.registry.createProfile('accountonly', {
|
||||
created: new Date().toISOString(),
|
||||
last_used: null,
|
||||
});
|
||||
|
||||
const origLog = console.log;
|
||||
console.log = () => {};
|
||||
try {
|
||||
await handleLoginCodex(ctx, ['accountonly']);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
|
||||
const meta = ctx.registry.getProfile('accountonly');
|
||||
expect(meta.last_used).toBeTruthy();
|
||||
expect(meta.account_id).toBe('acct-login-only');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,6 +228,52 @@ describe('handleRemoveCodex — confirmation', () => {
|
||||
expect(ctx.registry.hasProfile('preserveme')).toBe(true);
|
||||
});
|
||||
|
||||
it('cleans a partial preservation copy when delete preparation fails', async () => {
|
||||
const { handleRemoveCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/remove-command'
|
||||
);
|
||||
const ctx = await makeCtx('copyfail');
|
||||
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'copyfail');
|
||||
const parentDir = path.dirname(profileDir);
|
||||
const authJsonPath = path.join(profileDir, 'auth.json');
|
||||
fs.writeFileSync(authJsonPath, JSON.stringify({ tokens: { id_token: 'h.e30K.s' } }));
|
||||
|
||||
const realCpSync = fs.cpSync;
|
||||
spyOn(fs, 'cpSync').mockImplementation((src, dest, options) => {
|
||||
if (typeof dest === 'string' && dest.includes('.preserved.')) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, 'auth.json'), '{}');
|
||||
throw new Error('copy failed after partial write');
|
||||
}
|
||||
return realCpSync(src, dest, options);
|
||||
});
|
||||
|
||||
let exitCode = -1;
|
||||
const origExit = process.exit;
|
||||
const origErr = console.error;
|
||||
process.exit = (code?: number) => {
|
||||
exitCode = code ?? 0;
|
||||
throw new Error('exit');
|
||||
};
|
||||
console.error = () => {};
|
||||
|
||||
try {
|
||||
await handleRemoveCodex(ctx, ['copyfail', '--yes']);
|
||||
} catch {
|
||||
/* process.exit */
|
||||
} finally {
|
||||
process.exit = origExit;
|
||||
console.error = origErr;
|
||||
}
|
||||
|
||||
expect(exitCode).toBeGreaterThan(0);
|
||||
expect(fs.existsSync(authJsonPath)).toBe(true);
|
||||
expect(ctx.registry.hasProfile('copyfail')).toBe(true);
|
||||
expect(fs.readdirSync(parentDir).some((entry) => entry.startsWith('copyfail.preserved.'))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('restores profile data and registry when final deletion fails', async () => {
|
||||
const { handleRemoveCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/remove-command'
|
||||
@@ -269,4 +315,45 @@ describe('handleRemoveCodex — confirmation', () => {
|
||||
expect(ctx.registry.hasProfile('restoreme')).toBe(true);
|
||||
expect(ctx.registry.getDefault()).toBe('restoreme');
|
||||
});
|
||||
|
||||
it('restores from preserved copy when final deletion partially removes auth.json', async () => {
|
||||
const { handleRemoveCodex } = await import(
|
||||
'../../../../src/codex-auth/commands/remove-command'
|
||||
);
|
||||
const ctx = await makeCtx('partialrestore');
|
||||
const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'partialrestore');
|
||||
const authJsonPath = path.join(profileDir, 'auth.json');
|
||||
fs.writeFileSync(authJsonPath, JSON.stringify({ tokens: { id_token: 'h.e30K.s' } }));
|
||||
|
||||
const realRmSync = fs.rmSync;
|
||||
spyOn(fs, 'rmSync').mockImplementation((target, options) => {
|
||||
if (typeof target === 'string' && target.includes('.deleting.')) {
|
||||
realRmSync(path.join(target, 'auth.json'), { force: true });
|
||||
throw new Error('delete failed after auth removal');
|
||||
}
|
||||
return realRmSync(target, options);
|
||||
});
|
||||
|
||||
let exitCode = -1;
|
||||
const origExit = process.exit;
|
||||
const origErr = console.error;
|
||||
process.exit = (code?: number) => {
|
||||
exitCode = code ?? 0;
|
||||
throw new Error('exit');
|
||||
};
|
||||
console.error = () => {};
|
||||
|
||||
try {
|
||||
await handleRemoveCodex(ctx, ['partialrestore', '--yes']);
|
||||
} catch {
|
||||
/* process.exit */
|
||||
} finally {
|
||||
process.exit = origExit;
|
||||
console.error = origErr;
|
||||
}
|
||||
|
||||
expect(exitCode).toBeGreaterThan(0);
|
||||
expect(fs.existsSync(authJsonPath)).toBe(true);
|
||||
expect(ctx.registry.hasProfile('partialrestore')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,12 @@ async function captureStdout(fn: () => Promise<void>): Promise<string> {
|
||||
return chunks.join('');
|
||||
}
|
||||
|
||||
function buildToken(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
return `${header}.${body}.fakesig`;
|
||||
}
|
||||
|
||||
// ── empty list ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('handleShowCodex — empty list', () => {
|
||||
@@ -82,6 +88,48 @@ describe('handleShowCodex — default marker', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── JSON account metadata ───────────────────────────────────────────────────
|
||||
|
||||
describe('handleShowCodex — JSON account_id', () => {
|
||||
it('includes account_id from registry metadata or auth.json identity', async () => {
|
||||
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
|
||||
const ctx = await makeCtx('registryid', 'authid');
|
||||
ctx.registry.updateProfile('registryid', {
|
||||
account_id: 'acct-from-registry',
|
||||
email: 'registry@example.com',
|
||||
});
|
||||
|
||||
const authProfileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'authid');
|
||||
fs.mkdirSync(authProfileDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authProfileDir, 'auth.json'),
|
||||
JSON.stringify({
|
||||
tokens: {
|
||||
id_token: buildToken({
|
||||
email: 'auth@example.com',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_plan_type: 'plus',
|
||||
chatgpt_account_id: 'acct-from-auth-json',
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const out = await captureStdout(() => handleShowCodex(ctx, ['--json']));
|
||||
const parsed = JSON.parse(out) as {
|
||||
profiles: Array<{ name: string; account_id: string | null; email: string | null }>;
|
||||
};
|
||||
|
||||
expect(parsed.profiles.find((p) => p.name === 'registryid')?.account_id).toBe(
|
||||
'acct-from-registry'
|
||||
);
|
||||
expect(parsed.profiles.find((p) => p.name === 'authid')?.account_id).toBe(
|
||||
'acct-from-auth-json'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── active(missing) row at top (D14) ─────────────────────────────────────────
|
||||
|
||||
describe('handleShowCodex — active(missing) at top', () => {
|
||||
@@ -120,6 +168,19 @@ describe('handleShowCodex — detail view', () => {
|
||||
expect(out).toContain('<unknown>');
|
||||
});
|
||||
|
||||
it('detail JSON includes account_id from registry metadata when auth.json is missing', async () => {
|
||||
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
|
||||
const ctx = await makeCtx('registrydetail');
|
||||
ctx.registry.updateProfile('registrydetail', {
|
||||
account_id: 'acct-from-registry-detail',
|
||||
});
|
||||
|
||||
const out = await captureStdout(() => handleShowCodex(ctx, ['registrydetail', '--json']));
|
||||
const parsed = JSON.parse(out) as { account_id: string | null };
|
||||
|
||||
expect(parsed.account_id).toBe('acct-from-registry-detail');
|
||||
});
|
||||
|
||||
it('does not crash with malformed auth.json', async () => {
|
||||
const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command');
|
||||
const ctx = await makeCtx('malformed');
|
||||
|
||||
@@ -133,4 +133,10 @@ describe('decodeIdToken', () => {
|
||||
expect(hasStructurallyValidIdToken(`${header}.${payload}$.${signature}`)).toBe(false);
|
||||
expect(hasStructurallyValidIdToken(`${header}=.${payload}.${signature}`)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects JWT segments with impossible base64url length', () => {
|
||||
const [header, payload] = buildToken({}).split('.');
|
||||
expect(hasStructurallyValidIdToken(`${header}.${payload}.a`)).toBe(false);
|
||||
expect(decodeIdToken(`${header}.${payload}.a`)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,15 +76,27 @@ describe('resolveActiveProfile', () => {
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(stderrMessages.some((m) => m.includes('codex-auth'))).toBe(true);
|
||||
expect(stderrMessages.join('')).toContain('$CCS_HOME/.ccs/codex-profiles.yaml');
|
||||
expect(stderrMessages.join('')).not.toContain(registryPath);
|
||||
expect(stderrMessages.join('')).not.toContain('invalid yaml');
|
||||
});
|
||||
|
||||
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/
|
||||
);
|
||||
let thrown: unknown;
|
||||
try {
|
||||
resolveActiveProfile({ CCS_CODEX_PROFILE: 'work' });
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
|
||||
expect(thrown).toBeDefined();
|
||||
expect(String(thrown)).toContain('Refusing to fall back to ~/.codex');
|
||||
expect(String(thrown)).toContain('$CCS_HOME/.ccs/codex-profiles.yaml');
|
||||
expect(String(thrown)).not.toContain(registryPath);
|
||||
expect(String(thrown)).not.toContain('invalid yaml');
|
||||
});
|
||||
|
||||
it('returns source=env when CCS_CODEX_PROFILE matches a registry entry', () => {
|
||||
|
||||
@@ -31,16 +31,43 @@ describe('detectShell — Unix', () => {
|
||||
});
|
||||
|
||||
describe('detectShell — Windows', () => {
|
||||
it('returns pwsh when PSModulePath is set', () => {
|
||||
expect(detectShell({ PSModulePath: 'C:\\Windows\\system32\\...' }, 'win32')).toBe('pwsh');
|
||||
it('does not treat PSModulePath alone as PowerShell', () => {
|
||||
expect(
|
||||
detectShell(
|
||||
{ PSModulePath: 'C:\\Windows\\system32\\...', ComSpec: 'C:\\Windows\\System32\\cmd.exe' },
|
||||
'win32'
|
||||
)
|
||||
).toBe('cmd');
|
||||
});
|
||||
|
||||
it('returns cmd when PSModulePath is absent', () => {
|
||||
expect(detectShell({}, 'win32')).toBe('cmd');
|
||||
it('returns pwsh when SHELL points to PowerShell', () => {
|
||||
expect(
|
||||
detectShell(
|
||||
{
|
||||
SHELL: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
||||
PSModulePath: 'C:\\Windows\\system32\\...',
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
).toBe('pwsh');
|
||||
});
|
||||
|
||||
it('ignores SHELL on Windows — uses PSModulePath heuristic', () => {
|
||||
expect(detectShell({ SHELL: '/bin/bash', PSModulePath: 'C:\\ps' }, 'win32')).toBe('pwsh');
|
||||
it('returns pwsh when the parent process is PowerShell and ComSpec points to cmd', () => {
|
||||
expect(
|
||||
detectShell(
|
||||
{ PSModulePath: 'C:\\Windows\\system32\\...', ComSpec: 'C:\\Windows\\System32\\cmd.exe' },
|
||||
'win32',
|
||||
'pwsh.exe'
|
||||
)
|
||||
).toBe('pwsh');
|
||||
});
|
||||
|
||||
it('honors Git Bash style SHELL on Windows', () => {
|
||||
expect(detectShell({ SHELL: '/usr/bin/bash', PSModulePath: 'C:\\ps' }, 'win32')).toBe('bash');
|
||||
});
|
||||
|
||||
it('returns cmd when no explicit shell hint is available', () => {
|
||||
expect(detectShell({ PSModulePath: 'C:\\ps' }, 'win32')).toBe('cmd');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,6 +119,11 @@ describe('formatExport — pwsh', () => {
|
||||
const result = formatExport('pwsh', 'X', 'say "hello"');
|
||||
expect(result).toBe('$env:X = "say ""hello"""');
|
||||
});
|
||||
|
||||
it('escapes backticks before interpolation-sensitive characters', () => {
|
||||
const result = formatExport('pwsh', 'CODEX_HOME', 'C:\\Users\\kai`$tmp');
|
||||
expect(result).toBe('$env:CODEX_HOME = "C:\\Users\\kai```$tmp"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatExport — cmd', () => {
|
||||
@@ -108,8 +140,8 @@ describe('formatExport — cmd', () => {
|
||||
});
|
||||
|
||||
it('escapes cmd expansion-sensitive characters', () => {
|
||||
expect(formatExport('cmd', 'CODEX_HOME', 'C:\\Users\\100% ^ "quoted"')).toBe(
|
||||
'set "CODEX_HOME=C:\\Users\\100%% ^^ ^"quoted^""'
|
||||
expect(formatExport('cmd', 'CODEX_HOME', 'C:\\Users\\100% ^ "quoted" !bang!')).toBe(
|
||||
'set "CODEX_HOME=C:\\Users\\100%% ^^ ^"quoted^" ^^!bang^^!"'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
*/
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -28,6 +29,14 @@ import type { CodexAuthProfileEntry } from '@/hooks/use-codex-auth-profiles';
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function InlineCode({ children }: { children?: ReactNode }) {
|
||||
return (
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em] text-foreground">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLastUsed(iso: string | null): string {
|
||||
if (!iso) return 'never';
|
||||
try {
|
||||
@@ -60,8 +69,6 @@ function sourceLabel(source: 'default' | 'env' | 'explicit-codex-home', t: TFunc
|
||||
// ── Disabled action button with terminal-redirect tooltip ───────────────────
|
||||
|
||||
function TerminalOnlyButton({ label }: { label: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -73,7 +80,12 @@ function TerminalOnlyButton({ label }: { label: string }) {
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('codex.auth.terminalOnlyTooltip')}</TooltipContent>
|
||||
<TooltipContent>
|
||||
<Trans
|
||||
i18nKey="codex.auth.terminalOnlyTooltipRich"
|
||||
components={{ code: <InlineCode /> }}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
@@ -155,8 +167,12 @@ 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>{t('codex.auth.emptyRegistry')}</p>
|
||||
<p>{t('codex.auth.legacyCodexHome')}</p>
|
||||
<p>
|
||||
<Trans i18nKey="codex.auth.emptyRegistryRich" components={{ code: <InlineCode /> }} />
|
||||
</p>
|
||||
<p>
|
||||
<Trans i18nKey="codex.auth.legacyCodexHomeRich" components={{ code: <InlineCode /> }} />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -166,7 +182,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">
|
||||
{t('codex.auth.legacyMode')}
|
||||
<Trans i18nKey="codex.auth.legacyModeRich" components={{ code: <InlineCode /> }} />
|
||||
</div>
|
||||
<ProfileTable data={data} />
|
||||
</div>
|
||||
@@ -178,7 +194,11 @@ 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">
|
||||
{t('codex.auth.externalCodexHome', { path: data.active.codexHome })}
|
||||
<Trans
|
||||
i18nKey="codex.auth.externalCodexHomeRich"
|
||||
values={{ path: data.active.codexHome }}
|
||||
components={{ code: <InlineCode /> }}
|
||||
/>
|
||||
</div>
|
||||
<ProfileTable data={data} />
|
||||
</div>
|
||||
|
||||
@@ -2178,17 +2178,26 @@ const resources = {
|
||||
auth: {
|
||||
terminalOnlyTooltip:
|
||||
'Use ccsx auth switch <name> or ccsx auth remove <name> in terminal.',
|
||||
terminalOnlyTooltipRich:
|
||||
'Use <code>ccsx auth switch <name></code> or <code>ccsx auth remove <name></code> 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.',
|
||||
emptyRegistryRich:
|
||||
'[i] No codex-auth profiles. Run <code>ccsx auth create <name></code> to create one.',
|
||||
legacyCodexHome: 'Codex will use the default ~/.codex location.',
|
||||
legacyCodexHomeRich: 'Codex will use the default <code>~/.codex</code> location.',
|
||||
legacyMode:
|
||||
'[i] No active profile. Using ~/.codex (legacy). Run ccsx auth switch <name> in terminal to activate one.',
|
||||
legacyModeRich:
|
||||
'[i] No active profile. Using <code>~/.codex</code> (legacy). Run <code>ccsx auth switch <name></code> in terminal to activate one.',
|
||||
externalCodexHome:
|
||||
'[i] $CODEX_HOME set externally to {{path}}. Profile registry not in use for this session.',
|
||||
externalCodexHomeRich:
|
||||
'[i] <code>$CODEX_HOME</code> set externally to <code>{{path}}</code>. Profile registry not in use for this session.',
|
||||
activeProfile: 'Active profile:',
|
||||
unknownProfile: '(unknown)',
|
||||
planLabel: 'Plan:',
|
||||
@@ -2659,6 +2668,7 @@ const resources = {
|
||||
controlCenter: 'Control Center',
|
||||
overview: 'Overview',
|
||||
docs: 'Docs',
|
||||
authProfiles: 'Auth Profiles',
|
||||
nativeRuntime: 'Native Runtime',
|
||||
ccsProvider: 'CCS Provider',
|
||||
setup: 'Setup',
|
||||
@@ -4772,16 +4782,25 @@ const resources = {
|
||||
warningsTitle: '警告',
|
||||
auth: {
|
||||
terminalOnlyTooltip: '在终端使用 ccsx auth switch <name> 或 ccsx auth remove <name>。',
|
||||
terminalOnlyTooltipRich:
|
||||
'在终端使用 <code>ccsx auth switch <name></code> 或 <code>ccsx auth remove <name></code>。',
|
||||
activeSourceBadge: '{{source}}',
|
||||
statusOk: '正常',
|
||||
statusInvalid: '[!] 认证无效',
|
||||
loading: '正在加载认证配置...',
|
||||
loadError: '[!] 加载 codex-auth 配置失败。',
|
||||
emptyRegistry: '[i] 没有 codex-auth 配置。运行 ccsx auth create <name> 创建一个。',
|
||||
emptyRegistryRich:
|
||||
'[i] 没有 codex-auth 配置。运行 <code>ccsx auth create <name></code> 创建一个。',
|
||||
legacyCodexHome: 'Codex 将使用默认 ~/.codex 位置。',
|
||||
legacyCodexHomeRich: 'Codex 将使用默认 <code>~/.codex</code> 位置。',
|
||||
legacyMode:
|
||||
'[i] 没有活动配置。正在使用 ~/.codex(旧模式)。在终端运行 ccsx auth switch <name> 激活一个。',
|
||||
legacyModeRich:
|
||||
'[i] 没有活动配置。正在使用 <code>~/.codex</code>(旧模式)。在终端运行 <code>ccsx auth switch <name></code> 激活一个。',
|
||||
externalCodexHome: '[i] $CODEX_HOME 外部设置为 {{path}}。本会话未使用配置注册表。',
|
||||
externalCodexHomeRich:
|
||||
'[i] <code>$CODEX_HOME</code> 外部设置为 <code>{{path}}</code>。本会话未使用配置注册表。',
|
||||
activeProfile: '活动配置:',
|
||||
unknownProfile: '(未知)',
|
||||
planLabel: '套餐:',
|
||||
@@ -5228,6 +5247,7 @@ const resources = {
|
||||
controlCenter: '控制中心',
|
||||
overview: '概览',
|
||||
docs: '文档',
|
||||
authProfiles: '认证配置',
|
||||
nativeRuntime: '原生运行时',
|
||||
ccsProvider: 'CCS 提供商',
|
||||
setup: '安装',
|
||||
@@ -7452,17 +7472,26 @@ const resources = {
|
||||
auth: {
|
||||
terminalOnlyTooltip:
|
||||
'Dùng ccsx auth switch <name> hoặc ccsx auth remove <name> trong terminal.',
|
||||
terminalOnlyTooltipRich:
|
||||
'Dùng <code>ccsx auth switch <name></code> hoặc <code>ccsx auth remove <name></code> 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.',
|
||||
emptyRegistryRich:
|
||||
'[i] Chưa có hồ sơ codex-auth. Chạy <code>ccsx auth create <name></code> để tạo.',
|
||||
legacyCodexHome: 'Codex sẽ dùng vị trí mặc định ~/.codex.',
|
||||
legacyCodexHomeRich: 'Codex sẽ dùng vị trí mặc định <code>~/.codex</code>.',
|
||||
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.',
|
||||
legacyModeRich:
|
||||
'[i] Chưa có hồ sơ active. Đang dùng <code>~/.codex</code> (legacy). Chạy <code>ccsx auth switch <name></code> 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.',
|
||||
externalCodexHomeRich:
|
||||
'[i] <code>$CODEX_HOME</code> được đặt bên ngoài là <code>{{path}}</code>. Registry hồ sơ không dùng trong phiên này.',
|
||||
activeProfile: 'Hồ sơ active:',
|
||||
unknownProfile: '(không rõ)',
|
||||
planLabel: 'Gói:',
|
||||
@@ -7921,6 +7950,7 @@ const resources = {
|
||||
controlCenter: 'Trung tâm điều khiển',
|
||||
overview: 'Tổng quan',
|
||||
docs: 'Tài liệu',
|
||||
authProfiles: 'Hồ sơ auth',
|
||||
nativeRuntime: 'Runtime gốc',
|
||||
ccsProvider: 'CCS Provider',
|
||||
setup: 'Thiết lập',
|
||||
@@ -9859,6 +9889,8 @@ const resources = {
|
||||
auth: {
|
||||
terminalOnlyTooltip:
|
||||
'ターミナルで ccsx auth switch <name> または ccsx auth remove <name> を使用します。',
|
||||
terminalOnlyTooltipRich:
|
||||
'ターミナルで <code>ccsx auth switch <name></code> または <code>ccsx auth remove <name></code> を使用します。',
|
||||
activeSourceBadge: '{{source}}',
|
||||
statusOk: 'OK',
|
||||
statusInvalid: '[!] 認証が無効',
|
||||
@@ -9866,11 +9898,18 @@ const resources = {
|
||||
loadError: '[!] codex-auth プロファイルの読み込みに失敗しました。',
|
||||
emptyRegistry:
|
||||
'[i] codex-auth プロファイルがありません。ccsx auth create <name> を実行して作成します。',
|
||||
emptyRegistryRich:
|
||||
'[i] codex-auth プロファイルがありません。<code>ccsx auth create <name></code> を実行して作成します。',
|
||||
legacyCodexHome: 'Codex はデフォルトの ~/.codex を使用します。',
|
||||
legacyCodexHomeRich: 'Codex はデフォルトの <code>~/.codex</code> を使用します。',
|
||||
legacyMode:
|
||||
'[i] アクティブなプロファイルがありません。~/.codex(レガシー)を使用中です。ターミナルで ccsx auth switch <name> を実行して有効化します。',
|
||||
legacyModeRich:
|
||||
'[i] アクティブなプロファイルがありません。<code>~/.codex</code>(レガシー)を使用中です。ターミナルで <code>ccsx auth switch <name></code> を実行して有効化します。',
|
||||
externalCodexHome:
|
||||
'[i] $CODEX_HOME は外部で {{path}} に設定されています。このセッションではプロファイル registry は使われません。',
|
||||
externalCodexHomeRich:
|
||||
'[i] <code>$CODEX_HOME</code> は外部で <code>{{path}}</code> に設定されています。このセッションではプロファイル registry は使われません。',
|
||||
activeProfile: 'アクティブプロファイル:',
|
||||
unknownProfile: '(不明)',
|
||||
planLabel: 'プラン:',
|
||||
@@ -9894,6 +9933,7 @@ const resources = {
|
||||
controlCenter: 'コントロールセンター',
|
||||
overview: '概要',
|
||||
docs: 'ドキュメント',
|
||||
authProfiles: '認証プロファイル',
|
||||
nativeRuntime: 'ネイティブランタイム',
|
||||
ccsProvider: 'CCS プロバイダー',
|
||||
setup: 'セットアップ',
|
||||
@@ -12861,6 +12901,8 @@ const resources = {
|
||||
auth: {
|
||||
terminalOnlyTooltip:
|
||||
'터미널에서 ccsx auth switch <name> 또는 ccsx auth remove <name>을 사용하세요.',
|
||||
terminalOnlyTooltipRich:
|
||||
'터미널에서 <code>ccsx auth switch <name></code> 또는 <code>ccsx auth remove <name></code>을 사용하세요.',
|
||||
activeSourceBadge: '{{source}}',
|
||||
statusOk: 'OK',
|
||||
statusInvalid: '[!] 인증이 유효하지 않음',
|
||||
@@ -12868,11 +12910,18 @@ const resources = {
|
||||
loadError: '[!] codex-auth 프로필을 로드하지 못했습니다.',
|
||||
emptyRegistry:
|
||||
'[i] codex-auth 프로필이 없습니다. ccsx auth create <name>을 실행해 생성하세요.',
|
||||
emptyRegistryRich:
|
||||
'[i] codex-auth 프로필이 없습니다. <code>ccsx auth create <name></code>을 실행해 생성하세요.',
|
||||
legacyCodexHome: 'Codex는 기본 ~/.codex 위치를 사용합니다.',
|
||||
legacyCodexHomeRich: 'Codex는 기본 <code>~/.codex</code> 위치를 사용합니다.',
|
||||
legacyMode:
|
||||
'[i] 활성 프로필이 없습니다. ~/.codex(레거시)를 사용 중입니다. 터미널에서 ccsx auth switch <name>을 실행해 활성화하세요.',
|
||||
legacyModeRich:
|
||||
'[i] 활성 프로필이 없습니다. <code>~/.codex</code>(레거시)를 사용 중입니다. 터미널에서 <code>ccsx auth switch <name></code>을 실행해 활성화하세요.',
|
||||
externalCodexHome:
|
||||
'[i] $CODEX_HOME이 외부에서 {{path}}로 설정되었습니다. 이 세션에서는 프로필 registry를 사용하지 않습니다.',
|
||||
externalCodexHomeRich:
|
||||
'[i] <code>$CODEX_HOME</code>이 외부에서 <code>{{path}}</code>로 설정되었습니다. 이 세션에서는 프로필 registry를 사용하지 않습니다.',
|
||||
activeProfile: '활성 프로필:',
|
||||
unknownProfile: '(알 수 없음)',
|
||||
planLabel: '플랜:',
|
||||
@@ -13345,6 +13394,7 @@ const resources = {
|
||||
controlCenter: '제어 센터',
|
||||
overview: '개요',
|
||||
docs: '문서',
|
||||
authProfiles: '인증 프로필',
|
||||
nativeRuntime: '네이티브 런타임',
|
||||
ccsProvider: 'CCS 프로바이더',
|
||||
setup: '설정',
|
||||
|
||||
@@ -183,10 +183,7 @@ export function CodexPage() {
|
||||
<TabsTrigger value="overview">{t('codexPage.overview')}</TabsTrigger>
|
||||
<TabsTrigger value="controls">{t('codexPage.controlCenter')}</TabsTrigger>
|
||||
<TabsTrigger value="docs">{t('codexPage.docs')}</TabsTrigger>
|
||||
<TabsTrigger value="auth-profiles">
|
||||
{/* TODO i18n: missing key codexPage.authProfiles */}
|
||||
Auth Profiles
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="auth-profiles">{t('codexPage.authProfiles')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,15 +4,31 @@ import i18n from '@/lib/i18n';
|
||||
const locales = ['en', 'zh-CN', 'vi', 'ja', 'ko'] as const;
|
||||
|
||||
const codexAuthKeys = [
|
||||
['codex.auth.terminalOnlyTooltip'],
|
||||
['codex.auth.sourceDefault'],
|
||||
['codex.auth.sourceEnv'],
|
||||
['codex.auth.sourceExplicitCodexHome'],
|
||||
['codex.auth.terminalOnlyTooltipRich'],
|
||||
['codex.auth.activeSourceBadge', { source: 'default' }],
|
||||
['codex.auth.statusOk'],
|
||||
['codex.auth.statusInvalid'],
|
||||
['codex.auth.loading'],
|
||||
['codex.auth.loadError'],
|
||||
['codex.auth.emptyRegistry'],
|
||||
['codex.auth.externalCodexHome', { path: '/tmp/codex-home' }],
|
||||
['codex.auth.emptyRegistryRich'],
|
||||
['codex.auth.legacyCodexHomeRich'],
|
||||
['codex.auth.legacyModeRich'],
|
||||
['codex.auth.externalCodexHomeRich', { path: '/tmp/codex-home' }],
|
||||
['codex.auth.activeProfile'],
|
||||
['codex.auth.unknownProfile'],
|
||||
['codex.auth.planLabel'],
|
||||
['codex.auth.switchAction'],
|
||||
['codex.auth.removeAction'],
|
||||
['codex.auth.col.name'],
|
||||
['codex.auth.col.email'],
|
||||
['codex.auth.col.plan'],
|
||||
['codex.auth.col.lastUsed'],
|
||||
['codex.auth.col.status'],
|
||||
['codex.auth.col.actions'],
|
||||
['codexPage.authProfiles'],
|
||||
] as const;
|
||||
|
||||
const originalLanguage = i18n.language;
|
||||
@@ -30,6 +46,13 @@ describe('codex auth i18n', () => {
|
||||
|
||||
expect(translated).not.toBe(key);
|
||||
expect(translated).not.toContain('codex.auth.');
|
||||
expect(translated).not.toContain('codexPage.');
|
||||
if (key === 'codex.auth.externalCodexHomeRich') {
|
||||
expect(translated).toContain('/tmp/codex-home');
|
||||
}
|
||||
if (key === 'codex.auth.activeSourceBadge') {
|
||||
expect(translated).toContain('default');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user