diff --git a/e2e/cli-e2e/tests/print-config.e2e.test.ts b/e2e/cli-e2e/tests/print-config.e2e.test.ts index ad021d9a7..2365b2ea5 100644 --- a/e2e/cli-e2e/tests/print-config.e2e.test.ts +++ b/e2e/cli-e2e/tests/print-config.e2e.test.ts @@ -1,4 +1,4 @@ -import { cp } from 'node:fs/promises'; +import { cp, readFile } from 'node:fs/promises'; import path from 'node:path'; import { beforeAll, expect } from 'vitest'; import { nxTargetProject } from '@code-pushup/test-nx-utils'; @@ -72,4 +72,30 @@ describe('CLI print-config', () => { ); }, ); + + it('should print config to output file', async () => { + const { code, stdout } = await executeProcess({ + command: 'npx', + args: ['@code-pushup/cli', 'print-config', '--output=config.json'], + cwd: testFileDummySetup, + }); + + expect(code).toBe(0); + + const output = await readFile( + path.join(testFileDummySetup, 'config.json'), + 'utf8', + ); + expect(JSON.parse(output)).toEqual( + expect.objectContaining({ + plugins: [ + expect.objectContaining({ + slug: 'dummy-plugin', + title: 'Dummy Plugin', + }), + ], + }), + ); + expect(stdout).not.toContain('dummy-plugin'); + }); }); diff --git a/packages/ci/src/lib/cli/commands/print-config.ts b/packages/ci/src/lib/cli/commands/print-config.ts index dbf280770..a9adf0b98 100644 --- a/packages/ci/src/lib/cli/commands/print-config.ts +++ b/packages/ci/src/lib/cli/commands/print-config.ts @@ -1,4 +1,11 @@ -import { executeProcess, stringifyError } from '@code-pushup/utils'; +import { rm } from 'node:fs/promises'; +import path from 'node:path'; +import { + executeProcess, + generateRandomId, + readJsonFile, + stringifyError, +} from '@code-pushup/utils'; import type { CommandContext } from '../context.js'; export async function runPrintConfig({ @@ -7,27 +14,28 @@ export async function runPrintConfig({ directory, silent, }: CommandContext): Promise { + // random file name so command can be run in parallel + const outputFile = `code-pushup.${generateRandomId()}.config.json`; + const outputPath = path.join(directory, outputFile); + const { stdout } = await executeProcess({ command: bin, - args: [...(config ? [`--config=${config}`] : []), 'print-config'], + args: [ + ...(config ? [`--config=${config}`] : []), + 'print-config', + `--output=${outputFile}`, + ], cwd: directory, }); if (!silent) { console.info(stdout); } - // workaround for 1st lines like `> nx run utils:code-pushup -- print-config` - const lines = stdout.split(/\r?\n/); - const jsonLines = lines.slice(lines.indexOf('{'), lines.indexOf('}') + 1); - const stdoutSanitized = jsonLines.join('\n'); - try { - return JSON.parse(stdoutSanitized) as unknown; + const content = await readJsonFile(outputPath); + await rm(outputPath); + return content; } catch (error) { - if (silent) { - console.info('Invalid output from print-config:'); - console.info(stdout); - } throw new Error( `Error parsing output of print-config command - ${stringifyError(error)}`, ); diff --git a/packages/ci/src/lib/run-monorepo.ts b/packages/ci/src/lib/run-monorepo.ts index 7fcc91245..3bac54c08 100644 --- a/packages/ci/src/lib/run-monorepo.ts +++ b/packages/ci/src/lib/run-monorepo.ts @@ -128,7 +128,7 @@ async function runProjectsInBulk( const currProjectReports = await Promise.all( projects.map(async (project): Promise => { const ctx = createCommandContext(settings, project); - const config = await printPersistConfig(ctx, settings); + const config = await printPersistConfig(ctx); const reports = persistedFilesFromConfig(config, ctx); return { project, reports, config, ctx }; }), diff --git a/packages/ci/src/lib/run-utils.ts b/packages/ci/src/lib/run-utils.ts index b87e27567..9947c963a 100644 --- a/packages/ci/src/lib/run-utils.ts +++ b/packages/ci/src/lib/run-utils.ts @@ -69,7 +69,7 @@ export async function runOnProject( logger.info(`Running Code PushUp on monorepo project ${project.name}`); } - const config = await printPersistConfig(ctx, settings); + const config = await printPersistConfig(ctx); logger.debug( `Loaded persist config from print-config command - ${JSON.stringify(config.persist)}`, ); @@ -267,7 +267,7 @@ export async function checkPrintConfig( ? `Executing print-config for project ${project.name}` : 'Executing print-config'; try { - const config = await printPersistConfig(ctx, settings); + const config = await printPersistConfig(ctx); logger.debug( `${operation} verified code-pushup installed in base branch ${base.ref}`, ); @@ -283,9 +283,8 @@ export async function checkPrintConfig( export async function printPersistConfig( ctx: CommandContext, - settings: Settings, ): Promise> { - const json = await runPrintConfig({ ...ctx, silent: !settings.debug }); + const json = await runPrintConfig(ctx); return parsePersistConfig(json); } diff --git a/packages/ci/src/lib/run.integration.test.ts b/packages/ci/src/lib/run.integration.test.ts index 4a0d2a2f7..07ee4e94f 100644 --- a/packages/ci/src/lib/run.integration.test.ts +++ b/packages/ci/src/lib/run.integration.test.ts @@ -115,13 +115,21 @@ describe('runInCI', () => { break; case 'print-config': - stdout = await readFile(fixturePaths.config, 'utf8'); + let content = await readFile(fixturePaths.config, 'utf8'); if (nxMatch) { // simulate effect of custom persist.outputDir per Nx project - const config = JSON.parse(stdout) as CoreConfig; + const config = JSON.parse(content) as CoreConfig; // eslint-disable-next-line functional/immutable-data config.persist!.outputDir = outputDir; - stdout = JSON.stringify(config, null, 2); + content = JSON.stringify(config, null, 2); + } + const outputFile = args + ?.find(arg => arg.startsWith('--output=')) + ?.split('=')[1]; + if (outputFile) { + await writeFile(path.join(cwd as string, outputFile), content); + } else { + stdout = content; } break; @@ -235,7 +243,7 @@ describe('runInCI', () => { expect(utils.executeProcess).toHaveBeenCalledTimes(2); expect(utils.executeProcess).toHaveBeenNthCalledWith(1, { command: options.bin, - args: ['print-config'], + args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: workDir, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { @@ -307,7 +315,7 @@ describe('runInCI', () => { expect(utils.executeProcess).toHaveBeenCalledTimes(5); expect(utils.executeProcess).toHaveBeenNthCalledWith(1, { command: options.bin, - args: ['print-config'], + args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: workDir, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { @@ -317,7 +325,7 @@ describe('runInCI', () => { } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(3, { command: options.bin, - args: ['print-config'], + args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: workDir, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(4, { @@ -383,7 +391,7 @@ describe('runInCI', () => { expect(utils.executeProcess).toHaveBeenCalledTimes(3); expect(utils.executeProcess).toHaveBeenNthCalledWith(1, { command: options.bin, - args: ['print-config'], + args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: workDir, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { @@ -577,7 +585,7 @@ describe('runInCI', () => { ).toHaveLength(4); // 1 autorun for all projects, 3 print-configs for each project expect(utils.executeProcess).toHaveBeenCalledWith({ command: run, - args: ['print-config'], + args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: expect.stringContaining(workDir), } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ @@ -746,7 +754,7 @@ describe('runInCI', () => { ).toHaveLength(10); expect(utils.executeProcess).toHaveBeenCalledWith({ command: run, - args: ['print-config'], + args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: expect.stringContaining(workDir), } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ @@ -922,7 +930,7 @@ describe('runInCI', () => { ).toHaveLength(6); // 3 autoruns and 3 print-configs for each project expect(utils.executeProcess).toHaveBeenCalledWith({ command: options.bin, - args: ['print-config'], + args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: expect.stringContaining(workDir), } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ @@ -1077,7 +1085,7 @@ describe('runInCI', () => { ).toHaveLength(10); expect(utils.executeProcess).toHaveBeenCalledWith({ command: options.bin, - args: ['print-config'], + args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: expect.stringContaining(workDir), } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ diff --git a/packages/cli/README.md b/packages/cli/README.md index a1e0ecfe6..ca75f0722 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -303,7 +303,11 @@ Usage: Description: Print the resolved configuration. -Refer to the [Common Command Options](#common-command-options) for the list of available options. +In addition to the [Common Command Options](#common-command-options), the following options are recognized by the `print-config` command: + +| Option | Required | Type | Description | +| -------------- | :------: | -------- | -------------------------------------------------------- | +| **`--output`** | no | `string` | Path to output file to print config (default is stdout). | ### `merge-diffs` command diff --git a/packages/cli/src/lib/commands.ts b/packages/cli/src/lib/commands.ts index 7dacdf981..b96c98ef6 100644 --- a/packages/cli/src/lib/commands.ts +++ b/packages/cli/src/lib/commands.ts @@ -4,7 +4,7 @@ import { yargsCollectCommandObject } from './collect/collect-command.js'; import { yargsCompareCommandObject } from './compare/compare-command.js'; import { yargsHistoryCommandObject } from './history/history-command.js'; import { yargsMergeDiffsCommandObject } from './merge-diffs/merge-diffs-command.js'; -import { yargsConfigCommandObject } from './print-config/print-config-command.js'; +import { yargsPrintConfigCommandObject } from './print-config/print-config-command.js'; import { yargsUploadCommandObject } from './upload/upload-command.js'; export const commands: CommandModule[] = [ @@ -17,6 +17,6 @@ export const commands: CommandModule[] = [ yargsUploadCommandObject(), yargsHistoryCommandObject(), yargsCompareCommandObject(), - yargsConfigCommandObject(), + yargsPrintConfigCommandObject(), yargsMergeDiffsCommandObject(), ]; diff --git a/packages/cli/src/lib/implementation/print-config.model.ts b/packages/cli/src/lib/implementation/print-config.model.ts new file mode 100644 index 000000000..c8ec620dd --- /dev/null +++ b/packages/cli/src/lib/implementation/print-config.model.ts @@ -0,0 +1,3 @@ +export type PrintConfigOptions = { + output?: string; +}; diff --git a/packages/cli/src/lib/implementation/print-config.options.ts b/packages/cli/src/lib/implementation/print-config.options.ts new file mode 100644 index 000000000..a38dd4579 --- /dev/null +++ b/packages/cli/src/lib/implementation/print-config.options.ts @@ -0,0 +1,14 @@ +import type { Options } from 'yargs'; +import type { PrintConfigOptions } from './print-config.model.js'; + +export function yargsPrintConfigOptionsDefinition(): Record< + keyof PrintConfigOptions, + Options +> { + return { + output: { + describe: 'Output file path to use instead of stdout', + type: 'string', + }, + }; +} diff --git a/packages/cli/src/lib/print-config/print-config-command.ts b/packages/cli/src/lib/print-config/print-config-command.ts index 278d7d5ac..25af05aaa 100644 --- a/packages/cli/src/lib/print-config/print-config-command.ts +++ b/packages/cli/src/lib/print-config/print-config-command.ts @@ -1,18 +1,34 @@ +import { bold } from 'ansis'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; import type { CommandModule } from 'yargs'; import { ui } from '@code-pushup/utils'; import { filterKebabCaseKeys } from '../implementation/global.utils.js'; +import type { PrintConfigOptions } from '../implementation/print-config.model.js'; +import { yargsPrintConfigOptionsDefinition } from '../implementation/print-config.options.js'; -export function yargsConfigCommandObject() { +export function yargsPrintConfigCommandObject() { const command = 'print-config'; return { command, describe: 'Print config', - handler: yargsArgs => { - const { _, $0, ...args } = yargsArgs; + builder: yargsPrintConfigOptionsDefinition(), + handler: async yargsArgs => { // it is important to filter out kebab case keys // because yargs duplicates options in camel case and kebab case - const cleanArgs = filterKebabCaseKeys(args); - ui().logger.log(JSON.stringify(cleanArgs, null, 2)); + const { _, $0, ...args } = filterKebabCaseKeys(yargsArgs); + const { output, ...config } = args as PrintConfigOptions & + Record; + + const content = JSON.stringify(config, null, 2); + + if (output) { + await mkdir(path.dirname(output), { recursive: true }); + await writeFile(output, content); + ui().logger.info(`Config printed to file ${bold(output)}`); + } else { + ui().logger.log(content); + } }, } satisfies CommandModule; } diff --git a/packages/cli/src/lib/print-config/print-config-command.unit.test.ts b/packages/cli/src/lib/print-config/print-config-command.unit.test.ts index 23e09893b..32c830846 100644 --- a/packages/cli/src/lib/print-config/print-config-command.unit.test.ts +++ b/packages/cli/src/lib/print-config/print-config-command.unit.test.ts @@ -1,8 +1,11 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; import { describe, expect, vi } from 'vitest'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import { ui } from '@code-pushup/utils'; import { DEFAULT_CLI_CONFIGURATION } from '../../../mocks/constants.js'; import { yargsCli } from '../yargs-cli.js'; -import { yargsConfigCommandObject } from './print-config-command.js'; +import { yargsPrintConfigCommandObject } from './print-config-command.js'; vi.mock('@code-pushup/core', async () => { const { CORE_CONFIG_MOCK }: typeof import('@code-pushup/test-utils') = @@ -10,21 +13,42 @@ vi.mock('@code-pushup/core', async () => { const core: object = await vi.importActual('@code-pushup/core'); return { ...core, - readRcByPath: vi.fn().mockResolvedValue(CORE_CONFIG_MOCK), + autoloadRc: vi.fn().mockResolvedValue(CORE_CONFIG_MOCK), }; }); describe('print-config-command', () => { + it('should log config to stdout by default', async () => { + await yargsCli(['print-config'], { + ...DEFAULT_CLI_CONFIGURATION, + commands: [yargsPrintConfigCommandObject()], + }).parseAsync(); + + expect(ui()).toHaveLogged('log', expect.stringContaining('"plugins": [')); + }); + + it('should write config to file if output option is given', async () => { + const outputPath = path.join(MEMFS_VOLUME, 'config.json'); + await yargsCli(['print-config', `--output=${outputPath}`], { + ...DEFAULT_CLI_CONFIGURATION, + commands: [yargsPrintConfigCommandObject()], + }).parseAsync(); + + await expect(readFile(outputPath, 'utf8')).resolves.toContain( + '"plugins": [', + ); + expect(ui()).not.toHaveLogged( + 'log', + expect.stringContaining('"plugins": ['), + ); + expect(ui()).toHaveLogged('info', `Config printed to file ${outputPath}`); + }); + it('should filter out meta arguments and kebab duplicates', async () => { - await yargsCli( - [ - 'print-config', - '--verbose', - `--config=/test/code-pushup.config.ts`, - '--persist.outputDir=destinationDir', - ], - { ...DEFAULT_CLI_CONFIGURATION, commands: [yargsConfigCommandObject()] }, - ).parseAsync(); + await yargsCli(['print-config', '--persist.outputDir=destinationDir'], { + ...DEFAULT_CLI_CONFIGURATION, + commands: [yargsPrintConfigCommandObject()], + }).parseAsync(); expect(ui()).not.toHaveLogged('log', expect.stringContaining('"$0":')); expect(ui()).not.toHaveLogged('log', expect.stringContaining('"_":')); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 678b762fb..3d037a3e4 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,9 +1,17 @@ export { exists } from '@code-pushup/models'; +export { + camelCaseToKebabCase, + capitalize, + kebabCaseToCamelCase, + toSentenceCase, + toTitleCase, +} from './lib/case-conversions.js'; +export { createRunnerFiles } from './lib/create-runner-files.js'; export { comparePairs, matchArrayItemsByKey, type Diff } from './lib/diff.js'; export { stringifyError } from './lib/errors.js'; export { - ProcessError, executeProcess, + ProcessError, type ProcessConfig, type ProcessObserver, type ProcessResult, @@ -41,8 +49,8 @@ export { } from './lib/formatting.js'; export { getCurrentBranchOrTag, - getHashFromTag, getHashes, + getHashFromTag, getLatestCommit, getSemverTags, type LogResult, @@ -56,14 +64,15 @@ export { } from './lib/git/git.js'; export { groupByStatus } from './lib/group-by-status.js'; export { + hasNoNullableProps, isPromiseFulfilledResult, isPromiseRejectedResult, - hasNoNullableProps, } from './lib/guards.js'; export { logMultipleResults } from './lib/log-results.js'; -export { link, ui, type CliUi, type Column, isVerbose } from './lib/logging.js'; +export { isVerbose, link, ui, type CliUi, type Column } from './lib/logging.js'; export { mergeConfigs } from './lib/merge-configs.js'; export { getProgressBar, type ProgressBar } from './lib/progress.js'; +export { generateRandomId } from './lib/random.js'; export { CODE_PUSHUP_DOMAIN, CODE_PUSHUP_UNICODE_LOGO, @@ -95,13 +104,6 @@ export { formatReportScore, } from './lib/reports/utils.js'; export { isSemver, normalizeSemver, sortSemvers } from './lib/semver.js'; -export { - camelCaseToKebabCase, - kebabCaseToCamelCase, - capitalize, - toSentenceCase, - toTitleCase, -} from './lib/case-conversions.js'; export * from './lib/text-formats/index.js'; export { countOccurrences, @@ -121,13 +123,12 @@ export { type CliArgsObject, } from './lib/transform.js'; export type { + CamelCaseToKebabCase, ExcludeNullableProps, ExtractArray, ExtractArrays, ItemOrArray, Prettify, WithRequired, - CamelCaseToKebabCase, } from './lib/types.js'; export { parseSchema, SchemaValidationError } from './lib/zod-validation.js'; -export { createRunnerFiles } from './lib/create-runner-files.js'; diff --git a/packages/utils/src/lib/random.ts b/packages/utils/src/lib/random.ts new file mode 100644 index 000000000..5d6d79e06 --- /dev/null +++ b/packages/utils/src/lib/random.ts @@ -0,0 +1,3 @@ +export function generateRandomId(): string { + return Math.random().toString().slice(2); +} diff --git a/packages/utils/src/lib/random.unit.test.ts b/packages/utils/src/lib/random.unit.test.ts new file mode 100644 index 000000000..f771ca1db --- /dev/null +++ b/packages/utils/src/lib/random.unit.test.ts @@ -0,0 +1,11 @@ +import { generateRandomId } from './random.js'; + +describe('generateRandomId', () => { + it('should generate different IDs', () => { + expect(generateRandomId()).not.toEqual(generateRandomId()); + }); + + it('should generate integer string', () => { + expect(generateRandomId()).toMatch(/^\d+$/); + }); +});