From 422c0c71d673e633ee8e97cd201aaf47c7dc8d38 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Tue, 17 Feb 2026 12:48:34 -0500 Subject: [PATCH 1/2] 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 b65960323..b138560bb 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 de389cb9b..758a5bd8a 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 52a48a7d4..ca997df2d 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 85112ffe1..1e3ed0785 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 22fda2e40..08bb1f80f 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 e3851f419..39e7cdcc9 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 8e4af8b5b..7e081800b 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 221a00a2a..4f9e782f7 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 bffac6c15..000000000 --- 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 77b1e81cb..000000000 --- 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 854eef638..000000000 --- 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 000000000..063f4f85a --- /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 000000000..07d2d05fe --- /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 000000000..053675794 --- /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 000000000..4661a189f --- /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 000000000..206d57a94 --- /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 000000000..dd75741ed --- /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 000000000..ec458d2cc --- /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 000000000..3e809124e --- /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 000000000..e43d7715f --- /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 000000000..0cc41be61 --- /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 19249661e..000000000 --- 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 f44355076..000000000 --- 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 000000000..b37551dda --- /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/2] 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 ec458d2cc..ffa08c780 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)); }, }; }