diff --git a/eslint.config.js b/eslint.config.js index dfe648e3..6df9009f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -161,6 +161,7 @@ export default tseslint.config( "server/shared/utils.{js,ts}", "server/shared/frontmatter.ts", "server/shared/claude-cli-path.ts", + "server/shared/cli-runtime-env.ts", "server/shared/codex-cli-runtime.ts", ], // classify shared utility files so modules can depend on them explicitly mode: "file", diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts index 3d64ba66..9247fba4 100644 --- a/server/modules/websocket/services/shell-websocket.service.ts +++ b/server/modules/websocket/services/shell-websocket.service.ts @@ -5,7 +5,8 @@ import path from 'node:path'; import pty, { type IPty } from 'node-pty'; import { WebSocket, type RawData } from 'ws'; -import { createCodexRuntimeEnv, getCodexShellCommand } from '@/shared/codex-cli-runtime.js'; +import { createUserShellRuntimeEnv } from '@/shared/cli-runtime-env.js'; +import { getCodexShellCommand } from '@/shared/codex-cli-runtime.js'; import { parseIncomingJsonObject } from '@/shared/utils.js'; type ShellIncomingMessage = { @@ -286,14 +287,10 @@ export function handleShellConnection( os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand]; const termCols = readNumber(data.cols, 80); const termRows = readNumber(data.rows, 24); - const ptyEnv = - provider === 'codex' - ? createCodexRuntimeEnv() - : Object.fromEntries( - Object.entries(process.env).filter( - (entry): entry is [string, string] => entry[1] !== undefined - ) - ); + // Plain terminals inherit the server process PATH, which npm can prefix with + // /opt/claudecodeui/node_modules/.bin. Put user CLI bins first so shell + // commands resolve like the user's login shell instead of the app install. + const ptyEnv = createUserShellRuntimeEnv(); shellProcess = pty.spawn(shell, shellArgs, { name: 'xterm-256color', diff --git a/server/shared/cli-runtime-env.test.ts b/server/shared/cli-runtime-env.test.ts new file mode 100644 index 00000000..b637e164 --- /dev/null +++ b/server/shared/cli-runtime-env.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createUserShellRuntimeEnv } from '@/shared/cli-runtime-env.js'; + +const POSIX_PATH_DELIMITER = ':'; + +test('createUserShellRuntimeEnv prepends user CLI bins before app-local npm bins', () => { + const runtimeEnv = createUserShellRuntimeEnv( + { + NPM_CONFIG_PREFIX: '/home/devuser/.npm-global', + PATH: `/opt/claudecodeui/node_modules/.bin${POSIX_PATH_DELIMITER}/usr/bin`, + }, + { + homedir: () => '/home/devuser', + platform: 'linux', + } + ); + + assert.equal( + runtimeEnv.PATH, + [ + '/home/devuser/.npm-global/bin', + '/home/devuser/.local/bin', + '/opt/claudecodeui/node_modules/.bin', + '/usr/bin', + ].join(POSIX_PATH_DELIMITER) + ); +}); + +test('createUserShellRuntimeEnv does not duplicate existing user CLI path entries', () => { + const runtimeEnv = createUserShellRuntimeEnv( + { + PATH: [ + '/home/devuser/.npm-global/bin', + '/opt/claudecodeui/node_modules/.bin', + '/usr/bin', + ].join(POSIX_PATH_DELIMITER), + }, + { + homedir: () => '/home/devuser', + platform: 'linux', + } + ); + + assert.equal( + runtimeEnv.PATH, + [ + '/home/devuser/.local/bin', + '/home/devuser/.npm-global/bin', + '/opt/claudecodeui/node_modules/.bin', + '/usr/bin', + ].join(POSIX_PATH_DELIMITER) + ); +}); diff --git a/server/shared/cli-runtime-env.ts b/server/shared/cli-runtime-env.ts new file mode 100644 index 00000000..c07969d4 --- /dev/null +++ b/server/shared/cli-runtime-env.ts @@ -0,0 +1,109 @@ +import os from 'node:os'; +import path from 'node:path'; + +export type EnvRecord = Record; + +export type CliRuntimeEnvDependencies = { + env?: EnvRecord; + 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' ? ';' : ':'; +} + +/** + * 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'; +} + +/** + * Deduplicates non-empty string values while preserving their original order. + */ +function unique(values: string[]): string[] { + return Array.from(new Set(values.filter(Boolean))); +} + +/** + * Converts a process-style environment object into a string-only environment for child processes. + */ +export function toStringEnv(env: EnvRecord): Record { + return Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined) + ); +} + +/** + * Builds user/global CLI bin directories that should rank ahead of app-local npm bins. + */ +export function getPreferredUserCliBinDirectories( + dependencies: Required +): string[] { + const pathApi = getPathApi(dependencies.platform); + const homeDir = dependencies.homedir(); + const candidates: string[] = []; + const npmPrefix = dependencies.env.NPM_CONFIG_PREFIX?.trim(); + + if (npmPrefix) { + candidates.push(pathApi.join(npmPrefix, dependencies.platform === 'win32' ? '' : 'bin')); + } + + if (dependencies.platform === 'win32') { + const appData = dependencies.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); +} + +/** + * Creates a provider-neutral shell environment that prefers user/global CLI bins over app-local bins. + */ +export function createUserShellRuntimeEnv( + env: EnvRecord = process.env, + dependencies: CliRuntimeEnvDependencies = {} +): Record { + const deps: Required = { + env, + homedir: dependencies.homedir ?? os.homedir, + platform: dependencies.platform ?? process.platform, + }; + const pathKey = getPathEnvKey(env, deps.platform); + const delimiter = getPathDelimiter(deps.platform); + const currentPathEntries = (env[pathKey] ?? '').split(delimiter).filter(Boolean); + const preferredEntries = getPreferredUserCliBinDirectories(deps).filter( + (entry) => !currentPathEntries.includes(entry) + ); + const nextEnv: EnvRecord = { ...env }; + + if (preferredEntries.length > 0) { + nextEnv[pathKey] = [...preferredEntries, ...currentPathEntries].join(delimiter); + } + + return toStringEnv(nextEnv); +}