Files
ccs/tests/unit/codex-auth/commands/create-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

310 lines
9.6 KiB
TypeScript

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