Skip to content

Commit 0bc2003

Browse files
authored
feat(create-cli): add plugin selection step (#1261)
1 parent cbf25c7 commit 0bc2003

11 files changed

Lines changed: 322 additions & 19 deletions

File tree

packages/create-cli/src/index.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
#! /usr/bin/env node
22
import yargs from 'yargs';
33
import { hideBin } from 'yargs/helpers';
4-
import { CONFIG_FILE_FORMATS } from './lib/setup/types.js';
4+
import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js';
5+
import {
6+
CONFIG_FILE_FORMATS,
7+
type PluginSetupBinding,
8+
} from './lib/setup/types.js';
59
import { runSetupWizard } from './lib/setup/wizard.js';
610

11+
// TODO: create, import and pass plugin bindings (eslint, coverage, lighthouse, typescript, js-packages, jsdocs, axe)
12+
const bindings: PluginSetupBinding[] = [];
13+
714
const argv = await yargs(hideBin(process.argv))
815
.option('dry-run', {
916
type: 'boolean',
@@ -21,7 +28,15 @@ const argv = await yargs(hideBin(process.argv))
2128
choices: CONFIG_FILE_FORMATS,
2229
describe: 'Config file format (default: auto-detected from project)',
2330
})
31+
.option('plugins', {
32+
type: 'string',
33+
describe: 'Comma-separated plugin slugs to include (e.g. eslint,coverage)',
34+
coerce: parsePluginSlugs,
35+
})
36+
.check(parsed => {
37+
validatePluginSlugs(bindings, parsed.plugins);
38+
return true;
39+
})
2440
.parse();
2541

26-
// TODO: #1244 — provide plugin bindings from registry
27-
await runSetupWizard([], argv);
42+
await runSetupWizard(bindings, argv);

packages/create-cli/src/lib/setup/codegen.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ function addPlugins(
6868
plugins: PluginCodegenResult[],
6969
): void {
7070
if (plugins.length === 0) {
71-
builder.addLine('plugins: [],', 1);
71+
builder.addLine('plugins: [', 1);
72+
builder.addLine('// TODO: register some plugins', 2);
73+
builder.addLine('],', 1);
7274
} else {
7375
builder.addLine('plugins: [', 1);
7476
builder.addLines(

packages/create-cli/src/lib/setup/codegen.unit.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import type { PluginCodegenResult } from './types.js';
33

44
describe('generateConfigSource', () => {
55
describe('TypeScript format', () => {
6-
it('should generate config with empty plugins array', () => {
6+
it('should generate config with TODO placeholder when no plugins provided', () => {
77
expect(generateConfigSource([], 'ts')).toMatchInlineSnapshot(`
88
"import type { CoreConfig } from '@code-pushup/models';
99
1010
export default {
11-
plugins: [],
11+
plugins: [
12+
// TODO: register some plugins
13+
],
1214
} satisfies CoreConfig;
1315
"
1416
`);
@@ -104,11 +106,13 @@ describe('generateConfigSource', () => {
104106
});
105107

106108
describe('JavaScript format', () => {
107-
it('should generate JS config with empty plugins array', () => {
109+
it('should generate JS config with TODO placeholder when no plugins provided', () => {
108110
expect(generateConfigSource([], 'js')).toMatchInlineSnapshot(`
109111
"/** @type {import('@code-pushup/models').CoreConfig} */
110112
export default {
111-
plugins: [],
113+
plugins: [
114+
// TODO: register some plugins
115+
],
112116
};
113117
"
114118
`);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { PluginSetupBinding } from './types.js';
2+
3+
/** Parses a comma-separated string of plugin slugs into a deduplicated array. */
4+
export function parsePluginSlugs(value: string): string[] {
5+
return [
6+
...new Set(
7+
value
8+
.split(',')
9+
.map(s => s.trim())
10+
.filter(Boolean),
11+
),
12+
];
13+
}
14+
15+
/** Throws if any slug is not found in the available bindings. */
16+
export function validatePluginSlugs(
17+
bindings: PluginSetupBinding[],
18+
plugins?: string[],
19+
): void {
20+
if (plugins == null || plugins.length === 0) {
21+
return;
22+
}
23+
const unknown = plugins.filter(slug => !bindings.some(b => b.slug === slug));
24+
if (unknown.length > 0) {
25+
throw new TypeError(
26+
`Unknown plugin slugs: ${unknown.join(', ')}. Available: ${bindings.map(b => b.slug).join(', ')}`,
27+
);
28+
}
29+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { parsePluginSlugs, validatePluginSlugs } from './plugins.js';
2+
3+
describe('parsePluginSlugs', () => {
4+
it.each([
5+
['eslint,coverage', ['eslint', 'coverage']],
6+
[' eslint , coverage ', ['eslint', 'coverage']],
7+
['eslint,eslint', ['eslint']],
8+
['eslint,,coverage', ['eslint', 'coverage']],
9+
])('should parse %j into %j', (input, expected) => {
10+
expect(parsePluginSlugs(input)).toStrictEqual(expected);
11+
});
12+
});
13+
14+
describe('validatePluginSlugs', () => {
15+
const bindings = [
16+
{
17+
slug: 'eslint',
18+
title: 'ESLint',
19+
packageName: '@code-pushup/eslint-plugin',
20+
generateConfig: () => ({ imports: [], pluginInit: '' }),
21+
},
22+
{
23+
slug: 'coverage',
24+
title: 'Code Coverage',
25+
packageName: '@code-pushup/coverage-plugin',
26+
generateConfig: () => ({ imports: [], pluginInit: '' }),
27+
},
28+
];
29+
30+
it('should not throw for valid or missing slugs', () => {
31+
expect(() => validatePluginSlugs(bindings)).not.toThrow();
32+
expect(() =>
33+
validatePluginSlugs(bindings, ['eslint', 'coverage']),
34+
).not.toThrow();
35+
});
36+
37+
it('should throw TypeError on unknown slug', () => {
38+
expect(() => validatePluginSlugs(bindings, ['eslint', 'unknown'])).toThrow(
39+
TypeError,
40+
);
41+
expect(() => validatePluginSlugs(bindings, ['eslint', 'unknown'])).toThrow(
42+
'Unknown plugin slugs: unknown',
43+
);
44+
});
45+
});

packages/create-cli/src/lib/setup/prompts.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,65 @@
11
import { checkbox, input, select } from '@inquirer/prompts';
22
import { asyncSequential } from '@code-pushup/utils';
3-
import type { CliArgs, PluginPromptDescriptor } from './types.js';
3+
import type {
4+
CliArgs,
5+
PluginPromptDescriptor,
6+
PluginSetupBinding,
7+
} from './types.js';
48

5-
// TODO: #1244 — add promptPluginSelection (multi-select prompt with pre-selection callbacks)
9+
/**
10+
* Resolves which plugins to include in the generated config.
11+
*
12+
* Resolution order (first match wins):
13+
* 1. `--plugins`: user-provided slugs
14+
* 2. `--yes`: recommended plugins
15+
* 3. Interactive: checkbox prompt with recommended plugins pre-checked
16+
*/
17+
export async function promptPluginSelection(
18+
bindings: PluginSetupBinding[],
19+
targetDir: string,
20+
{ plugins, yes }: CliArgs,
21+
): Promise<PluginSetupBinding[]> {
22+
if (bindings.length === 0) {
23+
return [];
24+
}
25+
if (plugins != null && plugins.length > 0) {
26+
return bindings.filter(b => plugins.includes(b.slug));
27+
}
28+
const recommended = await detectRecommended(bindings, targetDir);
29+
if (yes) {
30+
return bindings.filter(({ slug }) => recommended.has(slug));
31+
}
32+
const selected = await checkbox({
33+
message: 'Plugins to include:',
34+
required: true,
35+
choices: bindings.map(({ title, slug }) => ({
36+
name: title,
37+
value: slug,
38+
checked: recommended.has(slug),
39+
})),
40+
});
41+
const selectedSet = new Set(selected);
42+
return bindings.filter(({ slug }) => selectedSet.has(slug));
43+
}
44+
45+
/**
46+
* Calls each binding's `isRecommended` callback (if provided)
47+
* and collects the slugs of bindings that returned `true`.
48+
*/
49+
async function detectRecommended(
50+
bindings: PluginSetupBinding[],
51+
targetDir: string,
52+
): Promise<Set<string>> {
53+
const recommended = new Set<string>();
54+
await Promise.all(
55+
bindings.map(async ({ slug, isRecommended }) => {
56+
if (isRecommended && (await isRecommended(targetDir))) {
57+
recommended.add(slug);
58+
}
59+
}),
60+
);
61+
return recommended;
62+
}
663

764
export async function promptPluginOptions(
865
descriptors: PluginPromptDescriptor[],

packages/create-cli/src/lib/setup/prompts.unit.test.ts

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { promptPluginOptions } from './prompts.js';
1+
import { promptPluginOptions, promptPluginSelection } from './prompts.js';
22
import type { PluginPromptDescriptor } from './types.js';
33

44
vi.mock('@inquirer/prompts', () => ({
@@ -89,3 +89,137 @@ describe('promptPluginOptions', () => {
8989
).resolves.toStrictEqual({ formats: [] });
9090
});
9191
});
92+
93+
describe('promptPluginSelection', () => {
94+
const bindings = [
95+
{
96+
slug: 'eslint',
97+
title: 'ESLint',
98+
packageName: '@code-pushup/eslint-plugin',
99+
generateConfig: () => ({ imports: [], pluginInit: '' }),
100+
},
101+
{
102+
slug: 'coverage',
103+
title: 'Code Coverage',
104+
packageName: '@code-pushup/coverage-plugin',
105+
generateConfig: () => ({ imports: [], pluginInit: '' }),
106+
},
107+
{
108+
slug: 'lighthouse',
109+
title: 'Lighthouse',
110+
packageName: '@code-pushup/lighthouse-plugin',
111+
generateConfig: () => ({ imports: [], pluginInit: '' }),
112+
},
113+
];
114+
115+
it('should return empty array when given no bindings', async () => {
116+
await expect(promptPluginSelection([], '/test', {})).resolves.toStrictEqual(
117+
[],
118+
);
119+
120+
expect(mockCheckbox).not.toHaveBeenCalled();
121+
});
122+
123+
describe('--plugins CLI arg', () => {
124+
it('should return matching bindings for valid slugs', async () => {
125+
await expect(
126+
promptPluginSelection(bindings, '/test', {
127+
plugins: ['eslint', 'lighthouse'],
128+
}),
129+
).resolves.toStrictEqual([bindings[0], bindings[2]]);
130+
131+
expect(mockCheckbox).not.toHaveBeenCalled();
132+
});
133+
});
134+
135+
describe('--yes (non-interactive)', () => {
136+
it('should return only recommended plugins when some are recommended', async () => {
137+
const result = await promptPluginSelection(
138+
[
139+
{ ...bindings[0]!, isRecommended: () => Promise.resolve(true) },
140+
bindings[1]!,
141+
bindings[2]!,
142+
],
143+
'/test',
144+
{ yes: true },
145+
);
146+
147+
expect(result).toBeArrayOfSize(1);
148+
expect(result[0]).toHaveProperty('slug', 'eslint');
149+
});
150+
151+
it('should return no plugins when none are recommended', async () => {
152+
await expect(
153+
promptPluginSelection(bindings, '/test', { yes: true }),
154+
).resolves.toBeArrayOfSize(0);
155+
});
156+
});
157+
158+
describe('interactive prompt', () => {
159+
it('should pre-check recommended plugins and leave others unchecked', async () => {
160+
mockCheckbox.mockResolvedValue(['eslint']);
161+
162+
await promptPluginSelection(
163+
[
164+
{ ...bindings[0]!, isRecommended: () => Promise.resolve(true) },
165+
bindings[1]!,
166+
bindings[2]!,
167+
],
168+
'/test',
169+
{},
170+
);
171+
172+
expect(mockCheckbox).toHaveBeenCalledWith(
173+
expect.objectContaining({
174+
required: true,
175+
choices: [
176+
{ name: 'ESLint', value: 'eslint', checked: true },
177+
{ name: 'Code Coverage', value: 'coverage', checked: false },
178+
{ name: 'Lighthouse', value: 'lighthouse', checked: false },
179+
],
180+
}),
181+
);
182+
});
183+
184+
it('should not pre-check any plugins when none are recommended', async () => {
185+
mockCheckbox.mockResolvedValue(['eslint']);
186+
187+
await promptPluginSelection(bindings, '/test', {});
188+
189+
expect(mockCheckbox).toHaveBeenCalledWith(
190+
expect.objectContaining({
191+
required: true,
192+
choices: [
193+
{ name: 'ESLint', value: 'eslint', checked: false },
194+
{ name: 'Code Coverage', value: 'coverage', checked: false },
195+
{ name: 'Lighthouse', value: 'lighthouse', checked: false },
196+
],
197+
}),
198+
);
199+
});
200+
201+
it('should return only user-selected bindings', async () => {
202+
mockCheckbox.mockResolvedValue(['coverage']);
203+
204+
await expect(
205+
promptPluginSelection(bindings, '/test', {}),
206+
).resolves.toStrictEqual([bindings[1]]);
207+
});
208+
});
209+
210+
describe('isRecommended callback', () => {
211+
it('should receive targetDir as argument', async () => {
212+
const isRecommended = vi.fn().mockResolvedValue(false);
213+
214+
mockCheckbox.mockResolvedValue(['eslint']);
215+
216+
await promptPluginSelection(
217+
[{ ...bindings[0]!, isRecommended }],
218+
'/my/project',
219+
{},
220+
);
221+
222+
expect(isRecommended).toHaveBeenCalledWith('/my/project');
223+
});
224+
});
225+
});

0 commit comments

Comments
 (0)