mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-02-16 11:23:09 +08:00
Validate args parameter in getExecCommand() against SAFE_ARGS_REGEX to prevent command injection when returned string is passed to a shell. Escape regex metacharacters in getCommandPattern() generic action branch to prevent malformed patterns and unintended matching. Clean up stdin listeners in readStdinJson() timeout path to prevent process hanging.
429 lines
12 KiB
JavaScript
429 lines
12 KiB
JavaScript
/**
|
|
* Package Manager Detection and Selection
|
|
* Automatically detects the preferred package manager or lets user choose
|
|
*
|
|
* Supports: npm, pnpm, yarn, bun
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { commandExists, getClaudeDir, readFile, writeFile } = require('./utils');
|
|
|
|
// Package manager definitions
|
|
const PACKAGE_MANAGERS = {
|
|
npm: {
|
|
name: 'npm',
|
|
lockFile: 'package-lock.json',
|
|
installCmd: 'npm install',
|
|
runCmd: 'npm run',
|
|
execCmd: 'npx',
|
|
testCmd: 'npm test',
|
|
buildCmd: 'npm run build',
|
|
devCmd: 'npm run dev'
|
|
},
|
|
pnpm: {
|
|
name: 'pnpm',
|
|
lockFile: 'pnpm-lock.yaml',
|
|
installCmd: 'pnpm install',
|
|
runCmd: 'pnpm',
|
|
execCmd: 'pnpm dlx',
|
|
testCmd: 'pnpm test',
|
|
buildCmd: 'pnpm build',
|
|
devCmd: 'pnpm dev'
|
|
},
|
|
yarn: {
|
|
name: 'yarn',
|
|
lockFile: 'yarn.lock',
|
|
installCmd: 'yarn',
|
|
runCmd: 'yarn',
|
|
execCmd: 'yarn dlx',
|
|
testCmd: 'yarn test',
|
|
buildCmd: 'yarn build',
|
|
devCmd: 'yarn dev'
|
|
},
|
|
bun: {
|
|
name: 'bun',
|
|
lockFile: 'bun.lockb',
|
|
installCmd: 'bun install',
|
|
runCmd: 'bun run',
|
|
execCmd: 'bunx',
|
|
testCmd: 'bun test',
|
|
buildCmd: 'bun run build',
|
|
devCmd: 'bun run dev'
|
|
}
|
|
};
|
|
|
|
// Priority order for detection
|
|
const DETECTION_PRIORITY = ['pnpm', 'bun', 'yarn', 'npm'];
|
|
|
|
// Config file path
|
|
function getConfigPath() {
|
|
return path.join(getClaudeDir(), 'package-manager.json');
|
|
}
|
|
|
|
/**
|
|
* Load saved package manager configuration
|
|
*/
|
|
function loadConfig() {
|
|
const configPath = getConfigPath();
|
|
const content = readFile(configPath);
|
|
|
|
if (content) {
|
|
try {
|
|
return JSON.parse(content);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Save package manager configuration
|
|
*/
|
|
function saveConfig(config) {
|
|
const configPath = getConfigPath();
|
|
writeFile(configPath, JSON.stringify(config, null, 2));
|
|
}
|
|
|
|
/**
|
|
* Detect package manager from lock file in project directory
|
|
*/
|
|
function detectFromLockFile(projectDir = process.cwd()) {
|
|
for (const pmName of DETECTION_PRIORITY) {
|
|
const pm = PACKAGE_MANAGERS[pmName];
|
|
const lockFilePath = path.join(projectDir, pm.lockFile);
|
|
|
|
if (fs.existsSync(lockFilePath)) {
|
|
return pmName;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Detect package manager from package.json packageManager field
|
|
*/
|
|
function detectFromPackageJson(projectDir = process.cwd()) {
|
|
const packageJsonPath = path.join(projectDir, 'package.json');
|
|
const content = readFile(packageJsonPath);
|
|
|
|
if (content) {
|
|
try {
|
|
const pkg = JSON.parse(content);
|
|
if (pkg.packageManager) {
|
|
// Format: "pnpm@8.6.0" or just "pnpm"
|
|
const pmName = pkg.packageManager.split('@')[0];
|
|
if (PACKAGE_MANAGERS[pmName]) {
|
|
return pmName;
|
|
}
|
|
}
|
|
} catch {
|
|
// Invalid package.json
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get available package managers (installed on system)
|
|
*
|
|
* WARNING: This spawns child processes (where.exe on Windows, which on Unix)
|
|
* for each package manager. Do NOT call this during session startup hooks —
|
|
* it can exceed Bun's spawn limit on Windows and freeze the plugin.
|
|
* Use detectFromLockFile() or detectFromPackageJson() for hot paths.
|
|
*/
|
|
function getAvailablePackageManagers() {
|
|
const available = [];
|
|
|
|
for (const pmName of Object.keys(PACKAGE_MANAGERS)) {
|
|
if (commandExists(pmName)) {
|
|
available.push(pmName);
|
|
}
|
|
}
|
|
|
|
return available;
|
|
}
|
|
|
|
/**
|
|
* Get the package manager to use for current project
|
|
*
|
|
* Detection priority:
|
|
* 1. Environment variable CLAUDE_PACKAGE_MANAGER
|
|
* 2. Project-specific config (in .claude/package-manager.json)
|
|
* 3. package.json packageManager field
|
|
* 4. Lock file detection
|
|
* 5. Global user preference (in ~/.claude/package-manager.json)
|
|
* 6. Default to npm (no child processes spawned)
|
|
*
|
|
* @param {object} options - Options
|
|
* @param {string} options.projectDir - Project directory to detect from (default: cwd)
|
|
* @returns {object} - { name, config, source }
|
|
*/
|
|
function getPackageManager(options = {}) {
|
|
const { projectDir = process.cwd() } = options;
|
|
|
|
// 1. Check environment variable
|
|
const envPm = process.env.CLAUDE_PACKAGE_MANAGER;
|
|
if (envPm && PACKAGE_MANAGERS[envPm]) {
|
|
return {
|
|
name: envPm,
|
|
config: PACKAGE_MANAGERS[envPm],
|
|
source: 'environment'
|
|
};
|
|
}
|
|
|
|
// 2. Check project-specific config
|
|
const projectConfigPath = path.join(projectDir, '.claude', 'package-manager.json');
|
|
const projectConfig = readFile(projectConfigPath);
|
|
if (projectConfig) {
|
|
try {
|
|
const config = JSON.parse(projectConfig);
|
|
if (config.packageManager && PACKAGE_MANAGERS[config.packageManager]) {
|
|
return {
|
|
name: config.packageManager,
|
|
config: PACKAGE_MANAGERS[config.packageManager],
|
|
source: 'project-config'
|
|
};
|
|
}
|
|
} catch {
|
|
// Invalid config
|
|
}
|
|
}
|
|
|
|
// 3. Check package.json packageManager field
|
|
const fromPackageJson = detectFromPackageJson(projectDir);
|
|
if (fromPackageJson) {
|
|
return {
|
|
name: fromPackageJson,
|
|
config: PACKAGE_MANAGERS[fromPackageJson],
|
|
source: 'package.json'
|
|
};
|
|
}
|
|
|
|
// 4. Check lock file
|
|
const fromLockFile = detectFromLockFile(projectDir);
|
|
if (fromLockFile) {
|
|
return {
|
|
name: fromLockFile,
|
|
config: PACKAGE_MANAGERS[fromLockFile],
|
|
source: 'lock-file'
|
|
};
|
|
}
|
|
|
|
// 5. Check global user preference
|
|
const globalConfig = loadConfig();
|
|
if (globalConfig && globalConfig.packageManager && PACKAGE_MANAGERS[globalConfig.packageManager]) {
|
|
return {
|
|
name: globalConfig.packageManager,
|
|
config: PACKAGE_MANAGERS[globalConfig.packageManager],
|
|
source: 'global-config'
|
|
};
|
|
}
|
|
|
|
// 6. Default to npm (always available with Node.js)
|
|
// NOTE: Previously this called getAvailablePackageManagers() which spawns
|
|
// child processes (where.exe/which) for each PM. This caused plugin freezes
|
|
// on Windows (see #162) because session-start hooks run during Bun init,
|
|
// and the spawned processes exceed Bun's spawn limit.
|
|
// Steps 1-5 already cover all config-based and file-based detection.
|
|
// If none matched, npm is the safe default.
|
|
return {
|
|
name: 'npm',
|
|
config: PACKAGE_MANAGERS.npm,
|
|
source: 'default'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set user's preferred package manager (global)
|
|
*/
|
|
function setPreferredPackageManager(pmName) {
|
|
if (!PACKAGE_MANAGERS[pmName]) {
|
|
throw new Error(`Unknown package manager: ${pmName}`);
|
|
}
|
|
|
|
const config = loadConfig() || {};
|
|
config.packageManager = pmName;
|
|
config.setAt = new Date().toISOString();
|
|
|
|
try {
|
|
saveConfig(config);
|
|
} catch (err) {
|
|
throw new Error(`Failed to save package manager preference: ${err.message}`);
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
/**
|
|
* Set project's preferred package manager
|
|
*/
|
|
function setProjectPackageManager(pmName, projectDir = process.cwd()) {
|
|
if (!PACKAGE_MANAGERS[pmName]) {
|
|
throw new Error(`Unknown package manager: ${pmName}`);
|
|
}
|
|
|
|
const configDir = path.join(projectDir, '.claude');
|
|
const configPath = path.join(configDir, 'package-manager.json');
|
|
|
|
const config = {
|
|
packageManager: pmName,
|
|
setAt: new Date().toISOString()
|
|
};
|
|
|
|
try {
|
|
writeFile(configPath, JSON.stringify(config, null, 2));
|
|
} catch (err) {
|
|
throw new Error(`Failed to save package manager config to ${configPath}: ${err.message}`);
|
|
}
|
|
return config;
|
|
}
|
|
|
|
// Allowed characters in script/binary names: alphanumeric, dash, underscore, dot, slash, @
|
|
// This prevents shell metacharacter injection while allowing scoped packages (e.g., @scope/pkg)
|
|
const SAFE_NAME_REGEX = /^[@a-zA-Z0-9_.\/-]+$/;
|
|
|
|
/**
|
|
* Get the command to run a script
|
|
* @param {string} script - Script name (e.g., "dev", "build", "test")
|
|
* @param {object} options - { projectDir }
|
|
* @throws {Error} If script name contains unsafe characters
|
|
*/
|
|
function getRunCommand(script, options = {}) {
|
|
if (!script || typeof script !== 'string') {
|
|
throw new Error('Script name must be a non-empty string');
|
|
}
|
|
if (!SAFE_NAME_REGEX.test(script)) {
|
|
throw new Error(`Script name contains unsafe characters: ${script}`);
|
|
}
|
|
|
|
const pm = getPackageManager(options);
|
|
|
|
switch (script) {
|
|
case 'install':
|
|
return pm.config.installCmd;
|
|
case 'test':
|
|
return pm.config.testCmd;
|
|
case 'build':
|
|
return pm.config.buildCmd;
|
|
case 'dev':
|
|
return pm.config.devCmd;
|
|
default:
|
|
return `${pm.config.runCmd} ${script}`;
|
|
}
|
|
}
|
|
|
|
// Allowed characters in arguments: alphanumeric, whitespace, dashes, dots, slashes,
|
|
// equals, colons, commas, quotes, @. Rejects shell metacharacters like ; | & ` $ ( ) { } < > !
|
|
const SAFE_ARGS_REGEX = /^[@a-zA-Z0-9\s_.\/:=,'"*+-]+$/;
|
|
|
|
/**
|
|
* Get the command to execute a package binary
|
|
* @param {string} binary - Binary name (e.g., "prettier", "eslint")
|
|
* @param {string} args - Arguments to pass
|
|
* @throws {Error} If binary name or args contain unsafe characters
|
|
*/
|
|
function getExecCommand(binary, args = '', options = {}) {
|
|
if (!binary || typeof binary !== 'string') {
|
|
throw new Error('Binary name must be a non-empty string');
|
|
}
|
|
if (!SAFE_NAME_REGEX.test(binary)) {
|
|
throw new Error(`Binary name contains unsafe characters: ${binary}`);
|
|
}
|
|
if (args && typeof args === 'string' && !SAFE_ARGS_REGEX.test(args)) {
|
|
throw new Error(`Arguments contain unsafe characters: ${args}`);
|
|
}
|
|
|
|
const pm = getPackageManager(options);
|
|
return `${pm.config.execCmd} ${binary}${args ? ' ' + args : ''}`;
|
|
}
|
|
|
|
/**
|
|
* Interactive prompt for package manager selection
|
|
* Returns a message for Claude to show to user
|
|
*
|
|
* NOTE: Does NOT spawn child processes to check availability.
|
|
* Lists all supported PMs and shows how to configure preference.
|
|
*/
|
|
function getSelectionPrompt() {
|
|
let message = '[PackageManager] No package manager preference detected.\n';
|
|
message += 'Supported package managers: ' + Object.keys(PACKAGE_MANAGERS).join(', ') + '\n';
|
|
message += '\nTo set your preferred package manager:\n';
|
|
message += ' - Global: Set CLAUDE_PACKAGE_MANAGER environment variable\n';
|
|
message += ' - Or add to ~/.claude/package-manager.json: {"packageManager": "pnpm"}\n';
|
|
message += ' - Or add to package.json: {"packageManager": "pnpm@8"}\n';
|
|
message += ' - Or add a lock file to your project (e.g., pnpm-lock.yaml)\n';
|
|
|
|
return message;
|
|
}
|
|
|
|
// Escape regex metacharacters in a string before interpolating into a pattern
|
|
function escapeRegex(str) {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
/**
|
|
* Generate a regex pattern that matches commands for all package managers
|
|
* @param {string} action - Action pattern (e.g., "run dev", "install", "test")
|
|
*/
|
|
function getCommandPattern(action) {
|
|
const patterns = [];
|
|
|
|
if (action === 'dev') {
|
|
patterns.push(
|
|
'npm run dev',
|
|
'pnpm( run)? dev',
|
|
'yarn dev',
|
|
'bun run dev'
|
|
);
|
|
} else if (action === 'install') {
|
|
patterns.push(
|
|
'npm install',
|
|
'pnpm install',
|
|
'yarn( install)?',
|
|
'bun install'
|
|
);
|
|
} else if (action === 'test') {
|
|
patterns.push(
|
|
'npm test',
|
|
'pnpm test',
|
|
'yarn test',
|
|
'bun test'
|
|
);
|
|
} else if (action === 'build') {
|
|
patterns.push(
|
|
'npm run build',
|
|
'pnpm( run)? build',
|
|
'yarn build',
|
|
'bun run build'
|
|
);
|
|
} else {
|
|
// Generic run command — escape regex metacharacters in action
|
|
const escaped = escapeRegex(action);
|
|
patterns.push(
|
|
`npm run ${escaped}`,
|
|
`pnpm( run)? ${escaped}`,
|
|
`yarn ${escaped}`,
|
|
`bun run ${escaped}`
|
|
);
|
|
}
|
|
|
|
return `(${patterns.join('|')})`;
|
|
}
|
|
|
|
module.exports = {
|
|
PACKAGE_MANAGERS,
|
|
DETECTION_PRIORITY,
|
|
getPackageManager,
|
|
setPreferredPackageManager,
|
|
setProjectPackageManager,
|
|
getAvailablePackageManagers,
|
|
detectFromLockFile,
|
|
detectFromPackageJson,
|
|
getRunCommand,
|
|
getExecCommand,
|
|
getSelectionPrompt,
|
|
getCommandPattern
|
|
};
|