Skip to content
145 changes: 144 additions & 1 deletion apps/frontend/src/main/__tests__/env-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { shouldUseShell, getSpawnOptions } from '../env-utils';
import { shouldUseShell, getSpawnOptions, getSpawnCommand } from '../env-utils';

describe('shouldUseShell', () => {
const originalPlatform = process.platform;
Expand Down Expand Up @@ -198,3 +198,146 @@ describe('getSpawnOptions', () => {
});
});
});

describe('getSpawnCommand', () => {
const originalPlatform = process.platform;

afterEach(() => {
// Restore original platform after each test
Object.defineProperty(process, 'platform', {
value: originalPlatform,
writable: true,
configurable: true,
});
});

describe('Windows platform', () => {
beforeEach(() => {
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true,
configurable: true,
});
});

it('should quote .cmd files with spaces', () => {
const cmd = getSpawnCommand('C:\\Users\\First Last\\AppData\\Roaming\\npm\\claude.cmd');
expect(cmd).toBe('"C:\\Users\\First Last\\AppData\\Roaming\\npm\\claude.cmd"');
});

it('should quote .cmd files without spaces too (idempotent)', () => {
const cmd = getSpawnCommand('C:\\Users\\admin\\AppData\\Roaming\\npm\\claude.cmd');
expect(cmd).toBe('"C:\\Users\\admin\\AppData\\Roaming\\npm\\claude.cmd"');
});

it('should quote .bat files with spaces', () => {
const cmd = getSpawnCommand('D:\\Program Files (x86)\\scripts\\setup.bat');
expect(cmd).toBe('"D:\\Program Files (x86)\\scripts\\setup.bat"');
});

it('should NOT quote .exe files', () => {
const cmd = getSpawnCommand('C:\\Program Files\\Git\\cmd\\git.exe');
expect(cmd).toBe('C:\\Program Files\\Git\\cmd\\git.exe');
});

it('should NOT quote extensionless files', () => {
const cmd = getSpawnCommand('D:\\Git\\bin\\bash');
expect(cmd).toBe('D:\\Git\\bin\\bash');
});

it('should handle uppercase .CMD and .BAT extensions', () => {
expect(getSpawnCommand('D:\\Tools\\CLAUDE.CMD')).toBe('"D:\\Tools\\CLAUDE.CMD"');
expect(getSpawnCommand('C:\\Scripts\\SETUP.BAT')).toBe('"C:\\Scripts\\SETUP.BAT"');
});
Comment on lines +248 to +251
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure the robustness of the getSpawnCommand function against double-quoting, it would be beneficial to add a test case that verifies it doesn't add quotes to a command that is already quoted. This would complement the proposed change in env-utils.ts.

    it('should handle uppercase .CMD and .BAT extensions', () => {
      expect(getSpawnCommand('D:\\Tools\\CLAUDE.CMD')).toBe('"D:\\Tools\\CLAUDE.CMD"');
      expect(getSpawnCommand('C:\\Scripts\\SETUP.BAT')).toBe('"C:\\Scripts\\SETUP.BAT"');
    });

    it('should NOT double-quote an already quoted .cmd file', () => {
      const cmd = getSpawnCommand('"C:\\Users\\Test User\\app.cmd"');
      expect(cmd).toBe('"C:\\Users\\Test User\\app.cmd"');
    });


it('should be idempotent - already quoted .cmd files stay quoted', () => {
const cmd = getSpawnCommand('"C:\\Users\\admin\\AppData\\Roaming\\npm\\claude.cmd"');
expect(cmd).toBe('"C:\\Users\\admin\\AppData\\Roaming\\npm\\claude.cmd"');
});

it('should be idempotent - already quoted .bat files stay quoted', () => {
const cmd = getSpawnCommand('"D:\\Program Files\\scripts\\setup.bat"');
expect(cmd).toBe('"D:\\Program Files\\scripts\\setup.bat"');
});

it('should be idempotent - double-quoting does not occur', () => {
const once = getSpawnCommand('C:\\Users\\admin\\npm\\claude.cmd');
const twice = getSpawnCommand(once);
expect(once).toBe(twice);
expect(once).toBe('"C:\\Users\\admin\\npm\\claude.cmd"');
});

it('should trim whitespace before processing', () => {
const cmd = getSpawnCommand(' C:\\Users\\admin\\npm\\claude.cmd ');
expect(cmd).toBe('"C:\\Users\\admin\\npm\\claude.cmd"');
});

it('should handle already-quoted .cmd with spaces', () => {
const cmd = getSpawnCommand('"C:\\Users\\First Last\\npm\\claude.cmd"');
expect(cmd).toBe('"C:\\Users\\First Last\\npm\\claude.cmd"');
});
});

