diff --git a/packages/nx-plugin/src/executors/cli/executor.int.test.ts b/packages/nx-plugin/src/executors/cli/executor.int.test.ts index fee1e59d8..3cffa4d9a 100644 --- a/packages/nx-plugin/src/executors/cli/executor.int.test.ts +++ b/packages/nx-plugin/src/executors/cli/executor.int.test.ts @@ -1,17 +1,34 @@ -import { afterEach, expect, vi } from 'vitest'; +import { afterEach, beforeEach, expect, vi } from 'vitest'; import { executorContext } from '@code-pushup/test-nx-utils'; -import * as executeProcessModule from '../../internal/execute-process.js'; import runAutorunExecutor from './executor.js'; import * as utils from './utils.js'; +const { executeProcessSpy } = vi.hoisted(() => ({ + executeProcessSpy: vi.fn().mockResolvedValue({ + code: 0, + stdout: '', + stderr: '', + date: new Date().toISOString(), + duration: 100, + }), +})); + +vi.mock('@code-pushup/utils', async () => { + const utils = await vi.importActual('@code-pushup/utils'); + return { + ...utils, + executeProcess: executeProcessSpy, + }; +}); + describe('runAutorunExecutor', () => { const parseAutorunExecutorOptionsSpy = vi.spyOn( utils, 'parseAutorunExecutorOptions', ); - const executeProcessSpy = vi.spyOn(executeProcessModule, 'executeProcess'); beforeEach(() => { + executeProcessSpy.mockClear(); executeProcessSpy.mockResolvedValue({ bin: 'npx ...', code: 0, @@ -43,10 +60,12 @@ describe('runAutorunExecutor', () => { }), ); expect(executeProcessSpy).toHaveBeenCalledTimes(1); - expect(executeProcessSpy).toHaveBeenCalledWith({ - command: 'npx', - args: expect.arrayContaining(['@code-pushup/cli']), - cwd: process.cwd(), - }); + expect(executeProcessSpy).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'npx', + args: expect.arrayContaining(['@code-pushup/cli']), + cwd: expect.any(String), + }), + ); }); }); diff --git a/packages/nx-plugin/src/executors/cli/executor.ts b/packages/nx-plugin/src/executors/cli/executor.ts index 2e644f184..974eff885 100644 --- a/packages/nx-plugin/src/executors/cli/executor.ts +++ b/packages/nx-plugin/src/executors/cli/executor.ts @@ -1,5 +1,4 @@ import { type ExecutorContext, logger } from '@nx/devkit'; -import { executeProcess } from '../../internal/execute-process.js'; import { createCliCommandObject, createCliCommandString, @@ -41,6 +40,7 @@ export default async function runAutorunExecutor( logger.warn(`DryRun execution of: ${commandString}`); } else { try { + const { executeProcess } = await import('@code-pushup/utils'); await executeProcess({ ...createCliCommandObject({ command, args: cliArgumentObject, bin }), ...(context.cwd ? { cwd: context.cwd } : {}), diff --git a/packages/nx-plugin/src/executors/cli/executor.unit.test.ts b/packages/nx-plugin/src/executors/cli/executor.unit.test.ts index c5684b0ba..c21032781 100644 --- a/packages/nx-plugin/src/executors/cli/executor.unit.test.ts +++ b/packages/nx-plugin/src/executors/cli/executor.unit.test.ts @@ -2,16 +2,32 @@ import { logger } from '@nx/devkit'; import { afterAll, afterEach, beforeEach, expect, vi } from 'vitest'; import { executorContext } from '@code-pushup/test-nx-utils'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; -import * as executeProcessModule from '../../internal/execute-process.js'; import runAutorunExecutor from './executor.js'; +const { executeProcessSpy } = vi.hoisted(() => ({ + executeProcessSpy: vi.fn().mockResolvedValue({ + code: 0, + stdout: '', + stderr: '', + date: new Date().toISOString(), + duration: 100, + }), +})); + +vi.mock('@code-pushup/utils', async () => { + const utils = await vi.importActual('@code-pushup/utils'); + return { + ...utils, + executeProcess: executeProcessSpy, + }; +}); + describe('runAutorunExecutor', () => { const processEnvCP = Object.fromEntries( Object.entries(process.env).filter(([k]) => k.startsWith('CP_')), ); const loggerInfoSpy = vi.spyOn(logger, 'info'); const loggerWarnSpy = vi.spyOn(logger, 'warn'); - const executeProcessSpy = vi.spyOn(executeProcessModule, 'executeProcess'); beforeAll(() => { Object.entries(process.env) @@ -25,6 +41,7 @@ describe('runAutorunExecutor', () => { beforeEach(() => { vi.unstubAllEnvs(); + executeProcessSpy.mockClear(); executeProcessSpy.mockResolvedValue({ bin: 'npx ...', code: 0, diff --git a/packages/nx-plugin/src/internal/execute-process.ts b/packages/nx-plugin/src/internal/execute-process.ts deleted file mode 100644 index 9f8f68a05..000000000 --- a/packages/nx-plugin/src/internal/execute-process.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Dynamically imports and executes function from utils. - * - * This is a workaround for Nx only supporting plugins in CommonJS format. - */ -export async function executeProcess( - cfg: import('@code-pushup/utils').ProcessConfig, -): Promise { - const { executeProcess } = await import('@code-pushup/utils'); - return executeProcess(cfg); -} diff --git a/packages/utils/eslint.config.js b/packages/utils/eslint.config.js index 1ad01224a..cc6013b07 100644 --- a/packages/utils/eslint.config.js +++ b/packages/utils/eslint.config.js @@ -17,7 +17,9 @@ export default tseslint.config( rules: { '@nx/dependency-checks': [ 'error', - { ignoredDependencies: ['esbuild'] }, // esbuild is a peer dependency of bundle-require + { + ignoredDependencies: ['esbuild', 'ora'], // esbuild is a peer dependency of bundle-require, ora has transitive dependencies with different versions + }, ], }, }, diff --git a/packages/utils/src/lib/command.ts b/packages/utils/src/lib/command.ts new file mode 100644 index 000000000..37da2c62e --- /dev/null +++ b/packages/utils/src/lib/command.ts @@ -0,0 +1,167 @@ +import ansis from 'ansis'; +import path from 'node:path'; + +type ArgumentValue = number | string | boolean | string[] | undefined; +export type CliArgsObject> = + T extends never + ? Record | { _: string } + : T; + +/** + * Escapes command line arguments that contain spaces, quotes, or other special characters. + * + * @param {string[]} args - Array of command arguments to escape. + * @returns {string[]} - Array of escaped arguments suitable for shell execution. + */ +export function escapeCliArgs(args: string[]): string[] { + return args.map(arg => { + if (arg.includes(' ') || arg.includes('"') || arg.includes("'")) { + return `"${arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return arg; + }); +} + +/** + * Formats environment variable values for display by stripping quotes and then escaping. + * + * @param {string} value - Environment variable value to format. + * @returns {string} - Formatted and escaped value suitable for display. + */ +export function formatEnvValue(value: string): string { + // Strip quotes from the value for display + const cleanValue = value.replace(/"/g, ''); + return escapeCliArgs([cleanValue])[0] ?? cleanValue; +} + +/** + * Builds a command string by escaping arguments that contain spaces, quotes, or other special characters. + * + * @param {string} command - The base command to execute. + * @param {string[]} args - Array of command arguments. + * @returns {string} - The complete command string with properly escaped arguments. + */ +export function buildCommandString( + command: string, + args: string[] = [], +): string { + if (args.length === 0) { + return command; + } + + return `${command} ${escapeCliArgs(args).join(' ')}`; +} + +/** + * Options for formatting a command log. + */ +export interface FormatCommandLogOptions { + command: string; + args?: string[]; + cwd?: string; + env?: Record; +} + +/** + * Formats a command string with optional cwd prefix, environment variables, and ANSI colors. + * + * @param {FormatCommandLogOptions} options - Command formatting options. + * @returns {string} - ANSI-colored formatted command string. + * + * @example + * + * formatCommandLog({cwd: 'tools/api', env: {API_KEY='•••' NODE_ENV='prod'}, command: 'node', args: ['cli.js', '--do', 'thing', 'fast']}) + * ┌─────────────────────────────────────────────────────────────────────────┐ + * │ tools/api $ API_KEY="•••" NODE_ENV="prod" node cli.js --do thing fast │ + * │ │ │ │ │ │ │ + * │ └ cwd │ │ │ └ args. │ + * │ │ │ └ command │ + * │ │ └ env variables │ + * │ └ prompt symbol ($) │ + * └─────────────────────────────────────────────────────────────────────────┘ + */ +export function formatCommandLog(options: FormatCommandLogOptions): string { + const { command, args = [], cwd = process.cwd(), env } = options; + const relativeDir = path.relative(process.cwd(), cwd); + + return [ + ...(relativeDir && relativeDir !== '.' + ? [ansis.italic(ansis.gray(relativeDir))] + : []), + ansis.yellow('$'), + ...(env && Object.keys(env).length > 0 + ? Object.entries(env).map(([key, value]) => { + return ansis.gray(`${key}=${formatEnvValue(value)}`); + }) + : []), + ansis.gray(command), + ansis.gray(args.join(' ')), + ].join(' '); +} + +/** + * Converts an object with different types of values into an array of command-line arguments. + * + * @example + * const args = objectToCliArgs({ + * _: ['node', 'index.js'], // node index.js + * name: 'Juanita', // --name=Juanita + * formats: ['json', 'md'] // --format=json --format=md + * }); + */ +export function objectToCliArgs< + T extends object = Record, +>(params?: CliArgsObject): string[] { + if (!params) { + return []; + } + + return Object.entries(params).flatMap(([key, value]) => { + // process/file/script + if (key === '_') { + return Array.isArray(value) ? value : [`${value}`]; + } + const prefix = key.length === 1 ? '-' : '--'; + // "-*" arguments (shorthands) + if (Array.isArray(value)) { + return value.map(v => `${prefix}${key}="${v}"`); + } + // "--*" arguments ========== + + if (typeof value === 'object') { + return Object.entries(value as Record).flatMap( + // transform nested objects to the dot notation `key.subkey` + ([k, v]) => objectToCliArgs({ [`${key}.${k}`]: v }), + ); + } + + if (typeof value === 'string') { + return [`${prefix}${key}="${value}"`]; + } + + if (typeof value === 'number') { + return [`${prefix}${key}=${value}`]; + } + + if (typeof value === 'boolean') { + return [`${prefix}${value ? '' : 'no-'}${key}`]; + } + + if (typeof value === 'undefined') { + return []; + } + + throw new Error(`Unsupported type ${typeof value} for key ${key}`); + }); +} + +/** + * Converts a file path to a CLI argument by wrapping it in quotes to handle spaces. + * + * @param {string} filePath - The file path to convert to a CLI argument. + * @returns {string} - The quoted file path suitable for CLI usage. + */ +export function filePathToCliArg(filePath: string): string { + // needs to be escaped if spaces included + return `"${filePath}"`; +} diff --git a/packages/utils/src/lib/command.unit.test.ts b/packages/utils/src/lib/command.unit.test.ts new file mode 100644 index 000000000..041a991c0 --- /dev/null +++ b/packages/utils/src/lib/command.unit.test.ts @@ -0,0 +1,323 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { removeColorCodes } from '@code-pushup/test-utils'; +import { + buildCommandString, + escapeCliArgs, + filePathToCliArg, + formatCommandLog, + objectToCliArgs, +} from './command.js'; + +describe('filePathToCliArg', () => { + it('should wrap path in quotes', () => { + expect(filePathToCliArg('My Project/index.js')).toBe( + '"My Project/index.js"', + ); + }); +}); + +describe('escapeCliArgs', () => { + it('should return empty array for empty input', () => { + const args: string[] = []; + const result = escapeCliArgs(args); + expect(result).toEqual([]); + }); + + it('should return arguments unchanged when no special characters', () => { + const args = ['simple', 'arguments', '--flag', 'value']; + const result = escapeCliArgs(args); + expect(result).toEqual(['simple', 'arguments', '--flag', 'value']); + }); + + it('should escape arguments containing spaces', () => { + const args = ['file with spaces.txt', 'normal']; + const result = escapeCliArgs(args); + expect(result).toEqual(['"file with spaces.txt"', 'normal']); + }); + + it('should escape arguments containing double quotes', () => { + const args = ['say "hello"', 'normal']; + const result = escapeCliArgs(args); + expect(result).toEqual(['"say \\"hello\\""', 'normal']); + }); + + it('should escape arguments containing single quotes', () => { + const args = ["don't", 'normal']; + const result = escapeCliArgs(args); + expect(result).toEqual(['"don\'t"', 'normal']); + }); + + it('should escape arguments containing both quote types', () => { + const args = ['mixed "double" and \'single\' quotes']; + const result = escapeCliArgs(args); + expect(result).toEqual(['"mixed \\"double\\" and \'single\' quotes"']); + }); + + it('should escape arguments containing multiple spaces', () => { + const args = ['multiple spaces here']; + const result = escapeCliArgs(args); + expect(result).toEqual(['"multiple spaces here"']); + }); + + it('should handle empty string arguments', () => { + const args = ['', 'normal', '']; + const result = escapeCliArgs(args); + expect(result).toEqual(['', 'normal', '']); + }); + + it('should handle arguments with only spaces', () => { + const args = [' ', 'normal']; + const result = escapeCliArgs(args); + expect(result).toEqual(['" "', 'normal']); + }); + + it('should handle complex mix of arguments', () => { + const args = [ + 'simple', + 'with spaces', + 'with"quotes', + "with'apostrophe", + '--flag', + 'value', + ]; + const result = escapeCliArgs(args); + expect(result).toEqual([ + 'simple', + '"with spaces"', + '"with\\"quotes"', + '"with\'apostrophe"', + '--flag', + 'value', + ]); + }); + + it('should handle arguments with consecutive quotes', () => { + const args = ['""""', "''''"]; + const result = escapeCliArgs(args); + expect(result).toEqual(['"\\"\\"\\"\\""', "\"''''\""]); + }); +}); + +describe('objectToCliArgs', () => { + it('should handle undefined', () => { + const params = { unsupported: undefined as any }; + expect(objectToCliArgs(params)).toStrictEqual([]); + }); + + it('should handle the "_" argument as script', () => { + const params = { _: 'bin.js' }; + const result = objectToCliArgs(params); + expect(result).toEqual(['bin.js']); + }); + + it('should handle the "_" argument with multiple values', () => { + const params = { _: ['bin.js', '--help'] }; + const result = objectToCliArgs(params); + expect(result).toEqual(['bin.js', '--help']); + }); + + it('should handle shorthands arguments', () => { + const params = { + e: `test`, + }; + const result = objectToCliArgs(params); + expect(result).toEqual([`-e="${params.e}"`]); + }); + + it('should handle string arguments', () => { + const params = { name: 'Juanita' }; + const result = objectToCliArgs(params); + expect(result).toEqual(['--name="Juanita"']); + }); + + it('should handle number arguments', () => { + const params = { parallel: 5 }; + const result = objectToCliArgs(params); + expect(result).toEqual(['--parallel=5']); + }); + + it('should handle boolean arguments', () => { + const params = { progress: true }; + const result = objectToCliArgs(params); + expect(result).toEqual(['--progress']); + }); + + it('should handle negated boolean arguments', () => { + const params = { progress: false }; + const result = objectToCliArgs(params); + expect(result).toEqual(['--no-progress']); + }); + + it('should handle array of string arguments', () => { + const params = { format: ['json', 'md'] }; + const result = objectToCliArgs(params); + expect(result).toEqual(['--format="json"', '--format="md"']); + }); + + it('should handle nested objects', () => { + const params = { persist: { format: ['json', 'md'], verbose: false } }; + const result = objectToCliArgs(params); + expect(result).toEqual([ + '--persist.format="json"', + '--persist.format="md"', + '--no-persist.verbose', + ]); + }); + + it('should throw error for unsupported type', () => { + expect(() => objectToCliArgs({ param: Symbol('') })).toThrow( + 'Unsupported type', + ); + }); +}); + +describe('buildCommandString', () => { + it('should return command only when no arguments provided', () => { + const command = 'npm'; + const result = buildCommandString(command); + expect(result).toBe('npm'); + }); + + it('should return command only when empty arguments array provided', () => { + const command = 'npm'; + const result = buildCommandString(command, []); + expect(result).toBe('npm'); + }); + + it('should handle simple arguments without special characters', () => { + const command = 'npm'; + const args = ['install', '--save-dev', 'vitest']; + const result = buildCommandString(command, args); + expect(result).toBe('npm install --save-dev vitest'); + }); + + it('should escape arguments containing spaces', () => { + const command = 'code'; + const args = ['My Project/index.js']; + const result = buildCommandString(command, args); + expect(result).toBe('code "My Project/index.js"'); + }); + + it('should escape arguments containing double quotes', () => { + const command = 'echo'; + const args = ['Hello "World"']; + const result = buildCommandString(command, args); + expect(result).toBe('echo "Hello \\"World\\""'); + }); + + it('should escape arguments containing single quotes', () => { + const command = 'echo'; + const args = ["Hello 'World'"]; + const result = buildCommandString(command, args); + expect(result).toBe('echo "Hello \'World\'"'); + }); + + it('should handle mixed arguments with and without special characters', () => { + const command = 'mycommand'; + const args = ['simple', 'with spaces', '--flag', 'with "quotes"']; + const result = buildCommandString(command, args); + expect(result).toBe( + 'mycommand simple "with spaces" --flag "with \\"quotes\\""', + ); + }); + + it('should handle arguments with multiple types of quotes', () => { + const command = 'test'; + const args = ['arg with "double" and \'single\' quotes']; + const result = buildCommandString(command, args); + expect(result).toBe('test "arg with \\"double\\" and \'single\' quotes"'); + }); + + it('should handle objects with undefined', () => { + const params = { format: undefined }; + const result = objectToCliArgs(params); + expect(result).toStrictEqual([]); + }); + + it('should handle empty string arguments', () => { + const command = 'test'; + const args = ['', 'normal']; + const result = buildCommandString(command, args); + expect(result).toBe('test normal'); + }); + + it('should handle arguments with only spaces', () => { + const command = 'test'; + const args = [' ']; + const result = buildCommandString(command, args); + expect(result).toBe('test " "'); + }); +}); + +describe('formatCommandLog', () => { + it('should format simple command', () => { + const result = removeColorCodes( + formatCommandLog({ + command: 'npx', + args: ['command', '--verbose'], + }), + ); + + expect(result).toBe('$ npx command --verbose'); + }); + + it('should format simple command with explicit process.cwd()', () => { + const result = removeColorCodes( + formatCommandLog({ + command: 'npx', + args: ['command', '--verbose'], + cwd: process.cwd(), + }), + ); + + expect(result).toBe('$ npx command --verbose'); + }); + + it('should format simple command with relative cwd', () => { + const result = removeColorCodes( + formatCommandLog({ + command: 'npx', + args: ['command', '--verbose'], + cwd: './wololo', + }), + ); + + expect(result).toBe(`wololo $ npx command --verbose`); + }); + + it('should format simple command with absolute non-current path converted to relative', () => { + const result = removeColorCodes( + formatCommandLog({ + command: 'npx', + args: ['command', '--verbose'], + cwd: path.join(process.cwd(), 'tmp'), + }), + ); + expect(result).toBe('tmp $ npx command --verbose'); + }); + + it('should format simple command with relative cwd in parent folder', () => { + const result = removeColorCodes( + formatCommandLog({ + command: 'npx', + args: ['command', '--verbose'], + cwd: '..', + }), + ); + + expect(result).toBe(`.. $ npx command --verbose`); + }); + + it('should format simple command using relative path to parent directory', () => { + const result = removeColorCodes( + formatCommandLog({ + command: 'npx', + args: ['command', '--verbose'], + cwd: path.dirname(process.cwd()), + }), + ); + + expect(result).toBe('.. $ npx command --verbose'); + }); +}); diff --git a/packages/utils/src/lib/execute-process.ts b/packages/utils/src/lib/execute-process.ts index e05bbf444..a778d32fc 100644 --- a/packages/utils/src/lib/execute-process.ts +++ b/packages/utils/src/lib/execute-process.ts @@ -6,7 +6,10 @@ import { spawn, } from 'node:child_process'; import type { Readable, Writable } from 'node:stream'; -import { logger } from './logger.js'; +import { formatCommandLog } from './command.js'; +import { isVerbose } from './env.js'; +import { type Logger, logger } from './logger.js'; +import { calcDuration } from './reports/utils.js'; /** * Represents the process result. @@ -103,6 +106,7 @@ export type ProcessConfig = Omit< args?: string[]; observer?: ProcessObserver; ignoreExitCode?: boolean; + verbose?: boolean; }; /** @@ -152,13 +156,31 @@ export type ProcessObserver = { * * @param cfg - see {@link ProcessConfig} */ -export function executeProcess(cfg: ProcessConfig): Promise { - const { command, args, observer, ignoreExitCode = false, ...options } = cfg; +export function executeProcess( + cfg: ProcessConfig, + loggerInstance: Logger = logger, +): Promise { + const { + command, + args, + observer, + ignoreExitCode = false, + verbose, + ...options + } = cfg; const { onStdout, onStderr, onError, onComplete } = observer ?? {}; + if (isVerbose() || verbose === true) { + loggerInstance.info( + formatCommandLog({ + command, + args, + cwd: cfg.cwd ? String(cfg.cwd) : process.cwd(), + }), + ); + } const bin = [command, ...(args ?? [])].join(' '); - - return logger.command( + return loggerInstance.command( bin, () => new Promise((resolve, reject) => { @@ -198,12 +220,12 @@ export function executeProcess(cfg: ProcessConfig): Promise { spawnedProcess.on('close', (code, signal) => { const result: ProcessResult = { bin, code, signal, stdout, stderr }; if (code === 0 || ignoreExitCode) { - logger.debug(output); + loggerInstance.debug(output); onComplete?.(); resolve(result); } else { // ensure stdout and stderr are logged to help debug failure - logger.debug(output, { force: true }); + loggerInstance.debug(output, { force: true }); const error = new ProcessError(result); onError?.(error); reject(error);