mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 20:17:03 +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:
1 parent
358b703d98
commit
bf92645b35
21 files changed
+2570
No files matched your search
@@ -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