diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bb778cf..972a7e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [2.4.1] - 2025-11-04 ### Fixed +- **CRITICAL: Windows npm Installation PATH Detection**: Fixed Node.js spawn() unable to resolve claude on Windows + - **Root Cause**: Node.js spawn() doesn't use Windows PATHEXT, can't resolve bare command names in SSH/npm context + - **Solution**: + 1. Pre-resolve absolute path using `where.exe`/`which` before spawning + 2. Prefer executables with extensions (.exe, .cmd, .bat) - `where.exe` returns no-extension file first + 3. Use `shell: true` for .cmd/.bat/.ps1 files (required to execute batch scripts on Windows) + - **Windows-specific Issues Solved**: + - `where.exe claude` returns both `claude` (no ext) and `claude.cmd`, but spawn() needs the .cmd wrapper + - `.cmd` files can't be spawned directly (EINVAL error), need shell: true to execute via cmd.exe + - **Impact**: Windows users can now use npm-installed CCS in SSH sessions with npm-installed Claude CLI + - **Files**: + - `bin/claude-detector.js`: Added execSync PATH resolution + extension preference logic + - `bin/ccs.js`: Added null checks + getSpawnOptions() helper for shell: true on .cmd files + - **Security**: Added 5-second timeout, documented command injection safety (hardcoded literals, controlled shell usage) + - **Diagnostics**: Enhanced error messages with platform, PATH directory count, executable name + - **Tested**: Verified with `where.exe claude` returning both entries, spawn EINVAL fixed with shell: true + - Native installation always worked; only affected npm global installs on Windows - **CRITICAL: PowerShell Terminal Termination**: Fixed PowerShell 7 terminal closing when using `irm | iex` installation - Changed `exit 1` to `return` in install.ps1 line 229 for piped script contexts - Terminal now stays open on installation errors, showing error messages properly diff --git a/bin/ccs.js b/bin/ccs.js index 01e34113..5b31660e 100755 --- a/bin/ccs.js +++ b/bin/ccs.js @@ -11,6 +11,18 @@ const { getSettingsPath } = require('./config-manager'); // Version (sync with package.json) const CCS_VERSION = require('../package.json').version; +// Helper: Get spawn options for claude execution +// On Windows, .cmd/.bat/.ps1 files need shell: true +function getSpawnOptions(claudePath) { + const isWindows = process.platform === 'win32'; + const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudePath); + + return { + stdio: 'inherit', + shell: needsShell // Required for .cmd files on Windows + }; +} + // Special command handlers function handleVersionCommand() { console.log(`CCS (Claude Code Switch) version ${CCS_VERSION}`); @@ -28,8 +40,15 @@ function handleVersionCommand() { function handleHelpCommand(remainingArgs) { const claudeCli = detectClaudeCli(); + // Check if claude was found + if (!claudeCli) { + showClaudeNotFoundError(); + process.exit(1); + } + // Execute claude --help - const child = spawn(claudeCli, ['--help', ...remainingArgs], { stdio: 'inherit' }); + const spawnOpts = getSpawnOptions(claudeCli); + const child = spawn(claudeCli, ['--help', ...remainingArgs], spawnOpts); child.on('exit', (code, signal) => { if (signal) { @@ -111,8 +130,15 @@ function main() { if (profile === 'default') { const claudeCli = detectClaudeCli(); + // Check if claude was found + if (!claudeCli) { + showClaudeNotFoundError(); + process.exit(1); + } + // Execute claude with args - const child = spawn(claudeCli, remainingArgs, { stdio: 'inherit' }); + const spawnOpts = getSpawnOptions(claudeCli); + const child = spawn(claudeCli, remainingArgs, spawnOpts); child.on('exit', (code, signal) => { if (signal) { @@ -136,9 +162,16 @@ function main() { // Detect Claude CLI const claudeCli = detectClaudeCli(); + // Check if claude was found + if (!claudeCli) { + showClaudeNotFoundError(); + process.exit(1); + } + // Execute claude with --settings const claudeArgs = ['--settings', settingsPath, ...remainingArgs]; - const child = spawn(claudeCli, claudeArgs, { stdio: 'inherit' }); + const spawnOpts = getSpawnOptions(claudeCli); + const child = spawn(claudeCli, claudeArgs, spawnOpts); child.on('exit', (code, signal) => { if (signal) { diff --git a/bin/claude-detector.js b/bin/claude-detector.js index 78006848..2315fb4f 100644 --- a/bin/claude-detector.js +++ b/bin/claude-detector.js @@ -1,6 +1,7 @@ 'use strict'; const fs = require('fs'); +const { execSync } = require('child_process'); const { showError, expandPath } = require('./helpers'); // Detect Claude CLI executable @@ -17,19 +18,62 @@ function detectClaudeCli() { console.warn(' Falling back to system PATH lookup...'); } - // Priority 2: Use 'claude' from PATH (trust the system) - // This is the standard case - if user installed Claude CLI, it's in their PATH - return 'claude'; + // Priority 2: Resolve 'claude' from PATH using which/where.exe + // This fixes Windows npm installation where spawn() can't resolve bare command names + // SECURITY: Commands are hardcoded literals with no user input - safe from injection + const isWindows = process.platform === 'win32'; + + try { + const cmd = isWindows ? 'where.exe claude' : 'which claude'; + const result = execSync(cmd, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000 // 5 second timeout to prevent hangs + }).trim(); + + // where.exe may return multiple lines (all matches in PATH order) + const matches = result.split('\n').map(p => p.trim()).filter(p => p); + + if (isWindows) { + // On Windows, prefer executables with extensions (.exe, .cmd, .bat) + // where.exe often returns file without extension first, then the actual .cmd wrapper + const withExtension = matches.find(p => /\.(exe|cmd|bat|ps1)$/i.test(p)); + const claudePath = withExtension || matches[0]; + + if (claudePath && fs.existsSync(claudePath)) { + return claudePath; + } + } else { + // On Unix, first match is fine + const claudePath = matches[0]; + + if (claudePath && fs.existsSync(claudePath)) { + return claudePath; + } + } + } catch (err) { + // Command failed - claude not in PATH + // Fall through to return null + } + + // Priority 3: Claude not found + return null; } -// Show Claude not found error +// Show Claude not found error with diagnostics function showClaudeNotFoundError() { const isWindows = process.platform === 'win32'; + const pathDirs = (process.env.PATH || '').split(isWindows ? ';' : ':'); const errorMsg = `Claude CLI not found in PATH CCS requires Claude CLI to be installed and available in your PATH. +[i] Diagnostic Info: + Platform: ${process.platform} + PATH directories: ${pathDirs.length} + Looking for: claude${isWindows ? '.exe' : ''} + Solutions: 1. Install Claude CLI: https://docs.claude.com/en/docs/claude-code/installation