fix(windows): resolve npm installation PATH detection on Windows

CRITICAL fix for Windows npm global installation unable to find Claude CLI in PATH,
particularly affecting SSH sessions. Native installation always worked.

Root Causes Identified:
1. Node.js spawn() doesn't use Windows PATHEXT variable
2. where.exe returns no-extension file first, but spawn() needs actual .cmd wrapper
3. .cmd files require shell: true to execute (batch scripts, not binaries)

Solutions Implemented:
1. Pre-resolve absolute path using where.exe/which before spawning
2. Prefer executables with extensions (.exe, .cmd, .bat) on Windows
3. Auto-detect .cmd/.bat/.ps1 files and use shell: true for execution

Changes:
- bin/claude-detector.js: Added execSync PATH resolution with extension preference
- bin/ccs.js: Added null checks + getSpawnOptions() helper for shell: true
- CHANGELOG.md: Comprehensive documentation of Windows-specific fixes

Security:
- Commands are hardcoded literals (no injection risk)
- 5-second timeout on execSync prevents hangs
- shell: true only used for verified .cmd files
- Documented security considerations

Testing:
-  Linux: All tests pass, no regressions
-  Windows SSH (i9-bootcamp): Verified fix resolves EINVAL and PATH errors

Impact: Windows npm users can now use CCS in SSH sessions with npm-installed Claude CLI

Fixes: #windows-npm-path-detection
This commit is contained in:
kaitranntt
2025-11-04 22:47:22 -05:00
parent 80a5200cae
commit 03dd3cf857
3 changed files with 101 additions and 7 deletions
+17
View File
@@ -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
+36 -3
View File
@@ -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) {
+48 -4
View File
@@ -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