diff --git a/packages/ci/src/lib/cli/commands/collect.ts b/packages/ci/src/lib/cli/commands/collect.ts index 9423c4202..49396668d 100644 --- a/packages/ci/src/lib/cli/commands/collect.ts +++ b/packages/ci/src/lib/cli/commands/collect.ts @@ -7,6 +7,7 @@ export async function runCollect( { hasFormats }: { hasFormats: boolean }, ): Promise { await executeProcess({ + verbose: isVerbose(), command: bin, args: [ ...(isVerbose() ? ['--verbose'] : []), diff --git a/packages/ci/src/lib/cli/commands/compare.ts b/packages/ci/src/lib/cli/commands/compare.ts index 6ce6d0726..24a685769 100644 --- a/packages/ci/src/lib/cli/commands/compare.ts +++ b/packages/ci/src/lib/cli/commands/compare.ts @@ -7,6 +7,7 @@ export async function runCompare( { hasFormats }: { hasFormats: boolean }, ): Promise { await executeProcess({ + verbose: isVerbose(), command: bin, args: [ 'compare', diff --git a/packages/ci/src/lib/cli/commands/merge-diffs.ts b/packages/ci/src/lib/cli/commands/merge-diffs.ts index 347bfc191..8032e4880 100644 --- a/packages/ci/src/lib/cli/commands/merge-diffs.ts +++ b/packages/ci/src/lib/cli/commands/merge-diffs.ts @@ -14,6 +14,7 @@ export async function runMergeDiffs( const filename = `merged-${DEFAULT_PERSIST_FILENAME}`; await executeProcess({ + verbose: isVerbose(), command: bin, args: [ 'merge-diffs', diff --git a/packages/ci/src/lib/cli/commands/print-config.ts b/packages/ci/src/lib/cli/commands/print-config.ts index 3d8512428..27eeddea8 100644 --- a/packages/ci/src/lib/cli/commands/print-config.ts +++ b/packages/ci/src/lib/cli/commands/print-config.ts @@ -27,6 +27,7 @@ export async function runPrintConfig({ path.resolve(directory, '.code-pushup', outputFile); await executeProcess({ + verbose: isVerbose(), command: bin, args: [ ...(config ? [`--config=${config}`] : []), diff --git a/packages/ci/src/lib/monorepo/handlers/nx.ts b/packages/ci/src/lib/monorepo/handlers/nx.ts index c11b4f596..7f22d2f0d 100644 --- a/packages/ci/src/lib/monorepo/handlers/nx.ts +++ b/packages/ci/src/lib/monorepo/handlers/nx.ts @@ -3,6 +3,7 @@ import { executeProcess, fileExists, interpolate, + isVerbose, stringifyError, toArray, } from '@code-pushup/utils'; @@ -16,6 +17,7 @@ export const nxHandler: MonorepoToolHandler = { (await fileExists(path.join(options.cwd, 'nx.json'))) && ( await executeProcess({ + verbose: isVerbose(), command: 'npx', args: ['nx', 'report'], cwd: options.cwd, @@ -28,6 +30,7 @@ export const nxHandler: MonorepoToolHandler = { async listProjects({ cwd, task, nxProjectsFilter, observer }) { const { stdout } = await executeProcess({ + verbose: isVerbose(), command: 'npx', args: [ 'nx', diff --git a/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts index e610b1638..e287da355 100644 --- a/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts @@ -77,6 +77,7 @@ describe('nxHandler', () => { '--exclude=*-e2e', '--json', ], + verbose: false, cwd: MEMFS_VOLUME, } satisfies utils.ProcessConfig); }); @@ -92,6 +93,7 @@ describe('nxHandler', () => { command: 'npx', args: ['nx', 'show', 'projects', '--with-target=code-pushup', '--json'], cwd: MEMFS_VOLUME, + verbose: false, } satisfies utils.ProcessConfig); }); diff --git a/packages/ci/src/lib/monorepo/list-projects.unit.test.ts b/packages/ci/src/lib/monorepo/list-projects.unit.test.ts index 5476737fe..e1b25ab31 100644 --- a/packages/ci/src/lib/monorepo/list-projects.unit.test.ts +++ b/packages/ci/src/lib/monorepo/list-projects.unit.test.ts @@ -71,6 +71,7 @@ describe('listMonorepoProjects', () => { args: ['nx', 'show', 'projects', '--with-target=code-pushup', '--json'], cwd: process.cwd(), observer: expect.any(Object), + verbose: false, }); }); diff --git a/packages/ci/src/lib/run.int.test.ts b/packages/ci/src/lib/run.int.test.ts index 329a6b211..c53000c1e 100644 --- a/packages/ci/src/lib/run.int.test.ts +++ b/packages/ci/src/lib/run.int.test.ts @@ -294,12 +294,14 @@ describe('runInCI', () => { args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: workDir, observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { command: options.bin, args: [], cwd: workDir, observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -372,30 +374,35 @@ describe('runInCI', () => { args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: workDir, observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { command: options.bin, args: [], cwd: workDir, observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(3, { command: options.bin, args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: workDir, observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(4, { command: options.bin, args: [], cwd: workDir, observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(5, { command: options.bin, args: ['compare'], cwd: workDir, observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -450,18 +457,21 @@ describe('runInCI', () => { args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: workDir, observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { command: options.bin, args: [], cwd: workDir, observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(3, { command: options.bin, args: ['compare'], cwd: workDir, observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -521,18 +531,21 @@ describe('runInCI', () => { args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: workDir, observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { command: options.bin, args: [], cwd: workDir, observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(3, { command: options.bin, args: ['compare'], cwd: workDir, observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -580,6 +593,7 @@ describe('runInCI', () => { args: expect.arrayContaining(['compare']), cwd: workDir, observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); }); }); @@ -714,12 +728,14 @@ describe('runInCI', () => { ], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: runMany, args: [], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -789,6 +805,7 @@ describe('runInCI', () => { args: [], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -952,18 +969,21 @@ describe('runInCI', () => { ], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: runMany, args: [], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: runMany, args: ['compare'], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: run, @@ -983,6 +1003,7 @@ describe('runInCI', () => { ], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -1058,12 +1079,14 @@ describe('runInCI', () => { args: [], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: runMany, args: ['compare'], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: run, @@ -1083,6 +1106,7 @@ describe('runInCI', () => { ], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).not.toHaveBeenCalledWith( expect.objectContaining({ @@ -1138,18 +1162,21 @@ describe('runInCI', () => { args: expect.any(Array), cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: runMany, args: expect.arrayContaining(['compare']), cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: run, args: expect.arrayContaining(['merge-diffs']), cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); }); }); @@ -1242,12 +1269,14 @@ describe('runInCI', () => { args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: options.bin, args: [], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -1411,18 +1440,21 @@ describe('runInCI', () => { args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: options.bin, args: [], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: options.bin, args: ['compare'], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: options.bin, @@ -1442,6 +1474,7 @@ describe('runInCI', () => { ], cwd: expect.stringContaining(workDir), observer: expectedObserver, + verbose: false, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); diff --git a/packages/core/src/lib/implementation/runner.ts b/packages/core/src/lib/implementation/runner.ts index 0db6becd0..42a0b7cde 100644 --- a/packages/core/src/lib/implementation/runner.ts +++ b/packages/core/src/lib/implementation/runner.ts @@ -39,6 +39,7 @@ export async function executeRunnerConfig( const { outputFile, outputTransform } = config; const { duration, date } = await executeProcess({ + verbose: isVerbose(), command: config.command, args: config.args, observer: { diff --git a/packages/core/src/lib/implementation/runner.unit.test.ts b/packages/core/src/lib/implementation/runner.unit.test.ts index 5f4bd8cf8..8778d57f3 100644 --- a/packages/core/src/lib/implementation/runner.unit.test.ts +++ b/packages/core/src/lib/implementation/runner.unit.test.ts @@ -78,6 +78,7 @@ describe('executeRunnerConfig', () => { onStdout: expect.any(Function), onStderr: expect.any(Function), }, + verbose: false, }); }); diff --git a/packages/create-cli/src/lib/init.ts b/packages/create-cli/src/lib/init.ts index 5aba7c9bc..18c272c9c 100644 --- a/packages/create-cli/src/lib/init.ts +++ b/packages/create-cli/src/lib/init.ts @@ -1,6 +1,7 @@ import { type ProcessConfig, executeProcess, + isVerbose, objectToCliArgs, } from '@code-pushup/utils'; import { @@ -29,6 +30,7 @@ export async function initCodePushup() { ...nxPluginGenerator('init', { skipNxJson: true, }), + verbose: isVerbose(), observer: { onStdout: data => { console.info(parseNxProcessOutput(data.toString())); @@ -39,12 +41,13 @@ export async function initCodePushup() { }, }); - const { stdout: configStdout, stderr: configStderr } = await executeProcess( - nxPluginGenerator('configuration', { + const { stdout: configStdout, stderr: configStderr } = await executeProcess({ + ...nxPluginGenerator('configuration', { skipTarget: true, project: setupResult.projectName, }), - ); + verbose: isVerbose(), + }); console.info(parseNxProcessOutput(configStdout)); console.warn(parseNxProcessOutput(configStderr)); diff --git a/packages/create-cli/src/lib/init.unit.test.ts b/packages/create-cli/src/lib/init.unit.test.ts index e231b0dfb..ada2a6b41 100644 --- a/packages/create-cli/src/lib/init.unit.test.ts +++ b/packages/create-cli/src/lib/init.unit.test.ts @@ -60,6 +60,7 @@ describe('initCodePushup', () => { command: 'npx', args: ['nx', 'g', '@code-pushup/nx-plugin:init', '--skipNxJson'], observer: expect.any(Object), + verbose: false, }); expect(spyParseNxProcessOutput).toHaveBeenNthCalledWith(1, 'stdout-mock'); expect(spyExecuteProcess).toHaveBeenNthCalledWith(2, { @@ -71,6 +72,7 @@ describe('initCodePushup', () => { '--skipTarget', `--project="${projectJson.name}"`, ], + verbose: false, }); expect(spyParseNxProcessOutput).toHaveBeenNthCalledWith(1, 'stdout-mock'); expect(spyParseNxProcessOutput).toHaveBeenCalledTimes(2); 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 740486f33..37a4ec846 100644 --- a/packages/nx-plugin/src/executors/cli/executor.int.test.ts +++ b/packages/nx-plugin/src/executors/cli/executor.int.test.ts @@ -51,6 +51,7 @@ describe('runAutorunExecutor', () => { onError: expect.any(Function), onStdout: expect.any(Function), }, + verbose: true, }); }); }); diff --git a/packages/nx-plugin/src/executors/cli/executor.ts b/packages/nx-plugin/src/executors/cli/executor.ts index 7eece1129..47f077553 100644 --- a/packages/nx-plugin/src/executors/cli/executor.ts +++ b/packages/nx-plugin/src/executors/cli/executor.ts @@ -1,4 +1,5 @@ import { type ExecutorContext, logger } from '@nx/devkit'; +import { isVerbose } from '@code-pushup/utils'; import { executeProcess } from '../../internal/execute-process.js'; import { createCliCommandObject, @@ -43,6 +44,7 @@ export default async function runAutorunExecutor( await executeProcess({ ...createCliCommandObject({ command, args: cliArgumentObject }), ...(context.cwd ? { cwd: context.cwd } : {}), + ...(isVerbose() || verbose ? { verbose: true } : {}), }); } catch (error) { logger.error(error); diff --git a/packages/nx-plugin/src/internal/command.ts b/packages/nx-plugin/src/internal/command.ts new file mode 100644 index 000000000..2b4c0124c --- /dev/null +++ b/packages/nx-plugin/src/internal/command.ts @@ -0,0 +1,168 @@ +/* COPY OF /Users/michael_hladky/WebstormProjects/cli/packages/utils/src/lib/execute-process.ts */ +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/nx-plugin/src/internal/execute-process.ts b/packages/nx-plugin/src/internal/execute-process.ts index cf61f3e84..6a7d8ada4 100644 --- a/packages/nx-plugin/src/internal/execute-process.ts +++ b/packages/nx-plugin/src/internal/execute-process.ts @@ -1,10 +1,13 @@ -import { gray } from 'ansis'; -import { spawn } from 'node:child_process'; -import { ui } from '@code-pushup/utils'; - -export function calcDuration(start: number, stop?: number): number { - return Math.round((stop ?? performance.now()) - start); -} +/* COPY OF /Users/michael_hladky/WebstormProjects/cli/packages/utils/src/lib/execute-process.ts */ +import { + type ChildProcess, + type ChildProcessByStdio, + type SpawnOptionsWithStdioTuple, + type StdioPipe, + spawn, +} from 'node:child_process'; +import type { Readable, Writable } from 'node:stream'; +import { formatCommandLog } from './command.js'; /** * Represents the process result. @@ -82,11 +85,14 @@ export class ProcessError extends Error { * args: ['--version'] * */ -export type ProcessConfig = { +export type ProcessConfig = Omit< + SpawnOptionsWithStdioTuple, + 'stdio' +> & { command: string; args?: string[]; - cwd?: string; observer?: ProcessObserver; + verbose?: boolean; ignoreExitCode?: boolean; }; @@ -104,7 +110,8 @@ export type ProcessConfig = { * } */ export type ProcessObserver = { - onStdout?: (stdout: string) => void; + onStdout?: (stdout: string, sourceProcess?: ChildProcess) => void; + onStderr?: (stderr: string, sourceProcess?: ChildProcess) => void; onError?: (error: ProcessError) => void; onComplete?: () => void; }; @@ -125,7 +132,7 @@ export type ProcessObserver = { * // async process execution * const result = await executeProcess({ * command: 'node', - * args: ['download-data'], + * args: ['download-data.js'], * observer: { * onStdout: updateProgress, * error: handleError, @@ -137,41 +144,60 @@ export type ProcessObserver = { * * @param cfg - see {@link ProcessConfig} */ -export function executeProcess(cfg: ProcessConfig): Promise { - const { observer, cwd, command, args, ignoreExitCode = false } = cfg; - const { onStdout, onError, onComplete } = observer ?? {}; +export function executeProcess( + cfg: ProcessConfig, + logger: { log: (str: string) => void } = { log: console.log }, +): Promise { + const { + command, + args, + observer, + ignoreExitCode = false, + verbose, + ...options + } = cfg; + const { onStdout, onStderr, onError, onComplete } = observer ?? {}; const date = new Date().toISOString(); const start = performance.now(); - const logCommand = [command, ...(args || [])].join(' '); - ui().logger.log( - gray( - `Executing command:\n${logCommand}\nIn working directory:\n${cfg.cwd ?? process.cwd()}`, - ), - ); + if (verbose === true) { + logger.log( + formatCommandLog({ + command, + args, + cwd: cfg.cwd ? String(cfg.cwd) : process.cwd(), + }), + ); + } return new Promise((resolve, reject) => { // shell:true tells Windows to use shell command for spawning a child process - const process = spawn(command, args, { cwd, shell: true }); + const spawnedProcess = spawn(command, args ?? [], { + shell: true, + windowsHide: true, + ...options, + }) as ChildProcessByStdio; + // eslint-disable-next-line functional/no-let let stdout = ''; // eslint-disable-next-line functional/no-let let stderr = ''; - process.stdout.on('data', data => { + spawnedProcess.stdout.on('data', data => { stdout += String(data); - onStdout?.(String(data)); + onStdout?.(String(data), spawnedProcess); }); - process.stderr.on('data', data => { + spawnedProcess.stderr.on('data', data => { stderr += String(data); + onStderr?.(String(data), spawnedProcess); }); - process.on('error', err => { + spawnedProcess.on('error', err => { stderr += err.toString(); }); - process.on('close', code => { + spawnedProcess.on('close', code => { const timings = { date, duration: calcDuration(start) }; if (code === 0 || ignoreExitCode) { onComplete?.(); @@ -184,3 +210,7 @@ export function executeProcess(cfg: ProcessConfig): Promise { }); }); } + +export function calcDuration(start: number, stop?: number): number { + return Math.round((stop ?? performance.now()) - start); +} diff --git a/packages/nx-plugin/src/internal/execute-process.unit.test.ts b/packages/nx-plugin/src/internal/execute-process.unit.test.ts deleted file mode 100644 index 5893b867f..000000000 --- a/packages/nx-plugin/src/internal/execute-process.unit.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { getAsyncProcessRunnerConfig } from '@code-pushup/test-utils'; -import { type ProcessObserver, executeProcess } from './execute-process.js'; - -describe('executeProcess', () => { - const spyObserver: ProcessObserver = { - onStdout: vi.fn(), - onError: vi.fn(), - onComplete: vi.fn(), - }; - const errorSpy = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should work with node command `node -v`', async () => { - const processResult = await executeProcess({ - command: `node`, - args: ['-v'], - observer: spyObserver, - }); - - // Note: called once or twice depending on environment (2nd time for a new line) - expect(spyObserver.onStdout).toHaveBeenCalled(); - expect(spyObserver.onComplete).toHaveBeenCalledOnce(); - expect(spyObserver.onError).not.toHaveBeenCalled(); - expect(processResult.stdout).toMatch(/v\d{1,2}(\.\d{1,2}){0,2}/); - }); - - it('should work with npx command `npx --help`', async () => { - const processResult = await executeProcess({ - command: `npx`, - args: ['--help'], - observer: spyObserver, - }); - expect(spyObserver.onStdout).toHaveBeenCalledOnce(); - expect(spyObserver.onComplete).toHaveBeenCalledOnce(); - expect(spyObserver.onError).not.toHaveBeenCalled(); - expect(processResult.stdout).toContain('npm exec'); - }); - - it('should work with script `node custom-script.js`', async () => { - const processResult = await executeProcess({ - ...getAsyncProcessRunnerConfig({ interval: 10, runs: 4 }), - observer: spyObserver, - }).catch(errorSpy); - - expect(errorSpy).not.toHaveBeenCalled(); - expect(processResult.stdout).toContain('process:complete'); - expect(spyObserver.onStdout).toHaveBeenCalledTimes(6); // intro + 4 runs + complete - expect(spyObserver.onError).not.toHaveBeenCalled(); - expect(spyObserver.onComplete).toHaveBeenCalledOnce(); - }); - - it('should work with async script `node custom-script.js` that throws an error', async () => { - const processResult = await executeProcess({ - ...getAsyncProcessRunnerConfig({ - interval: 10, - runs: 1, - throwError: true, - }), - observer: spyObserver, - }).catch(errorSpy); - - expect(errorSpy).toHaveBeenCalledOnce(); - expect(processResult).toBeUndefined(); - expect(spyObserver.onStdout).toHaveBeenCalledTimes(2); // intro + 1 run before error - expect(spyObserver.onError).toHaveBeenCalledOnce(); - expect(spyObserver.onComplete).not.toHaveBeenCalled(); - }); - - it('should successfully exit process after an error is thrown when ignoreExitCode is set', async () => { - const processResult = await executeProcess({ - ...getAsyncProcessRunnerConfig({ - interval: 10, - runs: 1, - throwError: true, - }), - observer: spyObserver, - ignoreExitCode: true, - }).catch(errorSpy); - - expect(errorSpy).not.toHaveBeenCalled(); - expect(processResult.code).toBe(1); - expect(processResult.stdout).toContain('process:update'); - expect(processResult.stderr).toContain('dummy-error'); - expect(spyObserver.onStdout).toHaveBeenCalledTimes(2); // intro + 1 run before error - expect(spyObserver.onError).not.toHaveBeenCalled(); - expect(spyObserver.onComplete).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/plugin-coverage/src/lib/runner/index.ts b/packages/plugin-coverage/src/lib/runner/index.ts index f22ed5476..e2d3ddc15 100644 --- a/packages/plugin-coverage/src/lib/runner/index.ts +++ b/packages/plugin-coverage/src/lib/runner/index.ts @@ -8,6 +8,7 @@ import { ensureDirectoryExists, executeProcess, filePathToCliArg, + isVerbose, objectToCliArgs, readJsonFile, ui, @@ -26,7 +27,7 @@ export async function executeRunner({ if (coverageToolCommand != null) { const { command, args } = coverageToolCommand; try { - await executeProcess({ command, args }); + await executeProcess({ command, args, verbose: isVerbose() }); } catch (error) { if (error instanceof ProcessError) { const loggingFn = continueOnCommandFail diff --git a/packages/plugin-eslint/src/lib/nx.int.test.ts b/packages/plugin-eslint/src/lib/nx.int.test.ts index 9ac98ee4c..758426b32 100644 --- a/packages/plugin-eslint/src/lib/nx.int.test.ts +++ b/packages/plugin-eslint/src/lib/nx.int.test.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; import type { MockInstance } from 'vitest'; -import { executeProcess } from '@code-pushup/utils'; +import { executeProcess, isVerbose } from '@code-pushup/utils'; import type { ESLintTarget } from './config.js'; import { eslintConfigFromNxProject } from './nx/find-project-without-deps.js'; import { @@ -29,6 +29,7 @@ describe.skipIf(process.platform === 'win32')('Nx helpers', () => { // HACK: somehow prevents "Failed to process project graph" errors await executeProcess({ + verbose: isVerbose(), command: 'npx nx graph --file=.nx/graph.json', cwd: workspaceDir, }); diff --git a/packages/plugin-eslint/src/lib/runner/lint.ts b/packages/plugin-eslint/src/lib/runner/lint.ts index b014738c1..d853c8d4d 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.ts @@ -4,6 +4,7 @@ import { distinct, executeProcess, filePathToCliArg, + isVerbose, toArray, } from '@code-pushup/utils'; import type { ESLintTarget } from '../config.js'; @@ -26,6 +27,7 @@ async function executeLint({ }: ESLintTarget): Promise { // running as CLI because ESLint#lintFiles() runs out of memory const { stdout, stderr, code } = await executeProcess({ + verbose: isVerbose(), command: 'npx', args: [ 'eslint', diff --git a/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts b/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts index 516e3501e..7d1649434 100644 --- a/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts +++ b/packages/plugin-eslint/src/lib/runner/lint.unit.test.ts @@ -112,6 +112,7 @@ describe('lint', () => { ], ignoreExitCode: true, cwd: MEMFS_VOLUME, + verbose: false, }); expect(eslint.calculateConfigForFile).toHaveBeenCalledTimes(3); diff --git a/packages/plugin-js-packages/src/lib/package-managers/derive-yarn.ts b/packages/plugin-js-packages/src/lib/package-managers/derive-yarn.ts index 91462b456..334c3abe9 100644 --- a/packages/plugin-js-packages/src/lib/package-managers/derive-yarn.ts +++ b/packages/plugin-js-packages/src/lib/package-managers/derive-yarn.ts @@ -1,7 +1,8 @@ -import { executeProcess } from '@code-pushup/utils'; +import { executeProcess, isVerbose } from '@code-pushup/utils'; export async function deriveYarnVersion() { const { stdout } = await executeProcess({ + verbose: isVerbose(), command: 'yarn', args: ['-v'], }); diff --git a/packages/plugin-js-packages/src/lib/package-managers/derive-yarn.unit.test.ts b/packages/plugin-js-packages/src/lib/package-managers/derive-yarn.unit.test.ts index b8ee0bc88..97563fab8 100644 --- a/packages/plugin-js-packages/src/lib/package-managers/derive-yarn.unit.test.ts +++ b/packages/plugin-js-packages/src/lib/package-managers/derive-yarn.unit.test.ts @@ -22,6 +22,7 @@ describe('deriveYarnVersion', () => { expect(executeProcessSpy).toHaveBeenCalledWith({ command: 'yarn', args: ['-v'], + verbose: false, }); }); @@ -33,6 +34,7 @@ describe('deriveYarnVersion', () => { expect(executeProcessSpy).toHaveBeenCalledWith({ command: 'yarn', args: ['-v'], + verbose: false, }); }); @@ -46,6 +48,7 @@ describe('deriveYarnVersion', () => { expect(executeProcessSpy).toHaveBeenCalledWith({ command: 'yarn', args: ['-v'], + verbose: false, }); }); }); 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 d1fa98a3f..b5a2d5b46 100644 --- a/packages/utils/src/lib/execute-process.ts +++ b/packages/utils/src/lib/execute-process.ts @@ -6,8 +6,7 @@ import { spawn, } from 'node:child_process'; import type { Readable, Writable } from 'node:stream'; -import { isVerbose } from './env.js'; -import { formatCommandLog } from './format-command-log.js'; +import { formatCommandLog } from './command.js'; import { ui } from './logging.js'; import { calcDuration } from './reports/utils.js'; @@ -94,6 +93,7 @@ export type ProcessConfig = Omit< command: string; args?: string[]; observer?: ProcessObserver; + verbose?: boolean; ignoreExitCode?: boolean; }; @@ -145,15 +145,29 @@ 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, + logger: { log: (str: string) => void } = ui().logger, +): Promise { + const { + command, + args, + observer, + ignoreExitCode = false, + verbose, + ...options + } = cfg; const { onStdout, onStderr, onError, onComplete } = observer ?? {}; const date = new Date().toISOString(); const start = performance.now(); - if (isVerbose()) { - ui().logger.log( - formatCommandLog(command, args, `${cfg.cwd ?? process.cwd()}`), + if (verbose === true) { + logger.log( + formatCommandLog({ + command, + args, + cwd: cfg.cwd ? String(cfg.cwd) : process.cwd(), + }), ); } diff --git a/packages/utils/src/lib/format-command-log.int.test.ts b/packages/utils/src/lib/format-command-log.int.test.ts deleted file mode 100644 index 28a916a55..000000000 --- a/packages/utils/src/lib/format-command-log.int.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { removeColorCodes } from '@code-pushup/test-utils'; -import { formatCommandLog } from './format-command-log.js'; - -describe('formatCommandLog', () => { - it('should format simple command', () => { - const result = removeColorCodes( - formatCommandLog('npx', ['command', '--verbose']), - ); - - expect(result).toBe('$ npx command --verbose'); - }); - - it('should format simple command with explicit process.cwd()', () => { - const result = removeColorCodes( - formatCommandLog('npx', ['command', '--verbose'], process.cwd()), - ); - - expect(result).toBe('$ npx command --verbose'); - }); - - it('should format simple command with relative cwd', () => { - const result = removeColorCodes( - formatCommandLog('npx', ['command', '--verbose'], './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( - 'npx', - ['command', '--verbose'], - 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('npx', ['command', '--verbose'], '..'), - ); - - expect(result).toBe(`.. $ npx command --verbose`); - }); - - it('should format simple command using relative path to parent directory', () => { - const result = removeColorCodes( - formatCommandLog( - 'npx', - ['command', '--verbose'], - path.dirname(process.cwd()), - ), - ); - - expect(result).toBe('.. $ npx command --verbose'); - }); -}); diff --git a/packages/utils/src/lib/format-command-log.ts b/packages/utils/src/lib/format-command-log.ts deleted file mode 100644 index 0ce5a89cd..000000000 --- a/packages/utils/src/lib/format-command-log.ts +++ /dev/null @@ -1,27 +0,0 @@ -import ansis from 'ansis'; -import path from 'node:path'; - -/** - * Formats a command string with optional cwd prefix and ANSI colors. - * - * @param {string} command - The command to execute. - * @param {string[]} args - Array of command arguments. - * @param {string} [cwd] - Optional current working directory for the command. - * @returns {string} - ANSI-colored formatted command string. - */ -export function formatCommandLog( - command: string, - args: string[] = [], - cwd: string = process.cwd(), -): string { - const relativeDir = path.relative(process.cwd(), cwd); - - return [ - ...(relativeDir && relativeDir !== '.' - ? [ansis.italic(ansis.gray(relativeDir))] - : []), - ansis.yellow('$'), - ansis.gray(command), - ansis.gray(args.map(arg => arg).join(' ')), - ].join(' '); -}