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
7 changes: 6 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ export default tseslint.config(
},
{
sourceTag: 'scope:tooling',
onlyDependOnLibsWithTags: ['scope:tooling', 'scope:shared'],
onlyDependOnLibsWithTags: [
'scope:tooling',
'scope:core',
'scope:plugin',
'scope:shared',
],
},
{
sourceTag: 'type:e2e',
Expand Down
12 changes: 12 additions & 0 deletions packages/create-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ The wizard will prompt you to select plugins and configure their options, then g
| **`--dry-run`** | `boolean` | `false` | Preview changes without writing files |
| **`--yes`**, `-y` | `boolean` | `false` | Skip prompts and use defaults |

### Plugin options

Each plugin exposes its own configuration keys that can be passed as CLI arguments to skip the corresponding prompts.

#### ESLint

| Option | Type | Default | Description |
| ------------------------- | --------- | ------------- | -------------------------- |
| **`--eslint.eslintrc`** | `string` | auto-detected | Path to ESLint config |
| **`--eslint.patterns`** | `string` | `src` or `.` | File patterns to lint |
| **`--eslint.categories`** | `boolean` | `true` | Add recommended categories |

### Examples

Run interactively (default):
Expand Down
1 change: 1 addition & 0 deletions packages/create-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"type": "module",
"dependencies": {
"@code-pushup/eslint-plugin": "0.119.1",
"@code-pushup/models": "0.119.1",
"@code-pushup/utils": "0.119.1",
"@inquirer/prompts": "^8.0.0",
Expand Down
5 changes: 3 additions & 2 deletions packages/create-cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#! /usr/bin/env node
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { eslintSetupBinding } from '@code-pushup/eslint-plugin';
import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js';
import {
CI_PROVIDERS,
Expand All @@ -10,8 +11,8 @@ import {
} 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[] = [];
// TODO: create, import and pass remaining plugin bindings (coverage, lighthouse, typescript, js-packages, jsdocs, axe)
const bindings: PluginSetupBinding[] = [eslintSetupBinding];

const argv = await yargs(hideBin(process.argv))
.option('dry-run', {
Expand Down
19 changes: 14 additions & 5 deletions packages/create-cli/src/lib/setup/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { checkbox, input, select } from '@inquirer/prompts';
import { checkbox, confirm, input, select } from '@inquirer/prompts';
import { asyncSequential } from '@code-pushup/utils';
import type {
CliArgs,
PluginAnswer,
PluginPromptDescriptor,
PluginSetupBinding,
} from './types.js';
Expand Down Expand Up @@ -64,7 +65,7 @@ async function detectRecommended(
export async function promptPluginOptions(
descriptors: PluginPromptDescriptor[],
cliArgs: CliArgs,
): Promise<Record<string, string | string[]>> {
): Promise<Record<string, PluginAnswer>> {
const fallback = cliArgs['yes']
? (descriptor: PluginPromptDescriptor) => descriptor.default
: runPrompt;
Expand All @@ -76,14 +77,17 @@ export async function promptPluginOptions(
return Object.fromEntries(entries);
}

function cliValue(key: string, cliArgs: CliArgs): string | undefined {
function cliValue(key: string, cliArgs: CliArgs): PluginAnswer | undefined {
const value = cliArgs[key];
return typeof value === 'string' ? value : undefined;
if (typeof value === 'string' || typeof value === 'boolean') {
return value;
}
return undefined;
}

async function runPrompt(
descriptor: PluginPromptDescriptor,
): Promise<string | string[]> {
): Promise<PluginAnswer> {
switch (descriptor.type) {
case 'input':
return input({
Expand All @@ -100,5 +104,10 @@ async function runPrompt(
message: descriptor.message,
choices: [...descriptor.choices],
});
case 'confirm':
return confirm({
message: descriptor.message,
default: descriptor.default,
});
}
}
75 changes: 9 additions & 66 deletions packages/create-cli/src/lib/setup/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import type { CategoryConfig, PluginMeta } from '@code-pushup/models';
import type { PluginCodegenResult } from '@code-pushup/models';
import type { MonorepoTool } from '@code-pushup/utils';

export type {
ImportDeclarationStructure,
PluginAnswer,
PluginCodegenResult,
PluginPromptDescriptor,
PluginSetupBinding,
} from '@code-pushup/models';

export const CI_PROVIDERS = ['github', 'gitlab', 'none'] as const;
export type CiProvider = (typeof CI_PROVIDERS)[number];

Expand All @@ -24,50 +32,6 @@ export type CliArgs = {
[key: string]: unknown;
};

type PromptBase = {
key: string;
message: string;
};

type PromptChoice<T extends string> = { name: string; value: T };

type InputPrompt = PromptBase & {
type: 'input';
default: string;
};

type SelectPrompt<T extends string = string> = PromptBase & {
type: 'select';
choices: PromptChoice<T>[];
default: T;
};

type CheckboxPrompt<T extends string = string> = PromptBase & {
type: 'checkbox';
choices: PromptChoice<T>[];
default: T[];
};

/** Declarative prompt definition used to collect plugin-specific options. */
export type PluginPromptDescriptor =
| InputPrompt
| SelectPrompt
| CheckboxPrompt;

export type ImportDeclarationStructure = {
moduleSpecifier: string;
defaultImport?: string;
namedImports?: string[];
isTypeOnly?: boolean;
};

/** Import declarations and plugin initialization code produced by `generateConfig`. */
export type PluginCodegenResult = {
imports: ImportDeclarationStructure[];
pluginInit: string;
categories?: CategoryConfig[];
};

export type ScopedPluginResult = {
scope: PluginScope;
result: PluginCodegenResult;
Expand All @@ -79,27 +43,6 @@ export type ConfigContext = {
tool: MonorepoTool | null;
};

/**
* 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']>;
prompts?: PluginPromptDescriptor[];
scope?: PluginScope;
isRecommended?: (targetDir: string) => Promise<boolean>;
generateConfig: (
answers: Record<string, string | string[]>,
context: ConfigContext,
) => PluginCodegenResult;
};

/** A project discovered in a monorepo workspace. */
export type WizardProject = {
name: string;
Expand Down
70 changes: 69 additions & 1 deletion packages/create-cli/src/lib/setup/wizard.int.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { eslintSetupBinding } from '@code-pushup/eslint-plugin';
import { cleanTestFolder } from '@code-pushup/test-utils';
import { getGitRoot } from '@code-pushup/utils';
import type { PluginSetupBinding } from './types.js';
Expand All @@ -19,7 +20,7 @@ const TEST_BINDINGS: PluginSetupBinding[] = [
title: 'Alpha Plugin',
packageName: '@code-pushup/alpha-plugin',
isRecommended: () => Promise.resolve(true),
prompts: [
prompts: async () => [
{
key: 'alpha.path',
message: 'Path to config',
Expand Down Expand Up @@ -222,4 +223,71 @@ describe('runSetupWizard', () => {
readFile(path.join(outputDir, '.gitignore'), 'utf8'),
).resolves.toBe('node_modules\n.code-pushup\n');
});

it('should generate config with ESLint plugin using defaults', async () => {
await runSetupWizard([eslintSetupBinding], {
yes: true,
plugins: ['eslint'],
'config-format': 'ts',
'target-dir': outputDir,
});

await expect(
readFile(path.join(outputDir, 'code-pushup.config.ts'), 'utf8'),
).resolves.toMatchInlineSnapshot(`
"import eslintPlugin from '@code-pushup/eslint-plugin';
import type { CoreConfig } from '@code-pushup/models';

export default {
plugins: [
await eslintPlugin(),
],
categories: [
{
slug: 'bug-prevention',
title: 'Bug prevention',
description: 'Lint rules that find **potential bugs** in your code.',
refs: [
{ type: 'group', plugin: 'eslint', slug: 'problems', weight: 1 },
],
},
{
slug: 'code-style',
title: 'Code style',
description: 'Lint rules that promote **good practices** and consistency in your code.',
refs: [
{ type: 'group', plugin: 'eslint', slug: 'suggestions', weight: 1 },
],
},
],
} satisfies CoreConfig;
"
`);
});

it('should generate config with custom ESLint options', async () => {
await runSetupWizard([eslintSetupBinding], {
yes: true,
plugins: ['eslint'],
'config-format': 'ts',
'target-dir': outputDir,
'eslint.eslintrc': 'custom-eslint.config.js',
'eslint.patterns': 'src',
'eslint.categories': false,
});

await expect(
readFile(path.join(outputDir, 'code-pushup.config.ts'), 'utf8'),
).resolves.toMatchInlineSnapshot(`
"import eslintPlugin from '@code-pushup/eslint-plugin';
import type { CoreConfig } from '@code-pushup/models';

export default {
plugins: [
await eslintPlugin({ eslintrc: 'custom-eslint.config.js', patterns: 'src' }),
],
} satisfies CoreConfig;
"
`);
});
});
15 changes: 8 additions & 7 deletions packages/create-cli/src/lib/setup/wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import {
import { promptPluginOptions, promptPluginSelection } from './prompts.js';
import type {
CliArgs,
ConfigContext,
FileChange,
PluginCodegenResult,
PluginSetupBinding,
Expand Down Expand Up @@ -62,7 +61,7 @@ export async function runSetupWizard(
selectedBindings,
async binding => ({
scope: binding.scope ?? 'project',
result: await resolveBinding(binding, cliArgs, context),
result: await resolveBinding(binding, cliArgs, targetDir),
}),
);

Expand Down Expand Up @@ -103,12 +102,14 @@ export async function runSetupWizard(
async function resolveBinding(
binding: PluginSetupBinding,
cliArgs: CliArgs,
context: ConfigContext,
targetDir: string,
): Promise<PluginCodegenResult> {
const answers = binding.prompts
? await promptPluginOptions(binding.prompts, cliArgs)
: {};
return binding.generateConfig(answers, context);
const descriptors = binding.prompts ? await binding.prompts(targetDir) : [];
const answers =
descriptors.length > 0
? await promptPluginOptions(descriptors, cliArgs)
: {};
return binding.generateConfig(answers);
}

async function writeStandaloneConfig(
Expand Down
7 changes: 7 additions & 0 deletions packages/models/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ export {
type PluginScoreTargets,
type PluginUrls,
} from './lib/plugin-config.js';
export type {
ImportDeclarationStructure,
PluginAnswer,
PluginCodegenResult,
PluginPromptDescriptor,
PluginSetupBinding,
} from './lib/plugin-setup.js';
export {
auditReportSchema,
pluginReportSchema,
Expand Down
Loading
Loading