diff --git a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts index fb5408812..43257d570 100644 --- a/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts +++ b/e2e/nx-plugin-e2e/tests/plugin-create-nodes.e2e.test.ts @@ -103,29 +103,6 @@ describe('nx-plugin', () => { }); }); - it('should consider plugin option bin in configuration target', async () => { - const cwd = path.join(testFileDir, 'configuration-option-bin'); - registerPluginInWorkspace(tree, { - plugin: '@code-pushup/nx-plugin', - options: { - bin: 'XYZ', - }, - }); - await materializeTree(tree, cwd); - - const { code, projectJson } = await nxShowProjectJson(cwd, project); - - expect(code).toBe(0); - - expect(projectJson.targets).toStrictEqual({ - 'code-pushup--configuration': expect.objectContaining({ - options: { - command: `nx g XYZ:configuration --project="${project}"`, - }, - }), - }); - }); - it('should NOT add config targets dynamically if the project is configured', async () => { const cwd = path.join(testFileDir, 'configuration-already-configured'); registerPluginInWorkspace(tree, '@code-pushup/nx-plugin'); @@ -199,8 +176,10 @@ describe('nx-plugin', () => { // Nx command expect(cleanStdout).toContain('nx run my-lib:code-pushup'); // Run CLI executor - expect(cleanStdout).toContain('Command: npx @code-pushup/cli'); - expect(cleanStdout).toContain('--dryRun --verbose'); + expect(cleanStdout).toContain('Command:'); + expect(cleanStdout).toContain('npx @code-pushup/cli'); + expect(cleanStdout).toContain('--verbose'); + expect(cleanStdout).toContain('--dryRun '); }); it('should consider plugin option bin in executor target', async () => { diff --git a/packages/nx-plugin/src/executors/cli/executor.ts b/packages/nx-plugin/src/executors/cli/executor.ts index be932a35d..a29ab0cbf 100644 --- a/packages/nx-plugin/src/executors/cli/executor.ts +++ b/packages/nx-plugin/src/executors/cli/executor.ts @@ -1,9 +1,5 @@ import { type ExecutorContext, logger } from '@nx/devkit'; import { executeProcess } from '../../internal/execute-process.js'; -import { - createCliCommandObject, - createCliCommandString, -} from '../internal/cli.js'; import { normalizeContext } from '../internal/context.js'; import type { AutorunCommandExecutorOptions } from './schema.js'; import { parseAutorunExecutorOptions } from './utils.js'; @@ -14,20 +10,33 @@ export type ExecutorOutput = { error?: Error; }; +/* eslint-disable-next-line max-lines-per-function */ export default async function runAutorunExecutor( terminalAndExecutorOptions: AutorunCommandExecutorOptions, context: ExecutorContext, ): Promise { + const { objectToCliArgs, formatCommandStatus } = await import( + '@code-pushup/utils' + ); const normalizedContext = normalizeContext(context); const cliArgumentObject = parseAutorunExecutorOptions( terminalAndExecutorOptions, normalizedContext, ); - const { dryRun, verbose, command, bin } = terminalAndExecutorOptions; - const commandString = createCliCommandString({ - command, - args: cliArgumentObject, + const { + dryRun, + verbose, + command: cliCommand, bin, + } = terminalAndExecutorOptions; + const command = bin ? `node` : 'npx'; + const positionals = [ + bin ?? '@code-pushup/cli', + ...(cliCommand ? [cliCommand] : []), + ]; + const args = [...positionals, ...objectToCliArgs(cliArgumentObject)]; + const commandString = formatCommandStatus([command, ...args].join(' '), { + cwd: context.cwd, }); if (verbose) { logger.info(`Run CLI executor ${command ?? ''}`); @@ -38,7 +47,8 @@ export default async function runAutorunExecutor( } else { try { await executeProcess({ - ...createCliCommandObject({ command, args: cliArgumentObject, bin }), + command, + args, ...(context.cwd ? { cwd: context.cwd } : {}), }); } catch (error) { 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 bcac3e153..3c9d3d1c3 100644 --- a/packages/nx-plugin/src/executors/cli/executor.unit.test.ts +++ b/packages/nx-plugin/src/executors/cli/executor.unit.test.ts @@ -122,7 +122,7 @@ describe('runAutorunExecutor', () => { expect.stringContaining(`Run CLI executor`), ); expect(loggerInfoSpy).toHaveBeenCalledWith( - expect.stringContaining('Command: npx @code-pushup/cli'), + expect.stringContaining('Command:'), ); }); @@ -132,9 +132,7 @@ describe('runAutorunExecutor', () => { expect(loggerInfoSpy).toHaveBeenCalledTimes(0); expect(loggerWarnSpy).toHaveBeenCalledTimes(1); expect(loggerWarnSpy).toHaveBeenCalledWith( - expect.stringContaining( - 'DryRun execution of: npx @code-pushup/cli --dryRun', - ), + expect.stringContaining('DryRun execution of'), ); }); }); diff --git a/packages/nx-plugin/src/executors/internal/cli.ts b/packages/nx-plugin/src/executors/internal/cli.ts deleted file mode 100644 index 6ae34f9c7..000000000 --- a/packages/nx-plugin/src/executors/internal/cli.ts +++ /dev/null @@ -1,77 +0,0 @@ -export function createCliCommandString(options?: { - args?: Record; - command?: string; - bin?: string; -}): string { - const { bin = '@code-pushup/cli', command, args } = options ?? {}; - return `npx ${bin} ${objectToCliArgs({ _: command ?? [], ...args }).join( - ' ', - )}`; -} - -export function createCliCommandObject(options?: { - args?: Record; - command?: string; - bin?: string; -}): import('@code-pushup/utils').ProcessConfig { - const { bin = '@code-pushup/cli', command, args } = options ?? {}; - return { - command: 'npx', - args: [bin, ...objectToCliArgs({ _: command ?? [], ...args })], - }; -} - -type ArgumentValue = number | string | boolean | string[]; -export type CliArgsObject> = - T extends never - ? Record | { _: string } - : T; - -// @TODO import from @code-pushup/utils => get rid of poppins for cjs support -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}`]).filter( - v => v != null, - ); - } - - const prefix = key.length === 1 ? '-' : '--'; - // "-*" arguments (shorthands) - if (Array.isArray(value)) { - return value.map(v => `${prefix}${key}="${v}"`); - } - - 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 (value === undefined) { - return []; - } - - throw new Error(`Unsupported type ${typeof value} for key ${key}`); - }); -} diff --git a/packages/nx-plugin/src/executors/internal/cli.unit.test.ts b/packages/nx-plugin/src/executors/internal/cli.unit.test.ts deleted file mode 100644 index 71f00ea0b..000000000 --- a/packages/nx-plugin/src/executors/internal/cli.unit.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - createCliCommandObject, - createCliCommandString, - objectToCliArgs, -} from './cli.js'; - -describe('objectToCliArgs', () => { - it('should empty params', () => { - const result = objectToCliArgs(); - expect(result).toEqual([]); - }); - - 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 = { verbose: true }; - const result = objectToCliArgs(params); - expect(result).toEqual(['--verbose']); - }); - - it('should handle negated boolean arguments', () => { - const params = { verbose: false }; - const result = objectToCliArgs(params); - expect(result).toEqual(['--no-verbose']); - }); - - 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 objects', () => { - const params = { format: { json: 'simple' } }; - const result = objectToCliArgs(params); - expect(result).toStrictEqual(['--format.json="simple"']); - }); - - it('should handle nested objects', () => { - const params = { persist: { format: ['json', 'md'], skipReports: false } }; - const result = objectToCliArgs(params); - expect(result).toEqual([ - '--persist.format="json"', - '--persist.format="md"', - '--no-persist.skipReports', - ]); - }); - - it('should handle objects with undefined', () => { - const params = { format: undefined }; - const result = objectToCliArgs(params); - expect(result).toStrictEqual([]); - }); - - it('should throw error for unsupported type', () => { - expect(() => objectToCliArgs({ param: Symbol('') })).toThrow( - 'Unsupported type', - ); - }); -}); - -describe('createCliCommandString', () => { - it('should create command out of object for arguments', () => { - const result = createCliCommandString({ args: { verbose: true } }); - expect(result).toBe('npx @code-pushup/cli --verbose'); - }); - - it('should create command out of object for arguments with positional', () => { - const result = createCliCommandString({ - args: { _: 'autorun', verbose: true }, - }); - expect(result).toBe('npx @code-pushup/cli autorun --verbose'); - }); -}); - -describe('createCliCommandObject', () => { - it('should create command out of object for arguments', () => { - expect(createCliCommandObject({ args: { verbose: true } })).toStrictEqual({ - args: ['@code-pushup/cli', '--verbose'], - command: 'npx', - }); - }); - - it('should create command out of object for arguments with positional', () => { - expect( - createCliCommandObject({ - args: { _: 'autorun', verbose: true }, - }), - ).toStrictEqual({ - args: ['@code-pushup/cli', 'autorun', '--verbose'], - command: 'npx', - }); - }); - - it('should create command out of object for arguments with bin', () => { - expect( - createCliCommandObject({ - bin: 'node_modules/@code-pushup/cli/src/bin.js', - }), - ).toStrictEqual({ - args: ['node_modules/@code-pushup/cli/src/bin.js'], - command: 'npx', - }); - }); -}); diff --git a/packages/nx-plugin/src/index.ts b/packages/nx-plugin/src/index.ts index c074211c6..12e2e4825 100644 --- a/packages/nx-plugin/src/index.ts +++ b/packages/nx-plugin/src/index.ts @@ -11,7 +11,6 @@ const plugin = { export default plugin; export type { AutorunCommandExecutorOptions } from './executors/cli/schema.js'; -export { objectToCliArgs } from './executors/internal/cli.js'; export { generateCodePushupConfig } from './generators/configuration/code-pushup-config.js'; export { configurationGenerator } from './generators/configuration/generator.js'; export type { ConfigurationGeneratorOptions } from './generators/configuration/schema.js'; diff --git a/packages/nx-plugin/src/plugin/target/configuration-target.ts b/packages/nx-plugin/src/plugin/target/configuration-target.ts index 64a526c79..375ba5f5c 100644 --- a/packages/nx-plugin/src/plugin/target/configuration-target.ts +++ b/packages/nx-plugin/src/plugin/target/configuration-target.ts @@ -1,19 +1,17 @@ import type { TargetConfiguration } from '@nx/devkit'; import type { RunCommandsOptions } from 'nx/src/executors/run-commands/run-commands.impl'; -import { objectToCliArgs } from '../../executors/internal/cli.js'; import { PACKAGE_NAME } from '../../internal/constants.js'; -export function createConfigurationTarget(options?: { +export async function createConfigurationTarget(options?: { projectName?: string; - bin?: string; -}): TargetConfiguration { - const { projectName, bin = PACKAGE_NAME } = options ?? {}; +}): Promise> { + const { projectName } = options ?? {}; + const { objectToCliArgs } = await import('@code-pushup/utils'); const args = objectToCliArgs({ ...(projectName ? { project: projectName } : {}), }); - const argsString = args.length > 0 ? args.join(' ') : ''; - const baseCommand = `nx g ${bin}:configuration`; + const argsString = args.length > 0 ? ` ${args.join(' ')}` : ''; return { - command: argsString ? `${baseCommand} ${argsString}` : baseCommand, + command: `nx g ${PACKAGE_NAME}:configuration${argsString}`, }; } diff --git a/packages/nx-plugin/src/plugin/target/configuration.target.unit.test.ts b/packages/nx-plugin/src/plugin/target/configuration.target.unit.test.ts index f520cc813..6753135f1 100644 --- a/packages/nx-plugin/src/plugin/target/configuration.target.unit.test.ts +++ b/packages/nx-plugin/src/plugin/target/configuration.target.unit.test.ts @@ -3,16 +3,16 @@ import { PACKAGE_NAME } from '../../internal/constants.js'; import { createConfigurationTarget } from './configuration-target.js'; describe('createConfigurationTarget', () => { - it('should return code-pushup--configuration target for given project', () => { - expect( + it('should return code-pushup--configuration target for given project', async () => { + await expect( createConfigurationTarget({ projectName: 'my-project' }), - ).toStrictEqual({ + ).resolves.toStrictEqual({ command: `nx g ${PACKAGE_NAME}:configuration --project="my-project"`, }); }); - it('should return code-pushup--configuration target without project name', () => { - expect(createConfigurationTarget()).toStrictEqual({ + it('should return code-pushup--configuration target without project name', async () => { + await expect(createConfigurationTarget()).resolves.toStrictEqual({ command: `nx g ${PACKAGE_NAME}:configuration`, }); }); diff --git a/packages/nx-plugin/src/plugin/target/targets.ts b/packages/nx-plugin/src/plugin/target/targets.ts index ae192ffd8..f68b1f903 100644 --- a/packages/nx-plugin/src/plugin/target/targets.ts +++ b/packages/nx-plugin/src/plugin/target/targets.ts @@ -27,9 +27,8 @@ export async function createTargets(normalizedContext: CreateTargetsOptions) { } : // if NO code-pushup.config.*.(ts|js|mjs) is present return configuration target { - [`${targetName}--configuration`]: createConfigurationTarget({ + [`${targetName}--configuration`]: await createConfigurationTarget({ projectName: normalizedContext.projectJson.name, - bin, }), }; } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index e1fba9c1b..6a27e8477 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -89,6 +89,7 @@ export { export { interpolate } from './lib/interpolate.js'; export { logMultipleResults } from './lib/log-results.js'; export { Logger, logger } from './lib/logger.js'; +export { formatCommandStatus } from './lib/command.js'; export { mergeConfigs } from './lib/merge-configs.js'; export { addIndex, diff --git a/packages/utils/src/lib/command.ts b/packages/utils/src/lib/command.ts new file mode 100644 index 000000000..212dd8604 --- /dev/null +++ b/packages/utils/src/lib/command.ts @@ -0,0 +1,41 @@ +import ansis from 'ansis'; +import path from 'node:path'; + +/** + * Formats a command string for display with status indicator. + * + * @param bin Command string with arguments. + * @param options Command options (cwd, env). + * @param status Command status ('pending' | 'success' | 'failure'). + * @returns Formatted command string with colored status indicator. + */ +export function formatCommandStatus( + bin: string, + options?: { + env?: Record; + cwd?: string; + }, + status: 'pending' | 'success' | 'failure' = 'pending', +): string { + const cwd = options?.cwd && path.relative(process.cwd(), options.cwd); + const cwdPrefix = cwd ? ansis.blue(cwd) : ''; + const envString = + options?.env && Object.keys(options.env).length > 0 + ? Object.entries(options.env).map(([key, value]) => + ansis.gray(`${key}="${value}"`), + ) + : []; + const statusColor = + status === 'pending' + ? ansis.blue('$') + : status === 'success' + ? ansis.green('$') + : ansis.red('$'); + + return [ + ...(cwdPrefix ? [cwdPrefix] : []), + statusColor, + ...envString, + bin, + ].join(' '); +} 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..5a38cbcf4 --- /dev/null +++ b/packages/utils/src/lib/command.unit.test.ts @@ -0,0 +1,63 @@ +import ansis from 'ansis'; +import path from 'node:path'; +import process from 'node:process'; +import { describe, expect, it } from 'vitest'; +import { formatCommandStatus } from './command.js'; + +describe('formatCommandStatus', () => { + it('should format complex command with cwd, env, and status', () => { + expect( + formatCommandStatus( + 'npx eslint . --format=json', + { + cwd: '', + env: { CP_VERBOSE: true }, + }, + 'failure', + ), + ).toBe( + `${ansis.blue('')} ${ansis.red('$')} ${ansis.gray('CP_VERBOSE="true"')} npx eslint . --format=json`, + ); + }); + + it.each([ + [undefined, ansis.blue], // default to pending + ['pending' as const, ansis.blue], + ['success' as const, ansis.green], + ['failure' as const, ansis.red], + ])(`should format command status %s explicitly`, (status, color) => { + expect( + formatCommandStatus('npx eslint . --format=json', {}, status), + ).toContain(`${color('$')}`); + }); + + it('should not include cwd prefix when cwd is same as process.cwd()', () => { + expect(formatCommandStatus('npx -v', { cwd: process.cwd() })).toStartWith( + `${ansis.blue('$')}`, + ); + }); + + it('should include cwd prefix when cwd is provided and different from process.cwd()', () => { + expect( + formatCommandStatus('npx -v', { cwd: path.join(process.cwd(), 'src') }), + ).toStartWith(`${ansis.blue('src')} `); + }); + + it('should format command with multiple environment variables', () => { + const result = formatCommandStatus('npx eslint .', { + env: { NODE_ENV: 'test', NODE_OPTIONS: '--import tsx' }, + }); + expect(result).toStartWith( + `${ansis.blue('$')} ${ansis.gray('NODE_ENV="test"')} ${ansis.gray('NODE_OPTIONS="--import tsx"')}`, + ); + }); + + it('should format command with environment variable containing spaces', () => { + const result = formatCommandStatus('node packages/cli/src/index.ts', { + env: { NODE_OPTIONS: '--import tsx' }, + }); + expect(result).toBe( + `${ansis.blue('$')} ${ansis.gray('NODE_OPTIONS="--import tsx"')} node packages/cli/src/index.ts`, + ); + }); +}); diff --git a/packages/utils/src/lib/logger.ts b/packages/utils/src/lib/logger.ts index 13d5607fd..81f9849f5 100644 --- a/packages/utils/src/lib/logger.ts +++ b/packages/utils/src/lib/logger.ts @@ -1,8 +1,8 @@ /* eslint-disable max-lines, no-console, @typescript-eslint/class-methods-use-this */ import ansis, { type AnsiColors } from 'ansis'; import os from 'node:os'; -import path from 'node:path'; import ora, { type Ora } from 'ora'; +import { formatCommandStatus } from './command.js'; import { dateToUnixTimestamp } from './dates.js'; import { isEnvVarEnabled } from './env.js'; import { stringifyError } from './errors.js'; @@ -240,14 +240,14 @@ export class Logger { command( bin: string, worker: () => Promise, - options?: { cwd?: string }, + options?: { + cwd?: string; + }, ): Promise { - const cwd = options?.cwd && path.relative(process.cwd(), options.cwd); - const cwdPrefix = cwd ? `${ansis.blue(cwd)} ` : ''; return this.#spinner(worker, { - pending: `${cwdPrefix}${ansis.blue('$')} ${bin}`, - success: () => `${cwdPrefix}${ansis.green('$')} ${bin}`, - failure: () => `${cwdPrefix}${ansis.red('$')} ${bin}`, + pending: formatCommandStatus(bin, options, 'pending'), + success: () => formatCommandStatus(bin, options, 'success'), + failure: () => formatCommandStatus(bin, options, 'failure'), }); } diff --git a/packages/utils/src/lib/transform.ts b/packages/utils/src/lib/transform.ts index 86caf9aef..1c49f36f9 100644 --- a/packages/utils/src/lib/transform.ts +++ b/packages/utils/src/lib/transform.ts @@ -102,6 +102,10 @@ export function objectToCliArgs< return [`${prefix}${value ? '' : 'no-'}${key}`]; } + if (value == null) { + return []; + } + throw new Error(`Unsupported type ${typeof value} for key ${key}`); }); } diff --git a/packages/utils/src/lib/transform.unit.test.ts b/packages/utils/src/lib/transform.unit.test.ts index e15e93224..ec80aca3b 100644 --- a/packages/utils/src/lib/transform.unit.test.ts +++ b/packages/utils/src/lib/transform.unit.test.ts @@ -226,8 +226,14 @@ describe('objectToCliArgs', () => { ]); }); + it('should handle objects with undefined or null', () => { + const params = { format: undefined }; + const result = objectToCliArgs(params); + expect(result).toStrictEqual([]); + }); + it('should throw error for unsupported type', () => { - const params = { unsupported: undefined as any }; + const params = { unsupported: Symbol('test') as any }; expect(() => objectToCliArgs(params)).toThrow('Unsupported type'); }); });