describe('Non-Windows platforms', () => {
it('should NOT quote commands on macOS', () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
writable: true,
configurable: true,
});
expect(getSpawnCommand('/usr/local/bin/claude')).toBe('/usr/local/bin/claude');
expect(getSpawnCommand('/opt/homebrew/bin/claude.cmd')).toBe('/opt/homebrew/bin/claude.cmd');
});

it('should NOT quote commands on Linux', () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
writable: true,
configurable: true,
});
expect(getSpawnCommand('/usr/bin/claude')).toBe('/usr/bin/claude');
expect(getSpawnCommand('/home/user/.local/bin/claude.bat')).toBe('/home/user/.local/bin/claude.bat');
});
});
Comment on lines 296 to 336
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add test coverage for whitespace handling on non-Windows platforms.

The tests verify that commands aren't quoted on macOS/Linux, but don't test whitespace trimming behavior. Adding a test case would catch the whitespace inconsistency identified in the implementation:

it('should trim whitespace on non-Windows platforms', () => {
  Object.defineProperty(process, 'platform', {
    value: 'darwin',
    writable: true,
    configurable: true,
  });
  expect(getSpawnCommand('  /usr/local/bin/claude  ')).toBe('/usr/local/bin/claude');
});

This test would currently fail due to the inconsistent whitespace handling in getSpawnCommand() at line 543 of env-utils.ts.

🤖 Prompt for AI Agents
In @apps/frontend/src/main/__tests__/env-utils.test.ts around lines 281 - 301,
getSpawnCommand currently leaves leading/trailing whitespace on non-Windows
platforms, causing inconsistent behavior; update getSpawnCommand to trim the
input command (e.g., command = command.trim()) before returning in the
non-Windows branch so macOS/Linux paths are normalized, and add a unit test in
env-utils.test.ts that sets process.platform to 'darwin'/'linux' and asserts
getSpawnCommand('  /usr/local/bin/claude  ') === '/usr/local/bin/claude' to
cover whitespace handling.

});

describe('shouldUseShell with quoted paths', () => {
const originalPlatform = process.platform;

afterEach(() => {
// Restore original platform after each test
Object.defineProperty(process, 'platform', {
value: originalPlatform,
writable: true,
configurable: true,
});
});

describe('Windows platform', () => {
beforeEach(() => {
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true,
configurable: true,
});
});

it('should detect .cmd files in quoted paths', () => {
expect(shouldUseShell('"C:\\Users\\admin\\npm\\claude.cmd"')).toBe(true);
expect(shouldUseShell('"D:\\Tools\\CLAUDE.CMD"')).toBe(true);
});

it('should detect .bat files in quoted paths', () => {
expect(shouldUseShell('"C:\\Scripts\\setup.bat"')).toBe(true);
expect(shouldUseShell('"D:\\Program Files\\script.BAT"')).toBe(true);
});

it('should NOT detect .exe files in quoted paths', () => {
expect(shouldUseShell('"C:\\Program Files\\git.exe"')).toBe(false);
});

it('should handle whitespace around quoted paths', () => {
expect(shouldUseShell(' "C:\\Users\\admin\\npm\\claude.cmd" ')).toBe(true);
});
});
});
41 changes: 39 additions & 2 deletions apps/frontend/src/main/env-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,8 +463,12 @@ export function shouldUseShell(command: string): boolean {
return false;
}

