diff --git a/src/codex-auth/codex-auth-help.ts b/src/codex-auth/codex-auth-help.ts new file mode 100644 index 00000000..135bc38b --- /dev/null +++ b/src/codex-auth/codex-auth-help.ts @@ -0,0 +1,83 @@ +/** + * Help text for `ccsx auth` command tree. + * ASCII-only. Includes ccsxp scope clarifier (H5). + */ + +export function printCodexAuthHelp(): void { + process.stdout.write(`CCS Concurrent Codex Account Management + +Usage + ccsx auth [options] + +Commands + create Create a new Codex profile (idempotent) + login Run \`codex login\` against the profile (auto-creates if missing) + switch Set the persistent default Codex profile + use Emit shell-eval exports to activate a profile in this shell only + show [name] List profiles or show details for one + remove Delete a profile (auth.json + profile dir + registry entry) + import-default Migrate legacy ~/.codex/auth.json into a new profile + +Shell activation (per terminal) + bash/zsh: eval "$(ccsx auth use work)" + fish: ccsx auth use work | source + pwsh: ccsx auth use work | Invoke-Expression + +Examples + ccsx auth create work + ccsx auth login work # OAuth in browser + ccsx auth create personal + ccsx auth login personal + eval "$(ccsx auth use work)" # terminal A + eval "$(ccsx auth use personal)" # terminal B + codex # each terminal uses its own account + ccsx auth show + ccsx auth switch personal # change persistent default + ccsx auth remove old --yes + +Options + --yes, -y Skip confirmation (remove) + --force Re-link config.toml (create) | override default check (remove) | + overwrite existing profile (import-default) + --json JSON output (show) + --shell Override shell detection (use): bash|zsh|fish|pwsh|cmd + --with-history Copy history.jsonl + sessions/ too (import-default, default: off) + --force-while-running Allow import-default even if Codex is running (risky) + +Notes + Auth state (auth.json) and history.jsonl are isolated per profile. + config.toml is shared via symlink to ~/.codex/config.toml. + Default profile (switch) is persistent across shells. + Active profile (use) is per-terminal via CODEX_HOME / CCS_CODEX_PROFILE. + + Note: This feature applies only to native \`codex\`. \`ccsxp\` ignores + CCS_CODEX_PROFILE and uses its own cliproxy pool. +`); +} + +export function printCodexAuthUseHelp(): void { + process.stdout.write(`ccsx auth use — Activate a Codex profile in the current shell + +Usage + ccsx auth use [--shell ] + +Description + Emits shell-evalable export statements to stdout. Use within eval "$(...)" + only. Output to stdout is shell-evaluatable; do not pipe to other commands. + + All errors and informational messages go to stderr so the eval is never + contaminated. + +Shell evaluation + bash/zsh: eval "$(ccsx auth use work)" + fish: ccsx auth use work | source + pwsh: ccsx auth use work | Invoke-Expression + cmd: (not supported natively; use PowerShell) + +Options + --shell Override auto-detected shell: bash|zsh|fish|pwsh|cmd + +Note: This profile applies only to native \`codex\`. \`ccsxp\` ignores +CCS_CODEX_PROFILE and uses its own cliproxy pool. +`); +} diff --git a/src/codex-auth/codex-auth-router.ts b/src/codex-auth/codex-auth-router.ts new file mode 100644 index 00000000..81c7ca0a --- /dev/null +++ b/src/codex-auth/codex-auth-router.ts @@ -0,0 +1,87 @@ +/** + * Codex auth command router. + * + * Exports runCodexAuth(argv) which routes argv[0] (the subcommand) to + * the appropriate handler. Returns an exit code (0 = success, non-zero = error). + * + * Phase 3 wires this into src/bin/codex-runtime.ts for argv[2]==='auth'. + */ + +import { CodexProfileRegistry } from './codex-profile-registry'; +import { printCodexAuthHelp } from './codex-auth-help'; +import { + handleCreateCodex, + handleLoginCodex, + handleSwitchCodex, + handleUseCodex, + handleShowCodex, + handleRemoveCodex, + handleImportDefaultCodex, +} from './commands/index'; +import type { CodexCommandContext } from './commands/types'; + +const packageJson = require('../../package.json') as { version: string }; + +/** + * Route a `ccsx auth [...args]` invocation. + * + * @param argv - Arguments after `auth`, e.g. ['create', 'work'] or ['--help'] + * @returns Exit code (0 success, 1 user error, 2+ system error) + */ +export async function runCodexAuth(argv: string[]): Promise { + const [subcommand, ...rest] = argv; + + // Help / no-arg + if (!subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help') { + printCodexAuthHelp(); + return 0; + } + + // Version passthrough + if (subcommand === '--version' || subcommand === '-v') { + process.stdout.write(`ccsx auth ${packageJson.version}\n`); + return 0; + } + + const registry = new CodexProfileRegistry(); + const ctx: CodexCommandContext = { + registry, + version: packageJson.version, + }; + + try { + switch (subcommand) { + case 'create': + await handleCreateCodex(ctx, rest); + return 0; + case 'login': + await handleLoginCodex(ctx, rest); + return 0; + case 'switch': + await handleSwitchCodex(ctx, rest); + return 0; + case 'use': + await handleUseCodex(ctx, rest); + return 0; + case 'show': + await handleShowCodex(ctx, rest); + return 0; + case 'remove': + await handleRemoveCodex(ctx, rest); + return 0; + case 'import-default': + await handleImportDefaultCodex(ctx, rest); + return 0; + default: + process.stderr.write(`[X] Unknown command: ${subcommand}\n`); + process.stderr.write(` ccsx auth --help\n`); + return 1; + } + } catch (err) { + // Unhandled errors from handlers (e.g. process.exit called inside) + // These should be rare — handlers use exitWithError() which calls process.exit + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[X] Unexpected error in ccsx auth ${subcommand}: ${msg}\n`); + return 1; + } +} diff --git a/src/codex-auth/commands/create-command.ts b/src/codex-auth/commands/create-command.ts new file mode 100644 index 00000000..c79cd32c --- /dev/null +++ b/src/codex-auth/commands/create-command.ts @@ -0,0 +1,182 @@ +/** + * codex-auth create command. + * Creates a new profile dir + shared config.toml symlink. + * After creation, auto-spawns `codex login` with CODEX_HOME pinned (D11). + * --force: re-link config.toml only, preserve auth.json (D9). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as childProcess from 'child_process'; +import { createLogger } from '../../services/logging'; +import { initUI, info, ok } from '../../utils/ui'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { resolveCodexProfileDir, ensureSharedConfigSymlink } from '../index'; +import { decodeAccountIdentity } from '../codex-account-identity'; +import { detectCodexCli } from '../../targets/codex-detector'; +import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types'; +import type { CodexCommandContext } from './types'; + +const logger = createLogger('codex-auth:cmd:create'); + +export async function handleCreateCodex(ctx: CodexCommandContext, args: string[]): Promise { + await initUI(); + const parsed = parseArgs(args); + rejectUnsupportedOptions(parsed, 'ccsx auth create [--force]'); + + const { profileName, force } = parsed; + + if (!profileName) { + console.log(`Usage: ccsx auth create [--force]`); + exitWithError('Profile name required', ExitCode.PROFILE_ERROR); + return; + } + + const nameError = getProfileNameError(profileName); + if (nameError) { + exitWithError(nameError, ExitCode.PROFILE_ERROR); + return; + } + + const { registry } = ctx; + const profileDir = resolveCodexProfileDir(profileName); + + // Idempotent: profile already exists + if (registry.hasProfile(profileName)) { + if (force) { + // --force: only re-link config.toml, preserve auth.json + console.log(info(`Profile already exists: ${profileName} (re-linking config.toml)`)); + _ensureSymlinkSafe(profileDir); + console.log(ok(`Profile config.toml re-linked.`)); + console.log(` Profile dir: ${profileDir}`); + } else { + console.log(info(`Profile already exists: ${profileName}`)); + console.log(` Profile dir: ${profileDir}`); + console.log(` Run: ccsx auth login ${profileName}`); + } + return; + } + + // Create profile dir + symlink FIRST (filesystem is more failure-prone than registry write). + // Avoids registry orphan if mkdir hits EACCES/ENOSPC. + try { + fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + _ensureSymlinkSafe(profileDir); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if ((err as NodeJS.ErrnoException).code === 'EACCES') { + exitWithError(msg, ExitCode.GENERAL_ERROR); + return; + } + throw err; + } + + // Now register in the profile registry + try { + registry.createProfile(profileName, { + created: new Date().toISOString(), + last_used: null, + email: undefined, + plan_type: undefined, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('corrupt') || msg.includes('Failed to write')) { + exitWithError( + `Profile registry is corrupt. Backup and remove the file to re-init.\n ${msg}`, + ExitCode.GENERAL_ERROR + ); + return; + } + throw err; + } + + const authJsonPath = path.join(profileDir, 'auth.json'); + const authExists = fs.existsSync(authJsonPath); + + console.log(info(`Creating Codex profile: ${profileName}`)); + console.log(''); + console.log(` Profile dir : ${profileDir}`); + console.log(` Auth state : ${authExists ? 'authenticated' : 'not authenticated'}`); + console.log(''); + console.log(ok('Profile created.')); + + // D11: auto-spawn codex login after creating the profile + await _spawnLogin(profileName, profileDir, ctx); +} + +function _ensureSymlinkSafe(profileDir: string): void { + try { + ensureSharedConfigSymlink(profileDir); + } catch (err) { + // Symlink creation failure — warn + continue (Windows fallback documented) + process.stderr.write( + `[!] Symlinks unavailable; using copy. config.toml edits won't propagate.\n` + ); + logger.warn('codex-auth.create.symlink-failed', 'Symlink creation failed', { + profileDir, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +async function _spawnLogin( + profileName: string, + profileDir: string, + ctx: CodexCommandContext +): Promise { + const codexCli = detectCodexCli(); + if (!codexCli) { + process.stderr.write(`[!] codex CLI not found — skipping auto-login.\n`); + process.stderr.write(` Install: npm i -g @openai/codex\n`); + process.stderr.write(` Then run: ccsx auth login ${profileName}\n`); + return; + } + + console.log(''); + console.log(`Next step: logging in to Codex...`); + console.log(` CODEX_HOME=${profileDir}`); + console.log(''); + + await new Promise((resolve) => { + const child = childProcess.spawn(codexCli, ['login'], { + stdio: 'inherit', + env: { ...process.env, CODEX_HOME: profileDir }, + windowsHide: true, + }); + + child.on('error', (err) => { + process.stderr.write(`[X] Failed to execute codex: ${err.message}\n`); + resolve(); + }); + + child.on('exit', (code) => { + const authJsonPath = path.join(profileDir, 'auth.json'); + if (code === 0 && fs.existsSync(authJsonPath)) { + const identity = decodeAccountIdentity(authJsonPath); + if (identity.email || identity.plan_type) { + ctx.registry.updateProfile(profileName, { + last_used: new Date().toISOString(), + email: identity.email, + plan_type: identity.plan_type ?? null, + account_id: identity.account_id, + }); + } + const emailStr = identity.email ? ` as ${identity.email}` : ''; + const planStr = identity.plan_type ? ` (plan: ${identity.plan_type})` : ''; + console.log(ok(`Logged in${emailStr}${planStr}`)); + } else if (code === 0) { + process.stderr.write( + `[!] codex login exited cleanly but no auth.json. Skipping registry update.\n` + ); + } else { + process.stderr.write( + `[!] Login cancelled or failed. Profile ${profileName} remains unauthenticated.\n` + ); + process.stderr.write(` Retry: ccsx auth login ${profileName}\n`); + } + resolve(); + }); + }); +} diff --git a/src/codex-auth/commands/index.ts b/src/codex-auth/commands/index.ts new file mode 100644 index 00000000..91719df1 --- /dev/null +++ b/src/codex-auth/commands/index.ts @@ -0,0 +1,19 @@ +/** + * Barrel export for codex-auth command handlers. + */ + +export { handleCreateCodex } from './create-command'; +export { handleLoginCodex } from './login-command'; +export { handleSwitchCodex } from './switch-command'; +export { handleUseCodex } from './use-command'; +export { handleShowCodex } from './show-command'; +export { handleRemoveCodex } from './remove-command'; +export { handleImportDefaultCodex } from './import-default-command'; +export type { CodexCommandContext, CodexAuthArgs, CodexProfileOutput } from './types'; +export { + parseArgs, + rejectUnsupportedOptions, + isValidCodexProfileName, + getProfileNameError, + formatRelativeTime, +} from './types'; diff --git a/src/codex-auth/commands/login-command.ts b/src/codex-auth/commands/login-command.ts new file mode 100644 index 00000000..c1e17ac5 --- /dev/null +++ b/src/codex-auth/commands/login-command.ts @@ -0,0 +1,138 @@ +/** + * codex-auth login command. + * Spawns `codex login` with CODEX_HOME pinned to the profile dir. + * Auto-creates profile if it doesn't exist yet. + * Updates registry with email/plan from JWT after successful login. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as childProcess from 'child_process'; +import { createLogger } from '../../services/logging'; +import { initUI, info, ok } from '../../utils/ui'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { resolveCodexProfileDir, ensureSharedConfigSymlink } from '../index'; +import { decodeAccountIdentity } from '../codex-account-identity'; +import { detectCodexCli } from '../../targets/codex-detector'; +import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types'; +import type { CodexCommandContext } from './types'; + +const logger = createLogger('codex-auth:cmd:login'); + +export async function handleLoginCodex(ctx: CodexCommandContext, args: string[]): Promise { + await initUI(); + const parsed = parseArgs(args); + rejectUnsupportedOptions(parsed, 'ccsx auth login '); + + const { profileName } = parsed; + + if (!profileName) { + console.log('Usage: ccsx auth login '); + exitWithError('Profile name required', ExitCode.PROFILE_ERROR); + return; + } + + const nameError = getProfileNameError(profileName); + if (nameError) { + exitWithError(nameError, ExitCode.PROFILE_ERROR); + return; + } + + const { registry } = ctx; + + // Auto-create profile if missing + if (!registry.hasProfile(profileName)) { + console.log(info(`Auto-creating profile ${profileName}`)); + registry.createProfile(profileName, { + created: new Date().toISOString(), + last_used: null, + }); + const profileDir = resolveCodexProfileDir(profileName); + fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + try { + ensureSharedConfigSymlink(profileDir); + } catch { + process.stderr.write(`[!] Symlink creation failed; continuing without shared config.\n`); + } + } + + const codexCli = detectCodexCli(); + if (!codexCli) { + console.log(''); + console.log('Install:'); + console.log(' npm i -g @openai/codex'); + console.log(' # or follow https://github.com/openai/codex#install'); + console.log(''); + console.log(`After installing, re-run:`); + console.log(` ccsx auth login ${profileName}`); + exitWithError('codex CLI not found', ExitCode.BINARY_ERROR); + return; + } + + const profileDir = resolveCodexProfileDir(profileName); + + // Ensure profile dir exists (may have been deleted) + if (!fs.existsSync(profileDir)) { + fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 }); + try { + ensureSharedConfigSymlink(profileDir); + } catch { + process.stderr.write(`[!] Symlink creation failed; continuing.\n`); + } + } + + const authJsonPath = path.join(profileDir, 'auth.json'); + const authJsonExisted = fs.existsSync(authJsonPath); + + console.log(info(`Launching codex login for profile: ${profileName}`)); + console.log(` CODEX_HOME=${profileDir}`); + console.log(''); + + const exitCode = await new Promise((resolve) => { + const child = childProcess.spawn(codexCli, ['login'], { + stdio: 'inherit', + env: { ...process.env, CODEX_HOME: profileDir }, + windowsHide: true, + }); + + child.on('error', (err) => { + process.stderr.write(`[X] Failed to execute codex: ${err.message}\n`); + logger.warn('codex-auth.login.spawn-error', 'Spawn failed', { error: err.message }); + resolve(ExitCode.BINARY_ERROR); + }); + + child.on('exit', (code) => { + resolve(code ?? 1); + }); + }); + + if (exitCode === 0 && fs.existsSync(authJsonPath)) { + const identity = decodeAccountIdentity(authJsonPath); + const now = new Date().toISOString(); + registry.updateProfile(profileName, { + last_used: now, + email: identity.email, + plan_type: identity.plan_type ?? null, + account_id: identity.account_id, + }); + const emailStr = identity.email ?? ''; + const planStr = identity.plan_type ? ` (plan: ${identity.plan_type})` : ''; + console.log(ok(`Logged in as ${emailStr}${planStr}`)); + console.log(` Profile: ${profileName}`); + console.log(` Updated: ${now}`); + } else if (exitCode === 0) { + process.stderr.write( + '[!] codex login exited cleanly but no auth.json. Skipping registry update.\n' + ); + } else { + if (!authJsonExisted) { + process.stderr.write( + `[!] Login cancelled or failed. Profile ${profileName} remains unauthenticated.\n` + ); + } else { + process.stderr.write('[!] Login failed. Previous credentials may still be valid.\n'); + } + process.exit(ExitCode.AUTH_ERROR); + } +} diff --git a/src/codex-auth/commands/remove-command.ts b/src/codex-auth/commands/remove-command.ts new file mode 100644 index 00000000..7e275b01 --- /dev/null +++ b/src/codex-auth/commands/remove-command.ts @@ -0,0 +1,125 @@ +/** + * codex-auth remove command. + * Deletes profile dir + registry entry. + * Guards: refuses to remove the default when others exist (unless --force). + * Best-effort warning if CCS_CODEX_PROFILE points to it. + * --yes skips confirmation prompt. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { initUI, info, ok } from '../../utils/ui'; +import { InteractivePrompt } from '../../utils/prompt'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { resolveCodexProfileDir } from '../codex-profile-paths'; +import { decodeAccountIdentity } from '../codex-account-identity'; +import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types'; +import type { CodexCommandContext } from './types'; + +export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]): Promise { + await initUI(); + const parsed = parseArgs(args); + rejectUnsupportedOptions(parsed, 'ccsx auth remove [--yes|-y] [--force]'); + + const { profileName, yes, force } = parsed; + + if (!profileName) { + console.log('Usage: ccsx auth remove [--yes|-y] [--force]'); + exitWithError('Profile name required', ExitCode.PROFILE_ERROR); + return; + } + + const nameError = getProfileNameError(profileName); + if (nameError) { + exitWithError(nameError, ExitCode.PROFILE_ERROR); + return; + } + + const { registry } = ctx; + + if (!registry.hasProfile(profileName)) { + exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR); + return; + } + + const allProfiles = registry.listProfiles(); + const isDefault = registry.getDefault() === profileName; + + // Default guard: refuse if others exist and no --force + if (isDefault && allProfiles.length > 1 && !force) { + const others = allProfiles.filter((n) => n !== profileName); + console.log(` Switch first: ccsx auth switch ${others[0]}`); + console.log(` Or override : ccsx auth remove ${profileName} --force`); + exitWithError('Cannot remove default profile', ExitCode.PROFILE_ERROR); + return; + } + + // Active-env warning (best-effort — can only see current shell) + if (process.env.CCS_CODEX_PROFILE === profileName) { + process.stderr.write(`[!] CCS_CODEX_PROFILE in this shell points to "${profileName}".\n`); + process.stderr.write(` After removal, codex sessions in this shell will fail until you\n`); + const others = allProfiles.filter((n) => n !== profileName); + if (others.length > 0) { + process.stderr.write( + ` run: eval "$(ccsx auth use ${others[0]})" or unset CCS_CODEX_PROFILE.\n` + ); + } else { + process.stderr.write(` run: unset CCS_CODEX_PROFILE\n`); + } + } + + const profileDir = resolveCodexProfileDir(profileName); + const authJsonPath = path.join(profileDir, 'auth.json'); + const authExists = fs.existsSync(authJsonPath); + const dirExists = fs.existsSync(profileDir); + + // Load cached email for impact summary + const meta = registry.getProfile(profileName); + let emailStr = meta.email ?? null; + if (!emailStr && authExists) { + const identity = decodeAccountIdentity(authJsonPath); + emailStr = identity.email ?? null; + } + + // Ghost case: dir already gone + if (!dirExists) { + process.stderr.write(`[!] Profile dir was already missing; removing registry entry only.\n`); + registry.removeProfile(profileName); + console.log(ok(`Profile removed: ${profileName}`)); + return; + } + + // Impact summary + console.log(`Profile "${profileName}" will be removed.`); + console.log(` Profile dir : ${profileDir}`); + console.log(` auth.json : ${authExists ? 'present (will be deleted)' : 'not found'}`); + console.log(` Email : ${emailStr ?? ''}`); + console.log(''); + + // Confirm unless --yes + if (!yes) { + const confirmed = await InteractivePrompt.confirm('Delete this profile?', { + default: false, + }); + if (!confirmed) { + console.log(info('Cancelled.')); + return; + } + } + + // Remove dir then registry entry + try { + fs.rmSync(profileDir, { recursive: true, force: true }); + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code === 'EACCES') { + exitWithError('Permission denied', ExitCode.GENERAL_ERROR); + return; + } + throw err; + } + + registry.removeProfile(profileName); + console.log(ok(`Profile removed: ${profileName}`)); +} diff --git a/src/codex-auth/commands/show-command.ts b/src/codex-auth/commands/show-command.ts new file mode 100644 index 00000000..ee5e9e25 --- /dev/null +++ b/src/codex-auth/commands/show-command.ts @@ -0,0 +1,123 @@ +/** + * codex-auth show command. + * List mode: table of all profiles with STATE column. + * Detail mode: delegated to show-detail-view.ts. + * --json: machine-readable output. + * D14: active(missing) row at top when CCS_CODEX_PROFILE points to deleted profile. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { initUI, info, table } from '../../utils/ui'; +import { resolveCodexProfileDir } from '../codex-profile-paths'; +import { decodeAccountIdentity } from '../codex-account-identity'; +import { showProfileDetail } from './show-detail-view'; +import { parseArgs, rejectUnsupportedOptions, formatRelativeTime } from './types'; +import type { CodexCommandContext, CodexProfileOutput } from './types'; + +export async function handleShowCodex(ctx: CodexCommandContext, args: string[]): Promise { + await initUI(); + const parsed = parseArgs(args); + rejectUnsupportedOptions(parsed, 'ccsx auth show [name] [--json]'); + + const { profileName, json } = parsed; + + if (profileName) { + return showProfileDetail(profileName, ctx, !!json); + } + return _showList(ctx, !!json); +} + +// ── List view ───────────────────────────────────────────────────────────────── + +function _showList(ctx: CodexCommandContext, json: boolean): void { + const { registry } = ctx; + const names = registry.listProfiles(); + const defaultName = registry.getDefault(); + const activeName = process.env.CCS_CODEX_PROFILE ?? null; + + // D14: check if CCS_CODEX_PROFILE points to a deleted/missing profile + const activeMissing = + activeName !== null && activeName.length > 0 && !registry.hasProfile(activeName); + + interface Row { + name: string; + email: string; + plan: string; + lastUsed: string; + state: string; + missing?: boolean; + } + + const rows: Row[] = []; + + // D14: active(missing) row at top + if (activeMissing) { + rows.push({ + name: activeName ?? '', + email: '', + plan: '-', + lastUsed: 'never', + state: 'active(missing)', + missing: true, + }); + } + + for (const name of names) { + const meta = registry.getProfile(name); + const states: string[] = []; + if (name === defaultName) states.push('default'); + if (name === activeName) states.push('active'); + + const profileDir = resolveCodexProfileDir(name); + const authJsonPath = path.join(profileDir, 'auth.json'); + let email = meta.email ?? ''; + if (fs.existsSync(authJsonPath) && !meta.email) { + const identity = decodeAccountIdentity(authJsonPath); + email = identity.email ?? ''; + } + + const lastUsed = meta.last_used ? formatRelativeTime(new Date(meta.last_used)) : 'never'; + + rows.push({ name, email, plan: meta.plan_type ?? '-', lastUsed, state: states.join(',') }); + } + + if (json) { + const profiles: CodexProfileOutput[] = rows.map((r) => { + const meta = r.missing ? null : registry.getProfile(r.name); + const profileDir = r.missing ? '' : resolveCodexProfileDir(r.name); + return { + name: r.name, + is_default: r.name === defaultName, + is_active: r.name === activeName, + created: meta?.created ?? '', + last_used: meta?.last_used ?? null, + email: r.email === '' ? null : r.email, + plan: r.plan === '-' ? null : r.plan, + account_id: null, + profile_dir: profileDir, + auth_json_exists: r.missing ? false : fs.existsSync(path.join(profileDir, 'auth.json')), + auth_json_mtime: null, + config_toml_link_target: null, + }; + }); + console.log(JSON.stringify({ profiles }, null, 2)); + return; + } + + if (names.length === 0 && !activeMissing) { + console.log(info('No Codex profiles yet.')); + console.log(' Create one: ccsx auth create '); + return; + } + + const count = names.length + (activeMissing ? 1 : 0); + console.log(`Codex Profiles (${count})`); + console.log(''); + + const header = ['NAME', 'EMAIL', 'PLAN', 'LAST_USED', 'STATE']; + const tableRows = [header, ...rows.map((r) => [r.name, r.email, r.plan, r.lastUsed, r.state])]; + console.log(table(tableRows, { colWidths: [14, 26, 8, 14, 18] })); + console.log(''); + console.log(info('Default persists across shells. Active is current shell only.')); +} diff --git a/src/codex-auth/commands/show-detail-view.ts b/src/codex-auth/commands/show-detail-view.ts new file mode 100644 index 00000000..970db545 --- /dev/null +++ b/src/codex-auth/commands/show-detail-view.ts @@ -0,0 +1,118 @@ +/** + * Detail view renderer for `ccsx auth show `. + * Extracted from show-command.ts to keep files under 200 lines. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { table } from '../../utils/ui'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { resolveCodexProfileDir } from '../codex-profile-paths'; +import { decodeAccountIdentity } from '../codex-account-identity'; +import type { CodexCommandContext, CodexProfileOutput } from './types'; + +export function showProfileDetail( + profileName: string, + ctx: CodexCommandContext, + json: boolean +): void { + const { registry } = ctx; + + if (!registry.hasProfile(profileName)) { + exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR); + return; + } + + const meta = registry.getProfile(profileName); + const profileDir = resolveCodexProfileDir(profileName); + const authJsonPath = path.join(profileDir, 'auth.json'); + const configTomlPath = path.join(profileDir, 'config.toml'); + + const authExists = fs.existsSync(authJsonPath); + let authMtime: string | null = null; + let identity = { + email: undefined as string | undefined, + plan_type: undefined as string | undefined, + account_id: undefined as string | undefined, + }; + let authState = 'missing'; + + if (authExists) { + try { + const stat = fs.statSync(authJsonPath); + authMtime = stat.mtime.toISOString(); + identity = decodeAccountIdentity(authJsonPath) as typeof identity; + authState = `present (mtime: ${new Date(authMtime).toLocaleString()})`; + } catch { + authState = 'present (unreadable)'; + } + } + + // Inspect config.toml symlink + let configTarget: string | null = null; + try { + const lstat = fs.lstatSync(configTomlPath); + if (lstat.isSymbolicLink()) { + configTarget = fs.readlinkSync(configTomlPath); + } else { + configTarget = `${configTomlPath} (regular file, not symlink)`; + } + } catch { + configTarget = null; + } + + const isDefault = registry.getDefault() === profileName; + const isActive = process.env.CCS_CODEX_PROFILE === profileName; + const states: string[] = []; + if (isDefault) states.push('default'); + if (isActive) states.push('active'); + const stateStr = states.join(','); + + if (json) { + const out: CodexProfileOutput = { + name: profileName, + is_default: isDefault, + is_active: isActive, + created: meta.created, + last_used: meta.last_used ?? null, + email: identity.email ?? null, + plan: meta.plan_type ?? null, + account_id: identity.account_id ?? null, + profile_dir: profileDir, + auth_json_exists: authExists, + auth_json_mtime: authMtime, + config_toml_link_target: configTarget, + }; + console.log(JSON.stringify(out, null, 2)); + return; + } + + const badge = stateStr ? ` (${stateStr})` : ''; + console.log(`Codex Profile: ${profileName}${badge}`); + console.log(''); + + const rows: [string, string][] = [ + ['Name', profileName], + ['Profile dir', profileDir], + ['config.toml', configTarget ? `-> ${configTarget} (symlink)` : '(not linked)'], + ['auth.json', authState], + ['Email', identity.email ?? (authExists ? '' : '')], + ['Plan', meta.plan_type ?? (authExists ? '' : '')], + ['Account ID', identity.account_id ?? '-'], + ['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'], + ['CCS_CODEX_PROFILE', process.env.CCS_CODEX_PROFILE ?? 'unset'], + ]; + + console.log(table(rows, { colWidths: [20, 55] })); + + // H4: warn if config.toml is a regular file (not symlink) + if (configTarget && configTarget.includes('regular file')) { + process.stderr.write( + `[!] config.toml is a regular file, not a symlink. Config changes won't propagate.\n` + ); + process.stderr.write(` Run: ccsx auth create ${profileName} --force\n`); + } +} diff --git a/src/codex-auth/commands/switch-command.ts b/src/codex-auth/commands/switch-command.ts new file mode 100644 index 00000000..955daf57 --- /dev/null +++ b/src/codex-auth/commands/switch-command.ts @@ -0,0 +1,55 @@ +/** + * codex-auth switch command. + * Sets the persistent default Codex profile in the registry. + */ + +import { initUI, ok } from '../../utils/ui'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types'; +import type { CodexCommandContext } from './types'; + +export async function handleSwitchCodex(ctx: CodexCommandContext, args: string[]): Promise { + await initUI(); + const parsed = parseArgs(args); + rejectUnsupportedOptions(parsed, 'ccsx auth switch '); + + const { profileName } = parsed; + + if (!profileName) { + console.log('Usage: ccsx auth switch '); + exitWithError('Profile name required', ExitCode.PROFILE_ERROR); + return; + } + + const nameError = getProfileNameError(profileName); + if (nameError) { + exitWithError(nameError, ExitCode.PROFILE_ERROR); + return; + } + + const { registry } = ctx; + + if (!registry.hasProfile(profileName)) { + const available = registry.listProfiles(); + const availableStr = available.length > 0 ? available.join(', ') : ''; + exitWithError( + `Profile not found: ${profileName}. Available: ${availableStr}`, + ExitCode.PROFILE_ERROR + ); + return; + } + + registry.setDefault(profileName); + + const meta = registry.getProfile(profileName); + const emailStr = meta.email ? `\n Email: ${meta.email}` : ''; + const planStr = meta.plan_type ? `\n Plan : ${meta.plan_type}` : ''; + + console.log(ok(`Default Codex profile: ${profileName}`)); + if (emailStr) process.stdout.write(emailStr + '\n'); + if (planStr) process.stdout.write(planStr + '\n'); + console.log(''); + console.log('[i] This is the persistent default. To use a different profile in the'); + console.log(` current shell only, run: eval "$(ccsx auth use )"`); +} diff --git a/src/codex-auth/commands/types.ts b/src/codex-auth/commands/types.ts new file mode 100644 index 00000000..a00d42ee --- /dev/null +++ b/src/codex-auth/commands/types.ts @@ -0,0 +1,111 @@ +/** + * Shared types and utilities for codex-auth command handlers. + */ + +import { color } from '../../utils/ui'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import type { CodexProfileRegistry } from '../codex-profile-registry'; + +// Re-export for convenience in command modules +export { formatRelativeTime } from '../../utils/time'; + +// ── Context ────────────────────────────────────────────────────────────────── + +export interface CodexCommandContext { + registry: CodexProfileRegistry; + version: string; +} + +// ── CLI args ───────────────────────────────────────────────────────────────── + +export interface CodexAuthArgs { + profileName?: string; + yes?: boolean; + json?: boolean; + force?: boolean; + shell?: string; + unknownFlags?: string[]; +} + +// ── Profile output shape (JSON mode) ───────────────────────────────────────── + +export interface CodexProfileOutput { + name: string; + is_default: boolean; + is_active: boolean; + created: string; + last_used: string | null; + email: string | null; + plan: string | null; + account_id: string | null; + profile_dir: string; + auth_json_exists: boolean; + auth_json_mtime: string | null; + config_toml_link_target: string | null; +} + +// ── Name validation ─────────────────────────────────────────────────────────── + +const RESERVED = new Set(['default', 'current']); + +/** + * Profile name must match /^[a-z0-9][a-z0-9_-]{0,63}$/ and not be reserved. + * Rejects uppercase, path separators, leading dash/underscore, length >64. + */ +export function isValidCodexProfileName(name: string): boolean { + if (!name || name.length > 64) return false; + if (RESERVED.has(name)) return false; + if (name.includes('/') || name.includes('\\')) return false; + return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(name); +} + +export function getProfileNameError(name: string): string | null { + if (!name) return 'Profile name is required.'; + if (RESERVED.has(name)) return `Profile name "${name}" is reserved.`; + if (name.includes('/') || name.includes('\\')) + return 'Profile name must not contain path separators.'; + if (name.length > 64) return 'Profile name must be 64 characters or fewer.'; + if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) + return 'Profile name must match [a-z0-9][a-z0-9_-]{0,63}.'; + return null; +} + +// ── Arg parsing ─────────────────────────────────────────────────────────────── + +export function parseArgs(args: string[]): CodexAuthArgs { + const result: CodexAuthArgs = { unknownFlags: [] }; + const positional: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--yes' || arg === '-y') { + result.yes = true; + } else if (arg === '--json') { + result.json = true; + } else if (arg === '--force') { + result.force = true; + } else if (arg === '--shell') { + result.shell = args[++i]; + } else if (arg.startsWith('--shell=')) { + result.shell = arg.slice('--shell='.length); + } else if (arg.startsWith('-') && arg !== '--') { + if (result.unknownFlags) result.unknownFlags.push(arg); + } else if (arg !== '--') { + positional.push(arg); + } + } + + if (positional.length > 0) { + result.profileName = positional[0]; + } + + return result; +} + +export function rejectUnsupportedOptions(parsed: CodexAuthArgs, usage: string): void { + if (parsed.unknownFlags && parsed.unknownFlags.length > 0) { + console.log(`Usage: ${color(usage, 'command')}`); + exitWithError('Unknown options', ExitCode.GENERAL_ERROR); + } +} diff --git a/src/codex-auth/commands/use-command.ts b/src/codex-auth/commands/use-command.ts new file mode 100644 index 00000000..94a42979 --- /dev/null +++ b/src/codex-auth/commands/use-command.ts @@ -0,0 +1,102 @@ +/** + * codex-auth use command. + * + * STDOUT DISCIPLINE (C2, R4): stdout contains ONLY shell-evalable export + * statements. ALL errors, hints, and info messages go to STDERR so that + * `eval "$(ccsx auth use )"` is never contaminated. + * + * Belt-and-suspenders for C2: the primary protection is + * `src/bin/codex-runtime-router.ts`, which dispatches `auth` subcommands + * BEFORE pre-dispatch runs at all. This IIFE is a fallback for any future + * code path (e.g., direct import from a different bin entry) that bypasses + * the router and would otherwise let pre-dispatch banners hit stdout. + */ + +(function guardPreDispatch() { + const argv = process.argv; + // argv[2] is the first user arg to ccsx (e.g. "auth"), argv[3] is the subcommand + for (let i = 2; i < argv.length - 1; i++) { + if (argv[i] === 'auth' && argv[i + 1] === 'use') { + process.env.CCS_NO_PRE_DISPATCH = '1'; + break; + } + } +})(); +// ── End guard — safe to import CCS modules now ─────────────────────────────── + +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { resolveCodexProfileDir } from '../codex-profile-paths'; +import { detectShell, formatExport } from '../shell-detect'; +import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types'; +import type { Shell } from '../shell-detect'; +import type { CodexCommandContext } from './types'; + +const VALID_SHELLS = new Set(['bash', 'zsh', 'fish', 'pwsh', 'cmd']); + +export async function handleUseCodex(ctx: CodexCommandContext, args: string[]): Promise { + const parsed = parseArgs(args); + rejectUnsupportedOptions(parsed, 'ccsx auth use [--shell ]'); + + const { profileName, shell: shellOverride } = parsed; + + // All errors → stderr, empty stdout + if (!profileName) { + process.stderr.write('[X] Profile name is required.\n'); + process.stderr.write('Usage: ccsx auth use [--shell ]\n'); + process.exit(ExitCode.PROFILE_ERROR); + return; + } + + const nameError = getProfileNameError(profileName); + if (nameError) { + process.stderr.write(`[X] ${nameError}\n`); + process.exit(ExitCode.PROFILE_ERROR); + return; + } + + if (shellOverride !== undefined && !VALID_SHELLS.has(shellOverride)) { + process.stderr.write( + `[X] Unsupported --shell value: "${shellOverride}". Valid: bash, zsh, fish, pwsh, cmd\n` + ); + process.exit(ExitCode.GENERAL_ERROR); + return; + } + + const { registry } = ctx; + + if (!registry.hasProfile(profileName)) { + const available = registry.listProfiles(); + const availableStr = available.length > 0 ? available.join(', ') : ''; + process.stderr.write(`[X] Profile not found: ${profileName}. Available: ${availableStr}\n`); + process.exit(ExitCode.PROFILE_ERROR); + return; + } + + const profileDir = resolveCodexProfileDir(profileName); + const shell: Shell = + shellOverride !== undefined + ? (shellOverride as Shell) + : detectShell(process.env, process.platform); + + // ── STDOUT: only export statements ────────────────────────────────────────── + process.stdout.write(formatExport(shell, 'CODEX_HOME', profileDir) + '\n'); + process.stdout.write(formatExport(shell, 'CCS_CODEX_PROFILE', profileName) + '\n'); + + // ── STDERR: human-readable hint ───────────────────────────────────────────── + process.stderr.write(`[i] Codex profile "${profileName}" active in this shell. Run: codex\n`); + + if (shell === 'cmd') { + process.stderr.write('[i] Note: cmd.exe cannot eval output from a subprocess natively.\n'); + process.stderr.write( + ' Use PowerShell: ccsx auth use ' + profileName + ' | Invoke-Expression\n' + ); + } + + // Note: This profile applies only to native `codex`. + // `ccsxp` ignores CCS_CODEX_PROFILE and uses its own cliproxy pool. +} + +// suppress unused import warning — exitWithError is available but we use process.exit +// for stdout purity in this command +void exitWithError; diff --git a/src/codex-auth/shell-detect.ts b/src/codex-auth/shell-detect.ts new file mode 100644 index 00000000..741c18c1 --- /dev/null +++ b/src/codex-auth/shell-detect.ts @@ -0,0 +1,62 @@ +/** + * Shell detection for codex-auth use command. + * Determines current shell to emit correct eval-safe export syntax. + */ + +export type Shell = 'bash' | 'zsh' | 'fish' | 'pwsh' | 'cmd'; + +/** + * Detect current shell from environment. + * On Windows: PSModulePath presence → pwsh, else cmd. + * On Unix: inspect $SHELL suffix. + */ +export function detectShell( + env: NodeJS.ProcessEnv = process.env, + platform: string = process.platform +): Shell { + if (platform === 'win32') { + return env.PSModulePath ? 'pwsh' : 'cmd'; + } + const sh = (env.SHELL ?? '').toLowerCase(); + if (sh.endsWith('/fish')) return 'fish'; + if (sh.endsWith('/zsh')) return 'zsh'; + return 'bash'; // default for bash, sh, dash, ksh +} + +/** + * Single-quote escape for POSIX shells (bash/zsh/fish). + * Closes the single-quote, inserts escaped quote, reopens. + */ +function posixSingleQuote(value: string): string { + return "'" + value.replace(/'/g, "'\\''") + "'"; +} + +/** + * Double-quote escape for PowerShell. + * Wraps in double quotes; escapes internal double quotes by doubling them + * and backtick-escapes $ to prevent variable interpolation. + */ +function pwshDoubleQuote(value: string): string { + return '"' + value.replace(/"/g, '""').replace(/\$/g, '`$') + '"'; +} + +/** + * Format a single env var export statement for the target shell. + * Used by use-command to emit eval-safe lines. + */ +export function formatExport(shell: Shell, key: string, value: string): string { + switch (shell) { + case 'fish': + return `set -gx ${key} ${posixSingleQuote(value)};`; + case 'pwsh': + return `$env:${key} = ${pwshDoubleQuote(value)}`; + case 'cmd': + // cmd.exe: no quoting — values are used verbatim. + // NOTE: cmd.exe cannot eval output from a node process natively. + // Users should prefer PowerShell. See --help for details. + return `set ${key}=${value}`; + default: + // bash / zsh + return `export ${key}=${posixSingleQuote(value)}`; + } +} diff --git a/src/dispatcher/pre-dispatch.ts b/src/dispatcher/pre-dispatch.ts index 725f2240..aef943c6 100644 --- a/src/dispatcher/pre-dispatch.ts +++ b/src/dispatcher/pre-dispatch.ts @@ -59,6 +59,14 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise)"`. + // See: src/codex-auth/commands/use-command.ts (C2 in plan.md §Validation findings) + if (process.env.CCS_NO_PRE_DISPATCH === '1') { + return false; + } + // Auto-migrate to unified config format (silent if already migrated) // Skip if user is explicitly running migrate command if (firstArg !== 'migrate') { diff --git a/tests/unit/codex-auth/codex-auth-router.test.ts b/tests/unit/codex-auth/codex-auth-router.test.ts new file mode 100644 index 00000000..5ac108ee --- /dev/null +++ b/tests/unit/codex-auth/codex-auth-router.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +let tempDir: string; +let ccsHome: string; +const ORIG_CCS_HOME = process.env.CCS_HOME; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-router-test-')); + ccsHome = path.join(tempDir, 'ccs'); + fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true }); + process.env.CCS_HOME = ccsHome; +}); + +afterEach(() => { + if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = ORIG_CCS_HOME; + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +async function loadRouter() { + // Re-import fresh each test via dynamic import cache busting with timestamp + const { runCodexAuth } = await import('../../../src/codex-auth/codex-auth-router'); + return runCodexAuth; +} + +describe('runCodexAuth — help and no-arg', () => { + it('no args → prints help and returns 0', async () => { + const runCodexAuth = await loadRouter(); + const out: string[] = []; + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk: string | Uint8Array) => { + out.push(String(chunk)); + return true; + }; + try { + const code = await runCodexAuth([]); + expect(code).toBe(0); + expect(out.join('')).toContain('ccsx auth'); + } finally { + process.stdout.write = origWrite; + } + }); + + it('--help → returns 0 and prints help', async () => { + const runCodexAuth = await loadRouter(); + const out: string[] = []; + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk: string | Uint8Array) => { + out.push(String(chunk)); + return true; + }; + try { + const code = await runCodexAuth(['--help']); + expect(code).toBe(0); + expect(out.join('')).toContain('Commands'); + } finally { + process.stdout.write = origWrite; + } + }); + + it('-h → returns 0', async () => { + const runCodexAuth = await loadRouter(); + const out: string[] = []; + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk: string | Uint8Array) => { + out.push(String(chunk)); + return true; + }; + try { + const code = await runCodexAuth(['-h']); + expect(code).toBe(0); + } finally { + process.stdout.write = origWrite; + } + }); +}); + +describe('runCodexAuth — unknown subcommand', () => { + it('unknown subcommand → returns 1 and writes to stderr', async () => { + const runCodexAuth = await loadRouter(); + const errOut: string[] = []; + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk: string | Uint8Array) => { + errOut.push(String(chunk)); + return true; + }; + try { + const code = await runCodexAuth(['bogus']); + expect(code).toBe(1); + expect(errOut.join('')).toContain('Unknown command'); + expect(errOut.join('')).toContain('bogus'); + } finally { + process.stderr.write = origWrite; + } + }); +}); + +describe('runCodexAuth — version', () => { + it('--version → returns 0 and prints version', async () => { + const runCodexAuth = await loadRouter(); + const out: string[] = []; + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk: string | Uint8Array) => { + out.push(String(chunk)); + return true; + }; + try { + const code = await runCodexAuth(['--version']); + expect(code).toBe(0); + expect(out.join('')).toMatch(/\d+\.\d+/); + } finally { + process.stdout.write = origWrite; + } + }); +}); + +describe('runCodexAuth — dispatches show without crashing', () => { + it('show with no profiles → exit 0', async () => { + const runCodexAuth = await loadRouter(); + const out: string[] = []; + const origLog = console.log; + const origWrite = process.stdout.write.bind(process.stdout); + console.log = (...a: unknown[]) => out.push(a.map(String).join(' ')); + process.stdout.write = (chunk: string | Uint8Array) => { + out.push(String(chunk)); + return true; + }; + try { + const code = await runCodexAuth(['show']); + expect(code).toBe(0); + expect(out.join('')).toContain('No Codex profiles'); + } finally { + console.log = origLog; + process.stdout.write = origWrite; + } + }); +}); diff --git a/tests/unit/codex-auth/commands/create-command.test.ts b/tests/unit/codex-auth/commands/create-command.test.ts new file mode 100644 index 00000000..62fa1d86 --- /dev/null +++ b/tests/unit/codex-auth/commands/create-command.test.ts @@ -0,0 +1,309 @@ +/** + * Tests for codex-auth create command. + * Mocks detectCodexCli and child_process.spawn to avoid real codex binary. + */ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as childProcess from 'child_process'; + +let tempDir: string; +let ccsHome: string; +const ORIG_CCS_HOME = process.env.CCS_HOME; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-create-test-')); + ccsHome = path.join(tempDir, 'ccs'); + fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true }); + process.env.CCS_HOME = ccsHome; +}); + +afterEach(() => { + if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = ORIG_CCS_HOME; + fs.rmSync(tempDir, { recursive: true, force: true }); + mock.restore(); +}); + +async function makeCtx() { + const { CodexProfileRegistry } = await import( + '../../../../src/codex-auth/codex-profile-registry' + ); + const reg = new CodexProfileRegistry(); + return { registry: reg, version: '0.0.0-test' }; +} + +/** Suppress console output during test. */ +function silenceConsole(): () => void { + const origLog = console.log; + const origErr = console.error; + const origWarn = console.warn; + const origWrite = process.stderr.write.bind(process.stderr); + console.log = () => {}; + console.error = () => {}; + console.warn = () => {}; + process.stderr.write = () => true; + return () => { + console.log = origLog; + console.error = origErr; + console.warn = origWarn; + process.stderr.write = origWrite; + }; +} + +function mockDetectCodexReturns(value: string | null) { + // We need to mock before importing the command module + // Use a global environment approach instead + if (value === null) { + process.env._TEST_CODEX_PATH = ''; + } else { + process.env._TEST_CODEX_PATH = value; + } +} + +describe('handleCreateCodex — happy path', () => { + it('creates profile dir and registry entry (no codex binary)', async () => { + const detectorMod = await import('../../../../src/targets/codex-detector'); + spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null); + + const { handleCreateCodex } = await import( + '../../../../src/codex-auth/commands/create-command' + ); + const ctx = await makeCtx(); + const restore = silenceConsole(); + try { + await handleCreateCodex(ctx, ['myprofile']); + } finally { + restore(); + } + + expect(ctx.registry.hasProfile('myprofile')).toBe(true); + const instancesDir = path.join(ccsHome, '.ccs', 'codex-instances', 'myprofile'); + expect(fs.existsSync(instancesDir)).toBe(true); + }); +}); + +describe('handleCreateCodex — idempotent re-run', () => { + it('no-op when profile already exists (no --force)', async () => { + const detectorMod = await import('../../../../src/targets/codex-detector'); + spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null); + + const { handleCreateCodex } = await import( + '../../../../src/codex-auth/commands/create-command' + ); + const ctx = await makeCtx(); + + const restore = silenceConsole(); + try { + await handleCreateCodex(ctx, ['dupprofile']); + await handleCreateCodex(ctx, ['dupprofile']); // second call is idempotent + } finally { + restore(); + } + // Profile still has exactly one entry + expect(ctx.registry.listProfiles().filter((n) => n === 'dupprofile').length).toBe(1); + }); +}); + +describe('handleCreateCodex — --force re-links symlink only', () => { + it('--force on existing profile does not wipe auth.json (D9)', async () => { + const detectorMod = await import('../../../../src/targets/codex-detector'); + spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null); + + const { handleCreateCodex } = await import( + '../../../../src/codex-auth/commands/create-command' + ); + const ctx = await makeCtx(); + + const restore = silenceConsole(); + try { + await handleCreateCodex(ctx, ['forceprofile']); + } finally { + restore(); + } + + // Write a fake auth.json to simulate logged-in state + const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'forceprofile'); + const authJsonPath = path.join(profileDir, 'auth.json'); + fs.writeFileSync(authJsonPath, JSON.stringify({ tokens: {} })); + + const restore2 = silenceConsole(); + try { + await handleCreateCodex(ctx, ['forceprofile', '--force']); + } finally { + restore2(); + } + + // auth.json must still exist (D9: preserve, re-link only) + expect(fs.existsSync(authJsonPath)).toBe(true); + }); +}); + +describe('handleCreateCodex — validation', () => { + it('refuses reserved name "default"', async () => { + const detectorMod = await import('../../../../src/targets/codex-detector'); + spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null); + + const { handleCreateCodex } = await import( + '../../../../src/codex-auth/commands/create-command' + ); + const ctx = await makeCtx(); + + let exitCalled = false; + const origExit = process.exit; + process.exit = (code?: number) => { + exitCalled = true; + void code; + throw new Error(`process.exit(${code})`); + }; + const restore = silenceConsole(); + try { + await handleCreateCodex(ctx, ['default']); + } catch (e) { + // expected — process.exit throws + expect(String(e)).toContain('process.exit'); + } finally { + restore(); + process.exit = origExit; + } + + expect(ctx.registry.hasProfile('default')).toBe(false); + }); + + it('refuses name with path separator', async () => { + const detectorMod = await import('../../../../src/targets/codex-detector'); + spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null); + + const { handleCreateCodex } = await import( + '../../../../src/codex-auth/commands/create-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 handleCreateCodex(ctx, ['foo/bar']); + } catch { + /* expected */ + } finally { + restore(); + process.exit = origExit; + } + + expect(exitCalled).toBe(true); + expect(ctx.registry.hasProfile('foo/bar')).toBe(false); + }); + + it('refuses empty name', async () => { + const detectorMod = await import('../../../../src/targets/codex-detector'); + spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null); + + const { handleCreateCodex } = await import( + '../../../../src/codex-auth/commands/create-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 handleCreateCodex(ctx, []); + } catch { + /* expected */ + } finally { + restore(); + process.exit = origExit; + } + + expect(exitCalled).toBe(true); + }); +}); + +describe('handleCreateCodex — auto-spawn login (D11)', () => { + it('invokes spawn with CODEX_HOME set to profile dir', async () => { + const detectorMod = await import('../../../../src/targets/codex-detector'); + spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex'); + + // Mock spawn to emit exit(0) and write auth.json + 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: 'h.e30K.s' } }) + ); + } + const ee = { + on: (evt: string, cb: (n: number) => void) => { + if (evt === 'exit') setImmediate(() => cb(0)); + return ee; + }, + }; + return ee as ReturnType; + } + ); + + const { handleCreateCodex } = await import( + '../../../../src/codex-auth/commands/create-command' + ); + const ctx = await makeCtx(); + + const restore = silenceConsole(); + try { + await handleCreateCodex(ctx, ['logintest']); + } finally { + restore(); + } + + expect(childProcess.spawn).toHaveBeenCalled(); + const spawnArgs = (childProcess.spawn as ReturnType).mock.calls[0]; + expect(String(spawnArgs[2]?.env?.CODEX_HOME)).toContain('logintest'); + }); + + it('login failure leaves profile dir created (retry-able)', 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 ee = { + on: (evt: string, cb: (n: number) => void) => { + if (evt === 'exit') setImmediate(() => cb(1)); + return ee; + }, + }; + return ee as ReturnType; + } + ); + + const { handleCreateCodex } = await import( + '../../../../src/codex-auth/commands/create-command' + ); + const ctx = await makeCtx(); + + const restore = silenceConsole(); + try { + await handleCreateCodex(ctx, ['faillogin']); + } finally { + restore(); + } + + const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'faillogin'); + expect(fs.existsSync(profileDir)).toBe(true); + expect(ctx.registry.hasProfile('faillogin')).toBe(true); + }); +}); diff --git a/tests/unit/codex-auth/commands/login-command.test.ts b/tests/unit/codex-auth/commands/login-command.test.ts new file mode 100644 index 00000000..7b7559ea --- /dev/null +++ b/tests/unit/codex-auth/commands/login-command.test.ts @@ -0,0 +1,152 @@ +/** + * Tests for codex-auth login command. + */ +import { afterEach, beforeEach, describe, expect, it, spyOn, mock } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as childProcess from 'child_process'; + +let tempDir: string; +let ccsHome: string; +const ORIG_CCS_HOME = process.env.CCS_HOME; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-login-test-')); + ccsHome = path.join(tempDir, 'ccs'); + fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true }); + process.env.CCS_HOME = ccsHome; +}); + +afterEach(() => { + if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = ORIG_CCS_HOME; + fs.rmSync(tempDir, { recursive: true, force: true }); + mock.restore(); +}); + +async function makeCtx() { + const { CodexProfileRegistry } = await import( + '../../../../src/codex-auth/codex-profile-registry' + ); + return { registry: new CodexProfileRegistry(), version: '0.0.0-test' }; +} + +function spawnReturnsCode(code: number, writeAuth = false) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + spyOn(childProcess, 'spawn').mockImplementation((_cmd: string, _args: string[], opts: any) => { + if (writeAuth && opts?.env?.CODEX_HOME) { + const dir = opts.env.CODEX_HOME as string; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'auth.json'), + JSON.stringify({ tokens: { id_token: 'h.e30K.s' } }) + ); + } + const ee = { + on: (evt: string, cb: (code: number) => void) => { + if (evt === 'exit') setTimeout(() => cb(code), 0); + return ee; + }, + }; + return ee as ReturnType; + }); +} + +describe('handleLoginCodex — binary missing', () => { + it('exits with BINARY_ERROR when codex not found', async () => { + const detectorMod = await import('../../../../src/targets/codex-detector'); + spyOn(detectorMod, 'detectCodexCli').mockReturnValue(null); + const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command'); + const ctx = await makeCtx(); + + let exitCode = -1; + const origExit = process.exit; + process.exit = (code?: number) => { + exitCode = code ?? 0; + throw new Error('process.exit'); + }; + try { + await handleLoginCodex(ctx, ['myprofile']); + } catch { + /* process.exit throws */ + } finally { + process.exit = origExit; + } + expect(exitCode).toBe(5); // ExitCode.BINARY_ERROR + }); +}); + +describe('handleLoginCodex — missing profile auto-creates', () => { + it('auto-creates profile entry when not in registry', async () => { + const detectorMod = await import('../../../../src/targets/codex-detector'); + spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex'); + spawnReturnsCode(0, true); + + const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command'); + const ctx = await makeCtx(); + + const out: string[] = []; + const origLog = console.log; + console.log = (...a: unknown[]) => out.push(a.join(' ')); + try { + await handleLoginCodex(ctx, ['newprofile']); + } finally { + console.log = origLog; + } + + expect(ctx.registry.hasProfile('newprofile')).toBe(true); + expect(out.some((l) => l.includes('Auto-creating'))).toBe(true); + }); +}); + +describe('handleLoginCodex — spawn called with CODEX_HOME pinned', () => { + it('passes CODEX_HOME env to spawn', async () => { + const detectorMod = await import('../../../../src/targets/codex-detector'); + spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex'); + spawnReturnsCode(0, true); + + const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command'); + const ctx = await makeCtx(); + ctx.registry.createProfile('pintest', { created: new Date().toISOString(), last_used: null }); + + const origLog = console.log; + console.log = () => {}; + try { + await handleLoginCodex(ctx, ['pintest']); + } finally { + console.log = origLog; + } + + expect(childProcess.spawn).toHaveBeenCalled(); + const call = (childProcess.spawn as ReturnType).mock.calls[0]; + expect(call[2]?.env?.CODEX_HOME).toContain('pintest'); + }); +}); + +describe('handleLoginCodex — clean exit updates registry', () => { + it('updates email/plan in registry after successful login', async () => { + const detectorMod = await import('../../../../src/targets/codex-detector'); + spyOn(detectorMod, 'detectCodexCli').mockReturnValue('/usr/bin/codex'); + spawnReturnsCode(0, true); // writes auth.json with minimal JWT + + const { handleLoginCodex } = await import('../../../../src/codex-auth/commands/login-command'); + const ctx = await makeCtx(); + ctx.registry.createProfile('updatetest', { + created: new Date().toISOString(), + last_used: null, + }); + + const origLog = console.log; + console.log = () => {}; + try { + await handleLoginCodex(ctx, ['updatetest']); + } finally { + console.log = origLog; + } + + const meta = ctx.registry.getProfile('updatetest'); + // last_used should now be set + expect(meta.last_used).toBeTruthy(); + }); +}); diff --git a/tests/unit/codex-auth/commands/remove-command.test.ts b/tests/unit/codex-auth/commands/remove-command.test.ts new file mode 100644 index 00000000..3c07022f --- /dev/null +++ b/tests/unit/codex-auth/commands/remove-command.test.ts @@ -0,0 +1,194 @@ +/** + * Tests for codex-auth remove command. + */ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +let tempDir: string; +let ccsHome: string; +const ORIG_CCS_HOME = process.env.CCS_HOME; +const ORIG_CCS_CODEX_PROFILE = process.env.CCS_CODEX_PROFILE; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-remove-test-')); + ccsHome = path.join(tempDir, 'ccs'); + fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true }); + process.env.CCS_HOME = ccsHome; + delete process.env.CCS_CODEX_PROFILE; +}); + +afterEach(() => { + if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = ORIG_CCS_HOME; + if (ORIG_CCS_CODEX_PROFILE === undefined) delete process.env.CCS_CODEX_PROFILE; + else process.env.CCS_CODEX_PROFILE = ORIG_CCS_CODEX_PROFILE; + fs.rmSync(tempDir, { recursive: true, force: true }); + mock.restore(); +}); + +async function makeCtx(...names: string[]) { + const { CodexProfileRegistry } = await import( + '../../../../src/codex-auth/codex-profile-registry' + ); + const reg = new CodexProfileRegistry(); + for (const n of names) { + reg.createProfile(n, { created: new Date().toISOString(), last_used: null }); + // Create the profile dir too + const dir = path.join(ccsHome, '.ccs', 'codex-instances', n); + fs.mkdirSync(dir, { recursive: true }); + } + return { registry: reg, version: '0.0.0-test' }; +} + +function mockConfirmYes() { + return import('../../../../src/utils/prompt').then((mod) => { + spyOn(mod.InteractivePrompt, 'confirm').mockResolvedValue(true); + }); +} + +function mockConfirmNo() { + return import('../../../../src/utils/prompt').then((mod) => { + spyOn(mod.InteractivePrompt, 'confirm').mockResolvedValue(false); + }); +} + +// ── non-default removes cleanly ─────────────────────────────────────────────── + +describe('handleRemoveCodex — normal removal', () => { + it('removes a non-default profile cleanly', async () => { + await mockConfirmYes(); + const { handleRemoveCodex } = await import( + '../../../../src/codex-auth/commands/remove-command' + ); + const ctx = await makeCtx('alpha', 'beta'); + ctx.registry.setDefault('alpha'); + + const out: string[] = []; + const origLog = console.log; + console.log = (...a: unknown[]) => out.push(a.join(' ')); + try { + await handleRemoveCodex(ctx, ['beta', '--yes']); + } finally { + console.log = origLog; + } + + expect(ctx.registry.hasProfile('beta')).toBe(false); + expect(out.some((l) => l.includes('removed'))).toBe(true); + }); +}); + +// ── default with others → refuses without --force ──────────────────────────── + +describe('handleRemoveCodex — default guard', () => { + it('refuses to remove default when others exist without --force', async () => { + const { handleRemoveCodex } = await import( + '../../../../src/codex-auth/commands/remove-command' + ); + const ctx = await makeCtx('alpha', 'beta'); + ctx.registry.setDefault('alpha'); + + let exitCode = -1; + const origExit = process.exit; + process.exit = (code?: number) => { + exitCode = code ?? 0; + throw new Error('exit'); + }; + + const out: string[] = []; + const origLog = console.log; + console.log = (...a: unknown[]) => out.push(a.join(' ')); + try { + await handleRemoveCodex(ctx, ['alpha']); + } catch { + /* process.exit */ + } finally { + process.exit = origExit; + console.log = origLog; + } + + expect(exitCode).toBeGreaterThan(0); + expect(ctx.registry.hasProfile('alpha')).toBe(true); // not removed + // Hint lines still go to stdout; user-facing error now goes to stderr via exitWithError + expect(out.some((l) => l.includes('ccsx auth switch'))).toBe(true); + }); + + it('allows removal of default with --force', async () => { + await mockConfirmYes(); + const { handleRemoveCodex } = await import( + '../../../../src/codex-auth/commands/remove-command' + ); + const ctx = await makeCtx('alpha', 'beta'); + ctx.registry.setDefault('alpha'); + + await handleRemoveCodex(ctx, ['alpha', '--force', '--yes']); + expect(ctx.registry.hasProfile('alpha')).toBe(false); + }); +}); + +// ── only profile → allows removal ──────────────────────────────────────────── + +describe('handleRemoveCodex — only profile', () => { + it('allows removal of the only profile (even if default)', async () => { + await mockConfirmYes(); + const { handleRemoveCodex } = await import( + '../../../../src/codex-auth/commands/remove-command' + ); + const ctx = await makeCtx('solo'); + ctx.registry.setDefault('solo'); + + await handleRemoveCodex(ctx, ['solo', '--yes']); + expect(ctx.registry.hasProfile('solo')).toBe(false); + }); +}); + +// ── confirmation prompt ─────────────────────────────────────────────────────── + +describe('handleRemoveCodex — confirmation', () => { + it('cancels when user declines confirmation', async () => { + await mockConfirmNo(); + const { handleRemoveCodex } = await import( + '../../../../src/codex-auth/commands/remove-command' + ); + const ctx = await makeCtx('keepme'); + + const out: string[] = []; + const origLog = console.log; + console.log = (...a: unknown[]) => out.push(a.join(' ')); + try { + await handleRemoveCodex(ctx, ['keepme']); // no --yes + } finally { + console.log = origLog; + } + + expect(ctx.registry.hasProfile('keepme')).toBe(true); // not removed + expect(out.some((l) => l.includes('Cancelled'))).toBe(true); + }); + + it('--yes skips prompt entirely', async () => { + // No mock — if prompt were called it would hang/throw in test + const promptMod = await import('../../../../src/utils/prompt'); + let promptCalled = false; + spyOn(promptMod.InteractivePrompt, 'confirm').mockImplementation(async () => { + promptCalled = true; + return true; + }); + + const { handleRemoveCodex } = await import( + '../../../../src/codex-auth/commands/remove-command' + ); + const ctx = await makeCtx('skipconfirm'); + + const origLog = console.log; + console.log = () => {}; + try { + await handleRemoveCodex(ctx, ['skipconfirm', '--yes']); + } finally { + console.log = origLog; + } + + expect(promptCalled).toBe(false); + expect(ctx.registry.hasProfile('skipconfirm')).toBe(false); + }); +}); diff --git a/tests/unit/codex-auth/commands/show-command.test.ts b/tests/unit/codex-auth/commands/show-command.test.ts new file mode 100644 index 00000000..d8e5bf02 --- /dev/null +++ b/tests/unit/codex-auth/commands/show-command.test.ts @@ -0,0 +1,137 @@ +/** + * Tests for codex-auth show command. + */ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +let tempDir: string; +let ccsHome: string; +const ORIG_CCS_HOME = process.env.CCS_HOME; +const ORIG_CCS_CODEX_PROFILE = process.env.CCS_CODEX_PROFILE; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-show-test-')); + ccsHome = path.join(tempDir, 'ccs'); + fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true }); + process.env.CCS_HOME = ccsHome; + delete process.env.CCS_CODEX_PROFILE; +}); + +afterEach(() => { + if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = ORIG_CCS_HOME; + if (ORIG_CCS_CODEX_PROFILE === undefined) delete process.env.CCS_CODEX_PROFILE; + else process.env.CCS_CODEX_PROFILE = ORIG_CCS_CODEX_PROFILE; + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +async function makeCtx(...names: string[]) { + const { CodexProfileRegistry } = await import( + '../../../../src/codex-auth/codex-profile-registry' + ); + const reg = new CodexProfileRegistry(); + for (const n of names) { + reg.createProfile(n, { created: new Date().toISOString(), last_used: null }); + } + return { registry: reg, version: '0.0.0-test' }; +} + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const origLog = console.log; + const origWrite = process.stdout.write.bind(process.stdout); + console.log = (...a: unknown[]) => chunks.push(a.map(String).join(' ') + '\n'); + process.stdout.write = (chunk: string | Uint8Array) => { + chunks.push(String(chunk)); + return true; + }; + try { + await fn(); + } finally { + console.log = origLog; + process.stdout.write = origWrite; + } + return chunks.join(''); +} + +// ── empty list ──────────────────────────────────────────────────────────────── + +describe('handleShowCodex — empty list', () => { + it('shows "No Codex profiles" message when registry is empty', async () => { + const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command'); + const ctx = await makeCtx(); + const out = await captureStdout(() => handleShowCodex(ctx, [])); + expect(out).toContain('No Codex profiles'); + expect(out).toContain('ccsx auth create'); + }); +}); + +// ── list with default marker ────────────────────────────────────────────────── + +describe('handleShowCodex — default marker', () => { + it('marks default profile in STATE column', async () => { + const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command'); + const ctx = await makeCtx('alpha', 'beta'); + ctx.registry.setDefault('alpha'); + + const out = await captureStdout(() => handleShowCodex(ctx, [])); + expect(out).toContain('alpha'); + expect(out).toContain('default'); + }); +}); + +// ── active(missing) row at top (D14) ───────────────────────────────────────── + +describe('handleShowCodex — active(missing) at top', () => { + it('shows active(missing) row at top when CCS_CODEX_PROFILE points to deleted profile', async () => { + const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command'); + const ctx = await makeCtx('realprofile'); + process.env.CCS_CODEX_PROFILE = 'deletedprofile'; // not in registry + + const out = await captureStdout(() => handleShowCodex(ctx, [])); + expect(out).toContain('active(missing)'); + // Table truncates long names — match on prefix + expect(out).toContain('deletedprof'); + // active(missing) row appears before realprofile in the table + const missingIdx = out.indexOf('deletedprof'); + const realIdx = out.indexOf('realprofile'); + expect(missingIdx).toBeLessThan(realIdx); + }); +}); + +// ── detail view ─────────────────────────────────────────────────────────────── + +describe('handleShowCodex — detail view', () => { + it('shows detail for named profile', async () => { + const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command'); + const ctx = await makeCtx('myprofile'); + const out = await captureStdout(() => handleShowCodex(ctx, ['myprofile'])); + expect(out).toContain('myprofile'); + expect(out).toContain('auth.json'); + expect(out).toContain('missing'); // auth.json not present + }); + + it('detail view shows for email when auth.json missing', async () => { + const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command'); + const ctx = await makeCtx('noauth'); + const out = await captureStdout(() => handleShowCodex(ctx, ['noauth'])); + expect(out).toContain(''); + }); + + it('does not crash with malformed auth.json', async () => { + const { handleShowCodex } = await import('../../../../src/codex-auth/commands/show-command'); + const ctx = await makeCtx('malformed'); + + // Write malformed auth.json + const profileDir = path.join(ccsHome, '.ccs', 'codex-instances', 'malformed'); + fs.mkdirSync(profileDir, { recursive: true }); + fs.writeFileSync(path.join(profileDir, 'auth.json'), 'NOT_JSON{{{'); + + const out = await captureStdout(() => handleShowCodex(ctx, ['malformed'])); + // Should show present but not crash; email shows or + expect(out).toContain('present'); + expect(out).not.toContain('Error'); + }); +}); diff --git a/tests/unit/codex-auth/commands/switch-command.test.ts b/tests/unit/codex-auth/commands/switch-command.test.ts new file mode 100644 index 00000000..a02908f6 --- /dev/null +++ b/tests/unit/codex-auth/commands/switch-command.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for codex-auth switch command. + */ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +let tempDir: string; +let ccsHome: string; +const ORIG_CCS_HOME = process.env.CCS_HOME; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-switch-test-')); + ccsHome = path.join(tempDir, 'ccs'); + fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true }); + process.env.CCS_HOME = ccsHome; +}); + +afterEach(() => { + if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = ORIG_CCS_HOME; + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +async function makeCtxWithProfiles(...names: string[]) { + const { CodexProfileRegistry } = await import( + '../../../../src/codex-auth/codex-profile-registry' + ); + const reg = new CodexProfileRegistry(); + for (const n of names) { + reg.createProfile(n, { created: new Date().toISOString(), last_used: null }); + } + return { registry: reg, version: '0.0.0-test' }; +} + +describe('handleSwitchCodex — sets default', () => { + it('switches default to named profile', async () => { + const { handleSwitchCodex } = await import( + '../../../../src/codex-auth/commands/switch-command' + ); + const ctx = await makeCtxWithProfiles('alpha', 'beta'); + + const out: string[] = []; + const origLog = console.log; + console.log = (...a: unknown[]) => out.push(a.join(' ')); + try { + await handleSwitchCodex(ctx, ['beta']); + } finally { + console.log = origLog; + } + + expect(ctx.registry.getDefault()).toBe('beta'); + expect(out.some((l) => l.includes('beta'))).toBe(true); + }); +}); + +describe('handleSwitchCodex — unknown profile', () => { + it('exits non-zero for unknown profile name', async () => { + const { handleSwitchCodex } = await import( + '../../../../src/codex-auth/commands/switch-command' + ); + const ctx = await makeCtxWithProfiles('alpha'); + + let exitCode = -1; + const origExit = process.exit; + process.exit = (code?: number) => { + exitCode = code ?? 0; + throw new Error('exit'); + }; + try { + await handleSwitchCodex(ctx, ['doesnotexist']); + } catch { + /* expected */ + } finally { + process.exit = origExit; + } + expect(exitCode).toBeGreaterThan(0); + }); +}); + +describe('handleSwitchCodex — output format', () => { + it('includes [OK] in output on success', async () => { + const { handleSwitchCodex } = await import( + '../../../../src/codex-auth/commands/switch-command' + ); + const ctx = await makeCtxWithProfiles('myprofile'); + + const out: string[] = []; + const origLog = console.log; + console.log = (...a: unknown[]) => out.push(a.join(' ')); + try { + await handleSwitchCodex(ctx, ['myprofile']); + } finally { + console.log = origLog; + } + + const combined = out.join('\n'); + expect(combined).toContain('myprofile'); + // Should mention persistent default note + expect(combined).toContain('persistent default'); + }); +}); diff --git a/tests/unit/codex-auth/commands/use-command.test.ts b/tests/unit/codex-auth/commands/use-command.test.ts new file mode 100644 index 00000000..1be3fe23 --- /dev/null +++ b/tests/unit/codex-auth/commands/use-command.test.ts @@ -0,0 +1,209 @@ +/** + * Tests for codex-auth use command. + * + * CRITICAL: stdout discipline — stdout must contain ONLY shell-eval lines. + * All info/error/hint text must go to stderr. + */ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +let tempDir: string; +let ccsHome: string; +const ORIG_CCS_HOME = process.env.CCS_HOME; +const ORIG_NO_PRE_DISPATCH = process.env.CCS_NO_PRE_DISPATCH; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-use-test-')); + ccsHome = path.join(tempDir, 'ccs'); + fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true }); + process.env.CCS_HOME = ccsHome; + // Ensure guard is active for tests + process.env.CCS_NO_PRE_DISPATCH = '1'; +}); + +afterEach(() => { + if (ORIG_CCS_HOME === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = ORIG_CCS_HOME; + if (ORIG_NO_PRE_DISPATCH === undefined) delete process.env.CCS_NO_PRE_DISPATCH; + else process.env.CCS_NO_PRE_DISPATCH = ORIG_NO_PRE_DISPATCH; + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +async function makeCtxWithProfile(name: string) { + const { CodexProfileRegistry } = await import( + '../../../../src/codex-auth/codex-profile-registry' + ); + const reg = new CodexProfileRegistry(); + reg.createProfile(name, { created: new Date().toISOString(), last_used: null }); + return { registry: reg, version: '0.0.0-test' }; +} + +/** Capture stdout and stderr separately while calling fn. */ +async function captureStreams( + fn: () => Promise +): Promise<{ stdout: string; stderr: string }> { + const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; + const origOut = process.stdout.write.bind(process.stdout); + const origErr = process.stderr.write.bind(process.stderr); + process.stdout.write = (chunk: string | Uint8Array) => { + stdoutChunks.push(String(chunk)); + return true; + }; + process.stderr.write = (chunk: string | Uint8Array) => { + stderrChunks.push(String(chunk)); + return true; + }; + try { + await fn(); + } finally { + process.stdout.write = origOut; + process.stderr.write = origErr; + } + return { stdout: stdoutChunks.join(''), stderr: stderrChunks.join('') }; +} + +// ── stdout discipline (CRITICAL) ────────────────────────────────────────────── + +describe('handleUseCodex — stdout discipline', () => { + it('stdout contains ONLY export lines for bash', async () => { + const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command'); + const ctx = await makeCtxWithProfile('work'); + + const { stdout, stderr } = await captureStreams(() => + handleUseCodex(ctx, ['work', '--shell', 'bash']) + ); + + const lines = stdout.trim().split('\n').filter(Boolean); + // Every stdout line must be a shell export statement + for (const line of lines) { + expect(line).toMatch(/^export [A-Z_]+=|^set -gx |^\$env:|^set [A-Z_]+=|^export [A-Z]/); + } + // Hint must be on stderr only + expect(stderr).toContain('active in this shell'); + expect(stdout).not.toContain('[i]'); + expect(stdout).not.toContain('[X]'); + expect(stdout).not.toContain('[!]'); + expect(stdout).not.toContain('[OK]'); + }); + + it('stdout empty, non-zero exit for unknown profile', async () => { + const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command'); + const ctx = await makeCtxWithProfile('work'); + + let exitCode = -1; + const origExit = process.exit; + process.exit = (code?: number) => { + exitCode = code ?? 0; + throw new Error('process.exit'); + }; + + const { stdout, stderr } = await captureStreams(async () => { + try { + await handleUseCodex(ctx, ['nonexistent', '--shell', 'bash']); + } catch { + /* process.exit */ + } + }).finally(() => { + process.exit = origExit; + }); + + expect(stdout).toBe(''); + expect(exitCode).toBeGreaterThan(0); + expect(stderr).toContain('Profile not found'); + }); +}); + +// ── shell syntax variants ───────────────────────────────────────────────────── + +describe('handleUseCodex — shell syntax', () => { + it('bash: export KEY=value', async () => { + const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command'); + const ctx = await makeCtxWithProfile('work'); + const { stdout } = await captureStreams(() => handleUseCodex(ctx, ['work', '--shell', 'bash'])); + expect(stdout).toContain('export CODEX_HOME='); + expect(stdout).toContain("export CCS_CODEX_PROFILE='work'"); + }); + + it('fish: set -gx KEY value;', async () => { + const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command'); + const ctx = await makeCtxWithProfile('work'); + const { stdout } = await captureStreams(() => handleUseCodex(ctx, ['work', '--shell', 'fish'])); + expect(stdout).toContain('set -gx CODEX_HOME'); + expect(stdout).toContain("set -gx CCS_CODEX_PROFILE 'work';"); + }); + + it('pwsh: $env:KEY = value', async () => { + const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command'); + const ctx = await makeCtxWithProfile('work'); + const { stdout } = await captureStreams(() => handleUseCodex(ctx, ['work', '--shell', 'pwsh'])); + expect(stdout).toContain('$env:CODEX_HOME'); + expect(stdout).toContain('$env:CCS_CODEX_PROFILE'); + }); + + it('cmd: set KEY=value (no quotes)', async () => { + const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command'); + const ctx = await makeCtxWithProfile('work'); + const { stdout } = await captureStreams(() => handleUseCodex(ctx, ['work', '--shell', 'cmd'])); + expect(stdout).toContain('set CODEX_HOME='); + expect(stdout).toContain('set CCS_CODEX_PROFILE=work'); + }); + + it('invalid --shell value → stderr error, empty stdout', async () => { + const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command'); + const ctx = await makeCtxWithProfile('work'); + + let exitCode = -1; + const origExit = process.exit; + process.exit = (code?: number) => { + exitCode = code ?? 0; + throw new Error('exit'); + }; + + const { stdout, stderr } = await captureStreams(async () => { + try { + await handleUseCodex(ctx, ['work', '--shell', 'ksh']); + } catch { + /* process.exit */ + } + }).finally(() => { + process.exit = origExit; + }); + + expect(stdout).toBe(''); + expect(exitCode).toBeGreaterThan(0); + expect(stderr).toContain('Unsupported'); + }); +}); + +// ── missing profile → stderr only ──────────────────────────────────────────── + +describe('handleUseCodex — missing profile stderr only', () => { + it('missing profile name → stderr, empty stdout', async () => { + const { handleUseCodex } = await import('../../../../src/codex-auth/commands/use-command'); + const ctx = await makeCtxWithProfile('work'); + + let exitCode = -1; + const origExit = process.exit; + process.exit = (code?: number) => { + exitCode = code ?? 0; + throw new Error('exit'); + }; + + const { stdout, stderr } = await captureStreams(async () => { + try { + await handleUseCodex(ctx, []); + } catch { + /* process.exit */ + } + }).finally(() => { + process.exit = origExit; + }); + + expect(stdout).toBe(''); + expect(exitCode).toBeGreaterThan(0); + expect(stderr).toContain('required'); + }); +}); diff --git a/tests/unit/codex-auth/shell-detect.test.ts b/tests/unit/codex-auth/shell-detect.test.ts new file mode 100644 index 00000000..4f767079 --- /dev/null +++ b/tests/unit/codex-auth/shell-detect.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'bun:test'; +import { detectShell, formatExport } from '../../../src/codex-auth/shell-detect'; +import type { Shell } from '../../../src/codex-auth/shell-detect'; + +// ── detectShell ─────────────────────────────────────────────────────────────── + +describe('detectShell — Unix', () => { + it('returns bash for /bin/bash', () => { + expect(detectShell({ SHELL: '/bin/bash' }, 'linux')).toBe('bash'); + }); + + it('returns zsh for /usr/bin/zsh', () => { + expect(detectShell({ SHELL: '/usr/bin/zsh' }, 'darwin')).toBe('zsh'); + }); + + it('returns fish for /usr/local/bin/fish', () => { + expect(detectShell({ SHELL: '/usr/local/bin/fish' }, 'linux')).toBe('fish'); + }); + + it('returns bash for /bin/sh (generic POSIX)', () => { + expect(detectShell({ SHELL: '/bin/sh' }, 'linux')).toBe('bash'); + }); + + it('returns bash when SHELL is unset', () => { + expect(detectShell({}, 'linux')).toBe('bash'); + }); + + it('returns bash for /usr/local/bin/bash (Homebrew)', () => { + expect(detectShell({ SHELL: '/usr/local/bin/bash' }, 'darwin')).toBe('bash'); + }); +}); + +describe('detectShell — Windows', () => { + it('returns pwsh when PSModulePath is set', () => { + expect(detectShell({ PSModulePath: 'C:\\Windows\\system32\\...' }, 'win32')).toBe('pwsh'); + }); + + it('returns cmd when PSModulePath is absent', () => { + expect(detectShell({}, 'win32')).toBe('cmd'); + }); + + it('ignores SHELL on Windows — uses PSModulePath heuristic', () => { + expect(detectShell({ SHELL: '/bin/bash', PSModulePath: 'C:\\ps' }, 'win32')).toBe('pwsh'); + }); +}); + +// ── formatExport ────────────────────────────────────────────────────────────── + +describe('formatExport — bash', () => { + it('wraps value in single quotes', () => { + expect(formatExport('bash', 'CODEX_HOME', '/home/user/.ccs/codex-instances/work')).toBe( + "export CODEX_HOME='/home/user/.ccs/codex-instances/work'" + ); + }); + + it('escapes single quotes in value', () => { + const result = formatExport('bash', 'X', "it's"); + expect(result).toBe("export X='it'\\''s'"); + }); +}); + +describe('formatExport — zsh', () => { + it('uses same syntax as bash', () => { + expect(formatExport('zsh', 'CCS_CODEX_PROFILE', 'work')).toBe( + "export CCS_CODEX_PROFILE='work'" + ); + }); +}); + +describe('formatExport — fish', () => { + it('uses set -gx syntax with semicolon', () => { + expect(formatExport('fish', 'CODEX_HOME', '/path/to/dir')).toBe( + "set -gx CODEX_HOME '/path/to/dir';" + ); + }); + + it('escapes single quotes', () => { + const result = formatExport('fish', 'X', "a'b"); + expect(result).toContain('set -gx X'); + expect(result).toContain("'a'\\''b'"); + }); +}); + +describe('formatExport — pwsh', () => { + it('uses $env: assignment with double quotes', () => { + expect(formatExport('pwsh', 'CODEX_HOME', 'C:\\Users\\foo')).toBe( + '$env:CODEX_HOME = "C:\\Users\\foo"' + ); + }); + + it('doubles internal double quotes', () => { + const result = formatExport('pwsh', 'X', 'say "hello"'); + expect(result).toBe('$env:X = "say ""hello"""'); + }); +}); + +describe('formatExport — cmd', () => { + it('uses set KEY=VALUE syntax without quotes', () => { + expect(formatExport('cmd', 'CODEX_HOME', 'C:\\Users\\foo\\.ccs\\codex-instances\\work')).toBe( + 'set CODEX_HOME=C:\\Users\\foo\\.ccs\\codex-instances\\work' + ); + }); +}); + +describe('formatExport — each shell produces distinct syntax', () => { + const shells: Shell[] = ['bash', 'zsh', 'fish', 'pwsh', 'cmd']; + it('all shells produce different output for same input', () => { + const outputs = shells.map((s) => formatExport(s, 'K', 'val')); + const unique = new Set(outputs); + // fish and bash differ; pwsh and cmd differ; bash and zsh are identical by design + expect(unique.size).toBeGreaterThanOrEqual(4); + }); +});