Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/create-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ 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)',
})
.parse();

// TODO: #1244 — provide plugin bindings from registry
// TODO: create, import and pass plugin bindings (eslint, coverage, lighthouse, typescript, js-packages, jsdocs, axe)
await runSetupWizard([], argv);
91 changes: 89 additions & 2 deletions packages/create-cli/src/lib/setup/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,95 @@
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` CLI argument: comma-separated slugs, validated against available bindings
* 2. `--yes` flag: recommended plugins (or all if none recommended)
* 3. Interactive: checkbox prompt with recommended plugins pre-checked
*/
export async function promptPluginSelection(
bindings: PluginSetupBinding[],
targetDir: string,
cliArgs: CliArgs,
): Promise<PluginSetupBinding[]> {
if (bindings.length === 0) {
return [];
}
const slugs = parsePluginSlugs(cliArgs.plugins);
if (slugs != null) {
return filterBindingsBySlugs(bindings, slugs);
}
const recommended = await detectRecommended(bindings, targetDir);
if (cliArgs.yes) {
return recommended.size > 0
? bindings.filter(({ slug }) => recommended.has(slug))
: bindings;
Comment thread
matejchalk marked this conversation as resolved.
Outdated
}
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));
}

function parsePluginSlugs(value: string | undefined): string[] | null {
if (value == null || value.trim() === '') {
return null;
}
return [
...new Set(
value
.split(',')
.map(s => s.trim())
.filter(Boolean),
),
];
}

function filterBindingsBySlugs(
bindings: PluginSetupBinding[],
slugs: string[],
): PluginSetupBinding[] {
const unknown = slugs.filter(slug => !bindings.some(b => b.slug === slug));
if (unknown.length > 0) {
throw new Error(
`Unknown plugin slugs: ${unknown.join(', ')}. Available: ${bindings.map(b => b.slug).join(', ')}`,
);
}
Comment thread
matejchalk marked this conversation as resolved.
Outdated
return bindings.filter(b => slugs.includes(b.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<Set<string>> {
const recommended = new Set<string>();
await Promise.all(
bindings.map(async ({ slug, isRecommended }) => {
if (isRecommended && (await isRecommended(targetDir))) {
recommended.add(slug);
}
}),
);
return recommended;
}

export async function promptPluginOptions(
descriptors: PluginPromptDescriptor[],
Expand Down
142 changes: 141 additions & 1 deletion packages/create-cli/src/lib/setup/prompts.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down Expand Up @@ -89,3 +89,143 @@ 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();
});

it('should throw on unknown slug', async () => {
await expect(
promptPluginSelection(bindings, '/test', { plugins: 'eslint,unknown' }),
).rejects.toThrow('Unknown plugin slugs: unknown');
});
});

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 all plugins when none are recommended', async () => {
await expect(
promptPluginSelection(bindings, '/test', { yes: true }),
).resolves.toStrictEqual(bindings);
});
});

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');
});
});
});
14 changes: 11 additions & 3 deletions packages/create-cli/src/lib/setup/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,19 @@ export type FileSystemAdapter = {
) => Promise<string | undefined>;
};

/**
* 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<PluginMeta['packageName']>;
// TODO: #1244 — add async pre-selection callback (e.g. detect eslint.config.js in repo)
isRecommended?: (targetDir: string) => Promise<boolean>;
prompts?: PluginPromptDescriptor[];
generateConfig: (
answers: Record<string, string | string[]>,
Expand All @@ -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 = {
Expand Down Expand Up @@ -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;
};
10 changes: 7 additions & 3 deletions packages/create-cli/src/lib/setup/wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
);

Expand Down
Loading