const trimmed = command.trim();
const unquoted =
trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed;

// Check if command ends with .cmd or .bat (case-insensitive)
return /\.(cmd|bat)$/i.test(command);
return /\.(cmd|bat)$/i.test(unquoted);
}

/**
Expand All @@ -473,14 +477,17 @@ export function shouldUseShell(command: string): boolean {
* Provides a consistent way to create spawn options that work across platforms.
* Handles the shell requirement for Windows .cmd/.bat files automatically.
*
* For .cmd/.bat files on Windows, returns options that tell the caller to use
* proper quoting for paths with spaces.
*
* @param command - The command path to execute
* @param baseOptions - Base spawn options to merge with (optional)
* @returns Spawn options with correct shell setting
*
* @example
* ```typescript
* const opts = getSpawnOptions(claudeCmd, { cwd: '/project', env: {...} });
* spawn(claudeCmd, ['--version'], opts);
* spawn(getSpawnCommand(claudeCmd), ['--version'], opts);
* ```
*/
export function getSpawnOptions(
Expand All @@ -505,3 +512,33 @@ export function getSpawnOptions(
shell: shouldUseShell(command),
};
}

/**
* Get the properly quoted command for use with spawn()
*
* For .cmd/.bat files on Windows with shell:true, the command path must be
* quoted to handle paths containing spaces correctly (e.g., C:\Users\OXFAM MONS\...).
*
* @param command - The command path to execute
* @returns The command (quoted if needed for .cmd/.bat files on Windows)
*
* @example
* ```typescript
* const cmd = getSpawnCommand(claudeCmd); // "C:\Users\OXFAM MONS\...\claude.cmd"
* const opts = getSpawnOptions(claudeCmd, { cwd: '/project', env: {...} });
* spawn(cmd, ['--version'], opts);
* ```
*/
export function getSpawnCommand(command: string): string {
// For .cmd/.bat files on Windows, quote the command to handle spaces
// The shell will parse the quoted path correctly
const trimmed = command.trim();
if (shouldUseShell(trimmed)) {
// Idempotent if already quoted
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
return trimmed;
}
return `"${trimmed}"`;
}
return command;
}
8 changes: 4 additions & 4 deletions apps/frontend/src/main/ipc-handlers/env-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { projectStore } from '../project-store';
import { parseEnvFile } from './utils';
import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils';
import { debugError } from '../../shared/utils/debug-logger';
import { getSpawnOptions } from '../env-utils';
import { getSpawnOptions, getSpawnCommand } from '../env-utils';

// GitLab environment variable keys
const GITLAB_ENV_KEYS = {
Expand Down Expand Up @@ -603,7 +603,7 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
try {
// Check if Claude CLI is available and authenticated
const result = await new Promise<ClaudeAuthResult>((resolve) => {
const proc = spawn(claudeCmd, ['--version'], getSpawnOptions(claudeCmd, {
const proc = spawn(getSpawnCommand(claudeCmd), ['--version'], getSpawnOptions(claudeCmd, {
cwd: project.path,
env: claudeEnv,
}));
Expand All @@ -623,7 +623,7 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
if (code === 0) {
// Claude CLI is available, check if authenticated
// Run a simple command that requires auth
const authCheck = spawn(claudeCmd, ['api', '--help'], getSpawnOptions(claudeCmd, {
const authCheck = spawn(getSpawnCommand(claudeCmd), ['api', '--help'], getSpawnOptions(claudeCmd, {
cwd: project.path,
env: claudeEnv,
}));
Expand Down Expand Up @@ -692,7 +692,7 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
try {
// Run claude setup-token which will open browser for OAuth
const result = await new Promise<ClaudeAuthResult>((resolve) => {
const proc = spawn(claudeCmd, ['setup-token'], getSpawnOptions(claudeCmd, {
const proc = spawn(getSpawnCommand(claudeCmd), ['setup-token'], getSpawnOptions(claudeCmd, {
cwd: project.path,
env: claudeEnv,
stdio: 'inherit' // This allows the terminal to handle the interactive auth
Expand Down
Loading