Files
Tam Nhu Tran bf92645b35 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).
2026-05-17 14:44:44 -04:00

104 lines
3.0 KiB
TypeScript

/**
* 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');
});
});