Skip to content

Commit 5dcdcd7

Browse files
committed
feat(create-cli): add plugin selection step
1 parent cbf25c7 commit 5dcdcd7

File tree

5 files changed

+253
-10
lines changed

5 files changed

+253
-10
lines changed

packages/create-cli/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ const argv = await yargs(hideBin(process.argv))
2121
choices: CONFIG_FILE_FORMATS,
2222
describe: 'Config file format (default: auto-detected from project)',
2323
})
24+
.option('plugins', {
25+
type: 'string',
26+
describe: 'Comma-separated plugin slugs to include (e.g. eslint,coverage)',
27+
})
2428
.parse();
2529

26-
// TODO: #1244 — provide plugin bindings from registry
30+
// TODO: create, import and pass plugin bindings (eslint, coverage, lighthouse, typescript, js-packages, jsdocs, axe)
2731
await runSetupWizard([], argv);

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

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,95 @@
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` CLI argument: comma-separated slugs, validated against available bindings
14+
* 2. `--yes` flag: recommended plugins (or all if none recommended)
15+
* 3. Interactive: checkbox prompt with recommended plugins pre-checked
16+
*/
17+
export async function promptPluginSelection(
18+
bindings: PluginSetupBinding[],
19+
targetDir: string,
20+
cliArgs: CliArgs,
21+
): Promise<PluginSetupBinding[]> {
22+
if (bindings.length === 0) {
23+
return [];
24+
}
25+
const slugs = parsePluginSlugs(cliArgs.plugins);
26+
if (slugs != null) {
27+
return filterBindingsBySlugs(bindings, slugs);
28+
}
29+
const recommended = await detectRecommended(bindings, targetDir);
30+
if (cliArgs.yes) {
31+
return recommended.size > 0
32+
? bindings.filter(({ slug }) => recommended.has(slug))
33+
: bindings;
34+
}
35+
const selected = await checkbox({
36+
message: 'Plugins to include:',
37+
required: true,
38+
choices: bindings.map(({ title, slug }) => ({
39+
name: title,
40+
value: slug,
41+
checked: recommended.has(slug),
42+
})),
43+
});
44+
const selectedSet = new Set(selected);
45+
return bindings.filter(({ slug }) => selectedSet.has(slug));
46+
}
47+
48+
function parsePluginSlugs(value: string | undefined): string[] | null {
49+
if (value == null || value.trim() === '') {
50+
return null;
51+
}
52+
return [
53+
...new Set(
54+
value
55+
.split(',')
56+
.map(s => s.trim())
57+
.filter(Boolean),
58+
),
59+
];
60+
}
61+
62+
function filterBindingsBySlugs(
63+
bindings: PluginSetupBinding[],
64+
slugs: string[],
65+
): PluginSetupBinding[] {
66+
const unknown = slugs.filter(slug => !bindings.some(b => b.slug === slug));
67+
if (unknown.length > 0) {
68+
throw new Error(
69+
`Unknown plugin slugs: ${unknown.join(', ')}. Available: ${bindings.map(b => b.slug).join(', ')}`,
70+
);
71+
}
72+
return bindings.filter(b => slugs.includes(b.slug));
73+
}
74+
75+
/**
76+
* Calls each binding's `isRecommended` callback (if provided)
77+
* and collects the slugs of bindings that returned `true`.
78+
*/
79+
async function detectRecommended(
80+
bindings: PluginSetupBinding[],
81+
targetDir: string,
82+
): Promise<Set<string>> {
83+
const recommended = new Set<string>();
84+
await Promise.all(
85+
bindings.map(async ({ slug, isRecommended }) => {
86+
if (isRecommended && (await isRecommended(targetDir))) {
87+
recommended.add(slug);
88+
}
89+
}),
90+
);
91+
return recommended;
92+
}
693

794
export async function promptPluginOptions(
895
descriptors: PluginPromptDescriptor[],

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

Lines changed: 141 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,143 @@ 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+
it('should throw on unknown slug', async () => {
135+
await expect(
136+
promptPluginSelection(bindings, '/test', { plugins: 'eslint,unknown' }),
137+
).rejects.toThrow('Unknown plugin slugs: unknown');
138+
});
139+
});
140+
141+
describe('--yes (non-interactive)', () => {
142+
it('should return only recommended plugins when some are recommended', async () => {
143+
const result = await promptPluginSelection(
144+
[
145+
{ ...bindings[0]!, isRecommended: () => Promise.resolve(true) },
146+
bindings[1]!,
147+
bindings[2]!,
148+
],
149+
'/test',
150+
{ yes: true },
151+
);
152+
153+
expect(result).toBeArrayOfSize(1);
154+
expect(result[0]).toHaveProperty('slug', 'eslint');
155+
});
156+
157+
it('should return all plugins when none are recommended', async () => {
158+
await expect(
159+
promptPluginSelection(bindings, '/test', { yes: true }),
160+
).resolves.toStrictEqual(bindings);
161+
});
162+
});
163+
164+
describe('interactive prompt', () => {
165+
it('should pre-check recommended plugins and leave others unchecked', async () => {
166+
mockCheckbox.mockResolvedValue(['eslint']);
167+
168+
await promptPluginSelection(
169+
[
170+
{ ...bindings[0]!, isRecommended: () => Promise.resolve(true) },
171+
bindings[1]!,
172+
bindings[2]!,
173+
],
174+
'/test',
175+
{},
176+
);
177+
178+
expect(mockCheckbox).toHaveBeenCalledWith(
179+
expect.objectContaining({
180+
required: true,
181+
choices: [
182+
{ name: 'ESLint', value: 'eslint', checked: true },
183+
{ name: 'Code Coverage', value: 'coverage', checked: false },
184+
{ name: 'Lighthouse', value: 'lighthouse', checked: false },
185+
],
186+
}),
187+
);
188+
});
189+
190+
it('should not pre-check any plugins when none are recommended', async () => {
191+
mockCheckbox.mockResolvedValue(['eslint']);
192+
193+
await promptPluginSelection(bindings, '/test', {});
194+
195+
expect(mockCheckbox).toHaveBeenCalledWith(
196+
expect.objectContaining({
197+
required: true,
198+
choices: [
199+
{ name: 'ESLint', value: 'eslint', checked: false },
200+
{ name: 'Code Coverage', value: 'coverage', checked: false },
201+
{ name: 'Lighthouse', value: 'lighthouse', checked: false },
202+
],
203+
}),
204+
);
205+
});
206+
207+
it('should return only user-selected bindings', async () => {
208+
mockCheckbox.mockResolvedValue(['coverage']);
209+
210+
await expect(
211+
promptPluginSelection(bindings, '/test', {}),
212+
).resolves.toStrictEqual([bindings[1]]);
213+
});
214+
});
215+
216+
describe('isRecommended callback', () => {
217+
it('should receive targetDir as argument', async () => {
218+
const isRecommended = vi.fn().mockResolvedValue(false);
219+
220+
mockCheckbox.mockResolvedValue(['eslint']);
221+
222+
await promptPluginSelection(
223+
[{ ...bindings[0]!, isRecommended }],
224+
'/my/project',
225+
{},
226+
);
227+
228+
expect(isRecommended).toHaveBeenCalledWith('/my/project');
229+
});
230+
});
231+
});

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,19 @@ export type FileSystemAdapter = {
2929
) => Promise<string | undefined>;
3030
};
3131

32+
/**
33+
* Defines how a plugin integrates with the setup wizard.
34+
*
35+
* Each supported plugin provides a binding that controls:
36+
* - Pre-selection: `isRecommended` detects if the plugin is relevant for the repository
37+
* - Configuration: `prompts` collect plugin-specific options interactively
38+
* - Code generation: `generateConfig` produces the import and initialization code
39+
*/
3240
export type PluginSetupBinding = {
3341
slug: PluginMeta['slug'];
3442
title: PluginMeta['title'];
3543
packageName: NonNullable<PluginMeta['packageName']>;
36-
// TODO: #1244 — add async pre-selection callback (e.g. detect eslint.config.js in repo)
44+
isRecommended?: (targetDir: string) => Promise<boolean>;
3745
prompts?: PluginPromptDescriptor[];
3846
generateConfig: (
3947
answers: Record<string, string | string[]>,
@@ -50,7 +58,7 @@ export type ImportDeclarationStructure = {
5058
export type PluginCodegenResult = {
5159
imports: ImportDeclarationStructure[];
5260
pluginInit: string;
53-
// TODO: #1244 — add categories support (categoryRefs for generated categories array)
61+
// TODO: add categories support (categoryRefs for generated categories array)
5462
};
5563

5664
type PromptBase = {
@@ -86,7 +94,7 @@ export type CliArgs = {
8694
'dry-run'?: boolean;
8795
yes?: boolean;
8896
'config-format'?: string;
89-
// TODO: #1244 — add 'plugins' field for CLI-based plugin selection
97+
plugins?: string;
9098
'target-dir'?: string;
9199
[key: string]: unknown;
92100
};

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
resolveConfigFilename,
1212
} from './config-format.js';
1313
import { resolveGitignore } from './gitignore.js';
14-
import { promptPluginOptions } from './prompts.js';
14+
import { promptPluginOptions, promptPluginSelection } from './prompts.js';
1515
import type {
1616
CliArgs,
1717
FileChange,
@@ -33,13 +33,17 @@ export async function runSetupWizard(
3333
const targetDir = cliArgs['target-dir'] ?? process.cwd();
3434

3535
// TODO: #1245 — prompt for standalone vs monorepo mode
36-
// TODO: #1244 — prompt user to select plugins from available bindings
36+
const selectedBindings = await promptPluginSelection(
37+
bindings,
38+
targetDir,
39+
cliArgs,
40+
);
3741

3842
const format = await promptConfigFormat(targetDir, cliArgs);
3943
const packageJson = await readPackageJson(targetDir);
4044
const filename = resolveConfigFilename(format, packageJson.type === 'module');
4145

42-
const pluginResults = await asyncSequential(bindings, binding =>
46+
const pluginResults = await asyncSequential(selectedBindings, binding =>
4347
resolveBinding(binding, cliArgs),
4448
);
4549

0 commit comments

Comments
 (0)