mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-17 13:52:07 +08:00
fix: prefer user cli bins in shell terminals
This commit is contained in:
@@ -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',
|
||||
|
||||
55
server/shared/cli-runtime-env.test.ts
Normal file
55
server/shared/cli-runtime-env.test.ts
Normal file
@@ -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)
|
||||
);
|
||||
});
|
||||
109
server/shared/cli-runtime-env.ts
Normal file
109
server/shared/cli-runtime-env.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export type EnvRecord = Record<string, string | undefined>;
|
||||
|
||||
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<string, string> {
|
||||
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<CliRuntimeEnvDependencies>
|
||||
): 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<string, string> {
|
||||
const deps: Required<CliRuntimeEnvDependencies> = {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user