Skip to content
77 changes: 76 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,78 @@ 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\\OXFAM MONS\\AppData\\Roaming\\npm\\claude.cmd');
expect(cmd).toBe('"C:\\Users\\OXFAM MONS\\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"');
    });

});

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.

});
30 changes: 29 additions & 1 deletion apps/frontend/src/main/env-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,14 +473,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 +508,28 @@ 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
if (shouldUseShell(command)) {
return `"${command}"`;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The current implementation of getSpawnCommand doesn't handle cases where the command string might already be quoted. This could lead to double-quoting (e.g., ""C:\\path\\to.cmd""), which would likely cause the spawn call to fail.

To make this utility function more robust and idempotent, I suggest adding a check to see if the command is already surrounded by double quotes before adding them.

Suggested change
if (shouldUseShell(command)) {
return `"${command}"`;
}
if (shouldUseShell(command)) {
return (command.startsWith('"') && command.endsWith('"')) ? command : `"${command}"`;
}

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