Skip to content

Commit 0b4c70b

Browse files
committed
feat(plugin-eslint): add setup wizard binding
1 parent 059fcde commit 0b4c70b

File tree

10 files changed

+431
-11
lines changed

10 files changed

+431
-11
lines changed

packages/create-cli/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ The wizard will prompt you to select plugins and configure their options, then g
2525
| **`--dry-run`** | `boolean` | `false` | Preview changes without writing files |
2626
| **`--yes`**, `-y` | `boolean` | `false` | Skip prompts and use defaults |
2727

28+
### Plugin options
29+
30+
Each plugin exposes its own configuration keys that can be passed as CLI arguments to skip the corresponding prompts.
31+
32+
#### ESLint
33+
34+
| Option | Type | Default | Description |
35+
| ------------------------- | ----------------- | ------------- | -------------------------- |
36+
| **`--eslint.eslintrc`** | `string` | auto-detected | Path to ESLint config |
37+
| **`--eslint.patterns`** | `string` | `src` or `.` | File patterns to lint |
38+
| **`--eslint.categories`** | `'yes'` \| `'no'` | `yes` | Add recommended categories |
39+
2840
### Examples
2941

3042
Run interactively (default):

packages/create-cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
},
2727
"type": "module",
2828
"dependencies": {
29+
"@code-pushup/eslint-plugin": "0.119.0",
2930
"@code-pushup/models": "0.119.0",
3031
"@code-pushup/utils": "0.119.0",
3132
"@inquirer/prompts": "^8.0.0",

packages/create-cli/src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#! /usr/bin/env node
22
import yargs from 'yargs';
33
import { hideBin } from 'yargs/helpers';
4+
import { eslintSetupBinding } from '@code-pushup/eslint-plugin';
45
import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js';
56
import {
67
CI_PROVIDERS,
@@ -10,8 +11,8 @@ import {
1011
} from './lib/setup/types.js';
1112
import { runSetupWizard } from './lib/setup/wizard.js';
1213

13-
// TODO: create, import and pass plugin bindings (eslint, coverage, lighthouse, typescript, js-packages, jsdocs, axe)
14-
const bindings: PluginSetupBinding[] = [];
14+
// TODO: create, import and pass remaining plugin bindings (coverage, lighthouse, typescript, js-packages, jsdocs, axe)
15+
const bindings: PluginSetupBinding[] = [eslintSetupBinding];
1516

1617
const argv = await yargs(hideBin(process.argv))
1718
.option('dry-run', {

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

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { readFile, writeFile } from 'node:fs/promises';
22
import path from 'node:path';
3+
import { eslintSetupBinding } from '@code-pushup/eslint-plugin';
34
import { cleanTestFolder } from '@code-pushup/test-utils';
45
import { getGitRoot } from '@code-pushup/utils';
56
import type { PluginSetupBinding } from './types.js';
@@ -19,7 +20,7 @@ const TEST_BINDINGS: PluginSetupBinding[] = [
1920
title: 'Alpha Plugin',
2021
packageName: '@code-pushup/alpha-plugin',
2122
isRecommended: () => Promise.resolve(true),
22-
prompts: [
23+
prompts: async () => [
2324
{
2425
key: 'alpha.path',
2526
message: 'Path to config',
@@ -222,4 +223,71 @@ describe('runSetupWizard', () => {
222223
readFile(path.join(outputDir, '.gitignore'), 'utf8'),
223224
).resolves.toBe('node_modules\n.code-pushup\n');
224225
});
226+
227+
it('should generate config with ESLint plugin using defaults', async () => {
228+
await runSetupWizard([eslintSetupBinding], {
229+
yes: true,
230+
plugins: ['eslint'],
231+
'config-format': 'ts',
232+
'target-dir': outputDir,
233+
});
234+
235+
await expect(
236+
readFile(path.join(outputDir, 'code-pushup.config.ts'), 'utf8'),
237+
).resolves.toMatchInlineSnapshot(`
238+
"import eslintPlugin from '@code-pushup/eslint-plugin';
239+
import type { CoreConfig } from '@code-pushup/models';
240+
241+
export default {
242+
plugins: [
243+
await eslintPlugin(),
244+
],
245+
categories: [
246+
{
247+
slug: 'bug-prevention',
248+
title: 'Bug prevention',
249+
description: 'Lint rules that find **potential bugs** in your code.',
250+
refs: [
251+
{ type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 },
252+
],
253+
},
254+
{
255+
slug: 'code-style',
256+
title: 'Code style',
257+
description: 'Lint rules that promote **good practices** and consistency in your code.',
258+
refs: [
259+
{ type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 },
260+
],
261+
},
262+
],
263+
} satisfies CoreConfig;
264+
"
265+
`);
266+
});
267+
268+
it('should generate config with custom ESLint options', async () => {
269+
await runSetupWizard([eslintSetupBinding], {
270+
yes: true,
271+
plugins: ['eslint'],
272+
'config-format': 'ts',
273+
'target-dir': outputDir,
274+
'eslint.eslintrc': 'custom-eslint.config.js',
275+
'eslint.patterns': 'src',
276+
'eslint.categories': 'no',
277+
});
278+
279+
await expect(
280+
readFile(path.join(outputDir, 'code-pushup.config.ts'), 'utf8'),
281+
).resolves.toMatchInlineSnapshot(`
282+
"import eslintPlugin from '@code-pushup/eslint-plugin';
283+
import type { CoreConfig } from '@code-pushup/models';
284+
285+
export default {
286+
plugins: [
287+
await eslintPlugin({ eslintrc: 'custom-eslint.config.js', patterns: 'src' }),
288+
],
289+
} satisfies CoreConfig;
290+
"
291+
`);
292+
});
225293
});

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import {
2828
import { promptPluginOptions, promptPluginSelection } from './prompts.js';
2929
import type {
3030
CliArgs,
31-
ConfigContext,
3231
FileChange,
3332
PluginCodegenResult,
3433
PluginSetupBinding,
@@ -62,7 +61,7 @@ export async function runSetupWizard(
6261
selectedBindings,
6362
async binding => ({
6463
scope: binding.scope ?? 'project',
65-
result: await resolveBinding(binding, cliArgs, context),
64+
result: await resolveBinding(binding, cliArgs, targetDir),
6665
}),
6766
);
6867

@@ -103,12 +102,14 @@ export async function runSetupWizard(
103102
async function resolveBinding(
104103
binding: PluginSetupBinding,
105104
cliArgs: CliArgs,
106-
context: ConfigContext,
105+
targetDir: string,
107106
): Promise<PluginCodegenResult> {
108-
const answers = binding.prompts
109-
? await promptPluginOptions(binding.prompts, cliArgs)
110-
: {};
111-
return binding.generateConfig(answers, context);
107+
const descriptors = binding.prompts ? await binding.prompts(targetDir) : [];
108+
const answers =
109+
descriptors.length > 0
110+
? await promptPluginOptions(descriptors, cliArgs)
111+
: {};
112+
return binding.generateConfig(answers);
112113
}
113114

114115
async function writeStandaloneConfig(

packages/plugin-eslint/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { eslintPlugin } from './lib/eslint-plugin.js';
22

33
export default eslintPlugin;
44

5+
export { eslintSetupBinding } from './lib/binding.js';
56
export type { ESLintPluginConfig } from './lib/config.js';
67
export { ESLINT_PLUGIN_SLUG } from './lib/constants.js';
78

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { readdir } from 'node:fs/promises';
2+
import path from 'node:path';
3+
import type { CategoryConfig, PluginSetupBinding } from '@code-pushup/models';
4+
import { directoryExists, readJsonFile, singleQuote } from '@code-pushup/utils';
5+
import {
6+
DEFAULT_PATTERN,
7+
ESLINT_PLUGIN_SLUG,
8+
ESLINT_PLUGIN_TITLE,
9+
} from './constants.js';
10+
11+
const PACKAGE_NAME = '@code-pushup/eslint-plugin';
12+
const ESLINT_CONFIG_PATTERN = /^(\.eslintrc(\.\w+)?|eslint\.config\.\w+)$/;
13+
14+
const ESLINT_CATEGORIES: CategoryConfig[] = [
15+
{
16+
slug: 'bug-prevention',
17+
title: 'Bug prevention',
18+
description: 'Lint rules that find **potential bugs** in your code.',
19+
refs: [
20+
{
21+
type: 'group',
22+
plugin: ESLINT_PLUGIN_SLUG,
23+
slug: 'problems',
24+
weight: 1,
25+
},
26+
],
27+
},
28+
{
29+
slug: 'code-style',
30+
title: 'Code style',
31+
description:
32+
'Lint rules that promote **good practices** and consistency in your code.',
33+
refs: [
34+
{
35+
type: 'group',
36+
plugin: ESLINT_PLUGIN_SLUG,
37+
slug: 'suggestions',
38+
weight: 1,
39+
},
40+
],
41+
},
42+
];
43+
44+
export const eslintSetupBinding = {
45+
slug: ESLINT_PLUGIN_SLUG,
46+
title: ESLINT_PLUGIN_TITLE,
47+
packageName: PACKAGE_NAME,
48+
isRecommended,
49+
prompts: async (targetDir: string) => [
50+
{
51+
key: 'eslint.eslintrc',
52+
message: 'Path to ESLint config',
53+
type: 'input',
54+
default: (await detectEslintConfig(targetDir)) ?? '',
55+
},
56+
{
57+
key: 'eslint.patterns',
58+
message: 'File patterns to lint',
59+
type: 'input',
60+
default: (await directoryExists(path.join(targetDir, 'src')))
61+
? 'src'
62+
: DEFAULT_PATTERN,
63+
},
64+
{
65+
key: 'eslint.categories',
66+
message: 'Add recommended categories (bug prevention, code style)?',
67+
type: 'select',
68+
choices: [
69+
{ name: 'Yes', value: 'yes' },
70+
{ name: 'No', value: 'no' },
71+
],
72+
default: 'yes',
73+
},
74+
],
75+
generateConfig: (answers: Record<string, string | string[]>) => {
76+
const withCategories = answers['eslint.categories'] !== 'no';
77+
const args = [
78+
resolveEslintrc(answers['eslint.eslintrc']),
79+
resolvePatterns(answers['eslint.patterns']),
80+
].filter(Boolean);
81+
82+
return {
83+
imports: [
84+
{ moduleSpecifier: PACKAGE_NAME, defaultImport: 'eslintPlugin' },
85+
],
86+
pluginInit:
87+
args.length > 0
88+
? `await eslintPlugin({ ${args.join(', ')} })`
89+
: 'await eslintPlugin()',
90+
...(withCategories ? { categories: ESLINT_CATEGORIES } : {}),
91+
};
92+
},
93+
} satisfies PluginSetupBinding;
94+
95+
async function detectEslintConfig(
96+
targetDir: string,
97+
): Promise<string | undefined> {
98+
const files = await readdir(targetDir, { encoding: 'utf8' });
99+
return files.find(file => ESLINT_CONFIG_PATTERN.test(file));
100+
}
101+
102+
async function isRecommended(targetDir: string): Promise<boolean> {
103+
if (await detectEslintConfig(targetDir)) {
104+
return true;
105+
}
106+
try {
107+
const packageJson = await readJsonFile<{
108+
dependencies?: Record<string, string>;
109+
devDependencies?: Record<string, string>;
110+
}>(path.join(targetDir, 'package.json'));
111+
return (
112+
'eslint' in (packageJson.dependencies ?? {}) ||
113+
'eslint' in (packageJson.devDependencies ?? {})
114+
);
115+
} catch {
116+
return false;
117+
}
118+
}
119+
120+
/** Omits `eslintrc` for standard config filenames (ESLint discovers them automatically). */
121+
function resolveEslintrc(value: string | string[] | undefined): string {
122+
if (typeof value !== 'string' || !value) {
123+
return '';
124+
}
125+
if (ESLINT_CONFIG_PATTERN.test(value)) {
126+
return '';
127+
}
128+
return `eslintrc: ${singleQuote(value)}`;
129+
}
130+
131+
/** Formats patterns as a string or array literal, omitting the plugin default. */
132+
function resolvePatterns(value: string | string[] | undefined): string {
133+
const items = typeof value === 'string' ? value.split(',') : (value ?? []);
134+
const patterns = items
135+
.map(s => s.trim())
136+
.filter(s => s !== '' && s !== DEFAULT_PATTERN)
137+
.map(singleQuote);
138+
if (patterns.length === 0) {
139+
return '';
140+
}
141+
if (patterns.length === 1) {
142+
return `patterns: ${patterns.join('')}`;
143+
}
144+
return `patterns: [${patterns.join(', ')}]`;
145+
}

0 commit comments

Comments
 (0)