Files
ccs/tests/unit/codex-auth/shell-detect.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

114 lines
4.0 KiB
TypeScript

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