Files
ccs/tests/unit/codex-auth/commands/use-command.test.ts
T
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

210 lines
7.6 KiB
TypeScript

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