From 422c0c71d673e633ee8e97cd201aaf47c7dc8d38 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Tue, 17 Feb 2026 12:48:34 -0500 Subject: [PATCH 1/6] feat(create-cli): setup wizard architecture --- e2e/create-cli-e2e/tests/init.e2e.test.ts | 1 + package-lock.json | 391 +++++++++++++++++- package.json | 1 + packages/create-cli/README.md | 31 +- packages/create-cli/eslint.config.js | 2 +- packages/create-cli/package.json | 6 +- packages/create-cli/project.json | 1 + packages/create-cli/src/index.ts | 22 +- packages/create-cli/src/lib/constants.ts | 11 - packages/create-cli/src/lib/init.ts | 44 -- packages/create-cli/src/lib/init.unit.test.ts | 127 ------ packages/create-cli/src/lib/setup/codegen.ts | 47 +++ .../src/lib/setup/codegen.unit.test.ts | 69 ++++ packages/create-cli/src/lib/setup/prompts.ts | 47 +++ .../src/lib/setup/prompts.unit.test.ts | 91 ++++ packages/create-cli/src/lib/setup/types.ts | 85 ++++ .../create-cli/src/lib/setup/virtual-fs.ts | 63 +++ .../src/lib/setup/virtual-fs.unit.test.ts | 181 ++++++++ .../src/lib/setup/wizard.int.test.ts | 106 +++++ packages/create-cli/src/lib/setup/wizard.ts | 70 ++++ .../src/lib/setup/wizard.unit.test.ts | 89 ++++ packages/create-cli/src/lib/utils.ts | 90 ---- .../create-cli/src/lib/utils.unit.test.ts | 97 ----- packages/create-cli/vitest.int.config.ts | 3 + 24 files changed, 1288 insertions(+), 387 deletions(-) delete mode 100644 packages/create-cli/src/lib/constants.ts delete mode 100644 packages/create-cli/src/lib/init.ts delete mode 100644 packages/create-cli/src/lib/init.unit.test.ts create mode 100644 packages/create-cli/src/lib/setup/codegen.ts create mode 100644 packages/create-cli/src/lib/setup/codegen.unit.test.ts create mode 100644 packages/create-cli/src/lib/setup/prompts.ts create mode 100644 packages/create-cli/src/lib/setup/prompts.unit.test.ts create mode 100644 packages/create-cli/src/lib/setup/types.ts create mode 100644 packages/create-cli/src/lib/setup/virtual-fs.ts create mode 100644 packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts create mode 100644 packages/create-cli/src/lib/setup/wizard.int.test.ts create mode 100644 packages/create-cli/src/lib/setup/wizard.ts create mode 100644 packages/create-cli/src/lib/setup/wizard.unit.test.ts delete mode 100644 packages/create-cli/src/lib/utils.ts delete mode 100644 packages/create-cli/src/lib/utils.unit.test.ts create mode 100644 packages/create-cli/vitest.int.config.ts diff --git a/e2e/create-cli-e2e/tests/init.e2e.test.ts b/e2e/create-cli-e2e/tests/init.e2e.test.ts index b659603233..b138560bb4 100644 --- a/e2e/create-cli-e2e/tests/init.e2e.test.ts +++ b/e2e/create-cli-e2e/tests/init.e2e.test.ts @@ -12,6 +12,7 @@ import { executeProcess, readJsonFile, readTextFile } from '@code-pushup/utils'; const fakeCacheFolderName = () => `fake-cache-${new Date().toISOString().replace(/[:.]/g, '-')}`; +// TODO: #1240 — rewrite e2e tests for the new setup wizard (old tests reference removed nx-plugin integration) /* after a new release of the nx-verdaccio plugin we can enable the test again. For now, it is too flaky to be productive. (5.jan.2025) */ describe.todo('create-cli-init', () => { const workspaceRoot = path.join(E2E_ENVIRONMENTS_DIR, nxTargetProject()); diff --git a/package-lock.json b/package-lock.json index de389cb9b7..758a5bd8a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@axe-core/playwright": "^4.11.0", "@code-pushup/portal-client": "^0.17.0", + "@inquirer/prompts": "^8.2.0", "@nx/devkit": "22.3.3", "@swc/helpers": "0.5.18", "ansis": "^3.3.2", @@ -3419,6 +3420,198 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.3.tgz", + "integrity": "sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.0.4.tgz", + "integrity": "sha512-DrAMU3YBGMUAp6ArwTIp/25CNDtDbxk7UjIrrtM25JVVrlVYlVzHh5HR1BDFu9JMyUoZ4ZanzeaHqNDttf3gVg==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.3", + "@inquirer/core": "^11.1.1", + "@inquirer/figures": "^2.0.3", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/checkbox/node_modules/@inquirer/figures": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.3.tgz", + "integrity": "sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.4.tgz", + "integrity": "sha512-WdaPe7foUnoGYvXzH4jp4wH/3l+dBhZ3uwhKjXjwdrq5tEIFaANxj6zrGHxLdsIA0yKM0kFPVcEalOZXBB5ISA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.1", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.1.tgz", + "integrity": "sha512-hV9o15UxX46OyQAtaoMqAOxGR8RVl1aZtDx1jHbCtSJy1tBdTfKxLPKf7utsE4cRy4tcmCQ4+vdV+ca+oNxqNA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.3", + "@inquirer/figures": "^2.0.3", + "@inquirer/type": "^4.0.3", + "cli-width": "^4.1.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^9.0.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/@inquirer/figures": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.3.tgz", + "integrity": "sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@inquirer/editor": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.0.4.tgz", + "integrity": "sha512-QI3Jfqcv6UO2/VJaEFONH8Im1ll++Xn/AJTBn9Xf+qx2M+H8KZAdQ5sAe2vtYlo+mLW+d7JaMJB4qWtK4BG3pw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.1", + "@inquirer/external-editor": "^2.0.3", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.4.tgz", + "integrity": "sha512-0I/16YwPPP0Co7a5MsomlZLpch48NzYfToyqYAOWtBmaXSB80RiNQ1J+0xx2eG+Wfxt0nHtpEWSRr6CzNVnOGg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.1", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-2.0.3.tgz", + "integrity": "sha512-LgyI7Agbda74/cL5MvA88iDpvdXI2KuMBCGRkbCl2Dg1vzHeOgs+s0SDcXV7b+WZJrv2+ERpWSM65Fpi9VfY3w==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@inquirer/figures": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.5.tgz", @@ -3428,6 +3621,200 @@ "node": ">=18" } }, + "node_modules/@inquirer/input": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.4.tgz", + "integrity": "sha512-4B3s3jvTREDFvXWit92Yc6jF1RJMDy2VpSqKtm4We2oVU65YOh2szY5/G14h4fHlyQdpUmazU5MPCFZPRJ0AOw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.1", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.4.tgz", + "integrity": "sha512-CmMp9LF5HwE+G/xWsC333TlCzYYbXMkcADkKzcawh49fg2a1ryLc7JL1NJYYt1lJ+8f4slikNjJM9TEL/AljYQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.1", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.4.tgz", + "integrity": "sha512-ZCEPyVYvHK4W4p2Gy6sTp9nqsdHQCfiPXIP9LbJVW4yCinnxL/dDDmPaEZVysGrj8vxVReRnpfS2fOeODe9zjg==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.3", + "@inquirer/core": "^11.1.1", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.2.0.tgz", + "integrity": "sha512-rqTzOprAj55a27jctS3vhvDDJzYXsr33WXTjODgVOru21NvBo9yIgLIAf7SBdSV0WERVly3dR6TWyp7ZHkvKFA==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.0.4", + "@inquirer/confirm": "^6.0.4", + "@inquirer/editor": "^5.0.4", + "@inquirer/expand": "^5.0.4", + "@inquirer/input": "^5.0.4", + "@inquirer/number": "^4.0.4", + "@inquirer/password": "^5.0.4", + "@inquirer/rawlist": "^5.2.0", + "@inquirer/search": "^4.1.0", + "@inquirer/select": "^5.0.4" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.0.tgz", + "integrity": "sha512-CciqGoOUMrFo6HxvOtU5uL8fkjCmzyeB6fG7O1vdVAZVSopUBYECOwevDBlqNLyyYmzpm2Gsn/7nLrpruy9RFg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.1", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.0.tgz", + "integrity": "sha512-EAzemfiP4IFvIuWnrHpgZs9lAhWDA0GM3l9F4t4mTQ22IFtzfrk8xbkMLcAN7gmVML9O/i+Hzu8yOUyAaL6BKA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.1", + "@inquirer/figures": "^2.0.3", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search/node_modules/@inquirer/figures": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.3.tgz", + "integrity": "sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/select": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.0.4.tgz", + "integrity": "sha512-s8KoGpPYMEQ6WXc0dT9blX2NtIulMdLOO3LA1UKOiv7KFWzlJ6eLkEYTDBIi+JkyKXyn8t/CD6TinxGjyLt57g==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.3", + "@inquirer/core": "^11.1.1", + "@inquirer/figures": "^2.0.3", + "@inquirer/type": "^4.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select/node_modules/@inquirer/figures": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.3.tgz", + "integrity": "sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.3.tgz", + "integrity": "sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -13333,7 +13720,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, "engines": { "node": ">= 12" } @@ -27360,8 +27746,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/saxes": { "version": "6.0.0", diff --git a/package.json b/package.json index 52a48a7d47..ca997df2d1 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "@axe-core/playwright": "^4.11.0", "@code-pushup/portal-client": "^0.17.0", + "@inquirer/prompts": "^8.2.0", "@nx/devkit": "22.3.3", "@swc/helpers": "0.5.18", "ansis": "^3.3.2", diff --git a/packages/create-cli/README.md b/packages/create-cli/README.md index 85112ffe19..1e3ed0785f 100644 --- a/packages/create-cli/README.md +++ b/packages/create-cli/README.md @@ -4,29 +4,40 @@ [![downloads](https://img.shields.io/npm/dm/%40code-pushup%2Fcreate-cli)](https://npmtrends.com/@code-pushup/create-cli) [![dependencies](https://img.shields.io/librariesio/release/npm/%40code-pushup/create-cli)](https://www.npmjs.com/package/@code-pushup/create-cli?activeTab=dependencies) -A CLI tool to set up Code PushUp in your repository. +An interactive setup wizard that scaffolds a `code-pushup.config.ts` file in your repository. ## Usage -To set up Code PushUp, run the following command: - ```bash -npx init @code-pushup/cli +npx @code-pushup/create-cli ``` -alternatives: +The wizard will prompt you to select plugins and configure their options, then generate a `code-pushup.config.ts` file. + +## Options + +| Flag | Description | Default | +| ------------- | -------------------------------------- | ------- | +| `--plugins` | Comma-separated plugin slugs to enable | | +| `--dry-run` | Preview changes without writing files | `false` | +| `--yes`, `-y` | Skip prompts and use defaults | `false` | + +### Examples + +Run interactively (default): ```bash npx @code-pushup/create-cli -npm exec @code-pushup/create-cli ``` -It should generate the following output: +Skip prompts and enable specific plugins: ```bash -> <✓> Generating @code-pushup/nx-plugin:init +npx @code-pushup/create-cli -y --plugins=eslint,coverage +``` -> <✓> Generating @code-pushup/nx-plugin:configuration +Preview the generated config without writing: -CREATE code-pushup.config.ts +```bash +npx @code-pushup/create-cli -y --dry-run ``` diff --git a/packages/create-cli/eslint.config.js b/packages/create-cli/eslint.config.js index 22fda2e404..08bb1f80fa 100644 --- a/packages/create-cli/eslint.config.js +++ b/packages/create-cli/eslint.config.js @@ -17,7 +17,7 @@ export default tseslint.config( rules: { '@nx/dependency-checks': [ 'error', - { ignoredDependencies: ['@code-pushup/nx-plugin'] }, // nx-plugin is run via CLI + { ignoredDependencies: ['@code-pushup/models'] }, ], }, }, diff --git a/packages/create-cli/package.json b/packages/create-cli/package.json index e3851f4199..39e7cdcc97 100644 --- a/packages/create-cli/package.json +++ b/packages/create-cli/package.json @@ -26,8 +26,10 @@ }, "type": "module", "dependencies": { - "@code-pushup/nx-plugin": "0.113.0", - "@code-pushup/utils": "0.113.0" + "@code-pushup/utils": "0.113.0", + "@inquirer/prompts": "^8.0.0", + "ts-morph": "^24.0.0", + "yargs": "^17.7.2" }, "files": [ "src", diff --git a/packages/create-cli/project.json b/packages/create-cli/project.json index 8e4af8b5b8..7e081800bc 100644 --- a/packages/create-cli/project.json +++ b/packages/create-cli/project.json @@ -8,6 +8,7 @@ "lint": {}, "unit-test": {}, + "int-test": {}, "code-pushup": {}, "code-pushup-eslint": {}, "code-pushup-coverage": {}, diff --git a/packages/create-cli/src/index.ts b/packages/create-cli/src/index.ts index 221a00a2a9..4f9e782f7a 100755 --- a/packages/create-cli/src/index.ts +++ b/packages/create-cli/src/index.ts @@ -1,4 +1,22 @@ #! /usr/bin/env node -import { initCodePushup } from './lib/init.js'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import type { CliArgs } from './lib/setup/types.js'; +import { runSetupWizard } from './lib/setup/wizard.js'; -await initCodePushup(); +const argv = await yargs(hideBin(process.argv)) + .option('dry-run', { + type: 'boolean', + default: false, + describe: 'Preview changes without writing files', + }) + .option('yes', { + alias: 'y', + type: 'boolean', + default: false, + describe: 'Skip prompts and use defaults', + }) + .parse(); + +// TODO: #1244 — provide plugin bindings from registry +await runSetupWizard([], argv as CliArgs); diff --git a/packages/create-cli/src/lib/constants.ts b/packages/create-cli/src/lib/constants.ts deleted file mode 100644 index bffac6c15a..0000000000 --- a/packages/create-cli/src/lib/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const NX_JSON_FILENAME = 'nx.json'; -export const NX_JSON_CONTENT = JSON.stringify({ - $schema: './node_modules/nx/schemas/nx-schema.json', - targetDefaults: {}, -}); -export const PROJECT_NAME = 'source-root'; -export const PROJECT_JSON_FILENAME = 'project.json'; -export const PROJECT_JSON_CONTENT = JSON.stringify({ - $schema: 'node_modules/nx/schemas/project-schema.json', - name: PROJECT_NAME, -}); diff --git a/packages/create-cli/src/lib/init.ts b/packages/create-cli/src/lib/init.ts deleted file mode 100644 index 77b1e81cb6..0000000000 --- a/packages/create-cli/src/lib/init.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - type ProcessConfig, - executeProcess, - objectToCliArgs, -} from '@code-pushup/utils'; -import { - parseNxProcessOutput, - setupNxContext, - teardownNxContext, -} from './utils.js'; - -export function nxPluginGenerator( - generator: 'init' | 'configuration', - opt: Record = {}, -): ProcessConfig { - return { - command: 'npx', - args: objectToCliArgs({ - _: ['nx', 'g', `@code-pushup/nx-plugin:${generator}`], - ...opt, - }), - }; -} - -export async function initCodePushup() { - const setupResult = await setupNxContext(); - - await executeProcess({ - ...nxPluginGenerator('init', { - skipNxJson: true, - }), - }); - - const { stdout: configStdout, stderr: configStderr } = await executeProcess( - nxPluginGenerator('configuration', { - skipTarget: true, - project: setupResult.projectName, - }), - ); - console.info(parseNxProcessOutput(configStdout)); - console.warn(parseNxProcessOutput(configStderr)); - - await teardownNxContext(setupResult); -} diff --git a/packages/create-cli/src/lib/init.unit.test.ts b/packages/create-cli/src/lib/init.unit.test.ts deleted file mode 100644 index 854eef638a..0000000000 --- a/packages/create-cli/src/lib/init.unit.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { vol } from 'memfs'; -import { MEMFS_VOLUME } from '@code-pushup/test-utils'; -import type { ProcessResult } from '@code-pushup/utils'; -import * as utils from '@code-pushup/utils'; -import { initCodePushup, nxPluginGenerator } from './init.js'; -import * as createUtils from './utils.js'; - -describe('nxPluginGenerator', () => { - it('should create valid command', () => { - expect(nxPluginGenerator('init', { skipNxJson: true })).toStrictEqual({ - command: 'npx', - args: ['nx', 'g', '@code-pushup/nx-plugin:init', '--skipNxJson'], - }); - }); -}); - -describe('initCodePushup', () => { - const spyExecuteProcess = vi.spyOn(utils, 'executeProcess'); - const spyParseNxProcessOutput = vi.spyOn(createUtils, 'parseNxProcessOutput'); - const spySetupNxContext = vi.spyOn(createUtils, 'setupNxContext'); - const spyTeardownNxContext = vi.spyOn(createUtils, 'teardownNxContext'); - - beforeEach(() => { - // needed to get test folder set up - vol.fromJSON( - { - 'random-file': '', - }, - MEMFS_VOLUME, - ); - vol.rm('random-file', () => void 0); - - spyExecuteProcess.mockResolvedValue({ - stdout: 'stdout-mock', - stderr: '', - } as ProcessResult); - }); - - afterEach(() => { - spyExecuteProcess.mockReset(); - }); - - it('should add packages and create config file', async () => { - const projectJson = { name: 'my-lib' }; - - vol.fromJSON( - { - 'nx.json': '{}', - 'project.json': JSON.stringify(projectJson), - }, - MEMFS_VOLUME, - ); - - await initCodePushup(); - - expect(spySetupNxContext).toHaveBeenCalledOnce(); - - expect(spyExecuteProcess).toHaveBeenNthCalledWith(1, { - command: 'npx', - args: ['nx', 'g', '@code-pushup/nx-plugin:init', '--skipNxJson'], - }); - expect(spyParseNxProcessOutput).toHaveBeenNthCalledWith(1, 'stdout-mock'); - expect(spyExecuteProcess).toHaveBeenNthCalledWith(2, { - command: 'npx', - args: [ - 'nx', - 'g', - '@code-pushup/nx-plugin:configuration', - '--skipTarget', - `--project="${projectJson.name}"`, - ], - }); - expect(spyParseNxProcessOutput).toHaveBeenNthCalledWith(1, 'stdout-mock'); - expect(spyParseNxProcessOutput).toHaveBeenCalledTimes(2); - expect(spyExecuteProcess).toHaveBeenCalledTimes(2); - expect(spyTeardownNxContext).toHaveBeenCalledOnce(); - expect(spyTeardownNxContext).toHaveBeenNthCalledWith(1, { - projectName: projectJson.name, - nxJsonTeardown: false, - projectJsonTeardown: false, - }); - }); - - it('should teardown nx.json if set up', async () => { - const projectJson = { name: 'my-lib' }; - vol.fromJSON( - { - 'project.json': JSON.stringify(projectJson), - }, - MEMFS_VOLUME, - ); - - await initCodePushup(); - - expect(spySetupNxContext).toHaveBeenCalledOnce(); - expect(spyTeardownNxContext).toHaveBeenCalledOnce(); - expect(spyTeardownNxContext).toHaveBeenNthCalledWith(1, { - projectName: projectJson.name, - nxJsonTeardown: true, - projectJsonTeardown: false, - }); - }); - - it('should teardown project.json if set up', async () => { - vol.fromJSON( - { - 'nx.json': '{}', - }, - MEMFS_VOLUME, - ); - - spyExecuteProcess.mockResolvedValue({ - stdout: 'stdout-mock', - stderr: '', - } as ProcessResult); - - await initCodePushup(); - - expect(spySetupNxContext).toHaveBeenCalledOnce(); - expect(spyTeardownNxContext).toHaveBeenCalledOnce(); - expect(spyTeardownNxContext).toHaveBeenNthCalledWith(1, { - projectName: 'source-root', - nxJsonTeardown: false, - projectJsonTeardown: true, - }); - }); -}); diff --git a/packages/create-cli/src/lib/setup/codegen.ts b/packages/create-cli/src/lib/setup/codegen.ts new file mode 100644 index 0000000000..063f4f85a3 --- /dev/null +++ b/packages/create-cli/src/lib/setup/codegen.ts @@ -0,0 +1,47 @@ +import { IndentationText, Project, QuoteKind } from 'ts-morph'; +import type { + ImportDeclarationStructure, + PluginCodegenResult, +} from './types.js'; + +const CORE_CONFIG_IMPORT: ImportDeclarationStructure = { + moduleSpecifier: '@code-pushup/models', + namedImports: ['CoreConfig'], + isTypeOnly: true, +}; + +function collectImports( + plugins: PluginCodegenResult[], +): ImportDeclarationStructure[] { + return [CORE_CONFIG_IMPORT, ...plugins.flatMap(({ imports }) => imports)]; +} + +function buildExportStatement(plugins: PluginCodegenResult[]): string { + const items = plugins.map(({ pluginInit }) => pluginInit).join(', '); + return `export default { plugins: [${items}] } satisfies CoreConfig;`; +} + +export function generateConfigSource(plugins: PluginCodegenResult[]): string { + const project = new Project({ + useInMemoryFileSystem: true, + manipulationSettings: { + quoteKind: QuoteKind.Single, + indentationText: IndentationText.TwoSpaces, + }, + }); + const sourceFile = project.createSourceFile('code-pushup.config.ts'); + + collectImports(plugins).forEach(imp => + sourceFile.addImportDeclaration({ + moduleSpecifier: imp.moduleSpecifier, + defaultImport: imp.defaultImport, + namedImports: imp.namedImports, + isTypeOnly: imp.isTypeOnly, + }), + ); + + sourceFile.addStatements(buildExportStatement(plugins)); + sourceFile.formatText(); + + return sourceFile.getFullText(); +} diff --git a/packages/create-cli/src/lib/setup/codegen.unit.test.ts b/packages/create-cli/src/lib/setup/codegen.unit.test.ts new file mode 100644 index 0000000000..07d2d05fea --- /dev/null +++ b/packages/create-cli/src/lib/setup/codegen.unit.test.ts @@ -0,0 +1,69 @@ +import { generateConfigSource } from './codegen.js'; +import type { PluginCodegenResult } from './types.js'; + +describe('generateConfigSource', () => { + it('should generate config with empty plugins array', () => { + expect(generateConfigSource([])).toBe( + [ + "import type { CoreConfig } from '@code-pushup/models';", + 'export default { plugins: [] } satisfies CoreConfig;', + '', + ].join('\n'), + ); + }); + + it('should generate config with a single plugin', () => { + const plugin: PluginCodegenResult = { + imports: [ + { + moduleSpecifier: '@code-pushup/eslint-plugin', + defaultImport: 'eslintPlugin', + }, + ], + pluginInit: 'await eslintPlugin()', + }; + + expect(generateConfigSource([plugin])).toBe( + [ + "import type { CoreConfig } from '@code-pushup/models';", + "import eslintPlugin from '@code-pushup/eslint-plugin';", + 'export default { plugins: [await eslintPlugin()] } satisfies CoreConfig;', + '', + ].join('\n'), + ); + }); + + it('should generate config with multiple plugins', () => { + const plugins: PluginCodegenResult[] = [ + { + imports: [ + { + moduleSpecifier: '@code-pushup/eslint-plugin', + defaultImport: 'eslintPlugin', + }, + ], + pluginInit: 'await eslintPlugin()', + }, + { + imports: [ + { + moduleSpecifier: '@code-pushup/coverage-plugin', + defaultImport: 'coveragePlugin', + }, + ], + pluginInit: + "await coveragePlugin({ reports: [{ resultsPath: 'coverage/lcov.info', pathToProject: '' }] })", + }, + ]; + + expect(generateConfigSource(plugins)).toBe( + [ + "import type { CoreConfig } from '@code-pushup/models';", + "import eslintPlugin from '@code-pushup/eslint-plugin';", + "import coveragePlugin from '@code-pushup/coverage-plugin';", + "export default { plugins: [await eslintPlugin(), await coveragePlugin({ reports: [{ resultsPath: 'coverage/lcov.info', pathToProject: '' }] })] } satisfies CoreConfig;", + '', + ].join('\n'), + ); + }); +}); diff --git a/packages/create-cli/src/lib/setup/prompts.ts b/packages/create-cli/src/lib/setup/prompts.ts new file mode 100644 index 0000000000..053675794a --- /dev/null +++ b/packages/create-cli/src/lib/setup/prompts.ts @@ -0,0 +1,47 @@ +import { checkbox, input, select } from '@inquirer/prompts'; +import { asyncSequential } from '@code-pushup/utils'; +import type { CliArgs, PluginPromptDescriptor } from './types.js'; + +// TODO: #1244 — add promptPluginSelection (multi-select prompt with pre-selection callbacks) + +export async function promptPluginOptions( + descriptors: PluginPromptDescriptor[], + cliArgs: CliArgs, +): Promise> { + const fallback = cliArgs['yes'] + ? (descriptor: PluginPromptDescriptor) => descriptor.default + : runPrompt; + + const entries = await asyncSequential(descriptors, async descriptor => [ + descriptor.key, + cliValue(descriptor.key, cliArgs) ?? (await fallback(descriptor)), + ]); + return Object.fromEntries(entries); +} + +function cliValue(key: string, cliArgs: CliArgs): string | undefined { + const value = cliArgs[key]; + return typeof value === 'string' ? value : undefined; +} + +async function runPrompt( + descriptor: PluginPromptDescriptor, +): Promise { + switch (descriptor.type) { + case 'input': + return input({ + message: descriptor.message, + default: descriptor.default, + }); + case 'select': + return select({ + message: descriptor.message, + choices: [...descriptor.choices], + }); + case 'checkbox': + return checkbox({ + message: descriptor.message, + choices: [...descriptor.choices], + }); + } +} diff --git a/packages/create-cli/src/lib/setup/prompts.unit.test.ts b/packages/create-cli/src/lib/setup/prompts.unit.test.ts new file mode 100644 index 0000000000..4661a189fe --- /dev/null +++ b/packages/create-cli/src/lib/setup/prompts.unit.test.ts @@ -0,0 +1,91 @@ +import { promptPluginOptions } from './prompts.js'; +import type { PluginPromptDescriptor } from './types.js'; + +vi.mock('@inquirer/prompts', () => ({ + checkbox: vi.fn(), + input: vi.fn(), + select: vi.fn(), +})); + +const { input: mockInput, checkbox: mockCheckbox } = vi.mocked( + await import('@inquirer/prompts'), +); + +describe('promptPluginOptions', () => { + const descriptors: PluginPromptDescriptor[] = [ + { + key: 'eslint.patterns', + message: 'Patterns', + type: 'input', + default: '.', + }, + ]; + + it('should use CLI arg when provided', async () => { + await expect( + promptPluginOptions(descriptors, { 'eslint.patterns': 'src' }), + ).resolves.toStrictEqual({ 'eslint.patterns': 'src' }); + + expect(mockInput).not.toHaveBeenCalled(); + }); + + it('should use default in non-interactive mode', async () => { + await expect( + promptPluginOptions(descriptors, { yes: true }), + ).resolves.toStrictEqual({ 'eslint.patterns': '.' }); + + expect(mockInput).not.toHaveBeenCalled(); + }); + + it('should call input prompt in interactive mode', async () => { + mockInput.mockResolvedValue('src/**/*.ts'); + + await expect(promptPluginOptions(descriptors, {})).resolves.toStrictEqual({ + 'eslint.patterns': 'src/**/*.ts', + }); + + expect(mockInput).toHaveBeenCalledOnce(); + }); + + it('should return checkbox values as array', async () => { + mockCheckbox.mockResolvedValue(['json', 'csv']); + + await expect( + promptPluginOptions( + [ + { + key: 'formats', + message: 'Select formats', + type: 'checkbox', + choices: [ + { name: 'JSON', value: 'json' }, + { name: 'CSV', value: 'csv' }, + ], + default: [], + }, + ], + {}, + ), + ).resolves.toStrictEqual({ formats: ['json', 'csv'] }); + }); + + it('should return empty array for checkbox in non-interactive mode', async () => { + await expect( + promptPluginOptions( + [ + { + key: 'formats', + message: 'Select formats', + type: 'checkbox', + choices: [ + { name: 'JSON', value: 'json' }, + { name: 'CSV', value: 'csv' }, + ], + default: [], + }, + ], + { yes: true }, + ), + ).resolves.toStrictEqual({ formats: [] }); + }); +}); diff --git a/packages/create-cli/src/lib/setup/types.ts b/packages/create-cli/src/lib/setup/types.ts new file mode 100644 index 0000000000..206d57a943 --- /dev/null +++ b/packages/create-cli/src/lib/setup/types.ts @@ -0,0 +1,85 @@ +import type { PluginMeta } from '@code-pushup/models'; + +/** Virtual file system that buffers writes in memory until flushed to disk. */ +export type Tree = { + root: string; + exists: (filePath: string) => boolean; + read: (filePath: string) => string | null; + write: (filePath: string, content: string) => void; + listChanges: () => FileChange[]; + flush: () => Promise; +}; + +export type FileChange = { + path: string; + type: 'CREATE' | 'UPDATE'; + content: string; +}; + +export type FileSystemAdapter = { + readFileSync: (path: string, encoding: 'utf8') => string; + writeFileSync: (path: string, content: string) => void; + existsSync: (path: string) => boolean; + mkdirSync: (path: string, options: { recursive: boolean }) => void; +}; + +export type PluginSetupBinding = { + slug: PluginMeta['slug']; + title: PluginMeta['title']; + packageName: NonNullable; + // TODO: #1244 — add async pre-selection callback (e.g. detect eslint.config.js in repo) + prompts?: PluginPromptDescriptor[]; + codegenConfig: ( + answers: Record, + ) => PluginCodegenResult; +}; + +export type ImportDeclarationStructure = { + moduleSpecifier: string; + defaultImport?: string; + namedImports?: string[]; + isTypeOnly?: boolean; +}; + +export type PluginCodegenResult = { + imports: ImportDeclarationStructure[]; + pluginInit: string; + // TODO: #1243 — add categories support (categoryRefs for generated categories array) +}; + +type PromptBase = { + key: string; + message: string; +}; + +type PromptChoice = { name: string; value: string }; + +type InputPrompt = PromptBase & { + type: 'input'; + default: string; +}; + +type SelectPrompt = PromptBase & { + type: 'select'; + choices: PromptChoice[]; + default: string; +}; + +type CheckboxPrompt = PromptBase & { + type: 'checkbox'; + choices: PromptChoice[]; + default: string[]; +}; + +export type PluginPromptDescriptor = + | InputPrompt + | SelectPrompt + | CheckboxPrompt; + +export type CliArgs = { + 'dry-run'?: boolean; + yes?: boolean; + // TODO: #1244 — add 'plugins' field for CLI-based plugin selection + 'target-dir'?: string; + [key: string]: unknown; +}; diff --git a/packages/create-cli/src/lib/setup/virtual-fs.ts b/packages/create-cli/src/lib/setup/virtual-fs.ts new file mode 100644 index 0000000000..dd75741edf --- /dev/null +++ b/packages/create-cli/src/lib/setup/virtual-fs.ts @@ -0,0 +1,63 @@ +/* eslint-disable n/no-sync */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import type { FileChange, FileSystemAdapter, Tree } from './types.js'; + +const DEFAULT_FS: FileSystemAdapter = { + readFileSync, + writeFileSync, + existsSync, + mkdirSync, +}; + +export function createTree( + root: string, + fs: FileSystemAdapter = DEFAULT_FS, +): Tree { + const pending = new Map< + string, + { content: string; type: 'CREATE' | 'UPDATE' } + >(); + + const resolve = (filePath: string): string => path.resolve(root, filePath); + + return { + root, + + exists: (filePath: string): boolean => + pending.has(filePath) || fs.existsSync(resolve(filePath)), + + read: (filePath: string): string | null => { + const entry = pending.get(filePath); + if (entry) { + return entry.content; + } + const absolutePath = resolve(filePath); + if (!fs.existsSync(absolutePath)) { + return null; + } + return fs.readFileSync(absolutePath, 'utf8'); + }, + + write: (filePath: string, content: string): void => { + const type = fs.existsSync(resolve(filePath)) ? 'UPDATE' : 'CREATE'; + pending.set(filePath, { content, type }); + }, + + listChanges: (): FileChange[] => + [...pending.entries()].map(([filePath, { content, type }]) => ({ + path: filePath, + type, + content, + })), + + async flush(): Promise { + [...pending.entries()].forEach(([filePath, { content }]) => { + const absolutePath = resolve(filePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, content); + }); + pending.clear(); + }, + }; +} diff --git a/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts b/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts new file mode 100644 index 0000000000..ec458d2ccd --- /dev/null +++ b/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts @@ -0,0 +1,181 @@ +import type { FileSystemAdapter } from './types.js'; +import { createTree } from './virtual-fs.js'; + +function createMockFs( + files: Record = {}, +): FileSystemAdapter & { written: Map; dirs: string[] } { + const store = new Map(Object.entries(files)); + const written = new Map(); + const dirs: string[] = []; + + return { + written, + dirs, + readFileSync(path: string) { + const content = store.get(path); + if (content == null) { + throw new Error(`ENOENT: no such file or directory, open '${path}'`); + } + return content; + }, + writeFileSync(path: string, content: string) { + store.set(path, content); + written.set(path, content); + }, + existsSync(path: string) { + return store.has(path); + }, + mkdirSync(_path: string, _options: { recursive: boolean }) { + // eslint-disable-next-line functional/immutable-data + dirs.push(_path); + }, + }; +} + +describe('createTree', () => { + it('should report the root directory', () => { + expect(createTree('/project').root).toBe('/project'); + }); + + describe('exists', () => { + it('should return false for non-existent files', () => { + expect( + createTree('/project', createMockFs()).exists('missing.ts'), + ).toBeFalse(); + }); + + it('should return true for files on disk', () => { + expect( + createTree( + '/project', + createMockFs({ '/project/existing.ts': 'content' }), + ).exists('existing.ts'), + ).toBeTrue(); + }); + + it('should return true for files written to the tree', () => { + const tree = createTree('/project', createMockFs()); + tree.write('new.ts', 'content'); + expect(tree.exists('new.ts')).toBeTrue(); + }); + }); + + describe('read', () => { + it('should return null for non-existent files', () => { + expect( + createTree('/project', createMockFs()).read('missing.ts'), + ).toBeNull(); + }); + + it('should read files from disk', () => { + expect( + createTree( + '/project', + createMockFs({ '/project/existing.ts': 'disk content' }), + ).read('existing.ts'), + ).toBe('disk content'); + }); + + it('should return pending content over disk content', () => { + const tree = createTree( + '/project', + createMockFs({ '/project/file.ts': 'old' }), + ); + tree.write('file.ts', 'new'); + expect(tree.read('file.ts')).toBe('new'); + }); + }); + + describe('write', () => { + it('should mark new files as CREATE', () => { + const tree = createTree('/project', createMockFs()); + tree.write('new.ts', 'content'); + + expect(tree.listChanges()).toStrictEqual([ + { path: 'new.ts', type: 'CREATE', content: 'content' }, + ]); + }); + + it('should mark existing files as UPDATE', () => { + const tree = createTree( + '/project', + createMockFs({ '/project/existing.ts': 'old' }), + ); + tree.write('existing.ts', 'new'); + + expect(tree.listChanges()).toStrictEqual([ + { path: 'existing.ts', type: 'UPDATE', content: 'new' }, + ]); + }); + }); + + describe('listChanges', () => { + it('should return empty array when no changes are detected', () => { + expect( + createTree('/project', createMockFs()).listChanges(), + ).toStrictEqual([]); + }); + + it('should return all pending changes', () => { + const tree = createTree( + '/project', + createMockFs({ '/project/existing.ts': 'old' }), + ); + tree.write('new.ts', 'created'); + tree.write('existing.ts', 'updated'); + + expect(tree.listChanges()).toHaveLength(2); + expect(tree.listChanges()).toContainEqual({ + path: 'new.ts', + type: 'CREATE', + content: 'created', + }); + expect(tree.listChanges()).toContainEqual({ + path: 'existing.ts', + type: 'UPDATE', + content: 'updated', + }); + }); + }); + + describe('flush', () => { + it('should write all pending files to the fs', async () => { + const fs = createMockFs(); + const tree = createTree('/project', fs); + tree.write('src/config.ts', 'export default {};'); + + await tree.flush(); + + expect(fs.written.get('/project/src/config.ts')).toBe( + 'export default {};', + ); + }); + + it('should create parent directories', async () => { + const fs = createMockFs(); + const tree = createTree('/project', fs); + tree.write('src/deep/config.ts', 'content'); + + await tree.flush(); + + expect(fs.dirs).toContain('/project/src/deep'); + }); + + it('should clear pending changes after flush', async () => { + const tree = createTree('/project', createMockFs()); + tree.write('file.ts', 'content'); + + await tree.flush(); + + expect(tree.listChanges()).toStrictEqual([]); + }); + + it('should not write anything when no changes are pending', async () => { + const fs = createMockFs(); + + await createTree('/project', fs).flush(); + + expect(fs.written.size).toBe(0); + }); + }); +}); diff --git a/packages/create-cli/src/lib/setup/wizard.int.test.ts b/packages/create-cli/src/lib/setup/wizard.int.test.ts new file mode 100644 index 0000000000..3e809124ec --- /dev/null +++ b/packages/create-cli/src/lib/setup/wizard.int.test.ts @@ -0,0 +1,106 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { cleanTestFolder } from '@code-pushup/test-utils'; +import type { PluginSetupBinding } from './types.js'; +import { runSetupWizard } from './wizard.js'; + +const TEST_BINDINGS: PluginSetupBinding[] = [ + { + slug: 'alpha', + title: 'Alpha Plugin', + packageName: '@code-pushup/alpha-plugin', + prompts: [ + { + key: 'alpha.path', + message: 'Path to config', + type: 'input', + default: 'alpha.config.js', + }, + ], + codegenConfig(answers) { + const configPath = answers['alpha.path'] ?? 'alpha.config.js'; + return { + imports: [ + { + moduleSpecifier: '@code-pushup/alpha-plugin', + defaultImport: 'alphaPlugin', + }, + ], + pluginInit: `alphaPlugin(${JSON.stringify(configPath)})`, + }; + }, + }, + { + slug: 'beta', + title: 'Beta Plugin', + packageName: '@code-pushup/beta-plugin', + codegenConfig: () => ({ + imports: [ + { + moduleSpecifier: '@code-pushup/beta-plugin', + defaultImport: 'betaPlugin', + }, + ], + pluginInit: 'betaPlugin()', + }), + }, +]; + +describe('runSetupWizard', () => { + const outputDir = path.join('tmp', 'int', 'create-cli', 'wizard'); + + beforeEach(async () => { + await cleanTestFolder(outputDir); + }); + + it('should write a valid config file with provided bindings', async () => { + await runSetupWizard(TEST_BINDINGS, { + yes: true, + 'target-dir': outputDir, + }); + + await expect( + readFile(path.join(outputDir, 'code-pushup.config.ts'), 'utf8'), + ).resolves.toBe( + [ + "import type { CoreConfig } from '@code-pushup/models';", + "import alphaPlugin from '@code-pushup/alpha-plugin';", + "import betaPlugin from '@code-pushup/beta-plugin';", + 'export default { plugins: [alphaPlugin("alpha.config.js"), betaPlugin()] } satisfies CoreConfig;', + '', + ].join('\n'), + ); + }); + + it('should not write files in dry-run mode', async () => { + await runSetupWizard(TEST_BINDINGS, { + yes: true, + 'dry-run': true, + 'target-dir': outputDir, + }); + + await expect( + readFile(path.join(outputDir, 'code-pushup.config.ts'), 'utf8'), + ).rejects.toThrow('ENOENT'); + }); + + it('should pass custom plugin options through to codegen', async () => { + await runSetupWizard(TEST_BINDINGS, { + 'alpha.path': 'custom.config.mjs', + yes: true, + 'target-dir': outputDir, + }); + + await expect( + readFile(path.join(outputDir, 'code-pushup.config.ts'), 'utf8'), + ).resolves.toBe( + [ + "import type { CoreConfig } from '@code-pushup/models';", + "import alphaPlugin from '@code-pushup/alpha-plugin';", + "import betaPlugin from '@code-pushup/beta-plugin';", + 'export default { plugins: [alphaPlugin("custom.config.mjs"), betaPlugin()] } satisfies CoreConfig;', + '', + ].join('\n'), + ); + }); +}); diff --git a/packages/create-cli/src/lib/setup/wizard.ts b/packages/create-cli/src/lib/setup/wizard.ts new file mode 100644 index 0000000000..e43d7715f2 --- /dev/null +++ b/packages/create-cli/src/lib/setup/wizard.ts @@ -0,0 +1,70 @@ +import { asyncSequential, logger } from '@code-pushup/utils'; +import { generateConfigSource } from './codegen.js'; +import { promptPluginOptions } from './prompts.js'; +import type { + CliArgs, + FileChange, + PluginCodegenResult, + PluginSetupBinding, +} from './types.js'; +import { createTree } from './virtual-fs.js'; + +const COLUMN_GAP = 3; + +export async function runSetupWizard( + bindings: PluginSetupBinding[], + cliArgs: CliArgs, +): Promise { + const targetDir = cliArgs['target-dir'] ?? process.cwd(); + + // TODO: #1245 — prompt for standalone vs monorepo mode + // TODO: #1244 — prompt user to select plugins from available bindings + + const pluginResults = await asyncSequential(bindings, binding => + resolveBinding(binding, cliArgs), + ); + + const tree = createTree(targetDir); + // TODO: #1243 — select config file format (TS/JS/MJS) based on user choice or tsconfig detection + tree.write('code-pushup.config.ts', generateConfigSource(pluginResults)); + + const changes = tree.listChanges(); + + if (cliArgs['dry-run']) { + logChanges(changes); + logger.info('Dry run — no files written.'); + } else { + await tree.flush(); + logChanges(changes); + logger.info('Setup complete.'); + logger.newline(); + logNextSteps([ + ['npx code-pushup collect', 'Run your first report'], + ['https://github.com/code-pushup/cli#readme', 'Documentation'], + ]); + } +} + +async function resolveBinding( + binding: PluginSetupBinding, + cliArgs: CliArgs, +): Promise { + const answers = binding.prompts + ? await promptPluginOptions(binding.prompts, cliArgs) + : {}; + return binding.codegenConfig(answers); +} + +function logChanges(changes: FileChange[]): void { + changes.forEach(change => { + logger.info(`${change.type} ${change.path}`); + }); +} + +function logNextSteps(steps: [string, string][]): void { + const colWidth = Math.max(...steps.map(([label]) => label.length)); + logger.info('Next steps:'); + steps.forEach(([label, description]) => { + logger.info(` ${label.padEnd(colWidth + COLUMN_GAP)}${description}`); + }); +} diff --git a/packages/create-cli/src/lib/setup/wizard.unit.test.ts b/packages/create-cli/src/lib/setup/wizard.unit.test.ts new file mode 100644 index 0000000000..0cc41be611 --- /dev/null +++ b/packages/create-cli/src/lib/setup/wizard.unit.test.ts @@ -0,0 +1,89 @@ +import { vol } from 'memfs'; +import { readFile } from 'node:fs/promises'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { logger } from '@code-pushup/utils'; +import type { PluginSetupBinding } from './types.js'; +import { runSetupWizard } from './wizard.js'; + +vi.mock('@inquirer/prompts', () => ({ + checkbox: vi.fn(), + input: vi.fn(), + select: vi.fn(), +})); + +const TEST_BINDING: PluginSetupBinding = { + slug: 'test-plugin', + title: 'Test Plugin', + packageName: '@code-pushup/test-plugin', + codegenConfig: () => ({ + imports: [ + { + moduleSpecifier: '@code-pushup/test-plugin', + defaultImport: 'testPlugin', + }, + ], + pluginInit: 'testPlugin()', + }), +}; + +describe('runSetupWizard', () => { + beforeEach(() => { + vol.fromJSON({}, MEMFS_VOLUME); + }); + + it('should generate config and log success', async () => { + await runSetupWizard([TEST_BINDING], { + yes: true, + 'target-dir': MEMFS_VOLUME, + }); + + await expect( + readFile(`${MEMFS_VOLUME}/code-pushup.config.ts`, 'utf8'), + ).resolves.toBe( + [ + "import type { CoreConfig } from '@code-pushup/models';", + "import testPlugin from '@code-pushup/test-plugin';", + 'export default { plugins: [testPlugin()] } satisfies CoreConfig;', + '', + ].join('\n'), + ); + + expect(logger.info).toHaveBeenCalledWith('CREATE code-pushup.config.ts'); + expect(logger.info).toHaveBeenCalledWith('Setup complete.'); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('npx code-pushup collect'), + ); + }); + + it('should log dry-run message without writing files', async () => { + await runSetupWizard([TEST_BINDING], { + yes: true, + 'dry-run': true, + 'target-dir': MEMFS_VOLUME, + }); + + expect(vol.toJSON(MEMFS_VOLUME)).toStrictEqual({}); + expect(logger.info).toHaveBeenCalledWith('CREATE code-pushup.config.ts'); + expect(logger.info).toHaveBeenCalledWith('Dry run — no files written.'); + }); + + it('should generate empty config with no bindings', async () => { + await runSetupWizard([], { + yes: true, + 'target-dir': MEMFS_VOLUME, + }); + + await expect( + readFile(`${MEMFS_VOLUME}/code-pushup.config.ts`, 'utf8'), + ).resolves.toBe( + [ + "import type { CoreConfig } from '@code-pushup/models';", + 'export default { plugins: [] } satisfies CoreConfig;', + '', + ].join('\n'), + ); + + expect(logger.info).toHaveBeenCalledWith('CREATE code-pushup.config.ts'); + expect(logger.info).toHaveBeenCalledWith('Setup complete.'); + }); +}); diff --git a/packages/create-cli/src/lib/utils.ts b/packages/create-cli/src/lib/utils.ts deleted file mode 100644 index 19249661e7..0000000000 --- a/packages/create-cli/src/lib/utils.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { readFile, rm, stat, writeFile } from 'node:fs/promises'; -import { CODE_PUSHUP_UNICODE_LOGO } from '@code-pushup/utils'; -import { - NX_JSON_CONTENT, - NX_JSON_FILENAME, - PROJECT_JSON_CONTENT, - PROJECT_JSON_FILENAME, - PROJECT_NAME, -} from './constants.js'; - -export type SetupResult = { - filename: string; - teardown: boolean; -}; - -export async function setupFile( - filename: string, - content = '', -): Promise { - const setupResult: SetupResult = { - filename, - teardown: false, - }; - - try { - const stats = await stat(filename); - if (!stats.isFile()) { - await writeFile(filename, content); - } - } catch (error) { - if ( - error instanceof Error && - error.message.includes('no such file or directory') - ) { - await writeFile(filename, content); - return { - ...setupResult, - teardown: true, - }; - } else { - console.error(error); - } - } - - return setupResult; -} - -export function parseNxProcessOutput(output: string) { - return output.trim().replace('NX', CODE_PUSHUP_UNICODE_LOGO); -} - -export async function setupNxContext(): Promise<{ - nxJsonTeardown: boolean; - projectJsonTeardown: boolean; - projectName: string; -}> { - const { teardown: nxJsonTeardown } = await setupFile( - NX_JSON_FILENAME, - NX_JSON_CONTENT, - ); - const { teardown: projectJsonTeardown } = await setupFile( - PROJECT_JSON_FILENAME, - PROJECT_JSON_CONTENT, - ); - - const projectJsonContent = await readFile(PROJECT_JSON_FILENAME, 'utf8'); - const { name = PROJECT_NAME } = JSON.parse(projectJsonContent) as { - name: string; - }; - - return { - nxJsonTeardown, - projectJsonTeardown, - projectName: name, - }; -} - -export async function teardownNxContext({ - nxJsonTeardown, - projectJsonTeardown, -}: { - nxJsonTeardown: boolean; - projectJsonTeardown: boolean; -}) { - const filesToDelete = [ - ...(nxJsonTeardown ? [NX_JSON_FILENAME] : []), - ...(projectJsonTeardown ? [PROJECT_JSON_FILENAME] : []), - ]; - await Promise.all(filesToDelete.map(file => rm(file))); -} diff --git a/packages/create-cli/src/lib/utils.unit.test.ts b/packages/create-cli/src/lib/utils.unit.test.ts deleted file mode 100644 index f443550764..0000000000 --- a/packages/create-cli/src/lib/utils.unit.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { vol } from 'memfs'; -import { rm } from 'node:fs/promises'; -import { - parseNxProcessOutput, - setupNxContext, - teardownNxContext, -} from './utils.js'; - -describe('parseNxProcessOutput', () => { - it('should replace NX with <✓>', () => { - expect(parseNxProcessOutput('NX some message')).toBe('<✓> some message'); - }); -}); - -describe('setupNxContext', () => { - beforeEach(async () => { - vol.reset(); - // to have the test folder set up we need to recreate it - vol.fromJSON({ - '.': '', - }); - await rm('.'); - }); - - it('should setup nx.json', async () => { - vol.fromJSON({ - 'project.json': '{"name": "my-lib"}', - }); - await expect(setupNxContext()).resolves.toStrictEqual({ - nxJsonTeardown: true, - projectJsonTeardown: false, - projectName: 'my-lib', - }); - expect(vol.toJSON()).toStrictEqual({ - '/test/nx.json': - '{"$schema":"./node_modules/nx/schemas/nx-schema.json","targetDefaults":{}}', - '/test/project.json': '{"name": "my-lib"}', - }); - }); - - it('should setup project.json', async () => { - vol.fromJSON({ - 'nx.json': '{}', - }); - await expect(setupNxContext()).resolves.toStrictEqual({ - nxJsonTeardown: false, - projectJsonTeardown: true, - projectName: 'source-root', - }); - expect(vol.toJSON()).toStrictEqual({ - '/test/nx.json': '{}', - '/test/project.json': - '{"$schema":"node_modules/nx/schemas/project-schema.json","name":"source-root"}', - }); - }); -}); - -describe('teardownNxContext', () => { - beforeEach(async () => { - vol.reset(); - // to have the test folder set up we need to recreate it - vol.fromJSON({ - '.': '', - }); - await rm('.'); - }); - - it('should delete nx.json', async () => { - vol.fromJSON({ - 'nx.json': '{}', - }); - await expect( - teardownNxContext({ - nxJsonTeardown: true, - projectJsonTeardown: false, - }), - ).resolves.toBeUndefined(); - expect(vol.toJSON()).toStrictEqual({ - '/test': null, - }); - }); - - it('should delete project.json', async () => { - vol.fromJSON({ - 'project.json': '{}', - }); - await expect( - teardownNxContext({ - nxJsonTeardown: false, - projectJsonTeardown: true, - }), - ).resolves.toBeUndefined(); - expect(vol.toJSON()).toStrictEqual({ - '/test': null, - }); - }); -}); diff --git a/packages/create-cli/vitest.int.config.ts b/packages/create-cli/vitest.int.config.ts new file mode 100644 index 0000000000..b37551ddad --- /dev/null +++ b/packages/create-cli/vitest.int.config.ts @@ -0,0 +1,3 @@ +import { createIntTestConfig } from '../../testing/test-setup-config/src/index.js'; + +export default createIntTestConfig('create-cli'); From 3e6bc841bea4c46ab0aa55a5f82baf73f287dda9 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Tue, 17 Feb 2026 13:03:32 -0500 Subject: [PATCH 2/6] fix(create-cli): ensure path normalization in mocks --- .../create-cli/src/lib/setup/virtual-fs.unit.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts b/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts index ec458d2ccd..ffa08c7800 100644 --- a/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts +++ b/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts @@ -1,3 +1,4 @@ +import { toUnixPath } from '@code-pushup/utils'; import type { FileSystemAdapter } from './types.js'; import { createTree } from './virtual-fs.js'; @@ -12,22 +13,22 @@ function createMockFs( written, dirs, readFileSync(path: string) { - const content = store.get(path); + const content = store.get(toUnixPath(path)); if (content == null) { throw new Error(`ENOENT: no such file or directory, open '${path}'`); } return content; }, writeFileSync(path: string, content: string) { - store.set(path, content); - written.set(path, content); + store.set(toUnixPath(path), content); + written.set(toUnixPath(path), content); }, existsSync(path: string) { - return store.has(path); + return store.has(toUnixPath(path)); }, mkdirSync(_path: string, _options: { recursive: boolean }) { // eslint-disable-next-line functional/immutable-data - dirs.push(_path); + dirs.push(toUnixPath(_path)); }, }; } From 4794e15562fee6c470a84fee13bee0ca3f9948d9 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Mon, 23 Feb 2026 13:01:42 -0500 Subject: [PATCH 3/6] refactor(create-cli): replace ts-morph with custom CodeBuilder --- packages/create-cli/package.json | 1 - packages/create-cli/src/lib/setup/codegen.ts | 87 +++++++++++++------ .../src/lib/setup/codegen.unit.test.ts | 82 ++++++++++++----- 3 files changed, 119 insertions(+), 51 deletions(-) diff --git a/packages/create-cli/package.json b/packages/create-cli/package.json index 39e7cdcc97..1ef72fee24 100644 --- a/packages/create-cli/package.json +++ b/packages/create-cli/package.json @@ -28,7 +28,6 @@ "dependencies": { "@code-pushup/utils": "0.113.0", "@inquirer/prompts": "^8.0.0", - "ts-morph": "^24.0.0", "yargs": "^17.7.2" }, "files": [ diff --git a/packages/create-cli/src/lib/setup/codegen.ts b/packages/create-cli/src/lib/setup/codegen.ts index 063f4f85a3..670ee08d71 100644 --- a/packages/create-cli/src/lib/setup/codegen.ts +++ b/packages/create-cli/src/lib/setup/codegen.ts @@ -1,4 +1,3 @@ -import { IndentationText, Project, QuoteKind } from 'ts-morph'; import type { ImportDeclarationStructure, PluginCodegenResult, @@ -10,38 +9,74 @@ const CORE_CONFIG_IMPORT: ImportDeclarationStructure = { isTypeOnly: true, }; +class CodeBuilder { + private lines: string[] = []; + private depth = 0; + + addLine(text: string): void { + this.lines.push(`${' '.repeat(this.depth)}${text}`); + } + + addEmptyLine(): void { + this.lines.push(''); + } + + indent(fn: () => void): void { + this.depth++; + fn(); + this.depth--; + } + + toString(): string { + return `${this.lines.join('\n')}\n`; + } +} + +function formatImport({ + moduleSpecifier, + defaultImport, + namedImports, + isTypeOnly, +}: ImportDeclarationStructure): string { + const named = namedImports?.length ? `{ ${namedImports.join(', ')} }` : ''; + const bindings = [defaultImport, named].filter(Boolean).join(', '); + const from = bindings ? `${bindings} from ` : ''; + const type = isTypeOnly ? 'type ' : ''; + return `import ${type}${from}'${moduleSpecifier}';`; +} + function collectImports( plugins: PluginCodegenResult[], ): ImportDeclarationStructure[] { - return [CORE_CONFIG_IMPORT, ...plugins.flatMap(({ imports }) => imports)]; -} - -function buildExportStatement(plugins: PluginCodegenResult[]): string { - const items = plugins.map(({ pluginInit }) => pluginInit).join(', '); - return `export default { plugins: [${items}] } satisfies CoreConfig;`; + return [ + CORE_CONFIG_IMPORT, + ...plugins.flatMap(({ imports }) => imports), + ].toSorted((a, b) => a.moduleSpecifier.localeCompare(b.moduleSpecifier)); } export function generateConfigSource(plugins: PluginCodegenResult[]): string { - const project = new Project({ - useInMemoryFileSystem: true, - manipulationSettings: { - quoteKind: QuoteKind.Single, - indentationText: IndentationText.TwoSpaces, - }, - }); - const sourceFile = project.createSourceFile('code-pushup.config.ts'); + const builder = new CodeBuilder(); - collectImports(plugins).forEach(imp => - sourceFile.addImportDeclaration({ - moduleSpecifier: imp.moduleSpecifier, - defaultImport: imp.defaultImport, - namedImports: imp.namedImports, - isTypeOnly: imp.isTypeOnly, - }), - ); + collectImports(plugins).forEach(declaration => { + builder.addLine(formatImport(declaration)); + }); - sourceFile.addStatements(buildExportStatement(plugins)); - sourceFile.formatText(); + builder.addEmptyLine(); + builder.addLine('export default {'); + builder.indent(() => { + if (plugins.length === 0) { + builder.addLine('plugins: [],'); + } else { + builder.addLine('plugins: ['); + builder.indent(() => { + plugins.forEach(({ pluginInit }) => { + builder.addLine(`${pluginInit},`); + }); + }); + builder.addLine('],'); + } + }); + builder.addLine('} satisfies CoreConfig;'); - return sourceFile.getFullText(); + return builder.toString(); } diff --git a/packages/create-cli/src/lib/setup/codegen.unit.test.ts b/packages/create-cli/src/lib/setup/codegen.unit.test.ts index 07d2d05fea..04a55db594 100644 --- a/packages/create-cli/src/lib/setup/codegen.unit.test.ts +++ b/packages/create-cli/src/lib/setup/codegen.unit.test.ts @@ -3,13 +3,14 @@ import type { PluginCodegenResult } from './types.js'; describe('generateConfigSource', () => { it('should generate config with empty plugins array', () => { - expect(generateConfigSource([])).toBe( - [ - "import type { CoreConfig } from '@code-pushup/models';", - 'export default { plugins: [] } satisfies CoreConfig;', - '', - ].join('\n'), - ); + expect(generateConfigSource([])).toMatchInlineSnapshot(` + "import type { CoreConfig } from '@code-pushup/models'; + + export default { + plugins: [], + } satisfies CoreConfig; + " + `); }); it('should generate config with a single plugin', () => { @@ -23,14 +24,43 @@ describe('generateConfigSource', () => { pluginInit: 'await eslintPlugin()', }; - expect(generateConfigSource([plugin])).toBe( - [ - "import type { CoreConfig } from '@code-pushup/models';", - "import eslintPlugin from '@code-pushup/eslint-plugin';", - 'export default { plugins: [await eslintPlugin()] } satisfies CoreConfig;', - '', - ].join('\n'), - ); + expect(generateConfigSource([plugin])).toMatchInlineSnapshot(` + "import eslintPlugin from '@code-pushup/eslint-plugin'; + import type { CoreConfig } from '@code-pushup/models'; + + export default { + plugins: [ + await eslintPlugin(), + ], + } satisfies CoreConfig; + " + `); + }); + + it('should generate config with combined default and named imports', () => { + const plugin: PluginCodegenResult = { + imports: [ + { + moduleSpecifier: '@code-pushup/eslint-plugin', + defaultImport: 'eslintPlugin', + namedImports: ['eslintConfigFromAllNxProjects'], + }, + ], + pluginInit: + 'await eslintPlugin({ eslintrc: eslintConfigFromAllNxProjects() })', + }; + + expect(generateConfigSource([plugin])).toMatchInlineSnapshot(` + "import eslintPlugin, { eslintConfigFromAllNxProjects } from '@code-pushup/eslint-plugin'; + import type { CoreConfig } from '@code-pushup/models'; + + export default { + plugins: [ + await eslintPlugin({ eslintrc: eslintConfigFromAllNxProjects() }), + ], + } satisfies CoreConfig; + " + `); }); it('should generate config with multiple plugins', () => { @@ -56,14 +86,18 @@ describe('generateConfigSource', () => { }, ]; - expect(generateConfigSource(plugins)).toBe( - [ - "import type { CoreConfig } from '@code-pushup/models';", - "import eslintPlugin from '@code-pushup/eslint-plugin';", - "import coveragePlugin from '@code-pushup/coverage-plugin';", - "export default { plugins: [await eslintPlugin(), await coveragePlugin({ reports: [{ resultsPath: 'coverage/lcov.info', pathToProject: '' }] })] } satisfies CoreConfig;", - '', - ].join('\n'), - ); + expect(generateConfigSource(plugins)).toMatchInlineSnapshot(` + "import coveragePlugin from '@code-pushup/coverage-plugin'; + import eslintPlugin from '@code-pushup/eslint-plugin'; + import type { CoreConfig } from '@code-pushup/models'; + + export default { + plugins: [ + await eslintPlugin(), + await coveragePlugin({ reports: [{ resultsPath: 'coverage/lcov.info', pathToProject: '' }] }), + ], + } satisfies CoreConfig; + " + `); }); }); From af9d6748422734ec86326d5e222ade3003d233fb Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Mon, 23 Feb 2026 13:06:38 -0500 Subject: [PATCH 4/6] refactor(create-cli): address review feedback --- packages/create-cli/README.md | 8 +-- packages/create-cli/eslint.config.js | 26 ++------ packages/create-cli/package.json | 1 + packages/create-cli/src/index.ts | 3 +- packages/create-cli/src/lib/setup/types.ts | 33 +++++----- .../create-cli/src/lib/setup/virtual-fs.ts | 43 ++++++------- .../src/lib/setup/virtual-fs.unit.test.ts | 64 +++++++++---------- .../src/lib/setup/wizard.int.test.ts | 48 ++++++++------ packages/create-cli/src/lib/setup/wizard.ts | 27 ++++---- .../src/lib/setup/wizard.unit.test.ts | 44 +++++++------ 10 files changed, 151 insertions(+), 146 deletions(-) diff --git a/packages/create-cli/README.md b/packages/create-cli/README.md index 1e3ed0785f..ebd3432389 100644 --- a/packages/create-cli/README.md +++ b/packages/create-cli/README.md @@ -9,7 +9,7 @@ An interactive setup wizard that scaffolds a `code-pushup.config.ts` file in you ## Usage ```bash -npx @code-pushup/create-cli +npm init @code-pushup/cli ``` The wizard will prompt you to select plugins and configure their options, then generate a `code-pushup.config.ts` file. @@ -27,17 +27,17 @@ The wizard will prompt you to select plugins and configure their options, then g Run interactively (default): ```bash -npx @code-pushup/create-cli +npm init @code-pushup/cli ``` Skip prompts and enable specific plugins: ```bash -npx @code-pushup/create-cli -y --plugins=eslint,coverage +npm init @code-pushup/cli -y --plugins=eslint,coverage ``` Preview the generated config without writing: ```bash -npx @code-pushup/create-cli -y --dry-run +npm init @code-pushup/cli -y --dry-run ``` diff --git a/packages/create-cli/eslint.config.js b/packages/create-cli/eslint.config.js index 08bb1f80fa..2656b27cb4 100644 --- a/packages/create-cli/eslint.config.js +++ b/packages/create-cli/eslint.config.js @@ -1,24 +1,12 @@ import tseslint from 'typescript-eslint'; import baseConfig from '../../eslint.config.js'; -export default tseslint.config( - ...baseConfig, - { - files: ['**/*.ts'], - languageOptions: { - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, +export default tseslint.config(...baseConfig, { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, }, }, - { - files: ['**/*.json'], - rules: { - '@nx/dependency-checks': [ - 'error', - { ignoredDependencies: ['@code-pushup/models'] }, - ], - }, - }, -); +}); diff --git a/packages/create-cli/package.json b/packages/create-cli/package.json index 1ef72fee24..bebdb43797 100644 --- a/packages/create-cli/package.json +++ b/packages/create-cli/package.json @@ -26,6 +26,7 @@ }, "type": "module", "dependencies": { + "@code-pushup/models": "0.113.0", "@code-pushup/utils": "0.113.0", "@inquirer/prompts": "^8.0.0", "yargs": "^17.7.2" diff --git a/packages/create-cli/src/index.ts b/packages/create-cli/src/index.ts index 4f9e782f7a..13353e391c 100755 --- a/packages/create-cli/src/index.ts +++ b/packages/create-cli/src/index.ts @@ -1,7 +1,6 @@ #! /usr/bin/env node import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; -import type { CliArgs } from './lib/setup/types.js'; import { runSetupWizard } from './lib/setup/wizard.js'; const argv = await yargs(hideBin(process.argv)) @@ -19,4 +18,4 @@ const argv = await yargs(hideBin(process.argv)) .parse(); // TODO: #1244 — provide plugin bindings from registry -await runSetupWizard([], argv as CliArgs); +await runSetupWizard([], argv); diff --git a/packages/create-cli/src/lib/setup/types.ts b/packages/create-cli/src/lib/setup/types.ts index 206d57a943..c349078df9 100644 --- a/packages/create-cli/src/lib/setup/types.ts +++ b/packages/create-cli/src/lib/setup/types.ts @@ -3,9 +3,9 @@ import type { PluginMeta } from '@code-pushup/models'; /** Virtual file system that buffers writes in memory until flushed to disk. */ export type Tree = { root: string; - exists: (filePath: string) => boolean; - read: (filePath: string) => string | null; - write: (filePath: string, content: string) => void; + exists: (filePath: string) => Promise; + read: (filePath: string) => Promise; + write: (filePath: string, content: string) => Promise; listChanges: () => FileChange[]; flush: () => Promise; }; @@ -17,10 +17,13 @@ export type FileChange = { }; export type FileSystemAdapter = { - readFileSync: (path: string, encoding: 'utf8') => string; - writeFileSync: (path: string, content: string) => void; - existsSync: (path: string) => boolean; - mkdirSync: (path: string, options: { recursive: boolean }) => void; + readFile: (path: string, encoding: 'utf8') => Promise; + writeFile: (path: string, content: string) => Promise; + exists: (path: string) => Promise; + mkdir: ( + path: string, + options: { recursive: true }, + ) => Promise; }; export type PluginSetupBinding = { @@ -29,7 +32,7 @@ export type PluginSetupBinding = { packageName: NonNullable; // TODO: #1244 — add async pre-selection callback (e.g. detect eslint.config.js in repo) prompts?: PluginPromptDescriptor[]; - codegenConfig: ( + generateConfig: ( answers: Record, ) => PluginCodegenResult; }; @@ -52,23 +55,23 @@ type PromptBase = { message: string; }; -type PromptChoice = { name: string; value: string }; +type PromptChoice = { name: string; value: T }; type InputPrompt = PromptBase & { type: 'input'; default: string; }; -type SelectPrompt = PromptBase & { +type SelectPrompt = PromptBase & { type: 'select'; - choices: PromptChoice[]; - default: string; + choices: PromptChoice[]; + default: T; }; -type CheckboxPrompt = PromptBase & { +type CheckboxPrompt = PromptBase & { type: 'checkbox'; - choices: PromptChoice[]; - default: string[]; + choices: PromptChoice[]; + default: T[]; }; export type PluginPromptDescriptor = diff --git a/packages/create-cli/src/lib/setup/virtual-fs.ts b/packages/create-cli/src/lib/setup/virtual-fs.ts index dd75741edf..8e8e593439 100644 --- a/packages/create-cli/src/lib/setup/virtual-fs.ts +++ b/packages/create-cli/src/lib/setup/virtual-fs.ts @@ -1,46 +1,43 @@ -/* eslint-disable n/no-sync */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { fileExists } from '@code-pushup/utils'; import type { FileChange, FileSystemAdapter, Tree } from './types.js'; const DEFAULT_FS: FileSystemAdapter = { - readFileSync, - writeFileSync, - existsSync, - mkdirSync, + readFile, + writeFile, + exists: fileExists, + mkdir, }; export function createTree( root: string, fs: FileSystemAdapter = DEFAULT_FS, ): Tree { - const pending = new Map< - string, - { content: string; type: 'CREATE' | 'UPDATE' } - >(); + const pending = new Map>(); const resolve = (filePath: string): string => path.resolve(root, filePath); return { root, - exists: (filePath: string): boolean => - pending.has(filePath) || fs.existsSync(resolve(filePath)), + exists: async (filePath: string): Promise => + pending.has(filePath) || fs.exists(resolve(filePath)), - read: (filePath: string): string | null => { + read: async (filePath: string): Promise => { const entry = pending.get(filePath); if (entry) { return entry.content; } const absolutePath = resolve(filePath); - if (!fs.existsSync(absolutePath)) { + if (!(await fs.exists(absolutePath))) { return null; } - return fs.readFileSync(absolutePath, 'utf8'); + return fs.readFile(absolutePath, 'utf8'); }, - write: (filePath: string, content: string): void => { - const type = fs.existsSync(resolve(filePath)) ? 'UPDATE' : 'CREATE'; + write: async (filePath: string, content: string): Promise => { + const type = (await fs.exists(resolve(filePath))) ? 'UPDATE' : 'CREATE'; pending.set(filePath, { content, type }); }, @@ -52,11 +49,13 @@ export function createTree( })), async flush(): Promise { - [...pending.entries()].forEach(([filePath, { content }]) => { - const absolutePath = resolve(filePath); - fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); - fs.writeFileSync(absolutePath, content); - }); + await Promise.all( + [...pending.entries()].map(async ([filePath, { content }]) => { + const absolutePath = resolve(filePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, content); + }), + ); pending.clear(); }, }; diff --git a/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts b/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts index ffa08c7800..25df696466 100644 --- a/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts +++ b/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts @@ -12,21 +12,21 @@ function createMockFs( return { written, dirs, - readFileSync(path: string) { + async readFile(path: string) { const content = store.get(toUnixPath(path)); if (content == null) { throw new Error(`ENOENT: no such file or directory, open '${path}'`); } return content; }, - writeFileSync(path: string, content: string) { + async writeFile(path: string, content: string) { store.set(toUnixPath(path), content); written.set(toUnixPath(path), content); }, - existsSync(path: string) { + async exists(path: string) { return store.has(toUnixPath(path)); }, - mkdirSync(_path: string, _options: { recursive: boolean }) { + async mkdir(_path: string, _options: { recursive: boolean }) { // eslint-disable-next-line functional/immutable-data dirs.push(toUnixPath(_path)); }, @@ -39,70 +39,70 @@ describe('createTree', () => { }); describe('exists', () => { - it('should return false for non-existent files', () => { - expect( + it('should return false for non-existent files', async () => { + await expect( createTree('/project', createMockFs()).exists('missing.ts'), - ).toBeFalse(); + ).resolves.toBeFalse(); }); - it('should return true for files on disk', () => { - expect( + it('should return true for files on disk', async () => { + await expect( createTree( '/project', createMockFs({ '/project/existing.ts': 'content' }), ).exists('existing.ts'), - ).toBeTrue(); + ).resolves.toBeTrue(); }); - it('should return true for files written to the tree', () => { + it('should return true for files written to the tree', async () => { const tree = createTree('/project', createMockFs()); - tree.write('new.ts', 'content'); - expect(tree.exists('new.ts')).toBeTrue(); + await tree.write('new.ts', 'content'); + await expect(tree.exists('new.ts')).resolves.toBeTrue(); }); }); describe('read', () => { - it('should return null for non-existent files', () => { - expect( + it('should return null for non-existent files', async () => { + await expect( createTree('/project', createMockFs()).read('missing.ts'), - ).toBeNull(); + ).resolves.toBeNull(); }); - it('should read files from disk', () => { - expect( + it('should read files from disk', async () => { + await expect( createTree( '/project', createMockFs({ '/project/existing.ts': 'disk content' }), ).read('existing.ts'), - ).toBe('disk content'); + ).resolves.toBe('disk content'); }); - it('should return pending content over disk content', () => { + it('should return pending content over disk content', async () => { const tree = createTree( '/project', createMockFs({ '/project/file.ts': 'old' }), ); - tree.write('file.ts', 'new'); - expect(tree.read('file.ts')).toBe('new'); + await tree.write('file.ts', 'new'); + await expect(tree.read('file.ts')).resolves.toBe('new'); }); }); describe('write', () => { - it('should mark new files as CREATE', () => { + it('should mark new files as CREATE', async () => { const tree = createTree('/project', createMockFs()); - tree.write('new.ts', 'content'); + await tree.write('new.ts', 'content'); expect(tree.listChanges()).toStrictEqual([ { path: 'new.ts', type: 'CREATE', content: 'content' }, ]); }); - it('should mark existing files as UPDATE', () => { + it('should mark existing files as UPDATE', async () => { const tree = createTree( '/project', createMockFs({ '/project/existing.ts': 'old' }), ); - tree.write('existing.ts', 'new'); + await tree.write('existing.ts', 'new'); expect(tree.listChanges()).toStrictEqual([ { path: 'existing.ts', type: 'UPDATE', content: 'new' }, @@ -117,13 +117,13 @@ describe('createTree', () => { ).toStrictEqual([]); }); - it('should return all pending changes', () => { + it('should return all pending changes', async () => { const tree = createTree( '/project', createMockFs({ '/project/existing.ts': 'old' }), ); - tree.write('new.ts', 'created'); - tree.write('existing.ts', 'updated'); + await tree.write('new.ts', 'created'); + await tree.write('existing.ts', 'updated'); expect(tree.listChanges()).toHaveLength(2); expect(tree.listChanges()).toContainEqual({ @@ -143,7 +143,7 @@ describe('createTree', () => { it('should write all pending files to the fs', async () => { const fs = createMockFs(); const tree = createTree('/project', fs); - tree.write('src/config.ts', 'export default {};'); + await tree.write('src/config.ts', 'export default {};'); await tree.flush(); @@ -155,7 +155,7 @@ describe('createTree', () => { it('should create parent directories', async () => { const fs = createMockFs(); const tree = createTree('/project', fs); - tree.write('src/deep/config.ts', 'content'); + await tree.write('src/deep/config.ts', 'content'); await tree.flush(); @@ -164,7 +164,7 @@ describe('createTree', () => { it('should clear pending changes after flush', async () => { const tree = createTree('/project', createMockFs()); - tree.write('file.ts', 'content'); + await tree.write('file.ts', 'content'); await tree.flush(); diff --git a/packages/create-cli/src/lib/setup/wizard.int.test.ts b/packages/create-cli/src/lib/setup/wizard.int.test.ts index 3e809124ec..43ef5ca8f0 100644 --- a/packages/create-cli/src/lib/setup/wizard.int.test.ts +++ b/packages/create-cli/src/lib/setup/wizard.int.test.ts @@ -17,7 +17,7 @@ const TEST_BINDINGS: PluginSetupBinding[] = [ default: 'alpha.config.js', }, ], - codegenConfig(answers) { + generateConfig(answers) { const configPath = answers['alpha.path'] ?? 'alpha.config.js'; return { imports: [ @@ -34,7 +34,7 @@ const TEST_BINDINGS: PluginSetupBinding[] = [ slug: 'beta', title: 'Beta Plugin', packageName: '@code-pushup/beta-plugin', - codegenConfig: () => ({ + generateConfig: () => ({ imports: [ { moduleSpecifier: '@code-pushup/beta-plugin', @@ -61,15 +61,19 @@ describe('runSetupWizard', () => { await expect( readFile(path.join(outputDir, 'code-pushup.config.ts'), 'utf8'), - ).resolves.toBe( - [ - "import type { CoreConfig } from '@code-pushup/models';", - "import alphaPlugin from '@code-pushup/alpha-plugin';", - "import betaPlugin from '@code-pushup/beta-plugin';", - 'export default { plugins: [alphaPlugin("alpha.config.js"), betaPlugin()] } satisfies CoreConfig;', - '', - ].join('\n'), - ); + ).resolves.toMatchInlineSnapshot(` + "import alphaPlugin from '@code-pushup/alpha-plugin'; + import betaPlugin from '@code-pushup/beta-plugin'; + import type { CoreConfig } from '@code-pushup/models'; + + export default { + plugins: [ + alphaPlugin("alpha.config.js"), + betaPlugin(), + ], + } satisfies CoreConfig; + " + `); }); it('should not write files in dry-run mode', async () => { @@ -93,14 +97,18 @@ describe('runSetupWizard', () => { await expect( readFile(path.join(outputDir, 'code-pushup.config.ts'), 'utf8'), - ).resolves.toBe( - [ - "import type { CoreConfig } from '@code-pushup/models';", - "import alphaPlugin from '@code-pushup/alpha-plugin';", - "import betaPlugin from '@code-pushup/beta-plugin';", - 'export default { plugins: [alphaPlugin("custom.config.mjs"), betaPlugin()] } satisfies CoreConfig;', - '', - ].join('\n'), - ); + ).resolves.toMatchInlineSnapshot(` + "import alphaPlugin from '@code-pushup/alpha-plugin'; + import betaPlugin from '@code-pushup/beta-plugin'; + import type { CoreConfig } from '@code-pushup/models'; + + export default { + plugins: [ + alphaPlugin("custom.config.mjs"), + betaPlugin(), + ], + } satisfies CoreConfig; + " + `); }); }); diff --git a/packages/create-cli/src/lib/setup/wizard.ts b/packages/create-cli/src/lib/setup/wizard.ts index e43d7715f2..e8ca0796a4 100644 --- a/packages/create-cli/src/lib/setup/wizard.ts +++ b/packages/create-cli/src/lib/setup/wizard.ts @@ -1,4 +1,4 @@ -import { asyncSequential, logger } from '@code-pushup/utils'; +import { asyncSequential, formatAsciiTable, logger } from '@code-pushup/utils'; import { generateConfigSource } from './codegen.js'; import { promptPluginOptions } from './prompts.js'; import type { @@ -9,8 +9,6 @@ import type { } from './types.js'; import { createTree } from './virtual-fs.js'; -const COLUMN_GAP = 3; - export async function runSetupWizard( bindings: PluginSetupBinding[], cliArgs: CliArgs, @@ -26,7 +24,10 @@ export async function runSetupWizard( const tree = createTree(targetDir); // TODO: #1243 — select config file format (TS/JS/MJS) based on user choice or tsconfig detection - tree.write('code-pushup.config.ts', generateConfigSource(pluginResults)); + await tree.write( + 'code-pushup.config.ts', + generateConfigSource(pluginResults), + ); const changes = tree.listChanges(); @@ -39,7 +40,7 @@ export async function runSetupWizard( logger.info('Setup complete.'); logger.newline(); logNextSteps([ - ['npx code-pushup collect', 'Run your first report'], + ['npx code-pushup', 'Collect your first report'], ['https://github.com/code-pushup/cli#readme', 'Documentation'], ]); } @@ -52,7 +53,7 @@ async function resolveBinding( const answers = binding.prompts ? await promptPluginOptions(binding.prompts, cliArgs) : {}; - return binding.codegenConfig(answers); + return binding.generateConfig(answers); } function logChanges(changes: FileChange[]): void { @@ -62,9 +63,13 @@ function logChanges(changes: FileChange[]): void { } function logNextSteps(steps: [string, string][]): void { - const colWidth = Math.max(...steps.map(([label]) => label.length)); - logger.info('Next steps:'); - steps.forEach(([label, description]) => { - logger.info(` ${label.padEnd(colWidth + COLUMN_GAP)}${description}`); - }); + logger.info( + formatAsciiTable( + { + title: 'Next steps:', + rows: steps, + }, + { borderless: true }, + ), + ); } diff --git a/packages/create-cli/src/lib/setup/wizard.unit.test.ts b/packages/create-cli/src/lib/setup/wizard.unit.test.ts index 0cc41be611..54fc4ae1c0 100644 --- a/packages/create-cli/src/lib/setup/wizard.unit.test.ts +++ b/packages/create-cli/src/lib/setup/wizard.unit.test.ts @@ -15,7 +15,7 @@ const TEST_BINDING: PluginSetupBinding = { slug: 'test-plugin', title: 'Test Plugin', packageName: '@code-pushup/test-plugin', - codegenConfig: () => ({ + generateConfig: () => ({ imports: [ { moduleSpecifier: '@code-pushup/test-plugin', @@ -37,21 +37,23 @@ describe('runSetupWizard', () => { 'target-dir': MEMFS_VOLUME, }); - await expect( - readFile(`${MEMFS_VOLUME}/code-pushup.config.ts`, 'utf8'), - ).resolves.toBe( - [ - "import type { CoreConfig } from '@code-pushup/models';", - "import testPlugin from '@code-pushup/test-plugin';", - 'export default { plugins: [testPlugin()] } satisfies CoreConfig;', - '', - ].join('\n'), - ); + await expect(readFile(`${MEMFS_VOLUME}/code-pushup.config.ts`, 'utf8')) + .resolves.toMatchInlineSnapshot(` + "import type { CoreConfig } from '@code-pushup/models'; + import testPlugin from '@code-pushup/test-plugin'; + + export default { + plugins: [ + testPlugin(), + ], + } satisfies CoreConfig; + " + `); expect(logger.info).toHaveBeenCalledWith('CREATE code-pushup.config.ts'); expect(logger.info).toHaveBeenCalledWith('Setup complete.'); expect(logger.info).toHaveBeenCalledWith( - expect.stringContaining('npx code-pushup collect'), + expect.stringContaining('npx code-pushup'), ); }); @@ -73,15 +75,15 @@ describe('runSetupWizard', () => { 'target-dir': MEMFS_VOLUME, }); - await expect( - readFile(`${MEMFS_VOLUME}/code-pushup.config.ts`, 'utf8'), - ).resolves.toBe( - [ - "import type { CoreConfig } from '@code-pushup/models';", - 'export default { plugins: [] } satisfies CoreConfig;', - '', - ].join('\n'), - ); + await expect(readFile(`${MEMFS_VOLUME}/code-pushup.config.ts`, 'utf8')) + .resolves.toMatchInlineSnapshot(` + "import type { CoreConfig } from '@code-pushup/models'; + + export default { + plugins: [], + } satisfies CoreConfig; + " + `); expect(logger.info).toHaveBeenCalledWith('CREATE code-pushup.config.ts'); expect(logger.info).toHaveBeenCalledWith('Setup complete.'); From a1a41455dc9c8644339d0be0d1bce1261f7cfa91 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Tue, 24 Feb 2026 09:28:05 -0500 Subject: [PATCH 5/6] refactor(create-cli): use explicit depth in CodeBuilder --- packages/create-cli/src/lib/setup/codegen.ts | 45 +++++++++----------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/packages/create-cli/src/lib/setup/codegen.ts b/packages/create-cli/src/lib/setup/codegen.ts index 670ee08d71..b4ebd8865c 100644 --- a/packages/create-cli/src/lib/setup/codegen.ts +++ b/packages/create-cli/src/lib/setup/codegen.ts @@ -11,20 +11,19 @@ const CORE_CONFIG_IMPORT: ImportDeclarationStructure = { class CodeBuilder { private lines: string[] = []; - private depth = 0; - addLine(text: string): void { - this.lines.push(`${' '.repeat(this.depth)}${text}`); + addLine(text: string, depth = 0): void { + this.lines.push(`${' '.repeat(depth)}${text}`); } - addEmptyLine(): void { - this.lines.push(''); + addLines(texts: string[], depth = 0): void { + texts.forEach(text => { + this.addLine(text, depth); + }); } - indent(fn: () => void): void { - this.depth++; - fn(); - this.depth--; + addEmptyLine(): void { + this.lines.push(''); } toString(): string { @@ -57,25 +56,19 @@ function collectImports( export function generateConfigSource(plugins: PluginCodegenResult[]): string { const builder = new CodeBuilder(); - collectImports(plugins).forEach(declaration => { - builder.addLine(formatImport(declaration)); - }); - + builder.addLines(collectImports(plugins).map(formatImport)); builder.addEmptyLine(); builder.addLine('export default {'); - builder.indent(() => { - if (plugins.length === 0) { - builder.addLine('plugins: [],'); - } else { - builder.addLine('plugins: ['); - builder.indent(() => { - plugins.forEach(({ pluginInit }) => { - builder.addLine(`${pluginInit},`); - }); - }); - builder.addLine('],'); - } - }); + if (plugins.length === 0) { + builder.addLine('plugins: [],', 1); + } else { + builder.addLine('plugins: [', 1); + builder.addLines( + plugins.map(({ pluginInit }) => `${pluginInit},`), + 2, + ); + builder.addLine('],', 1); + } builder.addLine('} satisfies CoreConfig;'); return builder.toString(); From 200c2d038b3e8c72087449f28115521a136c614b Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Tue, 24 Feb 2026 09:30:37 -0500 Subject: [PATCH 6/6] fix(create-cli): update virtual-fs mock --- .../create-cli/src/lib/setup/virtual-fs.unit.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts b/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts index 25df696466..341ea6e66e 100644 --- a/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts +++ b/packages/create-cli/src/lib/setup/virtual-fs.unit.test.ts @@ -4,10 +4,10 @@ import { createTree } from './virtual-fs.js'; function createMockFs( files: Record = {}, -): FileSystemAdapter & { written: Map; dirs: string[] } { +): FileSystemAdapter & { written: Map; dirs: Set } { const store = new Map(Object.entries(files)); const written = new Map(); - const dirs: string[] = []; + const dirs = new Set(); return { written, @@ -26,9 +26,8 @@ function createMockFs( async exists(path: string) { return store.has(toUnixPath(path)); }, - async mkdir(_path: string, _options: { recursive: boolean }) { - // eslint-disable-next-line functional/immutable-data - dirs.push(toUnixPath(_path)); + async mkdir(path: string): Promise { + dirs.add(toUnixPath(path)); }, }; }