Skip to content

Commit 48fb8a1

Browse files
committed
feat(create-cli): add monorepo setup mode
1 parent a04d8fa commit 48fb8a1

File tree

10 files changed

+876
-127
lines changed

10 files changed

+876
-127
lines changed

packages/create-cli/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js';
55
import {
66
CONFIG_FILE_FORMATS,
77
type PluginSetupBinding,
8+
SETUP_MODES,
89
} from './lib/setup/types.js';
910
import { runSetupWizard } from './lib/setup/wizard.js';
1011

@@ -33,6 +34,11 @@ const argv = await yargs(hideBin(process.argv))
3334
describe: 'Comma-separated plugin slugs to include (e.g. eslint,coverage)',
3435
coerce: parsePluginSlugs,
3536
})
37+
.option('mode', {
38+
type: 'string',
39+
choices: SETUP_MODES,
40+
describe: 'Setup mode (default: auto-detected from project)',
41+
})
3642
.check(parsed => {
3743
validatePluginSlugs(bindings, parsed.plugins);
3844
return true;
Lines changed: 106 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import path from 'node:path';
12
import type {
23
ConfigFileFormat,
34
ImportDeclarationStructure,
@@ -32,6 +33,60 @@ class CodeBuilder {
3233
}
3334
}
3435

36+
export function generateConfigSource(
37+
plugins: PluginCodegenResult[],
38+
format: ConfigFileFormat,
39+
): string {
40+
const builder = new CodeBuilder();
41+
addImports(builder, collectImports(plugins, format));
42+
if (format === 'ts') {
43+
builder.addLine('export default {');
44+
addPlugins(builder, plugins);
45+
builder.addLine('} satisfies CoreConfig;');
46+
} else {
47+
builder.addLine("/** @type {import('@code-pushup/models').CoreConfig} */");
48+
builder.addLine('export default {');
49+
addPlugins(builder, plugins);
50+
builder.addLine('};');
51+
}
52+
return builder.toString();
53+
}
54+
55+
export function generatePresetSource(
56+
plugins: PluginCodegenResult[],
57+
format: ConfigFileFormat,
58+
): string {
59+
const builder = new CodeBuilder();
60+
addImports(builder, collectImports(plugins, format));
61+
addPresetExport(builder, plugins, format);
62+
return builder.toString();
63+
}
64+
65+
export function generateProjectSource(
66+
projectName: string,
67+
presetImportPath: string,
68+
): string {
69+
const builder = new CodeBuilder();
70+
builder.addLine(
71+
formatImport({
72+
moduleSpecifier: presetImportPath,
73+
namedImports: ['createConfig'],
74+
}),
75+
);
76+
builder.addEmptyLine();
77+
builder.addLine(`export default await createConfig('${projectName}');`);
78+
return builder.toString();
79+
}
80+
81+
export function computeRelativePresetImport(
82+
projectRelativeDir: string,
83+
presetFilename: string,
84+
): string {
85+
const relativePath = path.relative(projectRelativeDir, presetFilename);
86+
const importPath = relativePath.replace(/\.ts$/, '.js');
87+
return importPath.startsWith('.') ? importPath : `./${importPath}`;
88+
}
89+
3590
function formatImport({
3691
moduleSpecifier,
3792
defaultImport,
@@ -45,76 +100,77 @@ function formatImport({
45100
return `import ${type}${from}'${moduleSpecifier}';`;
46101
}
47102

48-
function collectTsImports(
49-
plugins: PluginCodegenResult[],
103+
function sortImports(
104+
imports: ImportDeclarationStructure[],
50105
): ImportDeclarationStructure[] {
51-
return [
52-
CORE_CONFIG_IMPORT,
53-
...plugins.flatMap(({ imports }) => imports),
54-
].toSorted((a, b) => a.moduleSpecifier.localeCompare(b.moduleSpecifier));
106+
return imports.toSorted((a, b) =>
107+
a.moduleSpecifier.localeCompare(b.moduleSpecifier),
108+
);
55109
}
56110

57-
function collectJsImports(
111+
function collectImports(
58112
plugins: PluginCodegenResult[],
113+
format: ConfigFileFormat,
59114
): ImportDeclarationStructure[] {
60-
return plugins
61-
.flatMap(({ imports }) => imports)
62-
.map(({ isTypeOnly: _, ...rest }) => rest)
63-
.toSorted((a, b) => a.moduleSpecifier.localeCompare(b.moduleSpecifier));
115+
const pluginImports = plugins.flatMap(({ imports }) => imports);
116+
if (format === 'ts') {
117+
return sortImports([CORE_CONFIG_IMPORT, ...pluginImports]);
118+
}
119+
return sortImports(pluginImports.map(({ isTypeOnly: _, ...rest }) => rest));
120+
}
121+
122+
function addImports(
123+
builder: CodeBuilder,
124+
imports: ImportDeclarationStructure[],
125+
): void {
126+
if (imports.length > 0) {
127+
builder.addLines(imports.map(formatImport));
128+
builder.addEmptyLine();
129+
}
64130
}
65131

66132
function addPlugins(
67133
builder: CodeBuilder,
68134
plugins: PluginCodegenResult[],
135+
depth = 1,
69136
): void {
137+
builder.addLine('plugins: [', depth);
70138
if (plugins.length === 0) {
71-
builder.addLine('plugins: [', 1);
72-
builder.addLine('// TODO: register some plugins', 2);
73-
builder.addLine('],', 1);
139+
builder.addLine('// TODO: register some plugins', depth + 1);
74140
} else {
75-
builder.addLine('plugins: [', 1);
76141
builder.addLines(
77142
plugins.map(({ pluginInit }) => `${pluginInit},`),
78-
2,
143+
depth + 1,
79144
);
80-
builder.addLine('],', 1);
81145
}
146+
builder.addLine('],', depth);
82147
}
83148

84-
export function generateConfigSource(
149+
function addPresetExport(
150+
builder: CodeBuilder,
85151
plugins: PluginCodegenResult[],
86152
format: ConfigFileFormat,
87-
): string {
88-
return format === 'ts'
89-
? generateTsConfig(plugins)
90-
: generateJsConfig(plugins);
91-
}
92-
93-
function generateTsConfig(plugins: PluginCodegenResult[]): string {
94-
const builder = new CodeBuilder();
95-
96-
builder.addLines(collectTsImports(plugins).map(formatImport));
97-
builder.addEmptyLine();
98-
builder.addLine('export default {');
99-
addPlugins(builder, plugins);
100-
builder.addLine('} satisfies CoreConfig;');
101-
102-
return builder.toString();
103-
}
104-
105-
function generateJsConfig(plugins: PluginCodegenResult[]): string {
106-
const builder = new CodeBuilder();
107-
108-
const pluginImports = collectJsImports(plugins);
109-
if (pluginImports.length > 0) {
110-
builder.addLines(pluginImports.map(formatImport));
111-
builder.addEmptyLine();
153+
): void {
154+
if (format === 'ts') {
155+
builder.addLines([
156+
'/**',
157+
' * Creates a Code PushUp config for a project.',
158+
' * @param project Project name',
159+
' */',
160+
'export async function createConfig(project: string): Promise<CoreConfig> {',
161+
]);
162+
} else {
163+
builder.addLines([
164+
'/**',
165+
' * Creates a Code PushUp config for a project.',
166+
' * @param {string} project Project name',
167+
" * @returns {Promise<import('@code-pushup/models').CoreConfig>}",
168+
' */',
169+
'export async function createConfig(project) {',
170+
]);
112171
}
113-
114-
builder.addLine("/** @type {import('@code-pushup/models').CoreConfig} */");
115-
builder.addLine('export default {');
116-
addPlugins(builder, plugins);
117-
builder.addLine('};');
118-
119-
return builder.toString();
172+
builder.addLine('return {', 1);
173+
addPlugins(builder, plugins, 2);
174+
builder.addLine('};', 1);
175+
builder.addLine('}');
120176
}

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

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
1-
import { generateConfigSource } from './codegen.js';
1+
import {
2+
computeRelativePresetImport,
3+
generateConfigSource,
4+
generatePresetSource,
5+
generateProjectSource,
6+
} from './codegen.js';
27
import type { PluginCodegenResult } from './types.js';
38

9+
const ESLINT_PLUGIN: PluginCodegenResult = {
10+
imports: [
11+
{
12+
moduleSpecifier: '@code-pushup/eslint-plugin',
13+
defaultImport: 'eslintPlugin',
14+
},
15+
],
16+
pluginInit: "await eslintPlugin({ patterns: '.' })",
17+
};
18+
419
describe('generateConfigSource', () => {
520
describe('TypeScript format', () => {
621
it('should generate config with TODO placeholder when no plugins provided', () => {
@@ -187,3 +202,75 @@ describe('generateConfigSource', () => {
187202
});
188203
});
189204
});
205+
206+
describe('generatePresetSource', () => {
207+
it('should generate TS preset with function signature and plugins', () => {
208+
expect(generatePresetSource([ESLINT_PLUGIN], 'ts')).toMatchInlineSnapshot(`
209+
"import eslintPlugin from '@code-pushup/eslint-plugin';
210+
import type { CoreConfig } from '@code-pushup/models';
211+
212+
/**
213+
* Creates a Code PushUp config for a project.
214+
* @param project Project name
215+
*/
216+
export async function createConfig(project: string): Promise<CoreConfig> {
217+
return {
218+
plugins: [
219+
await eslintPlugin({ patterns: '.' }),
220+
],
221+
};
222+
}
223+
"
224+
`);
225+
});
226+
227+
it('should generate JS preset with JSDoc annotation', () => {
228+
expect(generatePresetSource([ESLINT_PLUGIN], 'js')).toMatchInlineSnapshot(`
229+
"import eslintPlugin from '@code-pushup/eslint-plugin';
230+
231+
/**
232+
* Creates a Code PushUp config for a project.
233+
* @param {string} project Project name
234+
* @returns {Promise<import('@code-pushup/models').CoreConfig>}
235+
*/
236+
export async function createConfig(project) {
237+
return {
238+
plugins: [
239+
await eslintPlugin({ patterns: '.' }),
240+
],
241+
};
242+
}
243+
"
244+
`);
245+
});
246+
});
247+
248+
describe('generateProjectSource', () => {
249+
it('should generate import and createConfig call', () => {
250+
const source = generateProjectSource(
251+
'my-app',
252+
'../../code-pushup.preset.js',
253+
);
254+
expect(source).toMatchInlineSnapshot(`
255+
"import { createConfig } from '../../code-pushup.preset.js';
256+
257+
export default await createConfig('my-app');
258+
"
259+
`);
260+
});
261+
});
262+
263+
describe('computeRelativePresetImport', () => {
264+
it.each([
265+
['packages/my-app', 'code-pushup.preset.ts', '../../code-pushup.preset.js'],
266+
['apps/web', 'code-pushup.preset.mjs', '../../code-pushup.preset.mjs'],
267+
['packages/lib', 'code-pushup.preset.js', '../../code-pushup.preset.js'],
268+
])(
269+
'should resolve %j relative to %j as %j',
270+
(projectDir, presetFilename, expected) => {
271+
expect(computeRelativePresetImport(projectDir, presetFilename)).toBe(
272+
expected,
273+
);
274+
},
275+
);
276+
});

packages/create-cli/src/lib/setup/config-format.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,18 @@ export async function promptConfigFormat(
3535
});
3636
}
3737

38-
/** Returns `code-pushup.config.{ts,js,mjs}` based on format and ESM context. */
39-
export function resolveConfigFilename(
38+
export function resolveFilename(
39+
baseName: string,
4040
format: ConfigFileFormat,
4141
isEsm: boolean,
4242
): string {
4343
if (format === 'ts') {
44-
return 'code-pushup.config.ts';
44+
return `${baseName}.ts`;
4545
}
4646
if (format === 'js' && isEsm) {
47-
return 'code-pushup.config.js';
47+
return `${baseName}.js`;
4848
}
49-
return 'code-pushup.config.mjs';
49+
return `${baseName}.mjs`;
5050
}
5151

5252
export async function readPackageJson(targetDir: string): Promise<PackageJson> {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { vol } from 'memfs';
22
import { MEMFS_VOLUME } from '@code-pushup/test-utils';
3-
import { promptConfigFormat, resolveConfigFilename } from './config-format.js';
3+
import { promptConfigFormat, resolveFilename } from './config-format.js';
44
import type { ConfigFileFormat } from './types.js';
55

66
vi.mock('@inquirer/prompts', () => ({
@@ -18,7 +18,7 @@ describe('resolveConfigFilename', () => {
1818
['js', true, 'code-pushup.config.js'],
1919
['js', false, 'code-pushup.config.mjs'],
2020
])('should resolve format %j (ESM: %j) to %j', (format, isEsm, expected) => {
21-
expect(resolveConfigFilename(format, isEsm)).toBe(expected);
21+
expect(resolveFilename('code-pushup.config', format, isEsm)).toBe(expected);
2222
});
2323
});
2424

0 commit comments

Comments
 (0)