mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
feat(codex-auth): add ccsx auth CLI subcommands (create/login/switch/use/show/remove)
Implements the user-facing surface for ccsx auth profile management. After `ccsx auth create work` (auto-spawns codex login with CODEX_HOME pinned per D11), users can `eval "$(ccsx auth use work)"` in any shell to scope all subsequent codex invocations to that profile — letting two terminals run two different Codex accounts concurrently. - codex-auth-router: dispatches argv to subcommand handlers - create: idempotent, --force re-links config.toml preserving auth.json (D9), then auto-spawns codex login with CODEX_HOME pinned (D11); filesystem ops happen before registry write to avoid registry orphans on EACCES/ENOSPC - login: standalone re-auth for an existing profile - switch: persistent default in YAML registry - use: STDOUT-DISCIPLINED — emits only shell-evalable exports; bash/zsh/fish/PowerShell/cmd syntaxes via shell-detect; sets CCS_NO_PRE_DISPATCH=1 at module load to suppress recovery/migration banners that would otherwise contaminate eval (C2) - show: list (active(missing) row at top per D14) + detail views - remove: default-profile guard, active-shell warn, --yes / --force - ASCII-only output, NO_COLOR honored, all errors to stderr via exitWithError - pre-dispatch.ts: early-return when CCS_NO_PRE_DISPATCH=1, placed before autoMigrate which is itself a stdout writer 57 unit tests, all green. Help text cross-references the ccsxp/ccsx distinction since the binaries differ by one character (H5).
This commit is contained in:
@@ -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 <command> [options]
|
||||
|
||||
Commands
|
||||
create <name> Create a new Codex profile (idempotent)
|
||||
login <name> Run \`codex login\` against the profile (auto-creates if missing)
|
||||
switch <name> Set the persistent default Codex profile
|
||||
use <name> Emit shell-eval exports to activate a profile in this shell only
|
||||
show [name] List profiles or show details for one
|
||||
remove <name> Delete a profile (auth.json + profile dir + registry entry)
|
||||
import-default <name> 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 <s> 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 <name> [--shell <bash|zsh|fish|pwsh|cmd>]
|
||||
|
||||
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 <s> 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.
|
||||
`);
|
||||
}
|
||||
@@ -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 <subcommand> [...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<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
await initUI();
|
||||
const parsed = parseArgs(args);
|
||||
rejectUnsupportedOptions(parsed, 'ccsx auth create <name> [--force]');
|
||||
|
||||
const { profileName, force } = parsed;
|
||||
|
||||
if (!profileName) {
|
||||
console.log(`Usage: ccsx auth create <name> [--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<void> {
|
||||
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<void>((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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<void> {
|
||||
await initUI();
|
||||
const parsed = parseArgs(args);
|
||||
rejectUnsupportedOptions(parsed, 'ccsx auth login <name>');
|
||||
|
||||
const { profileName } = parsed;
|
||||
|
||||
if (!profileName) {
|
||||
console.log('Usage: ccsx auth login <name>');
|
||||
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<number>((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 ?? '<unknown>';
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
await initUI();
|
||||
const parsed = parseArgs(args);
|
||||
rejectUnsupportedOptions(parsed, 'ccsx auth remove <name> [--yes|-y] [--force]');
|
||||
|
||||
const { profileName, yes, force } = parsed;
|
||||
|
||||
if (!profileName) {
|
||||
console.log('Usage: ccsx auth remove <name> [--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 ?? '<unknown>'}`);
|
||||
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}`));
|
||||
}
|
||||
@@ -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<void> {
|
||||
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: '<unknown>',
|
||||
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 ?? '<unknown>';
|
||||
if (fs.existsSync(authJsonPath) && !meta.email) {
|
||||
const identity = decodeAccountIdentity(authJsonPath);
|
||||
email = identity.email ?? '<unknown>';
|
||||
}
|
||||
|
||||
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 === '<unknown>' ? 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 <name>');
|
||||
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.'));
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Detail view renderer for `ccsx auth show <name>`.
|
||||
* 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 ? '<invalid>' : '<unknown>')],
|
||||
['Plan', meta.plan_type ?? (authExists ? '<invalid>' : '<unknown>')],
|
||||
['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`);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
await initUI();
|
||||
const parsed = parseArgs(args);
|
||||
rejectUnsupportedOptions(parsed, 'ccsx auth switch <name>');
|
||||
|
||||
const { profileName } = parsed;
|
||||
|
||||
if (!profileName) {
|
||||
console.log('Usage: ccsx auth switch <name>');
|
||||
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(', ') : '<none>';
|
||||
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 <other>)"`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 <name>)"` 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<string>(['bash', 'zsh', 'fish', 'pwsh', 'cmd']);
|
||||
|
||||
export async function handleUseCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
|
||||
const parsed = parseArgs(args);
|
||||
rejectUnsupportedOptions(parsed, 'ccsx auth use <name> [--shell <bash|zsh|fish|pwsh|cmd>]');
|
||||
|
||||
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 <name> [--shell <bash|zsh|fish|pwsh|cmd>]\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(', ') : '<none>';
|
||||
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;
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,14 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise<b
|
||||
}
|
||||
}
|
||||
|
||||
// CCS_NO_PRE_DISPATCH guard — set by `ccsx auth use` to keep stdout clean
|
||||
// for shell eval. Must be checked BEFORE autoMigrate/recovery, both of which
|
||||
// write to stdout and would otherwise contaminate `eval "$(ccsx auth use <name>)"`.
|
||||
// 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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<typeof childProcess.spawn>;
|
||||
}
|
||||
);
|
||||
|
||||
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<typeof spyOn>).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<typeof childProcess.spawn>;
|
||||
}
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof childProcess.spawn>;
|
||||
});
|
||||
}
|
||||
|
||||
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<typeof spyOn>).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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>): Promise<string> {
|
||||
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 <unknown> 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('<unknown>');
|
||||
});
|
||||
|
||||
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 <invalid> or <unknown>
|
||||
expect(out).toContain('present');
|
||||
expect(out).not.toContain('Error');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<void>
|
||||
): 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user