Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
21 changes: 18 additions & 3 deletions packages/create-cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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);
4 changes: 3 additions & 1 deletion packages/create-cli/src/lib/setup/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 8 additions & 4 deletions packages/create-cli/src/lib/setup/codegen.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
"
`);
Expand Down Expand Up @@ -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
],
};
"
`);
Expand Down
29 changes: 29 additions & 0 deletions packages/create-cli/src/lib/setup/plugins.ts
Original file line number Diff line number Diff line change
@@ -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(', ')}`,
);
}
}
45 changes: 45 additions & 0 deletions packages/create-cli/src/lib/setup/plugins.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
61 changes: 59 additions & 2 deletions packages/create-cli/src/lib/setup/prompts.ts
Original file line number Diff line number Diff line change
@@ -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<PluginSetupBinding[]> {
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<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
136 changes: 135 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,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');
});
});
});
Loading