fix: prefer user cli bins in shell terminals

This commit is contained in:
Haileyesus
2026-06-15 14:52:01 +03:00
parent 85f5d0a174
commit 9c79b8fdfb
4 changed files with 171 additions and 9 deletions

View File

@@ -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",

View File

@@ -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',

View 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)
);
});

View 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);
}