mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 18:21:09 +00:00
feat(codex-auth): wire ccsx bin router + ccsxp scope notice
Upgrades the previously-stub ccsx binary entry (src/bin/codex-runtime.ts) into an argv router: `ccsx auth <cmd>` dispatches to the Phase 2 router; any other argv resolves the active codex-auth profile and spawns codex with CODEX_HOME pointed at the profile dir. - resolve-active-profile: sync, hot-path-safe (<5ms), reads YAML registry via Phase 1 helpers; precedence is CODEX_HOME (explicit) > CCS_CODEX_PROFILE (env) > registry default > null (legacy ~/.codex fallback); fails open on any error (silent for missing registry, stderr warn for corrupt/missing-profile) - codex-runtime-router: extracted main() for testability; entry script is a thin 3-line wrapper; returns -1 sentinel for the CCS branch so the spawn lifecycle isn't terminated - ccsxp-runtime: H5 defensive stderr notice when CCS_CODEX_PROFILE is set, surfacing the boundary between codex-auth (native codex) and ccsxp (cliproxy pool) without changing functional behavior; CLIProxyAPI does not read CODEX_HOME so no pool contamination possible - 14 unit tests (8 resolver + 6 router); ccsxp regression suite (5 tests) untouched and still green
This commit is contained in:
@@ -68,6 +68,16 @@ function resolveCcsxpCodexHome() {
|
||||
return path.join(os.homedir(), '.codex');
|
||||
}
|
||||
|
||||
// H5: CCS_CODEX_PROFILE is ignored by ccsxp. The ccsx auth profile system
|
||||
// (src/codex-auth/) is intentionally NOT consulted here — ccsxp serves the
|
||||
// cliproxy round-robin pool, not per-user-account profiles. Emit a one-line
|
||||
// notice so users who set CCS_CODEX_PROFILE in their shell don't get confused
|
||||
// when ccsxp silently ignores it and overwrites CODEX_HOME below.
|
||||
if (process.env.CCS_CODEX_PROFILE) {
|
||||
process.stderr.write(
|
||||
"[i] CCS_CODEX_PROFILE is ignored by ccsxp; profile applies to native 'codex' only.\n"
|
||||
);
|
||||
}
|
||||
process.env.CODEX_HOME = resolveCcsxpCodexHome();
|
||||
|
||||
// ccsxp is the Codex + cliproxy shortcut. Keep the native Codex history root,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Codex runtime router — testable logic for src/bin/codex-runtime.ts.
|
||||
*
|
||||
* All inter-module deps are resolved via require() at call-time so tests can
|
||||
* inject stubs via require.cache before calling main().
|
||||
*
|
||||
* Routing:
|
||||
* argv[2] === 'auth' → delegate to runCodexAuth(argv.slice(3)), exit with code
|
||||
* else → resolve active profile, set CODEX_HOME, load ccs
|
||||
* CCS manages the process lifecycle; entry MUST NOT
|
||||
* call process.exit() when main returns -1.
|
||||
*
|
||||
* Return value contract:
|
||||
* ≥ 0 → auth branch: caller should process.exit(code)
|
||||
* -1 → CCS branch: CCS has taken over the process; caller must NOT exit
|
||||
*/
|
||||
|
||||
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
|
||||
|
||||
/**
|
||||
* Main entry-point for the ccsx / codex-runtime binary.
|
||||
*
|
||||
* @param argv - process.argv (or test-supplied equivalent)
|
||||
* @returns ≥0 exit code for auth branch; -1 for CCS branch (no exit needed)
|
||||
*/
|
||||
export async function main(argv: string[]): Promise<number> {
|
||||
const subcommand = argv[2];
|
||||
|
||||
// ── auth branch ─────────────────────────────────────────────────────────
|
||||
if (subcommand === 'auth') {
|
||||
const { runCodexAuth } = require('../codex-auth/codex-auth-router') as {
|
||||
runCodexAuth: (args: string[]) => Promise<number>;
|
||||
};
|
||||
return runCodexAuth(argv.slice(3));
|
||||
}
|
||||
|
||||
// ── non-auth branch: profile resolution ─────────────────────────────────
|
||||
|
||||
// F1: respect an explicit CODEX_HOME — ccsxp, user export, CI override, etc.
|
||||
const explicit = (process.env.CODEX_HOME ?? '').trim();
|
||||
if (!explicit) {
|
||||
try {
|
||||
const { resolveActiveProfile } = require('../codex-auth/resolve-active-profile') as {
|
||||
resolveActiveProfile: (
|
||||
env: NodeJS.ProcessEnv
|
||||
) => { name: string; dir: string; source: string } | null;
|
||||
};
|
||||
const resolved = resolveActiveProfile(process.env);
|
||||
if (resolved) {
|
||||
process.env.CODEX_HOME = resolved.dir;
|
||||
|
||||
try {
|
||||
const { ensureSharedConfigSymlink } = require('../codex-auth/codex-config-symlink') as {
|
||||
ensureSharedConfigSymlink: (dir: string) => void;
|
||||
};
|
||||
ensureSharedConfigSymlink(resolved.dir);
|
||||
} catch (symlinkErr) {
|
||||
const msg = symlinkErr instanceof Error ? symlinkErr.message : String(symlinkErr);
|
||||
process.stderr.write(
|
||||
`[!] codex-auth: shared config symlink failed (${msg}), continuing\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (resolverErr) {
|
||||
// Resolver module threw unexpectedly — degrade silently to legacy mode
|
||||
const msg = resolverErr instanceof Error ? resolverErr.message : String(resolverErr);
|
||||
process.stderr.write(`[!] codex-auth: profile resolution skipped (${msg})\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── delegate to CCS ─────────────────────────────────────────────────────
|
||||
// require() is evaluated AFTER env mutations above. CCS manages its own
|
||||
// process lifecycle (spawns codex, pipes stdio, calls process.exit).
|
||||
// Return -1 so the entry script knows NOT to call process.exit().
|
||||
require('../ccs');
|
||||
|
||||
return -1; // CCS is in control — entry must not call process.exit()
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
|
||||
require('../ccs');
|
||||
import { main } from './codex-runtime-router';
|
||||
// -1 means CCS has taken over the process lifecycle; do not exit.
|
||||
main(process.argv).then((code) => {
|
||||
if (code >= 0) process.exit(code);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Synchronous hot-path resolver for the active codex auth profile. <5ms typical.
|
||||
* Precedence: CCS_CODEX_PROFILE env → registry.default → null (legacy ~/.codex).
|
||||
* Errors degrade gracefully — never throw.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { getCodexAuthRegistryPath, resolveCodexProfileDir } from './codex-profile-paths';
|
||||
|
||||
export interface ResolvedProfile {
|
||||
name: string;
|
||||
dir: string;
|
||||
source: 'env' | 'default';
|
||||
}
|
||||
|
||||
interface RegistryShape {
|
||||
version?: string;
|
||||
default?: string | null;
|
||||
profiles?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** @param env - Process env map; defaults to process.env. Injectable for tests. */
|
||||
export function resolveActiveProfile(env: NodeJS.ProcessEnv = process.env): ResolvedProfile | null {
|
||||
const registryPath = getCodexAuthRegistryPath();
|
||||
|
||||
// F4: silent fallback — no registry means no profiles, legacy mode
|
||||
if (!fs.existsSync(registryPath)) return null;
|
||||
|
||||
let registry: RegistryShape;
|
||||
try {
|
||||
const raw = fs.readFileSync(registryPath, 'utf8');
|
||||
const parsed = yaml.load(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
process.stderr.write(
|
||||
`[!] codex-auth: registry at ${registryPath} is not a valid YAML object, falling back to ~/.codex\n`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
registry = parsed as RegistryShape;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(
|
||||
`[!] codex-auth: registry YAML corrupt at ${registryPath} (${msg}), falling back to ~/.codex\n`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const profiles = registry.profiles ?? {};
|
||||
|
||||
// F2: explicit env override
|
||||
const envName = (env.CCS_CODEX_PROFILE ?? '').trim();
|
||||
if (envName) {
|
||||
if (!Object.prototype.hasOwnProperty.call(profiles, envName)) {
|
||||
process.stderr.write(
|
||||
`[!] codex-auth: CCS_CODEX_PROFILE='${envName}' not found in registry, falling back to ~/.codex\n`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: envName,
|
||||
dir: path.resolve(resolveCodexProfileDir(envName)),
|
||||
source: 'env',
|
||||
};
|
||||
}
|
||||
|
||||
// F3: registry default
|
||||
const defaultName = registry.default ?? null;
|
||||
if (defaultName && Object.prototype.hasOwnProperty.call(profiles, defaultName)) {
|
||||
return {
|
||||
name: defaultName,
|
||||
dir: path.resolve(resolveCodexProfileDir(defaultName)),
|
||||
source: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
// F4: no profile configured
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Tests for the codex-runtime router module (src/bin/codex-runtime-router.ts).
|
||||
*
|
||||
* Strategy: mirrors ccsxp-runtime.test.ts — invalidate require.cache before
|
||||
* each test so the module re-evaluates with updated stubs and env state.
|
||||
* Stub runCodexAuth and require('../ccs') via require.cache injection.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
const routerPath = require.resolve('../../../src/bin/codex-runtime-router.ts');
|
||||
const ccsPath = require.resolve('../../../src/ccs.ts');
|
||||
const codexAuthRouterPath = require.resolve('../../../src/codex-auth/codex-auth-router.ts');
|
||||
const resolveProfilePath = require.resolve('../../../src/codex-auth/resolve-active-profile.ts');
|
||||
const symlinkPath = require.resolve('../../../src/codex-auth/codex-config-symlink.ts');
|
||||
|
||||
const ORIGINAL_CCS_HOME = process.env.CCS_HOME;
|
||||
const ORIGINAL_CODEX_HOME = process.env.CODEX_HOME;
|
||||
const ORIGINAL_CCS_CODEX_PROFILE = process.env.CCS_CODEX_PROFILE;
|
||||
|
||||
let tempDir: string;
|
||||
let ccsHome: string;
|
||||
let registryPath: string;
|
||||
let instancesDir: string;
|
||||
|
||||
function flushRouterCache() {
|
||||
delete require.cache[routerPath];
|
||||
delete require.cache[codexAuthRouterPath];
|
||||
delete require.cache[resolveProfilePath];
|
||||
delete require.cache[symlinkPath];
|
||||
// Keep ccsPath stub intact — tests inject it explicitly each time
|
||||
}
|
||||
|
||||
function writeRegistry(data: object): void {
|
||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||
fs.writeFileSync(registryPath, yaml.dump(data, { indent: 2 }), { mode: 0o600 });
|
||||
}
|
||||
|
||||
function makeProfileDir(name: string): string {
|
||||
const dir = path.join(instancesDir, name);
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
return dir;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-router-test-'));
|
||||
ccsHome = path.join(tempDir, 'ccs');
|
||||
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true, mode: 0o700 });
|
||||
process.env.CCS_HOME = ccsHome;
|
||||
registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
|
||||
instancesDir = path.join(ccsHome, '.ccs', 'codex-instances');
|
||||
delete process.env.CODEX_HOME;
|
||||
delete process.env.CCS_CODEX_PROFILE;
|
||||
flushRouterCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_CCS_HOME === undefined) delete process.env.CCS_HOME;
|
||||
else process.env.CCS_HOME = ORIGINAL_CCS_HOME;
|
||||
|
||||
if (ORIGINAL_CODEX_HOME === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = ORIGINAL_CODEX_HOME;
|
||||
|
||||
if (ORIGINAL_CCS_CODEX_PROFILE === undefined) delete process.env.CCS_CODEX_PROFILE;
|
||||
else process.env.CCS_CODEX_PROFILE = ORIGINAL_CCS_CODEX_PROFILE;
|
||||
|
||||
flushRouterCache();
|
||||
delete require.cache[ccsPath];
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Auth routing ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('codex-runtime router — auth subcommand routing', () => {
|
||||
it('routes argv[2]===auth to runCodexAuth with remaining args and returns its exit code', async () => {
|
||||
let capturedArgs: string[] | undefined;
|
||||
|
||||
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
|
||||
require.cache[codexAuthRouterPath] = {
|
||||
exports: {
|
||||
runCodexAuth: async (args: string[]) => {
|
||||
capturedArgs = args;
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
} as NodeJS.Module;
|
||||
|
||||
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
|
||||
const code = await main(['node', 'codex-runtime', 'auth', 'create', 'work']);
|
||||
|
||||
expect(capturedArgs).toEqual(['create', 'work']);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
it('returns non-zero exit code propagated from runCodexAuth', async () => {
|
||||
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
|
||||
require.cache[codexAuthRouterPath] = {
|
||||
exports: { runCodexAuth: async (_args: string[]) => 1 },
|
||||
} as NodeJS.Module;
|
||||
|
||||
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
|
||||
const code = await main(['node', 'codex-runtime', 'auth', 'login']);
|
||||
|
||||
expect(code).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Non-auth profile resolution ───────────────────────────────────────────────
|
||||
|
||||
describe('codex-runtime router — non-auth profile resolution', () => {
|
||||
it('sets CODEX_HOME from active profile when CCS_CODEX_PROFILE env matches registry', async () => {
|
||||
const profileDir = makeProfileDir('work');
|
||||
writeRegistry({
|
||||
version: '1.0',
|
||||
default: null,
|
||||
profiles: { work: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null } },
|
||||
});
|
||||
process.env.CCS_CODEX_PROFILE = 'work';
|
||||
|
||||
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
|
||||
// Let real resolve-active-profile + codex-config-symlink run; stub ccs only
|
||||
flushRouterCache();
|
||||
delete require.cache[ccsPath]; // ensure fresh load guard doesn't skip
|
||||
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
|
||||
|
||||
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
|
||||
await main(['node', 'codex-runtime', 'chat']);
|
||||
|
||||
expect(process.env.CODEX_HOME).toBe(profileDir);
|
||||
});
|
||||
|
||||
it('leaves CODEX_HOME unset when no registry exists and no env profile set', async () => {
|
||||
// No registry file, no CCS_CODEX_PROFILE
|
||||
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
|
||||
|
||||
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
|
||||
const code = await main(['node', 'codex-runtime', 'chat']);
|
||||
|
||||
expect(process.env.CODEX_HOME).toBeUndefined();
|
||||
expect(code).toBe(-1); // CCS branch: entry must not call process.exit()
|
||||
});
|
||||
|
||||
it('preserves an explicit CODEX_HOME already in env — does not overwrite', async () => {
|
||||
const explicitHome = path.join(tempDir, 'explicit-codex-home');
|
||||
fs.mkdirSync(explicitHome, { recursive: true });
|
||||
process.env.CODEX_HOME = explicitHome;
|
||||
|
||||
// Even with a populated registry default, explicit wins
|
||||
makeProfileDir('other');
|
||||
writeRegistry({
|
||||
version: '1.0',
|
||||
default: 'other',
|
||||
profiles: { other: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null } },
|
||||
});
|
||||
|
||||
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
|
||||
|
||||
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
|
||||
await main(['node', 'codex-runtime', 'chat']);
|
||||
|
||||
expect(process.env.CODEX_HOME).toBe(explicitHome);
|
||||
});
|
||||
|
||||
it('sets CODEX_HOME from registry default when no CCS_CODEX_PROFILE is set', async () => {
|
||||
const profileDir = makeProfileDir('personal');
|
||||
writeRegistry({
|
||||
version: '1.0',
|
||||
default: 'personal',
|
||||
profiles: {
|
||||
personal: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null },
|
||||
},
|
||||
});
|
||||
// No CCS_CODEX_PROFILE set
|
||||
|
||||
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
|
||||
flushRouterCache();
|
||||
require.cache[ccsPath] = { exports: {} } as NodeJS.Module;
|
||||
|
||||
const { main } = require(routerPath) as { main: (argv: string[]) => Promise<number> };
|
||||
await main(['node', 'codex-runtime', '--version']);
|
||||
|
||||
expect(process.env.CODEX_HOME).toBe(profileDir);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
let resolveActiveProfile: (
|
||||
env?: NodeJS.ProcessEnv
|
||||
) => { name: string; dir: string; source: 'env' | 'default' } | null;
|
||||
|
||||
const ORIGINAL_CCS_HOME = process.env.CCS_HOME;
|
||||
|
||||
let tempDir: string;
|
||||
let ccsHome: string;
|
||||
let registryPath: string;
|
||||
let instancesDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-profile-test-'));
|
||||
ccsHome = path.join(tempDir, 'ccs');
|
||||
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true, mode: 0o700 });
|
||||
process.env.CCS_HOME = ccsHome;
|
||||
|
||||
registryPath = path.join(ccsHome, '.ccs', 'codex-profiles.yaml');
|
||||
instancesDir = path.join(ccsHome, '.ccs', 'codex-instances');
|
||||
|
||||
// Re-import after setting CCS_HOME so module picks up updated dir
|
||||
const mod = await import('../../../src/codex-auth/resolve-active-profile');
|
||||
resolveActiveProfile = mod.resolveActiveProfile;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_CCS_HOME === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = ORIGINAL_CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Helper to write a registry YAML fixture
|
||||
function writeRegistry(data: object): void {
|
||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||
fs.writeFileSync(registryPath, yaml.dump(data, { indent: 2 }), { mode: 0o600 });
|
||||
}
|
||||
|
||||
function makeProfileDir(name: string): string {
|
||||
const dir = path.join(instancesDir, name);
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('resolveActiveProfile', () => {
|
||||
it('returns null silently when registry file does not exist', () => {
|
||||
// No registry written
|
||||
const result = resolveActiveProfile({});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null and warns to stderr when registry YAML is corrupt', () => {
|
||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||
fs.writeFileSync(registryPath, '{ invalid yaml: [[[', { mode: 0o600 });
|
||||
|
||||
const stderrMessages: string[] = [];
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
const spy = spyOn(process.stderr, 'write').mockImplementation(
|
||||
(msg: string | Uint8Array, ...rest: unknown[]) => {
|
||||
stderrMessages.push(typeof msg === 'string' ? msg : String(msg));
|
||||
return origWrite(msg as string, ...(rest as Parameters<typeof origWrite>).slice(1));
|
||||
}
|
||||
);
|
||||
|
||||
const result = resolveActiveProfile({});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(stderrMessages.some((m) => m.includes('codex-auth'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns source=env when CCS_CODEX_PROFILE matches a registry entry', () => {
|
||||
const profileDir = makeProfileDir('work');
|
||||
writeRegistry({
|
||||
version: '1.0',
|
||||
default: null,
|
||||
profiles: {
|
||||
work: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null },
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveActiveProfile({ CCS_CODEX_PROFILE: 'work' });
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('work');
|
||||
expect(result?.source).toBe('env');
|
||||
expect(result?.dir).toBe(profileDir);
|
||||
});
|
||||
|
||||
it('returns source=default when no env var and registry has a default profile', () => {
|
||||
const profileDir = makeProfileDir('personal');
|
||||
writeRegistry({
|
||||
version: '1.0',
|
||||
default: 'personal',
|
||||
profiles: {
|
||||
personal: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null },
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveActiveProfile({});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('personal');
|
||||
expect(result?.source).toBe('default');
|
||||
expect(result?.dir).toBe(profileDir);
|
||||
});
|
||||
|
||||
it('env > default precedence: CCS_CODEX_PROFILE overrides registry default', () => {
|
||||
makeProfileDir('work');
|
||||
makeProfileDir('personal');
|
||||
writeRegistry({
|
||||
version: '1.0',
|
||||
default: 'personal',
|
||||
profiles: {
|
||||
work: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null },
|
||||
personal: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null },
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveActiveProfile({ CCS_CODEX_PROFILE: 'work' });
|
||||
|
||||
expect(result?.name).toBe('work');
|
||||
expect(result?.source).toBe('env');
|
||||
});
|
||||
|
||||
it('returns null and warns when CCS_CODEX_PROFILE names a profile not in registry', () => {
|
||||
writeRegistry({
|
||||
version: '1.0',
|
||||
default: null,
|
||||
profiles: {},
|
||||
});
|
||||
|
||||
const stderrMessages: string[] = [];
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
const spy = spyOn(process.stderr, 'write').mockImplementation(
|
||||
(msg: string | Uint8Array, ...rest: unknown[]) => {
|
||||
stderrMessages.push(typeof msg === 'string' ? msg : String(msg));
|
||||
return origWrite(msg as string, ...(rest as Parameters<typeof origWrite>).slice(1));
|
||||
}
|
||||
);
|
||||
|
||||
const result = resolveActiveProfile({ CCS_CODEX_PROFILE: 'ghost' });
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(stderrMessages.some((m) => m.includes('ghost'))).toBe(true);
|
||||
});
|
||||
|
||||
it('treats empty/whitespace-only CCS_CODEX_PROFILE as unset, falls back to default', () => {
|
||||
makeProfileDir('default-profile');
|
||||
writeRegistry({
|
||||
version: '1.0',
|
||||
default: 'default-profile',
|
||||
profiles: {
|
||||
'default-profile': {
|
||||
type: 'codex',
|
||||
created: '2026-01-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resultEmpty = resolveActiveProfile({ CCS_CODEX_PROFILE: '' });
|
||||
expect(resultEmpty?.name).toBe('default-profile');
|
||||
expect(resultEmpty?.source).toBe('default');
|
||||
|
||||
const resultWhitespace = resolveActiveProfile({ CCS_CODEX_PROFILE: ' ' });
|
||||
expect(resultWhitespace?.name).toBe('default-profile');
|
||||
expect(resultWhitespace?.source).toBe('default');
|
||||
});
|
||||
|
||||
it('resolves the profile dir to an absolute path', () => {
|
||||
makeProfileDir('absolute-test');
|
||||
writeRegistry({
|
||||
version: '1.0',
|
||||
default: 'absolute-test',
|
||||
profiles: {
|
||||
'absolute-test': {
|
||||
type: 'codex',
|
||||
created: '2026-01-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveActiveProfile({});
|
||||
|
||||
expect(result?.dir).toBe(path.resolve(result?.dir ?? ''));
|
||||
expect(path.isAbsolute(result?.dir ?? '')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user