From a04d8fa2bb48513ad511ae37dc932d33417765d8 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Wed, 4 Mar 2026 12:07:30 -0500 Subject: [PATCH 1/2] refactor: extract monorepo utils to shared pkg --- packages/ci/package.json | 1 - packages/ci/src/index.ts | 5 -- packages/ci/src/lib/models.ts | 2 +- packages/ci/src/lib/monorepo/detect-tool.ts | 14 ---- .../ci/src/lib/monorepo/handlers/index.ts | 3 +- packages/ci/src/lib/monorepo/handlers/npm.ts | 11 +-- packages/ci/src/lib/monorepo/handlers/nx.ts | 5 +- packages/ci/src/lib/monorepo/handlers/pnpm.ts | 19 ++--- .../ci/src/lib/monorepo/handlers/turbo.ts | 4 +- packages/ci/src/lib/monorepo/handlers/yarn.ts | 12 +-- packages/ci/src/lib/monorepo/index.ts | 7 +- packages/ci/src/lib/monorepo/list-projects.ts | 15 ++-- packages/ci/src/lib/monorepo/tools.ts | 7 +- packages/ci/src/lib/run.int.test.ts | 3 +- packages/utils/eslint.config.js | 2 +- packages/utils/package.json | 2 + packages/utils/src/index.ts | 17 ++++ packages/utils/src/lib/monorepo.ts | 37 +++++++++ packages/utils/src/lib/monorepo.unit.test.ts | 78 +++++++++++++++++++ .../src/lib/workspace-packages.ts} | 13 +++- .../src/lib/workspace-packages.unit.test.ts} | 2 +- 21 files changed, 177 insertions(+), 82 deletions(-) delete mode 100644 packages/ci/src/lib/monorepo/detect-tool.ts create mode 100644 packages/utils/src/lib/monorepo.ts create mode 100644 packages/utils/src/lib/monorepo.unit.test.ts rename packages/{ci/src/lib/monorepo/packages.ts => utils/src/lib/workspace-packages.ts} (85%) rename packages/{ci/src/lib/monorepo/packages.unit.test.ts => utils/src/lib/workspace-packages.unit.test.ts} (99%) diff --git a/packages/ci/package.json b/packages/ci/package.json index da31fddda..d1c25459a 100644 --- a/packages/ci/package.json +++ b/packages/ci/package.json @@ -32,7 +32,6 @@ "ansis": "^3.3.2", "glob": "^11.0.1", "simple-git": "^3.20.0", - "yaml": "^2.5.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..d4735ffa0 100644 --- a/packages/ci/src/lib/monorepo/handlers/npm.ts +++ b/packages/ci/src/lib/monorepo/handlers/npm.ts @@ -1,21 +1,16 @@ -import path from 'node:path'; -import { fileExists } from '@code-pushup/utils'; import { + MONOREPO_TOOL_DETECTORS, 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)) - ); + return MONOREPO_TOOL_DETECTORS.npm(options.cwd); }, async listProjects(options) { diff --git a/packages/ci/src/lib/monorepo/handlers/nx.ts b/packages/ci/src/lib/monorepo/handlers/nx.ts index bcaec1e44..8f34bb41c 100644 --- a/packages/ci/src/lib/monorepo/handlers/nx.ts +++ b/packages/ci/src/lib/monorepo/handlers/nx.ts @@ -1,7 +1,6 @@ -import path from 'node:path'; import { + MONOREPO_TOOL_DETECTORS, executeProcess, - fileExists, interpolate, stringifyError, toArray, @@ -13,7 +12,7 @@ export const nxHandler: MonorepoToolHandler = { async isConfigured(options) { return ( - (await fileExists(path.join(options.cwd, 'nx.json'))) && + (await MONOREPO_TOOL_DETECTORS.nx(options.cwd)) && ( await executeProcess({ command: 'npx', diff --git a/packages/ci/src/lib/monorepo/handlers/pnpm.ts b/packages/ci/src/lib/monorepo/handlers/pnpm.ts index 45885e823..e67b22c83 100644 --- a/packages/ci/src/lib/monorepo/handlers/pnpm.ts +++ b/packages/ci/src/lib/monorepo/handlers/pnpm.ts @@ -1,30 +1,23 @@ -import path from 'node:path'; -import * as YAML from 'yaml'; -import { fileExists, readTextFile } from '@code-pushup/utils'; import { + MONOREPO_TOOL_DETECTORS, 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'))) - ); + return MONOREPO_TOOL_DETECTORS.pnpm(options.cwd); }, 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/turbo.ts b/packages/ci/src/lib/monorepo/handlers/turbo.ts index 49e2e32f5..7c49bdd5d 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'; @@ -17,7 +17,7 @@ export const turboHandler: MonorepoToolHandler = { async isConfigured(options) { const configPath = path.join(options.cwd, 'turbo.json'); return ( - (await fileExists(configPath)) && + (await MONOREPO_TOOL_DETECTORS.turbo(options.cwd)) && options.task in (await readJsonFile(configPath)).tasks ); }, diff --git a/packages/ci/src/lib/monorepo/handlers/yarn.ts b/packages/ci/src/lib/monorepo/handlers/yarn.ts index 8ba2dcf03..1771be24e 100644 --- a/packages/ci/src/lib/monorepo/handlers/yarn.ts +++ b/packages/ci/src/lib/monorepo/handlers/yarn.ts @@ -1,21 +1,17 @@ -import path from 'node:path'; -import { executeProcess, fileExists } from '@code-pushup/utils'; import { + MONOREPO_TOOL_DETECTORS, + 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)) - ); + return MONOREPO_TOOL_DETECTORS.yarn(options.cwd); }, async listProjects(options) { 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..ea49293da 100644 --- a/packages/ci/src/lib/monorepo/tools.ts +++ b/packages/ci/src/lib/monorepo/tools.ts @@ -1,5 +1,4 @@ -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; @@ -28,7 +27,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..972af2ee7 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 => { diff --git a/packages/utils/eslint.config.js b/packages/utils/eslint.config.js index fb34f86af..122f97a79 100644 --- a/packages/utils/eslint.config.js +++ b/packages/utils/eslint.config.js @@ -30,7 +30,7 @@ export default tseslint.config( rules: { '@nx/dependency-checks': [ 'error', - { ignoredDependencies: ['esbuild'] }, // esbuild is a peer dependency of bundle-require + { ignoredDependencies: ['esbuild', 'type-fest'] }, // esbuild is a peer dependency of bundle-require ], }, }, diff --git a/packages/utils/package.json b/packages/utils/package.json index 0108100f2..739fc2c24 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -32,11 +32,13 @@ "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", "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); From 08a8e022cbb269aab2321ec16b3e5837c16a87f5 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Fri, 6 Mar 2026 17:21:00 -0500 Subject: [PATCH 2/2] feat(create-cli): add monorepo setup mode --- packages/create-cli/src/index.ts | 6 + packages/create-cli/src/lib/setup/codegen.ts | 157 +++++++++++----- .../src/lib/setup/codegen.unit.test.ts | 89 ++++++++- .../create-cli/src/lib/setup/config-format.ts | 10 +- .../src/lib/setup/config-format.unit.test.ts | 4 +- packages/create-cli/src/lib/setup/monorepo.ts | 164 ++++++++++++++++ .../src/lib/setup/monorepo.unit.test.ts | 177 ++++++++++++++++++ packages/create-cli/src/lib/setup/types.ts | 161 ++++++++++------ packages/create-cli/src/lib/setup/wizard.ts | 96 +++++++++- .../src/lib/setup/wizard.unit.test.ts | 142 ++++++++++++++ 10 files changed, 879 insertions(+), 127 deletions(-) create mode 100644 packages/create-cli/src/lib/setup/monorepo.ts create mode 100644 packages/create-cli/src/lib/setup/monorepo.unit.test.ts 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..e43f9e7e9 --- /dev/null +++ b/packages/create-cli/src/lib/setup/monorepo.ts @@ -0,0 +1,164 @@ +import { select } from '@inquirer/prompts'; +import path from 'node:path'; +import { + MONOREPO_TOOL_DETECTORS, + type MonorepoTool, + type WorkspacePackage, + hasScript, + listPackages, + listWorkspaces, + loadNxProjectGraph, + readPnpmWorkspacePatterns, + toUnixPath, +} from '@code-pushup/utils'; +import { + type CliArgs, + SETUP_MODES, + type SetupMode, + type Tree, + type WizardProject, +} from './types.js'; + +const TARGET_NAME = 'code-pushup'; + +export async function promptSetupMode( + tool: MonorepoTool | null, + cliArgs: CliArgs, +): Promise { + if (isSetupMode(cliArgs.mode)) { + return cliArgs.mode; + } + const mode = tool ? 'monorepo' : 'standalone'; + if (cliArgs.yes) { + return mode; + } + return select({ + message: 'Setup mode:', + choices: [ + { name: 'Standalone (single config)', value: 'standalone' }, + { name: 'Monorepo (per-project configs)', value: 'monorepo' }, + ], + default: mode, + }); +} + +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)), + }; +} + +function isSetupMode(value: string | undefined): value is SetupMode { + const validValues: readonly string[] = SETUP_MODES; + return value != null && validValues.includes(value); +} 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..8a78b0794 --- /dev/null +++ b/packages/create-cli/src/lib/setup/monorepo.unit.test.ts @@ -0,0 +1,177 @@ +import { select } from '@inquirer/prompts'; +import { vol } from 'memfs'; +import { MEMFS_VOLUME } from '@code-pushup/test-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 return CLI arg when --mode is provided', async () => { + await expect(promptSetupMode('nx', { mode: 'standalone' })).resolves.toBe( + 'standalone', + ); + expect(select).not.toHaveBeenCalled(); + }); + + it('should auto-select monorepo when --yes and tool detected', async () => { + await expect(promptSetupMode('nx', { yes: true })).resolves.toBe( + 'monorepo', + ); + expect(select).not.toHaveBeenCalled(); + }); + + it('should auto-select standalone when --yes and no tool', async () => { + await expect(promptSetupMode(null, { yes: true })).resolves.toBe( + 'standalone', + ); + }); + + it('should prompt interactively with monorepo pre-selected when tool detected', async () => { + vi.mocked(select).mockResolvedValue('monorepo'); + + await promptSetupMode('pnpm', {}); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ default: 'monorepo' }), + ); + }); + + it('should prompt interactively with standalone pre-selected when no tool detected', async () => { + vi.mocked(select).mockResolvedValue('standalone'); + + await promptSetupMode(null, {}); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ default: '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..370342f96 100644 --- a/packages/create-cli/src/lib/setup/wizard.ts +++ b/packages/create-cli/src/lib/setup/wizard.ts @@ -1,22 +1,39 @@ +import path from 'node:path'; import { + type MonorepoTool, asyncSequential, + detectMonorepoTool, 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 +49,8 @@ export async function runSetupWizard( ): Promise { const targetDir = cliArgs['target-dir'] ?? process.cwd(); - // TODO: #1245 — prompt for standalone vs monorepo mode + const tool = await detectMonorepoTool(targetDir); + const mode = await promptSetupMode(tool, cliArgs); const selectedBindings = await promptPluginSelection( bindings, targetDir, @@ -41,15 +59,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, { tool, mode }), + }), ); 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 +104,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(); + }); + }); });