diff --git a/packages/genomic/__tests__/create-gen.test.ts b/packages/genomic/__tests__/create-gen.test.ts index 09b8965..cda028e 100644 --- a/packages/genomic/__tests__/create-gen.test.ts +++ b/packages/genomic/__tests__/create-gen.test.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -7,7 +7,7 @@ import { ExtractedVariables, extractVariables, GitCloner,promptUser, replaceVari jest.mock('child_process', () => { return { - execSync: jest.fn(), + execFileSync: jest.fn(), }; }); @@ -560,23 +560,31 @@ module.exports = { }); describe('GitCloner', () => { - const execSyncMock = execSync as jest.MockedFunction; + const execFileSyncMock = execFileSync as jest.MockedFunction< + typeof execFileSync + >; let gitCloner: GitCloner; beforeEach(() => { gitCloner = new GitCloner(); - execSyncMock.mockReset(); - execSyncMock.mockImplementation(() => undefined); + execFileSyncMock.mockReset(); + execFileSyncMock.mockImplementation(() => undefined); }); it('clones default branch when no branch provided', () => { const tempDir = path.join(testTempDir, 'clone-test'); gitCloner.clone('https://github.com/example/repo.git', tempDir); - const command = execSyncMock.mock.calls[0][0] as string; - expect(command).toContain( - 'git clone --single-branch --depth 1 https://github.com/example/repo.git' - ); - expect(command.trim().endsWith(tempDir)).toBe(true); + const [file, args] = execFileSyncMock.mock.calls[0]; + expect(file).toBe('git'); + expect(args).toEqual([ + 'clone', + '--single-branch', + '--depth', + '1', + '--', + 'https://github.com/example/repo.git', + tempDir, + ]); }); it('clones a specific branch when provided', () => { @@ -584,11 +592,26 @@ module.exports = { gitCloner.clone('https://github.com/example/repo.git', tempDir, { branch: 'dev', }); - const command = execSyncMock.mock.calls[0][0] as string; - expect(command).toContain( - 'git clone --branch dev --single-branch --depth 1 https://github.com/example/repo.git' - ); - expect(command.trim().endsWith(tempDir)).toBe(true); + const [file, args] = execFileSyncMock.mock.calls[0]; + expect(file).toBe('git'); + expect(args).toEqual([ + 'clone', + '--branch', + 'dev', + '--single-branch', + '--depth', + '1', + '--', + 'https://github.com/example/repo.git', + tempDir, + ]); + }); + + it('passes metacharacter-bearing destinations as a single argv entry', () => { + const tempDir = path.join(testTempDir, 'a; touch pwned'); + gitCloner.clone('https://github.com/example/repo.git', tempDir); + const [, args] = execFileSyncMock.mock.calls[0]; + expect(args?.[args.length - 1]).toBe(tempDir); }); }); diff --git a/packages/genomic/src/git/git-cloner.ts b/packages/genomic/src/git/git-cloner.ts index 0af5728..b21af61 100644 --- a/packages/genomic/src/git/git-cloner.ts +++ b/packages/genomic/src/git/git-cloner.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import * as fs from 'fs'; import { createSpinner } from 'inquirerer'; import * as os from 'os'; @@ -101,20 +101,19 @@ export class GitCloner { const singleBranch = options?.singleBranch ?? true; const silent = options?.silent ?? true; - const branchArgs = branch ? ` --branch ${branch}` : ''; - const singleBranchArgs = singleBranch ? ' --single-branch' : ''; - const depthArgs = ` --depth ${depth}`; - - const command = `git clone${branchArgs}${singleBranchArgs}${depthArgs} ${url} ${destination}`; + const args = ['clone']; + if (branch) args.push('--branch', branch); + if (singleBranch) args.push('--single-branch'); + args.push('--depth', String(depth), '--', url, destination); const spinner = silent ? createSpinner(`Cloning ${url}...`) : null; - + try { if (spinner) { spinner.start(); } - - execSync(command, { + + execFileSync('git', args, { stdio: silent ? 'pipe' : 'inherit', encoding: 'utf-8' }); diff --git a/packages/genomic/src/utils/npm-version-check.ts b/packages/genomic/src/utils/npm-version-check.ts index c30e7b6..da08e28 100644 --- a/packages/genomic/src/utils/npm-version-check.ts +++ b/packages/genomic/src/utils/npm-version-check.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import { VersionCheckResult } from './types'; @@ -13,8 +13,9 @@ export async function checkNpmVersion( currentVersion: string ): Promise { try { - const latestVersion = execSync( - `npm view ${packageName} version`, + const latestVersion = execFileSync( + 'npm', + ['view', packageName, 'version'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] } ).trim(); diff --git a/packages/inquirerer/__tests__/defaultFrom.test.ts b/packages/inquirerer/__tests__/defaultFrom.test.ts index e3624e3..93e826d 100644 --- a/packages/inquirerer/__tests__/defaultFrom.test.ts +++ b/packages/inquirerer/__tests__/defaultFrom.test.ts @@ -7,11 +7,14 @@ import { Question } from '../src/question'; jest.mock('readline'); jest.mock('child_process', () => ({ - execSync: jest.fn() + execSync: jest.fn(), + execFileSync: jest.fn() })); -import { execSync } from 'child_process'; -const mockedExecSync = execSync as jest.MockedFunction; +import { execFileSync } from 'child_process'; +const mockedExecFileSync = execFileSync as jest.MockedFunction< + typeof execFileSync +>; function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); @@ -99,7 +102,7 @@ describe('Inquirerer - defaultFrom feature', () => { describe('git resolvers', () => { it('should use git.user.name as default', async () => { - mockedExecSync.mockReturnValue('John Doe\n' as any); + mockedExecFileSync.mockReturnValue('John Doe\n' as any); const prompter = new Inquirerer({ input: mockInput, @@ -118,14 +121,15 @@ describe('Inquirerer - defaultFrom feature', () => { const result = await prompter.prompt({}, questions); expect(result).toEqual({ authorName: 'John Doe' }); - expect(mockedExecSync).toHaveBeenCalledWith( - 'git config --global user.name', + expect(mockedExecFileSync).toHaveBeenCalledWith( + 'git', + ['config', '--global', '--', 'user.name'], expect.any(Object) ); }); it('should use git.user.email as default', async () => { - mockedExecSync.mockReturnValue('john@example.com\n' as any); + mockedExecFileSync.mockReturnValue('john@example.com\n' as any); const prompter = new Inquirerer({ input: mockInput, @@ -144,14 +148,15 @@ describe('Inquirerer - defaultFrom feature', () => { const result = await prompter.prompt({}, questions); expect(result).toEqual({ authorEmail: 'john@example.com' }); - expect(mockedExecSync).toHaveBeenCalledWith( - 'git config --global user.email', + expect(mockedExecFileSync).toHaveBeenCalledWith( + 'git', + ['config', '--global', '--', 'user.email'], expect.any(Object) ); }); it('should fallback to static default when git config fails', async () => { - mockedExecSync.mockImplementation(() => { + mockedExecFileSync.mockImplementation(() => { throw new Error('Git not configured'); }); @@ -176,7 +181,7 @@ describe('Inquirerer - defaultFrom feature', () => { }); it('should resolve multiple git fields', async () => { - mockedExecSync + mockedExecFileSync .mockReturnValueOnce('Jane Smith\n' as any) .mockReturnValueOnce('jane@example.com\n' as any); @@ -339,7 +344,7 @@ describe('Inquirerer - defaultFrom feature', () => { describe('priority and fallbacks', () => { it('should prioritize argv over defaultFrom', async () => { - mockedExecSync.mockReturnValue('Git User\n' as any); + mockedExecFileSync.mockReturnValue('Git User\n' as any); const prompter = new Inquirerer({ input: mockInput, @@ -362,7 +367,7 @@ describe('Inquirerer - defaultFrom feature', () => { }); it('should use undefined when resolver returns undefined and no static default', async () => { - mockedExecSync.mockImplementation(() => { + mockedExecFileSync.mockImplementation(() => { throw new Error('Git not configured'); }); @@ -386,7 +391,7 @@ describe('Inquirerer - defaultFrom feature', () => { }); it('should handle mixed defaultFrom and static defaults', async () => { - mockedExecSync.mockReturnValue('Jane Doe\n' as any); + mockedExecFileSync.mockReturnValue('Jane Doe\n' as any); const prompter = new Inquirerer({ input: mockInput, @@ -527,7 +532,7 @@ describe('Inquirerer - defaultFrom feature', () => { }); it('should not override when defaultFrom resolver fails and field is required', async () => { - mockedExecSync.mockImplementation(() => { + mockedExecFileSync.mockImplementation(() => { throw new Error('Git not configured'); }); diff --git a/packages/inquirerer/__tests__/resolvers.test.ts b/packages/inquirerer/__tests__/resolvers.test.ts index 8db218c..8b3b2eb 100644 --- a/packages/inquirerer/__tests__/resolvers.test.ts +++ b/packages/inquirerer/__tests__/resolvers.test.ts @@ -10,10 +10,14 @@ import { // Mock child_process.execSync for git config tests jest.mock('child_process', () => ({ execSync: jest.fn(), + execFileSync: jest.fn(), })); -import { execSync } from 'child_process'; +import { execFileSync,execSync } from 'child_process'; const mockedExecSync = execSync as jest.MockedFunction; +const mockedExecFileSync = execFileSync as jest.MockedFunction< + typeof execFileSync +>; describe('DefaultResolverRegistry', () => { let registry: DefaultResolverRegistry; @@ -166,13 +170,14 @@ describe('Git Resolvers', () => { describe('getGitConfig', () => { it('should return git config value when successful', () => { - mockedExecSync.mockReturnValue('John Doe\n' as any); + mockedExecFileSync.mockReturnValue('John Doe\n' as any); const result = getGitConfig('user.name'); expect(result).toBe('John Doe'); - expect(mockedExecSync).toHaveBeenCalledWith( - 'git config --global user.name', + expect(mockedExecFileSync).toHaveBeenCalledWith( + 'git', + ['config', '--global', '--', 'user.name'], expect.objectContaining({ encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], @@ -181,7 +186,7 @@ describe('Git Resolvers', () => { }); it('should trim whitespace from git config value', () => { - mockedExecSync.mockReturnValue(' test@example.com \n' as any); + mockedExecFileSync.mockReturnValue(' test@example.com \n' as any); const result = getGitConfig('user.email'); @@ -189,7 +194,7 @@ describe('Git Resolvers', () => { }); it('should return undefined when git config fails', () => { - mockedExecSync.mockImplementation(() => { + mockedExecFileSync.mockImplementation(() => { throw new Error('Git config not found'); }); @@ -199,7 +204,7 @@ describe('Git Resolvers', () => { }); it('should return undefined when git config returns empty string', () => { - mockedExecSync.mockReturnValue('' as any); + mockedExecFileSync.mockReturnValue('' as any); const result = getGitConfig('user.name'); @@ -209,7 +214,7 @@ describe('Git Resolvers', () => { describe('git.user.name resolver', () => { it('should resolve git user name', async () => { - mockedExecSync.mockReturnValue('Jane Smith\n' as any); + mockedExecFileSync.mockReturnValue('Jane Smith\n' as any); const result = await globalResolverRegistry.resolve('git.user.name'); @@ -217,7 +222,7 @@ describe('Git Resolvers', () => { }); it('should return undefined when git config fails', async () => { - mockedExecSync.mockImplementation(() => { + mockedExecFileSync.mockImplementation(() => { throw new Error('Git not configured'); }); @@ -229,7 +234,7 @@ describe('Git Resolvers', () => { describe('git.user.email resolver', () => { it('should resolve git user email', async () => { - mockedExecSync.mockReturnValue('jane@example.com\n' as any); + mockedExecFileSync.mockReturnValue('jane@example.com\n' as any); const result = await globalResolverRegistry.resolve('git.user.email'); @@ -237,7 +242,7 @@ describe('Git Resolvers', () => { }); it('should return undefined when git config fails', async () => { - mockedExecSync.mockImplementation(() => { + mockedExecFileSync.mockImplementation(() => { throw new Error('Git not configured'); }); diff --git a/packages/inquirerer/__tests__/setFrom.test.ts b/packages/inquirerer/__tests__/setFrom.test.ts index ec7ff85..4d90337 100644 --- a/packages/inquirerer/__tests__/setFrom.test.ts +++ b/packages/inquirerer/__tests__/setFrom.test.ts @@ -7,11 +7,14 @@ import { Question } from '../src/question'; jest.mock('readline'); jest.mock('child_process', () => ({ - execSync: jest.fn() + execSync: jest.fn(), + execFileSync: jest.fn() })); -import { execSync } from 'child_process'; -const mockedExecSync = execSync as jest.MockedFunction; +import { execFileSync } from 'child_process'; +const mockedExecFileSync = execFileSync as jest.MockedFunction< + typeof execFileSync +>; describe('Inquirerer - setFrom feature', () => { let mockWrite: jest.Mock; @@ -106,7 +109,7 @@ describe('Inquirerer - setFrom feature', () => { }); it('should use git.user.name with setFrom', async () => { - mockedExecSync.mockReturnValue('John Doe\n' as any); + mockedExecFileSync.mockReturnValue('John Doe\n' as any); const prompter = new Inquirerer({ input: mockInput, @@ -125,8 +128,9 @@ describe('Inquirerer - setFrom feature', () => { const result = await prompter.prompt({}, questions); expect(result).toEqual({ authorName: 'John Doe' }); - expect(mockedExecSync).toHaveBeenCalledWith( - 'git config --global user.name', + expect(mockedExecFileSync).toHaveBeenCalledWith( + 'git', + ['config', '--global', '--', 'user.name'], expect.any(Object) ); }); @@ -216,7 +220,7 @@ describe('Inquirerer - setFrom feature', () => { }); it('should allow both setFrom and defaultFrom on different questions', async () => { - mockedExecSync.mockReturnValue('Git User\n' as any); + mockedExecFileSync.mockReturnValue('Git User\n' as any); const prompter = new Inquirerer({ input: mockInput, diff --git a/packages/inquirerer/src/resolvers/git.ts b/packages/inquirerer/src/resolvers/git.ts index 43210cc..2fc6a24 100644 --- a/packages/inquirerer/src/resolvers/git.ts +++ b/packages/inquirerer/src/resolvers/git.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import type { ResolverRegistry } from './types'; @@ -9,7 +9,7 @@ import type { ResolverRegistry } from './types'; */ export function getGitConfig(key: string): string | undefined { try { - const result = execSync(`git config --global ${key}`, { + const result = execFileSync('git', ['config', '--global', '--', key], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] // Suppress stderr });