diff --git a/packages/create-cli/src/index.ts b/packages/create-cli/src/index.ts index 48b003882..34105564e 100755 --- a/packages/create-cli/src/index.ts +++ b/packages/create-cli/src/index.ts @@ -1,9 +1,16 @@ #! /usr/bin/env node import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; -import { CONFIG_FILE_FORMATS } from './lib/setup/types.js'; +import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js'; +import { + CONFIG_FILE_FORMATS, + type PluginSetupBinding, +} from './lib/setup/types.js'; import { runSetupWizard } from './lib/setup/wizard.js'; +// TODO: create, import and pass plugin bindings (eslint, coverage, lighthouse, typescript, js-packages, jsdocs, axe) +const bindings: PluginSetupBinding[] = []; + const argv = await yargs(hideBin(process.argv)) .option('dry-run', { type: 'boolean', @@ -21,7 +28,15 @@ const argv = await yargs(hideBin(process.argv)) choices: CONFIG_FILE_FORMATS, describe: 'Config file format (default: auto-detected from project)', }) + .option('plugins', { + type: 'string', + describe: 'Comma-separated plugin slugs to include (e.g. eslint,coverage)', + coerce: parsePluginSlugs, + }) + .check(parsed => { + validatePluginSlugs(bindings, parsed.plugins); + return true; + }) .parse(); -// TODO: #1244 — provide plugin bindings from registry -await runSetupWizard([], argv); +await runSetupWizard(bindings, argv); diff --git a/packages/create-cli/src/lib/setup/codegen.ts b/packages/create-cli/src/lib/setup/codegen.ts index e6337b2d4..3184d1169 100644 --- a/packages/create-cli/src/lib/setup/codegen.ts +++ b/packages/create-cli/src/lib/setup/codegen.ts @@ -68,7 +68,9 @@ function addPlugins( plugins: PluginCodegenResult[], ): void { if (plugins.length === 0) { - builder.addLine('plugins: [],', 1); + builder.addLine('plugins: [', 1); + builder.addLine('// TODO: register some plugins', 2); + builder.addLine('],', 1); } else { builder.addLine('plugins: [', 1); builder.addLines( 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 dd8ed1098..473ece795 100644 --- a/packages/create-cli/src/lib/setup/codegen.unit.test.ts +++ b/packages/create-cli/src/lib/setup/codegen.unit.test.ts @@ -3,12 +3,14 @@ import type { PluginCodegenResult } from './types.js'; describe('generateConfigSource', () => { describe('TypeScript format', () => { - it('should generate config with empty plugins array', () => { + it('should generate config with TODO placeholder when no plugins provided', () => { expect(generateConfigSource([], 'ts')).toMatchInlineSnapshot(` "import type { CoreConfig } from '@code-pushup/models'; export default { - plugins: [], + plugins: [ + // TODO: register some plugins + ], } satisfies CoreConfig; " `); @@ -104,11 +106,13 @@ describe('generateConfigSource', () => { }); describe('JavaScript format', () => { - it('should generate JS config with empty plugins array', () => { + it('should generate JS config with TODO placeholder when no plugins provided', () => { expect(generateConfigSource([], 'js')).toMatchInlineSnapshot(` "/** @type {import('@code-pushup/models').CoreConfig} */ export default { - plugins: [], + plugins: [ + // TODO: register some plugins + ], }; " `); diff --git a/packages/create-cli/src/lib/setup/plugins.ts b/packages/create-cli/src/lib/setup/plugins.ts new file mode 100644 index 000000000..60f637a50 --- /dev/null +++ b/packages/create-cli/src/lib/setup/plugins.ts @@ -0,0 +1,29 @@ +import type { PluginSetupBinding } from './types.js'; + +/** Parses a comma-separated string of plugin slugs into a deduplicated array. */ +export function parsePluginSlugs(value: string): string[] { + return [ + ...new Set( + value + .split(',') + .map(s => s.trim()) + .filter(Boolean), + ), + ]; +} + +/** Throws if any slug is not found in the available bindings. */ +export function validatePluginSlugs( + bindings: PluginSetupBinding[], + plugins?: string[], +): void { + if (plugins == null || plugins.length === 0) { + return; + } + const unknown = plugins.filter(slug => !bindings.some(b => b.slug === slug)); + if (unknown.length > 0) { + throw new TypeError( + `Unknown plugin slugs: ${unknown.join(', ')}. Available: ${bindings.map(b => b.slug).join(', ')}`, + ); + } +} diff --git a/packages/create-cli/src/lib/setup/plugins.unit.test.ts b/packages/create-cli/src/lib/setup/plugins.unit.test.ts new file mode 100644 index 000000000..d9e75ffdc --- /dev/null +++ b/packages/create-cli/src/lib/setup/plugins.unit.test.ts @@ -0,0 +1,45 @@ +import { parsePluginSlugs, validatePluginSlugs } from './plugins.js'; + +describe('parsePluginSlugs', () => { + it.each([ + ['eslint,coverage', ['eslint', 'coverage']], + [' eslint , coverage ', ['eslint', 'coverage']], + ['eslint,eslint', ['eslint']], + ['eslint,,coverage', ['eslint', 'coverage']], + ])('should parse %j into %j', (input, expected) => { + expect(parsePluginSlugs(input)).toStrictEqual(expected); + }); +}); + +describe('validatePluginSlugs', () => { + const bindings = [ + { + slug: 'eslint', + title: 'ESLint', + packageName: '@code-pushup/eslint-plugin', + generateConfig: () => ({ imports: [], pluginInit: '' }), + }, + { + slug: 'coverage', + title: 'Code Coverage', + packageName: '@code-pushup/coverage-plugin', + generateConfig: () => ({ imports: [], pluginInit: '' }), + }, + ]; + + it('should not throw for valid or missing slugs', () => { + expect(() => validatePluginSlugs(bindings)).not.toThrow(); + expect(() => + validatePluginSlugs(bindings, ['eslint', 'coverage']), + ).not.toThrow(); + }); + + it('should throw TypeError on unknown slug', () => { + expect(() => validatePluginSlugs(bindings, ['eslint', 'unknown'])).toThrow( + TypeError, + ); + expect(() => validatePluginSlugs(bindings, ['eslint', 'unknown'])).toThrow( + 'Unknown plugin slugs: unknown', + ); + }); +}); diff --git a/packages/create-cli/src/lib/setup/prompts.ts b/packages/create-cli/src/lib/setup/prompts.ts index 053675794..7811ada06 100644 --- a/packages/create-cli/src/lib/setup/prompts.ts +++ b/packages/create-cli/src/lib/setup/prompts.ts @@ -1,8 +1,65 @@ import { checkbox, input, select } from '@inquirer/prompts'; import { asyncSequential } from '@code-pushup/utils'; -import type { CliArgs, PluginPromptDescriptor } from './types.js'; +import type { + CliArgs, + PluginPromptDescriptor, + PluginSetupBinding, +} from './types.js'; -// TODO: #1244 — add promptPluginSelection (multi-select prompt with pre-selection callbacks) +/** + * Resolves which plugins to include in the generated config. + * + * Resolution order (first match wins): + * 1. `--plugins`: user-provided slugs + * 2. `--yes`: recommended plugins + * 3. Interactive: checkbox prompt with recommended plugins pre-checked + */ +export async function promptPluginSelection( + bindings: PluginSetupBinding[], + targetDir: string, + { plugins, yes }: CliArgs, +): Promise { + if (bindings.length === 0) { + return []; + } + if (plugins != null && plugins.length > 0) { + return bindings.filter(b => plugins.includes(b.slug)); + } + const recommended = await detectRecommended(bindings, targetDir); + if (yes) { + return bindings.filter(({ slug }) => recommended.has(slug)); + } + const selected = await checkbox({ + message: 'Plugins to include:', + required: true, + choices: bindings.map(({ title, slug }) => ({ + name: title, + value: slug, + checked: recommended.has(slug), + })), + }); + const selectedSet = new Set(selected); + return bindings.filter(({ slug }) => selectedSet.has(slug)); +} + +/** + * Calls each binding's `isRecommended` callback (if provided) + * and collects the slugs of bindings that returned `true`. + */ +async function detectRecommended( + bindings: PluginSetupBinding[], + targetDir: string, +): Promise> { + const recommended = new Set(); + await Promise.all( + bindings.map(async ({ slug, isRecommended }) => { + if (isRecommended && (await isRecommended(targetDir))) { + recommended.add(slug); + } + }), + ); + return recommended; +} export async function promptPluginOptions( descriptors: PluginPromptDescriptor[], diff --git a/packages/create-cli/src/lib/setup/prompts.unit.test.ts b/packages/create-cli/src/lib/setup/prompts.unit.test.ts index 4661a189f..ee309546d 100644 --- a/packages/create-cli/src/lib/setup/prompts.unit.test.ts +++ b/packages/create-cli/src/lib/setup/prompts.unit.test.ts @@ -1,4 +1,4 @@ -import { promptPluginOptions } from './prompts.js'; +import { promptPluginOptions, promptPluginSelection } from './prompts.js'; import type { PluginPromptDescriptor } from './types.js'; vi.mock('@inquirer/prompts', () => ({ @@ -89,3 +89,137 @@ describe('promptPluginOptions', () => { ).resolves.toStrictEqual({ formats: [] }); }); }); + +describe('promptPluginSelection', () => { + const bindings = [ + { + slug: 'eslint', + title: 'ESLint', + packageName: '@code-pushup/eslint-plugin', + generateConfig: () => ({ imports: [], pluginInit: '' }), + }, + { + slug: 'coverage', + title: 'Code Coverage', + packageName: '@code-pushup/coverage-plugin', + generateConfig: () => ({ imports: [], pluginInit: '' }), + }, + { + slug: 'lighthouse', + title: 'Lighthouse', + packageName: '@code-pushup/lighthouse-plugin', + generateConfig: () => ({ imports: [], pluginInit: '' }), + }, + ]; + + it('should return empty array when given no bindings', async () => { + await expect(promptPluginSelection([], '/test', {})).resolves.toStrictEqual( + [], + ); + + expect(mockCheckbox).not.toHaveBeenCalled(); + }); + + describe('--plugins CLI arg', () => { + it('should return matching bindings for valid slugs', async () => { + await expect( + promptPluginSelection(bindings, '/test', { + plugins: ['eslint', 'lighthouse'], + }), + ).resolves.toStrictEqual([bindings[0], bindings[2]]); + + expect(mockCheckbox).not.toHaveBeenCalled(); + }); + }); + + describe('--yes (non-interactive)', () => { + it('should return only recommended plugins when some are recommended', async () => { + const result = await promptPluginSelection( + [ + { ...bindings[0]!, isRecommended: () => Promise.resolve(true) }, + bindings[1]!, + bindings[2]!, + ], + '/test', + { yes: true }, + ); + + expect(result).toBeArrayOfSize(1); + expect(result[0]).toHaveProperty('slug', 'eslint'); + }); + + it('should return no plugins when none are recommended', async () => { + await expect( + promptPluginSelection(bindings, '/test', { yes: true }), + ).resolves.toBeArrayOfSize(0); + }); + }); + + describe('interactive prompt', () => { + it('should pre-check recommended plugins and leave others unchecked', async () => { + mockCheckbox.mockResolvedValue(['eslint']); + + await promptPluginSelection( + [ + { ...bindings[0]!, isRecommended: () => Promise.resolve(true) }, + bindings[1]!, + bindings[2]!, + ], + '/test', + {}, + ); + + expect(mockCheckbox).toHaveBeenCalledWith( + expect.objectContaining({ + required: true, + choices: [ + { name: 'ESLint', value: 'eslint', checked: true }, + { name: 'Code Coverage', value: 'coverage', checked: false }, + { name: 'Lighthouse', value: 'lighthouse', checked: false }, + ], + }), + ); + }); + + it('should not pre-check any plugins when none are recommended', async () => { + mockCheckbox.mockResolvedValue(['eslint']); + + await promptPluginSelection(bindings, '/test', {}); + + expect(mockCheckbox).toHaveBeenCalledWith( + expect.objectContaining({ + required: true, + choices: [ + { name: 'ESLint', value: 'eslint', checked: false }, + { name: 'Code Coverage', value: 'coverage', checked: false }, + { name: 'Lighthouse', value: 'lighthouse', checked: false }, + ], + }), + ); + }); + + it('should return only user-selected bindings', async () => { + mockCheckbox.mockResolvedValue(['coverage']); + + await expect( + promptPluginSelection(bindings, '/test', {}), + ).resolves.toStrictEqual([bindings[1]]); + }); + }); + + describe('isRecommended callback', () => { + it('should receive targetDir as argument', async () => { + const isRecommended = vi.fn().mockResolvedValue(false); + + mockCheckbox.mockResolvedValue(['eslint']); + + await promptPluginSelection( + [{ ...bindings[0]!, isRecommended }], + '/my/project', + {}, + ); + + expect(isRecommended).toHaveBeenCalledWith('/my/project'); + }); + }); +}); diff --git a/packages/create-cli/src/lib/setup/types.ts b/packages/create-cli/src/lib/setup/types.ts index d6883eaa2..7bdc9956e 100644 --- a/packages/create-cli/src/lib/setup/types.ts +++ b/packages/create-cli/src/lib/setup/types.ts @@ -29,11 +29,19 @@ export type FileSystemAdapter = { ) => 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; - // TODO: #1244 — add async pre-selection callback (e.g. detect eslint.config.js in repo) + isRecommended?: (targetDir: string) => Promise; prompts?: PluginPromptDescriptor[]; generateConfig: ( answers: Record, @@ -50,7 +58,7 @@ export type ImportDeclarationStructure = { export type PluginCodegenResult = { imports: ImportDeclarationStructure[]; pluginInit: string; - // TODO: #1244 — add categories support (categoryRefs for generated categories array) + // TODO: add categories support (categoryRefs for generated categories array) }; type PromptBase = { @@ -86,7 +94,7 @@ export type CliArgs = { 'dry-run'?: boolean; yes?: boolean; 'config-format'?: string; - // TODO: #1244 — add 'plugins' field for CLI-based plugin selection + plugins?: string[]; 'target-dir'?: string; [key: string]: unknown; }; diff --git a/packages/create-cli/src/lib/setup/wizard.int.test.ts b/packages/create-cli/src/lib/setup/wizard.int.test.ts index 0371cdf92..b434e86e0 100644 --- a/packages/create-cli/src/lib/setup/wizard.int.test.ts +++ b/packages/create-cli/src/lib/setup/wizard.int.test.ts @@ -18,6 +18,7 @@ const TEST_BINDINGS: PluginSetupBinding[] = [ slug: 'alpha', title: 'Alpha Plugin', packageName: '@code-pushup/alpha-plugin', + isRecommended: () => Promise.resolve(true), prompts: [ { key: 'alpha.path', @@ -43,6 +44,7 @@ const TEST_BINDINGS: PluginSetupBinding[] = [ slug: 'beta', title: 'Beta Plugin', packageName: '@code-pushup/beta-plugin', + isRecommended: () => Promise.resolve(true), generateConfig: () => ({ imports: [ { diff --git a/packages/create-cli/src/lib/setup/wizard.ts b/packages/create-cli/src/lib/setup/wizard.ts index 4ed26ef72..87218aec1 100644 --- a/packages/create-cli/src/lib/setup/wizard.ts +++ b/packages/create-cli/src/lib/setup/wizard.ts @@ -11,7 +11,7 @@ import { resolveConfigFilename, } from './config-format.js'; import { resolveGitignore } from './gitignore.js'; -import { promptPluginOptions } from './prompts.js'; +import { promptPluginOptions, promptPluginSelection } from './prompts.js'; import type { CliArgs, FileChange, @@ -33,13 +33,17 @@ export async function runSetupWizard( const targetDir = cliArgs['target-dir'] ?? process.cwd(); // TODO: #1245 — prompt for standalone vs monorepo mode - // TODO: #1244 — prompt user to select plugins from available bindings + const selectedBindings = await promptPluginSelection( + bindings, + targetDir, + cliArgs, + ); const format = await promptConfigFormat(targetDir, cliArgs); const packageJson = await readPackageJson(targetDir); const filename = resolveConfigFilename(format, packageJson.type === 'module'); - const pluginResults = await asyncSequential(bindings, binding => + const pluginResults = await asyncSequential(selectedBindings, binding => resolveBinding(binding, cliArgs), ); 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 965e3244f..6a03126b5 100644 --- a/packages/create-cli/src/lib/setup/wizard.unit.test.ts +++ b/packages/create-cli/src/lib/setup/wizard.unit.test.ts @@ -15,6 +15,7 @@ const TEST_BINDING: PluginSetupBinding = { slug: 'test-plugin', title: 'Test Plugin', packageName: '@code-pushup/test-plugin', + isRecommended: () => Promise.resolve(true), generateConfig: () => ({ imports: [ { @@ -72,7 +73,7 @@ describe('runSetupWizard', () => { expect(logger.info).toHaveBeenCalledWith('Dry run — no files written.'); }); - it('should generate empty config with no bindings', async () => { + it('should generate config with TODO placeholder when no bindings provided', async () => { await runSetupWizard([], { yes: true, 'target-dir': MEMFS_VOLUME, @@ -83,7 +84,9 @@ describe('runSetupWizard', () => { "import type { CoreConfig } from '@code-pushup/models'; export default { - plugins: [], + plugins: [ + // TODO: register some plugins + ], } satisfies CoreConfig; " `);