Files
claudecodeui/server/shared/codex-cli-runtime.ts
2026-06-15 14:28:04 +03:00

289 lines
8.7 KiB
TypeScript

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const DEFAULT_CODEX_COMMAND = 'codex';
const CODEX_CLI_PATH_ENV_KEYS = ['CODEX_CLI_PATH', 'CLOUDCLI_CODEX_CLI_PATH'] as const;
/**
* Codex runtime precedence:
* 1. Explicit CODEX_CLI_PATH or CLOUDCLI_CODEX_CLI_PATH.
* 2. User/global installs such as NPM_CONFIG_PREFIX/bin, ~/.npm-global/bin, or ~/.local/bin.
* 3. Non-local PATH entries.
* 4. App-local node_modules/.bin as the final fallback.
*/
type EnvRecord = Record<string, string | undefined>;
export type ResolveCodexExecutablePathDependencies = {
env?: EnvRecord;
existsSync?: typeof fs.existsSync;
homedir?: typeof os.homedir;
platform?: NodeJS.Platform;
};
/**
* Returns the path implementation that matches the target runtime platform.
*/
function getPathApi(platform: NodeJS.Platform) {
return platform === 'win32' ? path.win32 : path.posix;
}
/**
* Returns the PATH delimiter used by the target runtime platform.
*/
function getPathDelimiter(platform: NodeJS.Platform): string {
return platform === 'win32' ? ';' : ':';
}
/**
* Removes one matching pair of surrounding quotes from a configured path value.
*/
function stripWrappingQuotes(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
/**
* Checks whether a command value looks like a filesystem path instead of a bare command name.
*/
function isPathLike(value: string, platform: NodeJS.Platform): boolean {
return value.includes('/') || value.includes('\\') || getPathApi(platform).isAbsolute(value);
}
/**
* Finds the environment key that represents PATH, preserving Windows case variants.
*/
function getPathEnvKey(env: EnvRecord, platform: NodeJS.Platform): string {
if (platform !== 'win32') {
return 'PATH';
}
return Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'Path';
}
/**
* Returns the Codex executable filenames to probe for the target platform.
*/
function getExecutableNames(platform: NodeJS.Platform): string[] {
if (platform !== 'win32') {
return [DEFAULT_CODEX_COMMAND];
}
return ['codex.exe', 'codex.cmd', 'codex.bat', 'codex.ps1', DEFAULT_CODEX_COMMAND];
}
/**
* Deduplicates non-empty string values while preserving their original order.
*/
function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean)));
}
/**
* Detects app-local npm bin directories so they can be treated as a fallback.
*/
function isNodeModulesBinPath(directoryPath: string, platform: NodeJS.Platform): boolean {
const pathApi = getPathApi(platform);
const normalized = directoryPath.replace(/[\\/]+$/, '');
return (
pathApi.basename(normalized).toLowerCase() === '.bin' &&
pathApi.basename(pathApi.dirname(normalized)).toLowerCase() === 'node_modules'
);
}
/**
* Resolves the first Codex executable that exists inside one directory.
*/
function resolveExecutableInDirectory(
directoryPath: string,
deps: Required<ResolveCodexExecutablePathDependencies>
): string | null {
const pathApi = getPathApi(deps.platform);
for (const executableName of getExecutableNames(deps.platform)) {
const candidate = pathApi.join(directoryPath, executableName);
if (deps.existsSync(candidate)) {
return candidate;
}
}
return null;
}
/**
* Reads an explicit Codex executable override from supported environment variables.
*/
function getConfiguredCodexPath(env: EnvRecord): string | null {
for (const key of CODEX_CLI_PATH_ENV_KEYS) {
const value = env[key]?.trim();
if (value) {
return stripWrappingQuotes(value);
}
}
return null;
}
/**
* Builds user/global Codex install candidates that rank ahead of PATH and app-local installs:
* NPM_CONFIG_PREFIX/bin, ~/.npm-global/bin, ~/.local/bin, or the Windows npm user folders.
*/
function getPreferredUserInstallCandidates(
deps: Required<ResolveCodexExecutablePathDependencies>
): string[] {
const pathApi = getPathApi(deps.platform);
const homeDir = deps.homedir();
const candidates: string[] = [];
const npmPrefix = deps.env.NPM_CONFIG_PREFIX?.trim();
if (npmPrefix) {
candidates.push(pathApi.join(npmPrefix, deps.platform === 'win32' ? '' : 'bin'));
}
if (deps.platform === 'win32') {
const appData = deps.env.APPDATA?.trim();
if (appData) {
candidates.push(appData, pathApi.join(appData, 'npm'));
}
candidates.push(pathApi.join(homeDir, 'AppData', 'Roaming', 'npm'));
} else {
candidates.push(
pathApi.join(homeDir, '.npm-global', 'bin'),
pathApi.join(homeDir, '.local', 'bin'),
);
}
return unique(candidates);
}
/**
* Searches PATH for Codex after user/global candidates, keeping node_modules/.bin as the last fallback.
*/
function resolveFromPath(
deps: Required<ResolveCodexExecutablePathDependencies>
): string | null {
const pathKey = getPathEnvKey(deps.env, deps.platform);
const pathValue = deps.env[pathKey] ?? '';
const directories = unique(pathValue.split(getPathDelimiter(deps.platform)).filter(Boolean));
let nodeModulesFallback: string | null = null;
for (const directory of directories) {
const candidate = resolveExecutableInDirectory(directory, deps);
if (!candidate) {
continue;
}
if (isNodeModulesBinPath(directory, deps.platform)) {
nodeModulesFallback ??= candidate;
continue;
}
return candidate;
}
return nodeModulesFallback;
}
/**
* Converts a process-style environment object into a string-only environment for child processes.
*/
function toStringEnv(env: EnvRecord): Record<string, string> {
return Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)
);
}
/**
* Resolves the Codex executable path for all backend entry points in this order:
* explicit CODEX_CLI_PATH/CLOUDCLI_CODEX_CLI_PATH, user/global installs, non-local PATH, app-local PATH.
*/
export function resolveCodexExecutablePath(
configuredPath: string | undefined = undefined,
dependencies: ResolveCodexExecutablePathDependencies = {}
): string {
const deps: Required<ResolveCodexExecutablePathDependencies> = {
env: dependencies.env ?? process.env,
existsSync: dependencies.existsSync ?? fs.existsSync,
homedir: dependencies.homedir ?? os.homedir,
platform: dependencies.platform ?? process.platform,
};
const normalizedConfiguredPath = stripWrappingQuotes(
configuredPath?.trim() || getConfiguredCodexPath(deps.env) || ''
);
if (normalizedConfiguredPath) {
if (!isPathLike(normalizedConfiguredPath, deps.platform)) {
return resolveFromPath(deps) ?? normalizedConfiguredPath;
}
return normalizedConfiguredPath;
}
for (const candidateDirectory of getPreferredUserInstallCandidates(deps)) {
const candidate = resolveExecutableInDirectory(candidateDirectory, deps);
if (candidate) {
return candidate;
}
}
return resolveFromPath(deps) ?? DEFAULT_CODEX_COMMAND;
}
/**
* Creates a Codex child-process environment with the selected runtime directory first on PATH,
* preserving the same source precedence as resolveCodexExecutablePath.
*/
export function createCodexRuntimeEnv(
env: EnvRecord = process.env,
dependencies: ResolveCodexExecutablePathDependencies = {}
): Record<string, string> {
const platform = dependencies.platform ?? process.platform;
const pathApi = getPathApi(platform);
const resolvedCodexPath = resolveCodexExecutablePath(undefined, {
...dependencies,
env,
platform,
});
const pathKey = getPathEnvKey(env, platform);
const currentPath = env[pathKey] ?? '';
const resolvedDirectory = isPathLike(resolvedCodexPath, platform)
? pathApi.dirname(resolvedCodexPath)
: '';
const nextEnv: EnvRecord = { ...env };
if (resolvedDirectory) {
const delimiter = getPathDelimiter(platform);
const pathEntries = currentPath.split(delimiter).filter(Boolean);
if (!pathEntries.includes(resolvedDirectory)) {
nextEnv[pathKey] = currentPath
? `${resolvedDirectory}${delimiter}${currentPath}`
: resolvedDirectory;
}
}
return toStringEnv(nextEnv);
}
/**
* Returns the shell-safe Codex command used by interactive PTY launches.
*/
export function getCodexShellCommand(
dependencies: ResolveCodexExecutablePathDependencies = {}
): string {
const platform = dependencies.platform ?? process.platform;
const resolvedCodexPath = resolveCodexExecutablePath(undefined, dependencies);
if (!isPathLike(resolvedCodexPath, platform)) {
return resolvedCodexPath;
}
if (platform === 'win32') {
return `& '${resolvedCodexPath.replace(/'/g, "''")}'`;
}
return `'${resolvedCodexPath.replace(/'/g, "'\\''")}'`;
}