diff --git a/packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts b/packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts index 1733b41601..95e61b7015 100644 --- a/packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts +++ b/packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts @@ -44,7 +44,9 @@ describe('npmProvider', () => { expect(result.exclusive).toBe(true); expect(result.suggestions).toHaveLength(2); + expect(result.suggestions[0].label).toBe('build'); expect(result.suggestions[0].value).toBe('build'); + expect(result.suggestions[1].label).toBe('build:dev'); expect(result.suggestions[1].value).toBe('build:dev'); expect(fs.readFile).toHaveBeenCalledWith( expect.stringContaining('package.json'), @@ -52,6 +54,32 @@ describe('npmProvider', () => { ); }); + it('escapes script names with shell metacharacters', async () => { + const mockPackageJson = { + scripts: { + 'build(prod)': 'tsc', + 'test:watch': 'vitest', + }, + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockPackageJson)); + + const result = await npmProvider.getCompletions( + ['npm', 'run', 'bu'], + 2, + '/tmp', + ); + + expect(result.exclusive).toBe(true); + expect(result.suggestions).toHaveLength(1); + expect(result.suggestions[0].label).toBe('build(prod)'); + + // Windows does not escape spaces/parens in cmds by default in our function, but Unix does. + const isWin = process.platform === 'win32'; + expect(result.suggestions[0].value).toBe( + isWin ? 'build(prod)' : 'build\\(prod\\)', + ); + }); + it('handles missing package.json gracefully', async () => { vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); diff --git a/packages/cli/src/ui/hooks/shell-completions/npmProvider.ts b/packages/cli/src/ui/hooks/shell-completions/npmProvider.ts index a1a0a0a4b2..32b88ca5ca 100644 --- a/packages/cli/src/ui/hooks/shell-completions/npmProvider.ts +++ b/packages/cli/src/ui/hooks/shell-completions/npmProvider.ts @@ -7,6 +7,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import type { ShellCompletionProvider, CompletionResult } from './types.js'; +import { escapeShellPath } from '../useShellCompletion.js'; const NPM_SUBCOMMANDS = [ 'build', @@ -64,7 +65,7 @@ export const npmProvider: ShellCompletionProvider = { .filter((s) => s.startsWith(partial)) .map((s) => ({ label: s, - value: s, + value: escapeShellPath(s), description: 'npm script', })), exclusive: true,