diff --git a/packages/ci/eslint.config.js b/packages/ci/eslint.config.js index 13888c2a8..cf8428a77 100644 --- a/packages/ci/eslint.config.js +++ b/packages/ci/eslint.config.js @@ -12,15 +12,6 @@ export default tseslint.config( }, }, }, - { - files: ['**/*.json'], - rules: { - '@nx/dependency-checks': [ - 'error', - { ignoredDependencies: ['type-fest'] }, // only for internal typings - ], - }, - }, { files: ['**/*.test.ts'], rules: { diff --git a/packages/ci/package.json b/packages/ci/package.json index da31fddda..395f55c22 100644 --- a/packages/ci/package.json +++ b/packages/ci/package.json @@ -32,7 +32,7 @@ "ansis": "^3.3.2", "glob": "^11.0.1", "simple-git": "^3.20.0", - "yaml": "^2.5.1", + "type-fest": "^4.26.1", "zod": "^4.2.1" }, "files": [ diff --git a/packages/ci/src/index.ts b/packages/ci/src/index.ts index 3e67b1b3e..3f0c8c23a 100644 --- a/packages/ci/src/index.ts +++ b/packages/ci/src/index.ts @@ -1,10 +1,5 @@ export type { SourceFileIssue } from './lib/issues.js'; export type * from './lib/models.js'; -export { - isMonorepoTool, - MONOREPO_TOOLS, - type MonorepoTool, -} from './lib/monorepo/index.js'; export { runInCI } from './lib/run.js'; export { configPatternsSchema } from './lib/schemas.js'; export { diff --git a/packages/ci/src/lib/models.ts b/packages/ci/src/lib/models.ts index e6ec3db1d..6a3d3d75a 100644 --- a/packages/ci/src/lib/models.ts +++ b/packages/ci/src/lib/models.ts @@ -1,6 +1,6 @@ import type { Format, PersistConfig, UploadConfig } from '@code-pushup/models'; +import type { MonorepoTool } from '@code-pushup/utils'; import type { SourceFileIssue } from './issues.js'; -import type { MonorepoTool } from './monorepo/index.js'; /** * Customization options for {@link runInCI} diff --git a/packages/ci/src/lib/monorepo/detect-tool.ts b/packages/ci/src/lib/monorepo/detect-tool.ts deleted file mode 100644 index 288de72e0..000000000 --- a/packages/ci/src/lib/monorepo/detect-tool.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { MONOREPO_TOOL_HANDLERS } from './handlers/index.js'; -import type { MonorepoHandlerOptions, MonorepoTool } from './tools.js'; - -export async function detectMonorepoTool( - options: MonorepoHandlerOptions, -): Promise { - // eslint-disable-next-line functional/no-loop-statements - for (const handler of MONOREPO_TOOL_HANDLERS) { - if (await handler.isConfigured(options)) { - return handler.tool; - } - } - return null; -} diff --git a/packages/ci/src/lib/monorepo/handlers/index.ts b/packages/ci/src/lib/monorepo/handlers/index.ts index 7bc1e202e..59da8fddf 100644 --- a/packages/ci/src/lib/monorepo/handlers/index.ts +++ b/packages/ci/src/lib/monorepo/handlers/index.ts @@ -1,4 +1,5 @@ -import type { MonorepoTool, MonorepoToolHandler } from '../tools.js'; +import type { MonorepoTool } from '@code-pushup/utils'; +import type { MonorepoToolHandler } from '../tools.js'; import { npmHandler } from './npm.js'; import { nxHandler } from './nx.js'; import { pnpmHandler } from './pnpm.js'; diff --git a/packages/ci/src/lib/monorepo/handlers/npm.ts b/packages/ci/src/lib/monorepo/handlers/npm.ts index 859bd17db..49fbeec08 100644 --- a/packages/ci/src/lib/monorepo/handlers/npm.ts +++ b/packages/ci/src/lib/monorepo/handlers/npm.ts @@ -1,23 +1,13 @@ -import path from 'node:path'; -import { fileExists } from '@code-pushup/utils'; import { hasCodePushUpDependency, hasScript, - hasWorkspacesEnabled, listWorkspaces, -} from '../packages.js'; +} from '@code-pushup/utils'; import type { MonorepoToolHandler } from '../tools.js'; export const npmHandler: MonorepoToolHandler = { tool: 'npm', - async isConfigured(options) { - return ( - (await fileExists(path.join(options.cwd, 'package-lock.json'))) && - (await hasWorkspacesEnabled(options.cwd)) - ); - }, - async listProjects(options) { const { workspaces, rootPackageJson } = await listWorkspaces(options.cwd); return workspaces diff --git a/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts index 0bb5719aa..3491e6564 100644 --- a/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/npm.unit.test.ts @@ -18,47 +18,6 @@ describe('npmHandler', () => { const pkgJsonContent = (content: PackageJson): string => JSON.stringify(content); - describe('isConfigured', () => { - it('should detect NPM workspaces when package-lock.json exists and "workspaces" set in package.json', async () => { - vol.fromJSON( - { - 'package.json': pkgJsonContent({ - private: true, - workspaces: ['packages/*'], - }), - 'package-lock.json': '', - }, - MEMFS_VOLUME, - ); - await expect(npmHandler.isConfigured(options)).resolves.toBeTrue(); - }); - - it('should NOT detect NPM workspaces when "workspaces" not set in package.json', async () => { - vol.fromJSON( - { - 'package.json': pkgJsonContent({}), - 'package-lock.json': '', - }, - MEMFS_VOLUME, - ); - await expect(npmHandler.isConfigured(options)).resolves.toBeFalse(); - }); - - it("should NOT detect NPM workspaces when package-lock.json doesn't exist", async () => { - vol.fromJSON( - { - 'package.json': pkgJsonContent({ - private: true, - workspaces: ['packages/*'], - }), - 'yarn.lock': '', - }, - MEMFS_VOLUME, - ); - await expect(npmHandler.isConfigured(options)).resolves.toBeFalse(); - }); - }); - describe('listProjects', () => { it('should list all NPM workspaces with code-pushup script', async () => { vol.fromJSON( diff --git a/packages/ci/src/lib/monorepo/handlers/nx.ts b/packages/ci/src/lib/monorepo/handlers/nx.ts index bcaec1e44..09275b26d 100644 --- a/packages/ci/src/lib/monorepo/handlers/nx.ts +++ b/packages/ci/src/lib/monorepo/handlers/nx.ts @@ -1,7 +1,5 @@ -import path from 'node:path'; import { executeProcess, - fileExists, interpolate, stringifyError, toArray, @@ -11,21 +9,18 @@ import type { MonorepoToolHandler } from '../tools.js'; export const nxHandler: MonorepoToolHandler = { tool: 'nx', - async isConfigured(options) { - return ( - (await fileExists(path.join(options.cwd, 'nx.json'))) && - ( - await executeProcess({ - command: 'npx', - args: ['nx', 'report'], - cwd: options.cwd, - ignoreExitCode: true, - }) - ).code === 0 - ); - }, - async listProjects({ cwd, task, nxProjectsFilter }) { + const { code, stderr } = await executeProcess({ + command: 'npx', + args: ['nx', 'report'], + cwd, + ignoreExitCode: true, + }); + if (code !== 0) { + const suffix = stderr ? ` - ${stderr}` : ''; + throw new Error(`'nx report' failed with exit code ${code}${suffix}`); + } + const { stdout } = await executeProcess({ command: 'npx', args: [ 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..42093f680 100644 --- a/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/nx.unit.test.ts @@ -1,4 +1,3 @@ -import { vol } from 'memfs'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import * as utils from '@code-pushup/utils'; import type { @@ -16,42 +15,15 @@ describe('nxHandler', () => { nxProjectsFilter: '--with-target={task}', }; - describe('isConfigured', () => { - it('should detect Nx when nx.json exists and `nx report` succeeds', async () => { - vol.fromJSON({ 'nx.json': '{}' }, MEMFS_VOLUME); - vi.spyOn(utils, 'executeProcess').mockResolvedValue({ - code: 0, - stdout: 'NX Report complete - copy this into the issue template', - } as utils.ProcessResult); - - await expect(nxHandler.isConfigured(options)).resolves.toBeTrue(); - }); - - it("should NOT detect Nx when nx.json doesn't exist", async () => { - vol.fromJSON({ 'turbo.json': '{}' }, MEMFS_VOLUME); - vi.spyOn(utils, 'executeProcess').mockResolvedValue({ - code: 0, - } as utils.ProcessResult); - - await expect(nxHandler.isConfigured(options)).resolves.toBeFalse(); - }); - - it('should NOT detect Nx when `nx report` fails with non-zero exit code', async () => { - vol.fromJSON({ 'nx.json': '' }, MEMFS_VOLUME); - vi.spyOn(utils, 'executeProcess').mockResolvedValue({ - code: 1, - stderr: 'Error: ValueExpected in nx.json', - } as utils.ProcessResult); - - await expect(nxHandler.isConfigured(options)).resolves.toBeFalse(); - }); - }); - describe('listProjects', () => { + const nxReportSuccess = { code: 0 } as utils.ProcessResult; + beforeEach(() => { - vi.spyOn(utils, 'executeProcess').mockResolvedValue({ - stdout: '["backend","frontend"]', - } as utils.ProcessResult); + vi.spyOn(utils, 'executeProcess') + .mockResolvedValueOnce(nxReportSuccess) + .mockResolvedValueOnce({ + stdout: '["backend","frontend"]', + } as utils.ProcessResult); }); it('should list projects from `nx show projects`', async () => { @@ -95,10 +67,26 @@ describe('nxHandler', () => { } satisfies utils.ProcessConfig); }); + it('should throw if `nx report` fails', async () => { + vi.spyOn(utils, 'executeProcess') + .mockReset() + .mockResolvedValueOnce({ + code: 1, + stderr: 'Error: ValueExpected in nx.json', + } as utils.ProcessResult); + + await expect(nxHandler.listProjects(options)).rejects.toThrow( + "'nx report' failed with exit code 1 - Error: ValueExpected in nx.json", + ); + }); + it('should throw if `nx show projects` outputs invalid JSON', async () => { - vi.spyOn(utils, 'executeProcess').mockResolvedValue({ - stdout: 'backend\nfrontend\n', - } as utils.ProcessResult); + vi.spyOn(utils, 'executeProcess') + .mockReset() + .mockResolvedValueOnce(nxReportSuccess) + .mockResolvedValueOnce({ + stdout: 'backend\nfrontend\n', + } as utils.ProcessResult); await expect(nxHandler.listProjects(options)).rejects.toThrow( "Invalid non-JSON output from 'nx show projects' - SyntaxError: Unexpected token", @@ -106,9 +94,12 @@ describe('nxHandler', () => { }); it("should throw if `nx show projects` JSON output isn't array of strings", async () => { - vi.spyOn(utils, 'executeProcess').mockResolvedValue({ - stdout: '"backend"', - } as utils.ProcessResult); + vi.spyOn(utils, 'executeProcess') + .mockReset() + .mockResolvedValueOnce(nxReportSuccess) + .mockResolvedValueOnce({ + stdout: '"backend"', + } as utils.ProcessResult); await expect(nxHandler.listProjects(options)).rejects.toThrow( 'Invalid JSON output from \'nx show projects\', expected array of strings, received "backend"', diff --git a/packages/ci/src/lib/monorepo/handlers/pnpm.ts b/packages/ci/src/lib/monorepo/handlers/pnpm.ts index 45885e823..835b650dc 100644 --- a/packages/ci/src/lib/monorepo/handlers/pnpm.ts +++ b/packages/ci/src/lib/monorepo/handlers/pnpm.ts @@ -1,30 +1,18 @@ -import path from 'node:path'; -import * as YAML from 'yaml'; -import { fileExists, readTextFile } from '@code-pushup/utils'; import { hasCodePushUpDependency, hasScript, listPackages, + readPnpmWorkspacePatterns, readRootPackageJson, -} from '../packages.js'; +} from '@code-pushup/utils'; import type { MonorepoToolHandler } from '../tools.js'; -const WORKSPACE_FILE = 'pnpm-workspace.yaml'; - export const pnpmHandler: MonorepoToolHandler = { tool: 'pnpm', - async isConfigured(options) { - return ( - (await fileExists(path.join(options.cwd, WORKSPACE_FILE))) && - (await fileExists(path.join(options.cwd, 'package.json'))) - ); - }, - async listProjects(options) { - const yaml = await readTextFile(path.join(options.cwd, WORKSPACE_FILE)); - const workspace = YAML.parse(yaml) as { packages?: string[] }; - const packages = await listPackages(options.cwd, workspace.packages); + const patterns = await readPnpmWorkspacePatterns(options.cwd); + const packages = await listPackages(options.cwd, patterns); const rootPackageJson = await readRootPackageJson(options.cwd); return packages .filter( diff --git a/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts index 4ef39bfbd..0e1a8a339 100644 --- a/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/pnpm.unit.test.ts @@ -19,43 +19,6 @@ describe('pnpmHandler', () => { const pkgJsonContent = (content: PackageJson): string => JSON.stringify(content); - describe('isConfigured', () => { - it('should detect PNPM workspace when pnpm-workspace.yaml and package.json files exist', async () => { - vol.fromJSON( - { - 'package.json': pkgJsonContent({}), - 'pnpm-workspace.yaml': 'packages:\n- apps/*\n- libs/*\n\n', - }, - MEMFS_VOLUME, - ); - await expect(pnpmHandler.isConfigured(options)).resolves.toBeTrue(); - }); - - it("should NOT detect PNPM workspace when pnpm-workspace.yaml doesn't exist", async () => { - vol.fromJSON( - { - 'package.json': pkgJsonContent({}), - 'pnpm-lock.yaml': '', - }, - MEMFS_VOLUME, - ); - await expect(pnpmHandler.isConfigured(options)).resolves.toBeFalse(); - }); - - it("should NOT detect PNPM workspace when root package.json doesn't exist", async () => { - vol.fromJSON( - { - 'packages/cli/package.json': pkgJsonContent({}), - 'packages/cli/pnpm-lock.yaml': '', - 'packages/core/package.json': pkgJsonContent({}), - 'packages/core/pnpm-lock.yaml': '', - }, - MEMFS_VOLUME, - ); - await expect(pnpmHandler.isConfigured(options)).resolves.toBeFalse(); - }); - }); - describe('listProjects', () => { it('should list all PNPM workspace packages with code-pushup script', async () => { vol.fromJSON( diff --git a/packages/ci/src/lib/monorepo/handlers/turbo.ts b/packages/ci/src/lib/monorepo/handlers/turbo.ts index 49e2e32f5..d8e9de3a2 100644 --- a/packages/ci/src/lib/monorepo/handlers/turbo.ts +++ b/packages/ci/src/lib/monorepo/handlers/turbo.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { fileExists, readJsonFile } from '@code-pushup/utils'; +import { MONOREPO_TOOL_DETECTORS, readJsonFile } from '@code-pushup/utils'; import type { MonorepoToolHandler } from '../tools.js'; import { npmHandler } from './npm.js'; import { pnpmHandler } from './pnpm.js'; @@ -14,18 +14,17 @@ type TurboConfig = { export const turboHandler: MonorepoToolHandler = { tool: 'turbo', - async isConfigured(options) { + async listProjects(options) { const configPath = path.join(options.cwd, 'turbo.json'); - return ( - (await fileExists(configPath)) && - options.task in (await readJsonFile(configPath)).tasks - ); - }, + if ( + !(options.task in (await readJsonFile(configPath)).tasks) + ) { + throw new Error(`Task "${options.task}" not found in turbo.json`); + } - async listProjects(options) { // eslint-disable-next-line functional/no-loop-statements for (const handler of WORKSPACE_HANDLERS) { - if (await handler.isConfigured(options)) { + if (await MONOREPO_TOOL_DETECTORS[handler.tool](options.cwd)) { const projects = await handler.listProjects(options); return projects .filter(({ bin }) => bin.includes(`run ${options.task}`)) // must have package.json script diff --git a/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts index 776dab0df..5ba0dadf3 100644 --- a/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/turbo.unit.test.ts @@ -21,61 +21,6 @@ describe('turboHandler', () => { const turboJsonContent = (content: { tasks: Record }) => JSON.stringify(content); - describe('isConfigured', () => { - it('should detect Turborepo when turbo.json exists and has code-pushup task', async () => { - vol.fromJSON( - { - 'package.json': pkgJsonContent({}), - 'turbo.json': turboJsonContent({ - tasks: { - 'code-pushup': { - env: ['CP_API_KEY'], - outputs: ['.code-pushup'], - }, - }, - }), - }, - MEMFS_VOLUME, - ); - await expect(turboHandler.isConfigured(options)).resolves.toBeTrue(); - }); - - it("should NOT detect Turborepo when turbo.json doesn't exist", async () => { - vol.fromJSON( - { - 'package.json': pkgJsonContent({}), - 'pnpm-lock.yaml': '', - }, - MEMFS_VOLUME, - ); - await expect(turboHandler.isConfigured(options)).resolves.toBeFalse(); - }); - - it("should NOT detect Turborepo when turbo.json doesn't include code-pushup task", async () => { - vol.fromJSON( - { - 'package.json': pkgJsonContent({}), - 'turbo.json': turboJsonContent({ - tasks: { - build: { - dependsOn: ['^build'], - outputs: ['dist/**'], - }, - lint: {}, - test: {}, - dev: { - cache: false, - persistent: true, - }, - }, - }), - }, - MEMFS_VOLUME, - ); - await expect(turboHandler.isConfigured(options)).resolves.toBeFalse(); - }); - }); - describe('listProjects', () => { it.each([ [ @@ -148,6 +93,22 @@ describe('turboHandler', () => { }, ); + it('should throw if task not found in turbo.json', async () => { + vol.fromJSON( + { + 'package.json': pkgJsonContent({}), + 'turbo.json': turboJsonContent({ + tasks: { build: {}, lint: {} }, + }), + }, + MEMFS_VOLUME, + ); + + await expect(turboHandler.listProjects(options)).rejects.toThrow( + 'Task "code-pushup" not found in turbo.json', + ); + }); + it('should throw if no supported package manager configured', async () => { vol.fromJSON( { @@ -171,19 +132,16 @@ describe('turboHandler', () => { name: 'api', directory: path.join(MEMFS_VOLUME, 'api'), bin: 'npx turbo run code-pushup --', - binUncached: 'npx turbo run code-pushup --', }, { name: 'cms', directory: path.join(MEMFS_VOLUME, 'cms'), bin: 'npx turbo run code-pushup --', - binUncached: 'npx turbo run code-pushup --', }, { name: 'web', directory: path.join(MEMFS_VOLUME, 'web'), bin: 'npx turbo run code-pushup --', - binUncached: 'npx turbo run code-pushup --', }, ], }; diff --git a/packages/ci/src/lib/monorepo/handlers/yarn.ts b/packages/ci/src/lib/monorepo/handlers/yarn.ts index 8ba2dcf03..2e8d4a442 100644 --- a/packages/ci/src/lib/monorepo/handlers/yarn.ts +++ b/packages/ci/src/lib/monorepo/handlers/yarn.ts @@ -1,23 +1,14 @@ -import path from 'node:path'; -import { executeProcess, fileExists } from '@code-pushup/utils'; import { + executeProcess, hasCodePushUpDependency, hasScript, - hasWorkspacesEnabled, listWorkspaces, -} from '../packages.js'; +} from '@code-pushup/utils'; import type { MonorepoToolHandler } from '../tools.js'; export const yarnHandler: MonorepoToolHandler = { tool: 'yarn', - async isConfigured(options) { - return ( - (await fileExists(path.join(options.cwd, 'yarn.lock'))) && - (await hasWorkspacesEnabled(options.cwd)) - ); - }, - async listProjects(options) { const { workspaces, rootPackageJson } = await listWorkspaces(options.cwd); return workspaces diff --git a/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts b/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts index 6d2c3419f..d2e411b51 100644 --- a/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts +++ b/packages/ci/src/lib/monorepo/handlers/yarn.unit.test.ts @@ -20,47 +20,6 @@ describe('yarnHandler', () => { const pkgJsonContent = (content: PackageJson): string => JSON.stringify(content); - describe('isConfigured', () => { - it('should detect Yarn workspaces when yarn.lock exists and "workspaces" set in package.json', async () => { - vol.fromJSON( - { - 'package.json': pkgJsonContent({ - private: true, - workspaces: ['packages/*'], - }), - 'yarn.lock': '', - }, - MEMFS_VOLUME, - ); - await expect(yarnHandler.isConfigured(options)).resolves.toBeTrue(); - }); - - it('should NOT detect Yarn workspaces when "workspaces" not set in package.json', async () => { - vol.fromJSON( - { - 'package.json': pkgJsonContent({}), - 'yarn.lock': '', - }, - MEMFS_VOLUME, - ); - await expect(yarnHandler.isConfigured(options)).resolves.toBeFalse(); - }); - - it("should NOT detect Yarn workspaces when yarn.lock doesn't exist", async () => { - vol.fromJSON( - { - 'package.json': pkgJsonContent({ - private: true, - workspaces: ['packages/*'], - }), - 'package-lock.json': '', - }, - MEMFS_VOLUME, - ); - await expect(yarnHandler.isConfigured(options)).resolves.toBeFalse(); - }); - }); - describe('listProjects', () => { it('should list all Yarn workspaces with code-pushup script', async () => { vol.fromJSON( diff --git a/packages/ci/src/lib/monorepo/index.ts b/packages/ci/src/lib/monorepo/index.ts index 2a36e8579..0780d9c2a 100644 --- a/packages/ci/src/lib/monorepo/index.ts +++ b/packages/ci/src/lib/monorepo/index.ts @@ -1,7 +1,2 @@ export { listMonorepoProjects, type RunManyCommand } from './list-projects.js'; -export { - isMonorepoTool, - MONOREPO_TOOLS, - type MonorepoTool, - type ProjectConfig, -} from './tools.js'; +export type { ProjectConfig } from './tools.js'; diff --git a/packages/ci/src/lib/monorepo/list-projects.ts b/packages/ci/src/lib/monorepo/list-projects.ts index 9cbc07ba0..4bda40c2d 100644 --- a/packages/ci/src/lib/monorepo/list-projects.ts +++ b/packages/ci/src/lib/monorepo/list-projects.ts @@ -1,15 +1,14 @@ import { glob } from 'glob'; import path from 'node:path'; +import { + type MonorepoTool, + detectMonorepoTool, + listPackages, +} from '@code-pushup/utils'; import { logDebug, logInfo } from '../log.js'; import type { Settings } from '../models.js'; -import { detectMonorepoTool } from './detect-tool.js'; import { getToolHandler } from './handlers/index.js'; -import { listPackages } from './packages.js'; -import type { - MonorepoHandlerOptions, - MonorepoTool, - ProjectConfig, -} from './tools.js'; +import type { MonorepoHandlerOptions, ProjectConfig } from './tools.js'; export type MonorepoProjects = { tool: MonorepoTool | null; @@ -74,7 +73,7 @@ async function resolveMonorepoTool( return settings.monorepo; } - const tool = await detectMonorepoTool(options); + const tool = await detectMonorepoTool(options.cwd); if (tool) { logInfo(`Auto-detected monorepo tool ${tool}`); } else { diff --git a/packages/ci/src/lib/monorepo/tools.ts b/packages/ci/src/lib/monorepo/tools.ts index 4a1256798..93757b453 100644 --- a/packages/ci/src/lib/monorepo/tools.ts +++ b/packages/ci/src/lib/monorepo/tools.ts @@ -1,9 +1,7 @@ -export const MONOREPO_TOOLS = ['nx', 'turbo', 'yarn', 'pnpm', 'npm'] as const; -export type MonorepoTool = (typeof MONOREPO_TOOLS)[number]; +import type { MonorepoTool } from '@code-pushup/utils'; export type MonorepoToolHandler = { tool: MonorepoTool; - isConfigured: (options: MonorepoHandlerOptions) => Promise; listProjects: (options: MonorepoHandlerOptions) => Promise; createRunManyCommand: ( options: MonorepoHandlerOptions, @@ -28,7 +26,3 @@ export type ProjectConfig = { bin: string; directory?: string; }; - -export function isMonorepoTool(value: string): value is MonorepoTool { - return MONOREPO_TOOLS.includes(value as MonorepoTool); -} diff --git a/packages/ci/src/lib/run.int.test.ts b/packages/ci/src/lib/run.int.test.ts index 7cf5b6dad..8ddce87cc 100644 --- a/packages/ci/src/lib/run.int.test.ts +++ b/packages/ci/src/lib/run.int.test.ts @@ -28,7 +28,7 @@ import { teardownTestFolder, } from '@code-pushup/test-utils'; import * as utils from '@code-pushup/utils'; -import { logger } from '@code-pushup/utils'; +import { type MonorepoTool, logger } from '@code-pushup/utils'; import type { Comment, GitBranch, @@ -37,7 +37,6 @@ import type { ProviderAPIClient, RunResult, } from './models.js'; -import type { MonorepoTool } from './monorepo/index.js'; import { runInCI } from './run.js'; vi.mock('@code-pushup/portal-client', async importOriginal => { @@ -221,6 +220,17 @@ describe('runInCI', () => { if (cfg.command.includes('code-pushup')) { return simulateCodePushUpExecution(cfg); } + if ( + cfg.command === 'npx' && + cfg.args![0] === 'nx' && + cfg.args![1] === 'report' + ) { + return Promise.resolve({ + code: 0, + stdout: '', + stderr: '', + } as utils.ProcessResult); + } if (cfg.command === 'yarn' && cfg.args![0] === '-v') { return Promise.resolve({ code: 0, diff --git a/packages/create-cli/src/index.ts b/packages/create-cli/src/index.ts index 34105564e..654b55736 100755 --- a/packages/create-cli/src/index.ts +++ b/packages/create-cli/src/index.ts @@ -5,6 +5,7 @@ import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js'; import { CONFIG_FILE_FORMATS, type PluginSetupBinding, + SETUP_MODES, } from './lib/setup/types.js'; import { runSetupWizard } from './lib/setup/wizard.js'; @@ -33,6 +34,11 @@ const argv = await yargs(hideBin(process.argv)) describe: 'Comma-separated plugin slugs to include (e.g. eslint,coverage)', coerce: parsePluginSlugs, }) + .option('mode', { + type: 'string', + choices: SETUP_MODES, + describe: 'Setup mode (default: auto-detected from project)', + }) .check(parsed => { validatePluginSlugs(bindings, parsed.plugins); return true; diff --git a/packages/create-cli/src/lib/setup/codegen.ts b/packages/create-cli/src/lib/setup/codegen.ts index 3184d1169..2683f16a8 100644 --- a/packages/create-cli/src/lib/setup/codegen.ts +++ b/packages/create-cli/src/lib/setup/codegen.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; +import { toUnixPath } from '@code-pushup/utils'; import type { ConfigFileFormat, ImportDeclarationStructure, @@ -32,6 +34,60 @@ class CodeBuilder { } } +export function generateConfigSource( + plugins: PluginCodegenResult[], + format: ConfigFileFormat, +): string { + const builder = new CodeBuilder(); + addImports(builder, collectImports(plugins, format)); + if (format === 'ts') { + builder.addLine('export default {'); + addPlugins(builder, plugins); + builder.addLine('} satisfies CoreConfig;'); + } else { + builder.addLine("/** @type {import('@code-pushup/models').CoreConfig} */"); + builder.addLine('export default {'); + addPlugins(builder, plugins); + builder.addLine('};'); + } + return builder.toString(); +} + +export function generatePresetSource( + plugins: PluginCodegenResult[], + format: ConfigFileFormat, +): string { + const builder = new CodeBuilder(); + addImports(builder, collectImports(plugins, format)); + addPresetExport(builder, plugins, format); + return builder.toString(); +} + +export function generateProjectSource( + projectName: string, + presetImportPath: string, +): string { + const builder = new CodeBuilder(); + builder.addLine( + formatImport({ + moduleSpecifier: presetImportPath, + namedImports: ['createConfig'], + }), + ); + builder.addEmptyLine(); + builder.addLine(`export default await createConfig('${projectName}');`); + return builder.toString(); +} + +export function computeRelativePresetImport( + projectRelativeDir: string, + presetFilename: string, +): string { + const relativePath = path.relative(projectRelativeDir, presetFilename); + const importPath = toUnixPath(relativePath).replace(/\.ts$/, '.js'); + return importPath.startsWith('.') ? importPath : `./${importPath}`; +} + function formatImport({ moduleSpecifier, defaultImport, @@ -45,76 +101,77 @@ function formatImport({ return `import ${type}${from}'${moduleSpecifier}';`; } -function collectTsImports( - plugins: PluginCodegenResult[], +function sortImports( + imports: ImportDeclarationStructure[], ): ImportDeclarationStructure[] { - return [ - CORE_CONFIG_IMPORT, - ...plugins.flatMap(({ imports }) => imports), - ].toSorted((a, b) => a.moduleSpecifier.localeCompare(b.moduleSpecifier)); + return imports.toSorted((a, b) => + a.moduleSpecifier.localeCompare(b.moduleSpecifier), + ); } -function collectJsImports( +function collectImports( plugins: PluginCodegenResult[], + format: ConfigFileFormat, ): ImportDeclarationStructure[] { - return plugins - .flatMap(({ imports }) => imports) - .map(({ isTypeOnly: _, ...rest }) => rest) - .toSorted((a, b) => a.moduleSpecifier.localeCompare(b.moduleSpecifier)); + const pluginImports = plugins.flatMap(({ imports }) => imports); + if (format === 'ts') { + return sortImports([CORE_CONFIG_IMPORT, ...pluginImports]); + } + return sortImports(pluginImports.map(({ isTypeOnly: _, ...rest }) => rest)); +} + +function addImports( + builder: CodeBuilder, + imports: ImportDeclarationStructure[], +): void { + if (imports.length > 0) { + builder.addLines(imports.map(formatImport)); + builder.addEmptyLine(); + } } function addPlugins( builder: CodeBuilder, plugins: PluginCodegenResult[], + depth = 1, ): void { + builder.addLine('plugins: [', depth); if (plugins.length === 0) { - builder.addLine('plugins: [', 1); - builder.addLine('// TODO: register some plugins', 2); - builder.addLine('],', 1); + builder.addLine('// TODO: register some plugins', depth + 1); } else { - builder.addLine('plugins: [', 1); builder.addLines( plugins.map(({ pluginInit }) => `${pluginInit},`), - 2, + depth + 1, ); - builder.addLine('],', 1); } + builder.addLine('],', depth); } -export function generateConfigSource( +function addPresetExport( + builder: CodeBuilder, plugins: PluginCodegenResult[], format: ConfigFileFormat, -): string { - return format === 'ts' - ? generateTsConfig(plugins) - : generateJsConfig(plugins); -} - -function generateTsConfig(plugins: PluginCodegenResult[]): string { - const builder = new CodeBuilder(); - - builder.addLines(collectTsImports(plugins).map(formatImport)); - builder.addEmptyLine(); - builder.addLine('export default {'); - addPlugins(builder, plugins); - builder.addLine('} satisfies CoreConfig;'); - - return builder.toString(); -} - -function generateJsConfig(plugins: PluginCodegenResult[]): string { - const builder = new CodeBuilder(); - - const pluginImports = collectJsImports(plugins); - if (pluginImports.length > 0) { - builder.addLines(pluginImports.map(formatImport)); - builder.addEmptyLine(); +): void { + if (format === 'ts') { + builder.addLines([ + '/**', + ' * Creates a Code PushUp config for a project.', + ' * @param project Project name', + ' */', + 'export async function createConfig(project: string): Promise {', + ]); + } else { + builder.addLines([ + '/**', + ' * Creates a Code PushUp config for a project.', + ' * @param {string} project Project name', + " * @returns {Promise}", + ' */', + 'export async function createConfig(project) {', + ]); } - - builder.addLine("/** @type {import('@code-pushup/models').CoreConfig} */"); - builder.addLine('export default {'); - addPlugins(builder, plugins); - builder.addLine('};'); - - return builder.toString(); + builder.addLine('return {', 1); + addPlugins(builder, plugins, 2); + builder.addLine('};', 1); + builder.addLine('}'); } diff --git a/packages/create-cli/src/lib/setup/codegen.unit.test.ts b/packages/create-cli/src/lib/setup/codegen.unit.test.ts index 473ece795..c320f8f1e 100644 --- a/packages/create-cli/src/lib/setup/codegen.unit.test.ts +++ b/packages/create-cli/src/lib/setup/codegen.unit.test.ts @@ -1,6 +1,21 @@ -import { generateConfigSource } from './codegen.js'; +import { + computeRelativePresetImport, + generateConfigSource, + generatePresetSource, + generateProjectSource, +} from './codegen.js'; import type { PluginCodegenResult } from './types.js'; +const ESLINT_PLUGIN: PluginCodegenResult = { + imports: [ + { + moduleSpecifier: '@code-pushup/eslint-plugin', + defaultImport: 'eslintPlugin', + }, + ], + pluginInit: "await eslintPlugin({ patterns: '.' })", +}; + describe('generateConfigSource', () => { describe('TypeScript format', () => { it('should generate config with TODO placeholder when no plugins provided', () => { @@ -187,3 +202,75 @@ describe('generateConfigSource', () => { }); }); }); + +describe('generatePresetSource', () => { + it('should generate TS preset with function signature and plugins', () => { + expect(generatePresetSource([ESLINT_PLUGIN], 'ts')).toMatchInlineSnapshot(` + "import eslintPlugin from '@code-pushup/eslint-plugin'; + import type { CoreConfig } from '@code-pushup/models'; + + /** + * Creates a Code PushUp config for a project. + * @param project Project name + */ + export async function createConfig(project: string): Promise { + return { + plugins: [ + await eslintPlugin({ patterns: '.' }), + ], + }; + } + " + `); + }); + + it('should generate JS preset with JSDoc annotation', () => { + expect(generatePresetSource([ESLINT_PLUGIN], 'js')).toMatchInlineSnapshot(` + "import eslintPlugin from '@code-pushup/eslint-plugin'; + + /** + * Creates a Code PushUp config for a project. + * @param {string} project Project name + * @returns {Promise} + */ + export async function createConfig(project) { + return { + plugins: [ + await eslintPlugin({ patterns: '.' }), + ], + }; + } + " + `); + }); +}); + +describe('generateProjectSource', () => { + it('should generate import and createConfig call', () => { + const source = generateProjectSource( + 'my-app', + '../../code-pushup.preset.js', + ); + expect(source).toMatchInlineSnapshot(` + "import { createConfig } from '../../code-pushup.preset.js'; + + export default await createConfig('my-app'); + " + `); + }); +}); + +describe('computeRelativePresetImport', () => { + it.each([ + ['packages/my-app', 'code-pushup.preset.ts', '../../code-pushup.preset.js'], + ['apps/web', 'code-pushup.preset.mjs', '../../code-pushup.preset.mjs'], + ['packages/lib', 'code-pushup.preset.js', '../../code-pushup.preset.js'], + ])( + 'should resolve %j relative to %j as %j', + (projectDir, presetFilename, expected) => { + expect(computeRelativePresetImport(projectDir, presetFilename)).toBe( + expected, + ); + }, + ); +}); diff --git a/packages/create-cli/src/lib/setup/config-format.ts b/packages/create-cli/src/lib/setup/config-format.ts index 09a3798c0..ce1427d4c 100644 --- a/packages/create-cli/src/lib/setup/config-format.ts +++ b/packages/create-cli/src/lib/setup/config-format.ts @@ -35,18 +35,18 @@ export async function promptConfigFormat( }); } -/** Returns `code-pushup.config.{ts,js,mjs}` based on format and ESM context. */ -export function resolveConfigFilename( +export function resolveFilename( + baseName: string, format: ConfigFileFormat, isEsm: boolean, ): string { if (format === 'ts') { - return 'code-pushup.config.ts'; + return `${baseName}.ts`; } if (format === 'js' && isEsm) { - return 'code-pushup.config.js'; + return `${baseName}.js`; } - return 'code-pushup.config.mjs'; + return `${baseName}.mjs`; } export async function readPackageJson(targetDir: string): Promise { diff --git a/packages/create-cli/src/lib/setup/config-format.unit.test.ts b/packages/create-cli/src/lib/setup/config-format.unit.test.ts index 6a5bba232..600e501bc 100644 --- a/packages/create-cli/src/lib/setup/config-format.unit.test.ts +++ b/packages/create-cli/src/lib/setup/config-format.unit.test.ts @@ -1,6 +1,6 @@ import { vol } from 'memfs'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; -import { promptConfigFormat, resolveConfigFilename } from './config-format.js'; +import { promptConfigFormat, resolveFilename } from './config-format.js'; import type { ConfigFileFormat } from './types.js'; vi.mock('@inquirer/prompts', () => ({ @@ -18,7 +18,7 @@ describe('resolveConfigFilename', () => { ['js', true, 'code-pushup.config.js'], ['js', false, 'code-pushup.config.mjs'], ])('should resolve format %j (ESM: %j) to %j', (format, isEsm, expected) => { - expect(resolveConfigFilename(format, isEsm)).toBe(expected); + expect(resolveFilename('code-pushup.config', format, isEsm)).toBe(expected); }); }); diff --git a/packages/create-cli/src/lib/setup/monorepo.ts b/packages/create-cli/src/lib/setup/monorepo.ts new file mode 100644 index 000000000..c125b72d6 --- /dev/null +++ b/packages/create-cli/src/lib/setup/monorepo.ts @@ -0,0 +1,182 @@ +import { select } from '@inquirer/prompts'; +import path from 'node:path'; +import { + MONOREPO_TOOL_DETECTORS, + type MonorepoTool, + type WorkspacePackage, + detectMonorepoTool, + hasScript, + listPackages, + listWorkspaces, + loadNxProjectGraph, + logger, + readPnpmWorkspacePatterns, + toUnixPath, +} from '@code-pushup/utils'; +import type { + CliArgs, + ConfigContext, + SetupMode, + Tree, + WizardProject, +} from './types.js'; + +const TARGET_NAME = 'code-pushup'; + +export async function promptSetupMode( + targetDir: string, + cliArgs: CliArgs, +): Promise { + switch (cliArgs.mode) { + case 'standalone': + return toContext(cliArgs.mode, null); + case 'monorepo': { + const tool = await detectMonorepoTool(targetDir); + return toContext(cliArgs.mode, tool); + } + case undefined: { + const tool = await detectMonorepoTool(targetDir); + const mode = cliArgs.yes ? inferMode(tool) : await promptMode(tool); + return toContext(mode, tool); + } + } +} + +async function promptMode(tool: MonorepoTool | null): Promise { + return select({ + message: 'Setup mode:', + choices: [ + { name: 'Standalone (single config)', value: 'standalone' }, + { name: 'Monorepo (per-project configs)', value: 'monorepo' }, + ], + default: inferMode(tool), + }); +} + +function inferMode(tool: MonorepoTool | null): SetupMode { + return tool ? 'monorepo' : 'standalone'; +} + +function toContext(mode: SetupMode, tool: MonorepoTool | null): ConfigContext { + if (mode === 'monorepo' && tool == null) { + logger.warn('No monorepo tool detected, falling back to standalone mode.'); + return { mode: 'standalone', tool: null }; + } + return { mode, tool }; +} + +export async function listProjects( + cwd: string, + tool: MonorepoTool, +): Promise { + switch (tool) { + case 'nx': + return listNxProjects(cwd); + case 'pnpm': + return listPnpmProjects(cwd); + case 'turbo': + return listTurboProjects(cwd); + case 'yarn': + case 'npm': + return listWorkspaceProjects(cwd); + } +} + +async function listNxProjects(cwd: string): Promise { + const graph = await loadNxProjectGraph(); + return Object.values(graph.nodes).map(({ name, data }) => ({ + name, + directory: path.join(cwd, data.root), + relativeDir: toUnixPath(data.root), + })); +} + +async function listPnpmProjects(cwd: string): Promise { + const patterns = await readPnpmWorkspacePatterns(cwd); + const packages = await listPackages(cwd, patterns); + return packages.map(pkg => toProject(cwd, pkg)); +} + +async function listTurboProjects(cwd: string): Promise { + if (await MONOREPO_TOOL_DETECTORS.pnpm(cwd)) { + return listPnpmProjects(cwd); + } + return listWorkspaceProjects(cwd); +} + +async function listWorkspaceProjects(cwd: string): Promise { + const { workspaces } = await listWorkspaces(cwd); + return workspaces.map(pkg => toProject(cwd, pkg)); +} + +export async function addCodePushUpCommand( + tree: Tree, + project: WizardProject, + tool: MonorepoTool | null, +): Promise { + if (tool === 'nx') { + const added = await addNxTarget(tree, project); + if (added) { + return; + } + } + await addPackageJsonScript(tree, project); +} + +async function addNxTarget( + tree: Tree, + project: WizardProject, +): Promise { + const filePath = toUnixPath(path.join(project.relativeDir, 'project.json')); + const raw = await tree.read(filePath); + if (raw == null) { + return false; + } + const config = JSON.parse(raw); + if (config.targets[TARGET_NAME] != null) { + return true; + } + const updated = { + ...config, + targets: { + ...config.targets, + [TARGET_NAME]: { + executor: 'nx:run-commands', + options: { command: 'npx code-pushup' }, + }, + }, + }; + await tree.write(filePath, `${JSON.stringify(updated, null, 2)}\n`); + return true; +} + +async function addPackageJsonScript( + tree: Tree, + project: WizardProject, +): Promise { + const filePath = toUnixPath(path.join(project.relativeDir, 'package.json')); + const raw = await tree.read(filePath); + if (raw == null) { + return; + } + const packageJson = JSON.parse(raw); + if (hasScript(packageJson, TARGET_NAME)) { + return; + } + const updated = { + ...packageJson, + scripts: { + ...packageJson.scripts, + [TARGET_NAME]: 'code-pushup', + }, + }; + await tree.write(filePath, `${JSON.stringify(updated, null, 2)}\n`); +} + +function toProject(cwd: string, pkg: WorkspacePackage): WizardProject { + return { + name: pkg.name, + directory: pkg.directory, + relativeDir: toUnixPath(path.relative(cwd, pkg.directory)), + }; +} diff --git a/packages/create-cli/src/lib/setup/monorepo.unit.test.ts b/packages/create-cli/src/lib/setup/monorepo.unit.test.ts new file mode 100644 index 000000000..c56f8e5d2 --- /dev/null +++ b/packages/create-cli/src/lib/setup/monorepo.unit.test.ts @@ -0,0 +1,226 @@ +import { select } from '@inquirer/prompts'; +import { vol } from 'memfs'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { logger } from '@code-pushup/utils'; +import { addCodePushUpCommand, promptSetupMode } from './monorepo.js'; +import type { WizardProject } from './types.js'; +import { createTree } from './virtual-fs.js'; + +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), +})); + +describe('promptSetupMode', () => { + it('should skip detection when --mode standalone is provided', async () => { + await expect( + promptSetupMode(MEMFS_VOLUME, { mode: 'standalone' }), + ).resolves.toStrictEqual({ mode: 'standalone', tool: null }); + expect(select).not.toHaveBeenCalled(); + }); + + it('should detect tool when --mode monorepo is provided', async () => { + vol.fromJSON( + { 'pnpm-workspace.yaml': 'packages:\n - packages/*' }, + MEMFS_VOLUME, + ); + + await expect( + promptSetupMode(MEMFS_VOLUME, { mode: 'monorepo' }), + ).resolves.toStrictEqual({ mode: 'monorepo', tool: 'pnpm' }); + expect(select).not.toHaveBeenCalled(); + }); + + it('should fall back to standalone with warning when --mode monorepo but no tool detected', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + + await expect( + promptSetupMode(MEMFS_VOLUME, { mode: 'monorepo' }), + ).resolves.toStrictEqual({ mode: 'standalone', tool: null }); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('falling back to standalone'), + ); + }); + + it('should auto-select monorepo when --yes and tool detected', async () => { + vol.fromJSON( + { 'pnpm-workspace.yaml': 'packages:\n - packages/*' }, + MEMFS_VOLUME, + ); + + await expect( + promptSetupMode(MEMFS_VOLUME, { yes: true }), + ).resolves.toStrictEqual({ mode: 'monorepo', tool: 'pnpm' }); + expect(select).not.toHaveBeenCalled(); + }); + + it('should auto-select standalone when --yes and no tool', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + + await expect( + promptSetupMode(MEMFS_VOLUME, { yes: true }), + ).resolves.toStrictEqual({ mode: 'standalone', tool: null }); + }); + + it('should prompt interactively with monorepo pre-selected when tool detected', async () => { + vol.fromJSON( + { 'pnpm-workspace.yaml': 'packages:\n - packages/*' }, + MEMFS_VOLUME, + ); + vi.mocked(select).mockResolvedValue('monorepo'); + + await promptSetupMode(MEMFS_VOLUME, {}); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ default: 'monorepo' }), + ); + }); + + it('should prompt interactively with standalone pre-selected when no tool', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + vi.mocked(select).mockResolvedValue('standalone'); + + await promptSetupMode(MEMFS_VOLUME, {}); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ default: 'standalone' }), + ); + }); + + it('should fall back to standalone with warning when user selects monorepo but no tool detected', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + vi.mocked(select).mockResolvedValue('monorepo'); + + await expect(promptSetupMode(MEMFS_VOLUME, {})).resolves.toStrictEqual({ + mode: 'standalone', + tool: null, + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('falling back to standalone'), + ); + }); +}); + +describe('addCodePushUpCommand', () => { + const PROJECT: WizardProject = { + name: 'my-app', + directory: `${MEMFS_VOLUME}/packages/my-app`, + relativeDir: 'packages/my-app', + }; + + it('should add Nx target when project.json exists', async () => { + vol.fromJSON( + { + 'packages/my-app/project.json': JSON.stringify({ + name: 'my-app', + targets: {}, + }), + }, + MEMFS_VOLUME, + ); + const tree = createTree(MEMFS_VOLUME); + + await addCodePushUpCommand(tree, PROJECT, 'nx'); + + expect(tree.listChanges()).toPartiallyContain({ + path: 'packages/my-app/project.json', + content: `${JSON.stringify( + { + name: 'my-app', + targets: { + 'code-pushup': { + executor: 'nx:run-commands', + options: { command: 'npx code-pushup' }, + }, + }, + }, + null, + 2, + )}\n`, + }); + }); + + it('should add package.json script for non-Nx tools', async () => { + vol.fromJSON( + { + 'packages/my-app/package.json': JSON.stringify({ + name: 'my-app', + scripts: { test: 'vitest' }, + }), + }, + MEMFS_VOLUME, + ); + const tree = createTree(MEMFS_VOLUME); + + await addCodePushUpCommand(tree, PROJECT, 'pnpm'); + + expect(tree.listChanges()).toPartiallyContain({ + path: 'packages/my-app/package.json', + content: `${JSON.stringify( + { + name: 'my-app', + scripts: { test: 'vitest', 'code-pushup': 'code-pushup' }, + }, + null, + 2, + )}\n`, + }); + }); + + it('should fall back to package.json when Nx project has no project.json', async () => { + vol.fromJSON( + { + 'packages/my-app/package.json': JSON.stringify({ + name: 'my-app', + scripts: {}, + }), + }, + MEMFS_VOLUME, + ); + const tree = createTree(MEMFS_VOLUME); + + await addCodePushUpCommand(tree, PROJECT, 'nx'); + + expect(tree.listChanges()).toPartiallyContain({ + path: 'packages/my-app/package.json', + }); + expect(tree.listChanges()).not.toPartiallyContain({ + path: 'packages/my-app/project.json', + }); + }); + + it('should not overwrite existing code-pushup target', async () => { + vol.fromJSON( + { + 'packages/my-app/project.json': JSON.stringify({ + name: 'my-app', + targets: { + 'code-pushup': { executor: 'custom:executor' }, + }, + }), + }, + MEMFS_VOLUME, + ); + const tree = createTree(MEMFS_VOLUME); + + await addCodePushUpCommand(tree, PROJECT, 'nx'); + + expect(tree.listChanges()).toBeEmpty(); + }); + + it('should not overwrite existing code-pushup script', async () => { + vol.fromJSON( + { + 'packages/my-app/package.json': JSON.stringify({ + name: 'my-app', + scripts: { 'code-pushup': 'custom-command' }, + }), + }, + MEMFS_VOLUME, + ); + const tree = createTree(MEMFS_VOLUME); + + await addCodePushUpCommand(tree, PROJECT, 'pnpm'); + + expect(tree.listChanges()).toBeEmpty(); + }); +}); diff --git a/packages/create-cli/src/lib/setup/types.ts b/packages/create-cli/src/lib/setup/types.ts index 7bdc9956e..62dfb028b 100644 --- a/packages/create-cli/src/lib/setup/types.ts +++ b/packages/create-cli/src/lib/setup/types.ts @@ -1,64 +1,23 @@ import type { PluginMeta } from '@code-pushup/models'; +import type { MonorepoTool } from '@code-pushup/utils'; export const CONFIG_FILE_FORMATS = ['ts', 'js', 'mjs'] as const; export type ConfigFileFormat = (typeof CONFIG_FILE_FORMATS)[number]; -/** Virtual file system that buffers writes in memory until flushed to disk. */ -export type Tree = { - root: string; - exists: (filePath: string) => Promise; - read: (filePath: string) => Promise; - write: (filePath: string, content: string) => Promise; - listChanges: () => FileChange[]; - flush: () => Promise; -}; - -export type FileChange = { - path: string; - type: 'CREATE' | 'UPDATE'; - content: string; -}; - -export type FileSystemAdapter = { - readFile: (path: string, encoding: 'utf8') => Promise; - writeFile: (path: string, content: string) => Promise; - exists: (path: string) => Promise; - mkdir: ( - path: string, - options: { recursive: true }, - ) => Promise; -}; - -/** - * Defines how a plugin integrates with the setup wizard. - * - * Each supported plugin provides a binding that controls: - * - Pre-selection: `isRecommended` detects if the plugin is relevant for the repository - * - Configuration: `prompts` collect plugin-specific options interactively - * - Code generation: `generateConfig` produces the import and initialization code - */ -export type PluginSetupBinding = { - slug: PluginMeta['slug']; - title: PluginMeta['title']; - packageName: NonNullable; - isRecommended?: (targetDir: string) => Promise; - prompts?: PluginPromptDescriptor[]; - generateConfig: ( - answers: Record, - ) => PluginCodegenResult; -}; +export const SETUP_MODES = ['standalone', 'monorepo'] as const; +export type SetupMode = (typeof SETUP_MODES)[number]; -export type ImportDeclarationStructure = { - moduleSpecifier: string; - defaultImport?: string; - namedImports?: string[]; - isTypeOnly?: boolean; -}; +export const PLUGIN_SCOPES = ['project', 'root'] as const; +export type PluginScope = (typeof PLUGIN_SCOPES)[number]; -export type PluginCodegenResult = { - imports: ImportDeclarationStructure[]; - pluginInit: string; - // TODO: add categories support (categoryRefs for generated categories array) +export type CliArgs = { + 'dry-run'?: boolean; + yes?: boolean; + 'config-format'?: string; + mode?: SetupMode; + plugins?: string[]; + 'target-dir'?: string; + [key: string]: unknown; }; type PromptBase = { @@ -85,16 +44,96 @@ type CheckboxPrompt = PromptBase & { default: T[]; }; +/** Declarative prompt definition used to collect plugin-specific options. */ export type PluginPromptDescriptor = | InputPrompt | SelectPrompt | CheckboxPrompt; -export type CliArgs = { - 'dry-run'?: boolean; - yes?: boolean; - 'config-format'?: string; - plugins?: string[]; - 'target-dir'?: string; - [key: string]: unknown; +export type ImportDeclarationStructure = { + moduleSpecifier: string; + defaultImport?: string; + namedImports?: string[]; + isTypeOnly?: boolean; +}; + +/** Import declarations and plugin initialization code produced by `generateConfig`. */ +export type PluginCodegenResult = { + imports: ImportDeclarationStructure[]; + pluginInit: string; + // TODO: add categories support (categoryRefs for generated categories array) +}; + +export type ScopedPluginResult = { + scope: PluginScope; + result: PluginCodegenResult; +}; + +/** Context describing the current setup mode, passed to plugin codegen. */ +export type ConfigContext = { + mode: SetupMode; + tool: MonorepoTool | null; +}; + +/** + * Defines how a plugin integrates with the setup wizard. + * + * Each supported plugin provides a binding that controls: + * - Pre-selection: `isRecommended` detects if the plugin is relevant for the repository + * - Configuration: `prompts` collect plugin-specific options interactively + * - Code generation: `generateConfig` produces the import and initialization code + */ +export type PluginSetupBinding = { + slug: PluginMeta['slug']; + title: PluginMeta['title']; + packageName: NonNullable; + prompts?: PluginPromptDescriptor[]; + scope?: PluginScope; + isRecommended?: (targetDir: string) => Promise; + generateConfig: ( + answers: Record, + context: ConfigContext, + ) => PluginCodegenResult; +}; + +/** A project discovered in a monorepo workspace. */ +export type WizardProject = { + name: string; + directory: string; + relativeDir: string; +}; + +export type WriteContext = { + tree: Tree; + format: ConfigFileFormat; + configFilename: string; + isEsm: boolean; +}; + +/** A single file operation recorded by the virtual tree. */ +export type FileChange = { + path: string; + type: 'CREATE' | 'UPDATE'; + content: string; +}; + +/** Virtual file system that buffers writes in memory until flushed to disk. */ +export type Tree = { + root: string; + exists: (filePath: string) => Promise; + read: (filePath: string) => Promise; + write: (filePath: string, content: string) => Promise; + listChanges: () => FileChange[]; + flush: () => Promise; +}; + +/** Abstraction over `node:fs` used by the virtual tree for disk I/O. */ +export type FileSystemAdapter = { + readFile: (path: string, encoding: 'utf8') => Promise; + writeFile: (path: string, content: string) => Promise; + exists: (path: string) => Promise; + mkdir: ( + path: string, + options: { recursive: true }, + ) => Promise; }; diff --git a/packages/create-cli/src/lib/setup/wizard.ts b/packages/create-cli/src/lib/setup/wizard.ts index 87218aec1..64350061e 100644 --- a/packages/create-cli/src/lib/setup/wizard.ts +++ b/packages/create-cli/src/lib/setup/wizard.ts @@ -1,22 +1,38 @@ +import path from 'node:path'; import { + type MonorepoTool, asyncSequential, formatAsciiTable, getGitRoot, logger, + toUnixPath, } from '@code-pushup/utils'; -import { generateConfigSource } from './codegen.js'; +import { + computeRelativePresetImport, + generateConfigSource, + generatePresetSource, + generateProjectSource, +} from './codegen.js'; import { promptConfigFormat, readPackageJson, - resolveConfigFilename, + resolveFilename, } from './config-format.js'; import { resolveGitignore } from './gitignore.js'; +import { + addCodePushUpCommand, + listProjects, + promptSetupMode, +} from './monorepo.js'; import { promptPluginOptions, promptPluginSelection } from './prompts.js'; import type { CliArgs, + ConfigContext, FileChange, PluginCodegenResult, PluginSetupBinding, + ScopedPluginResult, + WriteContext, } from './types.js'; import { createTree } from './virtual-fs.js'; @@ -32,7 +48,7 @@ export async function runSetupWizard( ): Promise { const targetDir = cliArgs['target-dir'] ?? process.cwd(); - // TODO: #1245 — prompt for standalone vs monorepo mode + const { mode, tool } = await promptSetupMode(targetDir, cliArgs); const selectedBindings = await promptPluginSelection( bindings, targetDir, @@ -41,15 +57,29 @@ export async function runSetupWizard( const format = await promptConfigFormat(targetDir, cliArgs); const packageJson = await readPackageJson(targetDir); - const filename = resolveConfigFilename(format, packageJson.type === 'module'); + const isEsm = packageJson.type === 'module'; + const configFilename = resolveFilename('code-pushup.config', format, isEsm); - const pluginResults = await asyncSequential(selectedBindings, binding => - resolveBinding(binding, cliArgs), + const resolved: ScopedPluginResult[] = await asyncSequential( + selectedBindings, + async binding => ({ + scope: binding.scope ?? 'project', + result: await resolveBinding(binding, cliArgs, { mode, tool }), + }), ); const gitRoot = await getGitRoot(); const tree = createTree(gitRoot); - await tree.write(filename, generateConfigSource(pluginResults, format)); + + const writeContext: WriteContext = { tree, format, configFilename, isEsm }; + + await (mode === 'monorepo' && tool != null + ? writeMonorepoConfigs(writeContext, resolved, targetDir, tool) + : writeStandaloneConfig( + writeContext, + resolved.map(r => r.result), + )); + await resolveGitignore(tree); logChanges(tree.listChanges()); @@ -72,11 +102,59 @@ export async function runSetupWizard( async function resolveBinding( binding: PluginSetupBinding, cliArgs: CliArgs, + context: ConfigContext, ): Promise { const answers = binding.prompts ? await promptPluginOptions(binding.prompts, cliArgs) : {}; - return binding.generateConfig(answers); + return binding.generateConfig(answers, context); +} + +async function writeStandaloneConfig( + { tree, format, configFilename }: WriteContext, + results: PluginCodegenResult[], +): Promise { + await tree.write(configFilename, generateConfigSource(results, format)); +} + +async function writeMonorepoConfigs( + { tree, format, configFilename, isEsm }: WriteContext, + resolved: ScopedPluginResult[], + targetDir: string, + tool: MonorepoTool, +): Promise { + const projectResults = resolved + .filter(r => r.scope === 'project') + .map(r => r.result); + + const rootResults = resolved + .filter(r => r.scope === 'root') + .map(r => r.result); + + const presetFilename = resolveFilename('code-pushup.preset', format, isEsm); + await tree.write( + presetFilename, + generatePresetSource(projectResults, format), + ); + + if (rootResults.length > 0) { + await tree.write(configFilename, generateConfigSource(rootResults, format)); + } + + const projects = await listProjects(targetDir, tool); + await Promise.all( + projects.map(async project => { + const importPath = computeRelativePresetImport( + project.relativeDir, + presetFilename, + ); + await tree.write( + toUnixPath(path.join(project.relativeDir, configFilename)), + generateProjectSource(project.name, importPath), + ); + await addCodePushUpCommand(tree, project, tool); + }), + ); } function logChanges(changes: FileChange[]): void { diff --git a/packages/create-cli/src/lib/setup/wizard.unit.test.ts b/packages/create-cli/src/lib/setup/wizard.unit.test.ts index 6a03126b5..12fb57b44 100644 --- a/packages/create-cli/src/lib/setup/wizard.unit.test.ts +++ b/packages/create-cli/src/lib/setup/wizard.unit.test.ts @@ -2,6 +2,7 @@ import { vol } from 'memfs'; import { readFile } from 'node:fs/promises'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import { logger } from '@code-pushup/utils'; +import { addCodePushUpCommand, listProjects } from './monorepo.js'; import type { PluginSetupBinding } from './types.js'; import { runSetupWizard } from './wizard.js'; @@ -11,6 +12,12 @@ vi.mock('@inquirer/prompts', () => ({ select: vi.fn(), })); +vi.mock('./monorepo.js', async importOriginal => ({ + ...(await importOriginal()), + listProjects: vi.fn().mockResolvedValue([]), + addCodePushUpCommand: vi.fn().mockResolvedValue(undefined), +})); + const TEST_BINDING: PluginSetupBinding = { slug: 'test-plugin', title: 'Test Plugin', @@ -151,4 +158,139 @@ describe('runSetupWizard', () => { expect(logger.info).toHaveBeenCalledWith('CREATE code-pushup.config.js'); }); }); + + describe('Monorepo config', () => { + const PROJECT_BINDING: PluginSetupBinding = { + slug: 'test-plugin', + title: 'Test Plugin', + packageName: '@code-pushup/test-plugin', + isRecommended: () => Promise.resolve(true), + generateConfig: () => ({ + imports: [ + { + moduleSpecifier: '@code-pushup/test-plugin', + defaultImport: 'testPlugin', + }, + ], + pluginInit: 'testPlugin()', + }), + }; + + const ROOT_BINDING: PluginSetupBinding = { + slug: 'root-plugin', + title: 'Root Plugin', + packageName: '@code-pushup/root-plugin', + scope: 'root', + isRecommended: () => Promise.resolve(true), + generateConfig: () => ({ + imports: [ + { + moduleSpecifier: '@code-pushup/root-plugin', + defaultImport: 'rootPlugin', + }, + ], + pluginInit: 'rootPlugin()', + }), + }; + + beforeEach(() => { + vol.fromJSON( + { + 'tsconfig.json': '{}', + 'pnpm-workspace.yaml': 'packages:\n - packages/*\n', + }, + MEMFS_VOLUME, + ); + vi.mocked(listProjects).mockResolvedValue([ + { + name: 'app-a', + directory: `${MEMFS_VOLUME}/packages/app-a`, + relativeDir: 'packages/app-a', + }, + { + name: 'app-b', + directory: `${MEMFS_VOLUME}/packages/app-b`, + relativeDir: 'packages/app-b', + }, + ]); + }); + + it('should generate preset and per-project configs', async () => { + await runSetupWizard([PROJECT_BINDING], { + yes: true, + mode: 'monorepo', + 'target-dir': MEMFS_VOLUME, + }); + + await expect(readFile(`${MEMFS_VOLUME}/code-pushup.preset.ts`, 'utf8')) + .resolves.toMatchInlineSnapshot(` + "import type { CoreConfig } from '@code-pushup/models'; + import testPlugin from '@code-pushup/test-plugin'; + + /** + * Creates a Code PushUp config for a project. + * @param project Project name + */ + export async function createConfig(project: string): Promise { + return { + plugins: [ + testPlugin(), + ], + }; + } + " + `); + + await expect( + readFile( + `${MEMFS_VOLUME}/packages/app-a/code-pushup.config.ts`, + 'utf8', + ), + ).resolves.toMatchInlineSnapshot(` + "import { createConfig } from '../../code-pushup.preset.js'; + + export default await createConfig('app-a'); + " + `); + + await expect( + readFile( + `${MEMFS_VOLUME}/packages/app-b/code-pushup.config.ts`, + 'utf8', + ), + ).resolves.toMatchInlineSnapshot(` + "import { createConfig } from '../../code-pushup.preset.js'; + + export default await createConfig('app-b'); + " + `); + + expect(addCodePushUpCommand).toHaveBeenCalledTimes(2); + }); + + it('should generate root config for root-scoped plugins', async () => { + await runSetupWizard([PROJECT_BINDING, ROOT_BINDING], { + yes: true, + mode: 'monorepo', + 'target-dir': MEMFS_VOLUME, + }); + + await expect(readFile(`${MEMFS_VOLUME}/code-pushup.config.ts`, 'utf8')) + .resolves.toMatchInlineSnapshot(` + "import type { CoreConfig } from '@code-pushup/models'; + import rootPlugin from '@code-pushup/root-plugin'; + + export default { + plugins: [ + rootPlugin(), + ], + } satisfies CoreConfig; + " + `); + + await expect( + readFile(`${MEMFS_VOLUME}/code-pushup.preset.ts`, 'utf8'), + ).resolves.toBeTruthy(); + }); + }); }); diff --git a/packages/utils/package.json b/packages/utils/package.json index 0108100f2..99966c114 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -32,11 +32,14 @@ "build-md": "^0.4.2", "bundle-require": "^5.1.0", "esbuild": "^0.25.2", + "glob": "^11.0.1", "ora": "^9.0.0", "semver": "^7.6.0", "simple-git": "^3.20.0", "string-width": "^8.1.0", "wrap-ansi": "^9.0.2", + "yaml": "^2.5.1", + "type-fest": "^4.26.1", "zod": "^4.2.1" }, "peerDependencies": { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 991771532..d32d747c4 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -185,3 +185,20 @@ export type { WithRequired, } from './lib/types.js'; export * from './lib/import-module.js'; +export { + detectMonorepoTool, + isMonorepoTool, + MONOREPO_TOOL_DETECTORS, + MONOREPO_TOOLS, + type MonorepoTool, +} from './lib/monorepo.js'; +export { + hasCodePushUpDependency, + hasScript, + hasWorkspacesEnabled, + listPackages, + listWorkspaces, + readPnpmWorkspacePatterns, + readRootPackageJson, + type WorkspacePackage, +} from './lib/workspace-packages.js'; diff --git a/packages/utils/src/lib/monorepo.ts b/packages/utils/src/lib/monorepo.ts new file mode 100644 index 000000000..b5a55479f --- /dev/null +++ b/packages/utils/src/lib/monorepo.ts @@ -0,0 +1,37 @@ +import path from 'node:path'; +import { fileExists } from './file-system.js'; +import { hasWorkspacesEnabled } from './workspace-packages.js'; + +export const MONOREPO_TOOLS = ['nx', 'turbo', 'yarn', 'pnpm', 'npm'] as const; +export type MonorepoTool = (typeof MONOREPO_TOOLS)[number]; + +export const MONOREPO_TOOL_DETECTORS: Record< + MonorepoTool, + (cwd: string) => Promise +> = { + nx: cwd => fileExists(path.join(cwd, 'nx.json')), + turbo: cwd => fileExists(path.join(cwd, 'turbo.json')), + yarn: async cwd => + (await fileExists(path.join(cwd, 'yarn.lock'))) && + (await hasWorkspacesEnabled(cwd)), + pnpm: cwd => fileExists(path.join(cwd, 'pnpm-workspace.yaml')), + npm: async cwd => + (await fileExists(path.join(cwd, 'package-lock.json'))) && + (await hasWorkspacesEnabled(cwd)), +}; + +export async function detectMonorepoTool( + cwd: string, +): Promise { + // eslint-disable-next-line functional/no-loop-statements + for (const tool of MONOREPO_TOOLS) { + if (await MONOREPO_TOOL_DETECTORS[tool](cwd)) { + return tool; + } + } + return null; +} + +export function isMonorepoTool(value: string): value is MonorepoTool { + return MONOREPO_TOOLS.includes(value as MonorepoTool); +} diff --git a/packages/utils/src/lib/monorepo.unit.test.ts b/packages/utils/src/lib/monorepo.unit.test.ts new file mode 100644 index 000000000..b4f917184 --- /dev/null +++ b/packages/utils/src/lib/monorepo.unit.test.ts @@ -0,0 +1,78 @@ +import { vol } from 'memfs'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { detectMonorepoTool } from './monorepo.js'; + +describe('detectMonorepoTool', () => { + it('should detect Nx by nx.json', async () => { + vol.fromJSON({ 'nx.json': '{}' }, MEMFS_VOLUME); + await expect(detectMonorepoTool(MEMFS_VOLUME)).resolves.toBe('nx'); + }); + + it('should detect Turborepo by turbo.json', async () => { + vol.fromJSON({ 'turbo.json': '{}' }, MEMFS_VOLUME); + await expect(detectMonorepoTool(MEMFS_VOLUME)).resolves.toBe('turbo'); + }); + + it('should detect Yarn workspaces by yarn.lock + workspaces config', async () => { + vol.fromJSON( + { + 'yarn.lock': '', + 'package.json': JSON.stringify({ + private: true, + workspaces: ['packages/*'], + }), + }, + MEMFS_VOLUME, + ); + await expect(detectMonorepoTool(MEMFS_VOLUME)).resolves.toBe('yarn'); + }); + + it('should detect pnpm by pnpm-workspace.yaml', async () => { + vol.fromJSON( + { 'pnpm-workspace.yaml': 'packages:\n - packages/*' }, + MEMFS_VOLUME, + ); + await expect(detectMonorepoTool(MEMFS_VOLUME)).resolves.toBe('pnpm'); + }); + + it('should detect npm workspaces by package-lock.json + workspaces config', async () => { + vol.fromJSON( + { + 'package-lock.json': '{}', + 'package.json': JSON.stringify({ + private: true, + workspaces: ['packages/*'], + }), + }, + MEMFS_VOLUME, + ); + await expect(detectMonorepoTool(MEMFS_VOLUME)).resolves.toBe('npm'); + }); + + it('should return null when no monorepo tool detected', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + await expect(detectMonorepoTool(MEMFS_VOLUME)).resolves.toBeNull(); + }); + + it('should prioritize Nx over other tools', async () => { + vol.fromJSON( + { + 'nx.json': '{}', + 'pnpm-workspace.yaml': 'packages:\n - packages/*', + }, + MEMFS_VOLUME, + ); + await expect(detectMonorepoTool(MEMFS_VOLUME)).resolves.toBe('nx'); + }); + + it('should not detect yarn without workspaces enabled', async () => { + vol.fromJSON( + { + 'yarn.lock': '', + 'package.json': JSON.stringify({ name: 'my-app' }), + }, + MEMFS_VOLUME, + ); + await expect(detectMonorepoTool(MEMFS_VOLUME)).resolves.toBeNull(); + }); +}); diff --git a/packages/ci/src/lib/monorepo/packages.ts b/packages/utils/src/lib/workspace-packages.ts similarity index 85% rename from packages/ci/src/lib/monorepo/packages.ts rename to packages/utils/src/lib/workspace-packages.ts index 43b43a778..3951fe563 100644 --- a/packages/ci/src/lib/monorepo/packages.ts +++ b/packages/utils/src/lib/workspace-packages.ts @@ -1,9 +1,10 @@ import { glob } from 'glob'; import path from 'node:path'; import type { PackageJson } from 'type-fest'; -import { readJsonFile } from '@code-pushup/utils'; +import * as YAML from 'yaml'; +import { readJsonFile, readTextFile } from './file-system.js'; -type WorkspacePackage = { +export type WorkspacePackage = { name: string; directory: string; packageJson: PackageJson; @@ -59,6 +60,14 @@ export async function readRootPackageJson(cwd: string): Promise { return await readJsonFile(path.join(cwd, 'package.json')); } +export async function readPnpmWorkspacePatterns( + cwd: string, +): Promise { + const content = await readTextFile(path.join(cwd, 'pnpm-workspace.yaml')); + const workspace = YAML.parse(content) as { packages?: string[] }; + return workspace.packages ?? []; +} + export function hasDependency(packageJson: PackageJson, name: string): boolean { const { dependencies = {}, devDependencies = {} } = packageJson; return name in devDependencies || name in dependencies; diff --git a/packages/ci/src/lib/monorepo/packages.unit.test.ts b/packages/utils/src/lib/workspace-packages.unit.test.ts similarity index 99% rename from packages/ci/src/lib/monorepo/packages.unit.test.ts rename to packages/utils/src/lib/workspace-packages.unit.test.ts index af7c31669..899dc20c0 100644 --- a/packages/ci/src/lib/monorepo/packages.unit.test.ts +++ b/packages/utils/src/lib/workspace-packages.unit.test.ts @@ -10,7 +10,7 @@ import { listPackages, listWorkspaces, readRootPackageJson, -} from './packages.js'; +} from './workspace-packages.js'; const pkgJsonContent = (content: PackageJson) => JSON.stringify(content, null, 2);