From 79742d79365aba2f84b09b508079e2c1b759d385 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:34:34 +0000 Subject: [PATCH 01/36] chore(deps-dev): bump @playwright/test from 1.63.0 to 1.64.1 Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.63.0 to 1.64.1. - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](https://github.com/microsoft/playwright/compare/v1.63.0...v1.64.1) --- updated-dependencies: - dependency-name: "@playwright/test" dependency-version: 1.64.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index a2e58bf..2ab09d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -947,13 +947,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", - "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.63.0" + "playwright": "1.64.1" }, "bin": { "playwright": "cli.js" @@ -3504,13 +3504,13 @@ } }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" @@ -3520,9 +3520,9 @@ } }, "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "dev": true, "license": "Apache-2.0", "bin": { From 2d8ecfd3f80af43cd29fd08bcd0cd31776dcaa68 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Fri, 23 Oct 2026 20:58:39 +0300 Subject: [PATCH 02/36] fix(deps): move the Next.js templates to next 16.4.2 template-nextjs and template-nextjs-esm pin next and eslint-config-next to exactly 16.3.8, which is affected by three new Next.js advisories, all fixed in 16.4.1: two critical unauthenticated remote code executions (GHSA-2464-fhr4-w9xw in the Image Optimization API with SVG files, GHSA-673q-mjm6-r5xr in `use cache` functions returning typed arrays) and a high denial of service in App Router Server Actions (GHSA-p3m5-59mp-jv5r). Every project scaffolded from them got it. Dependabot raises no alert, because the templates have no lockfile. Pin both to 16.4.2, published 2026-10-15; 16.4.3 is too new to take. --- template-nextjs-esm/package.json | 4 ++-- template-nextjs/package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/template-nextjs-esm/package.json b/template-nextjs-esm/package.json index 37bb91e..6a31f8e 100644 --- a/template-nextjs-esm/package.json +++ b/template-nextjs-esm/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "clsx": "^2.1.1", - "next": "16.3.8", + "next": "16.4.2", "react": "^19.3.0", "react-dom": "^19.3.0" }, @@ -26,7 +26,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "eslint": "^10.12.0", - "eslint-config-next": "16.3.8", + "eslint-config-next": "16.4.2", "prettier": "^3.9.9", "typescript": "^6.0.3" } diff --git a/template-nextjs/package.json b/template-nextjs/package.json index 079e9e9..cb796b7 100644 --- a/template-nextjs/package.json +++ b/template-nextjs/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "clsx": "^2.1.1", - "next": "16.3.8", + "next": "16.4.2", "react": "^19.3.0", "react-dom": "^19.3.0" }, @@ -25,7 +25,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "eslint": "^10.12.0", - "eslint-config-next": "16.3.8", + "eslint-config-next": "16.4.2", "prettier": "^3.9.9", "typescript": "^6.0.3" } From 47c49f07130b108d819086d8aff63f6816840d43 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:50:39 +0000 Subject: [PATCH 03/36] fix(deps): bump the templates group across 2 directories with 2 updates Bumps the templates group with 2 updates in the /template-nextjs directory: [next](https://github.com/vercel/next.js) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next). Bumps the templates group with 2 updates in the /template-nextjs-esm directory: [next](https://github.com/vercel/next.js) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next). Updates `next` from 16.4.2 to 16.4.3 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v16.4.2...v16.4.3) Updates `eslint-config-next` from 16.4.2 to 16.4.3 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/commits/v16.4.3/packages/eslint-config-next) Updates `next` from 16.4.2 to 16.4.3 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v16.4.2...v16.4.3) Updates `eslint-config-next` from 16.4.2 to 16.4.3 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/commits/v16.4.3/packages/eslint-config-next) Updates `next` from 16.4.2 to 16.4.3 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v16.4.2...v16.4.3) Updates `eslint-config-next` from 16.4.2 to 16.4.3 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/commits/v16.4.3/packages/eslint-config-next) Updates `next` from 16.4.2 to 16.4.3 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v16.4.2...v16.4.3) Updates `eslint-config-next` from 16.4.2 to 16.4.3 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/commits/v16.4.3/packages/eslint-config-next) --- updated-dependencies: - dependency-name: eslint-config-next dependency-version: 16.4.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: templates - dependency-name: eslint-config-next dependency-version: 16.4.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: templates - dependency-name: eslint-config-next dependency-version: 16.4.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: templates - dependency-name: eslint-config-next dependency-version: 16.4.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: templates - dependency-name: next dependency-version: 16.4.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: templates - dependency-name: next dependency-version: 16.4.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: templates - dependency-name: next dependency-version: 16.4.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: templates - dependency-name: next dependency-version: 16.4.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: templates ... Signed-off-by: dependabot[bot] --- template-nextjs-esm/package.json | 4 ++-- template-nextjs/package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/template-nextjs-esm/package.json b/template-nextjs-esm/package.json index 6a31f8e..b5c0049 100644 --- a/template-nextjs-esm/package.json +++ b/template-nextjs-esm/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "clsx": "^2.1.1", - "next": "16.4.2", + "next": "16.4.3", "react": "^19.3.0", "react-dom": "^19.3.0" }, @@ -26,7 +26,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "eslint": "^10.12.0", - "eslint-config-next": "16.4.2", + "eslint-config-next": "16.4.3", "prettier": "^3.9.9", "typescript": "^6.0.3" } diff --git a/template-nextjs/package.json b/template-nextjs/package.json index cb796b7..18715b5 100644 --- a/template-nextjs/package.json +++ b/template-nextjs/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "clsx": "^2.1.1", - "next": "16.4.2", + "next": "16.4.3", "react": "^19.3.0", "react-dom": "^19.3.0" }, @@ -25,7 +25,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "eslint": "^10.12.0", - "eslint-config-next": "16.4.2", + "eslint-config-next": "16.4.3", "prettier": "^3.9.9", "typescript": "^6.0.3" } From ba477cddfd92a2c43c0d228f5d48b7e2e5d51616 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 12:25:15 +0200 Subject: [PATCH 04/36] fix(deps): bump @metricinsights/pp-dev to ^1.5.0 in the templates pp-dev 1.5.0 is the first minor since 1.4.7: working as other MI users, server.allowedHosts for tunnels, sync.maxBackups / sync.buildTimeoutMs, the host check and CSRF protection on the dev server's own routes, the pp-dev next restart-on-config-change fix and the security updates of its runtime dependencies. Raise the floor in every template so new projects start on it. Nothing else in the templates has to change for 1.5.0: the grouped pp-dev.config schema, withPPDev() in next.config.* and the scripts are unchanged. --- template-nextjs-esm/package.json | 2 +- template-nextjs/package.json | 2 +- template-react/package.json | 2 +- template-vanilla-ts/package.json | 2 +- template-vanilla/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/template-nextjs-esm/package.json b/template-nextjs-esm/package.json index 6a31f8e..cc0b721 100644 --- a/template-nextjs-esm/package.json +++ b/template-nextjs-esm/package.json @@ -20,7 +20,7 @@ "react-dom": "^19.3.0" }, "devDependencies": { - "@metricinsights/pp-dev": "^1.4.7", + "@metricinsights/pp-dev": "^1.5.0", "@stylistic/eslint-plugin": "^5.10.0", "@types/node": "^26.6.4", "@types/react": "^19.3.0", diff --git a/template-nextjs/package.json b/template-nextjs/package.json index cb796b7..103d39e 100644 --- a/template-nextjs/package.json +++ b/template-nextjs/package.json @@ -19,7 +19,7 @@ "react-dom": "^19.3.0" }, "devDependencies": { - "@metricinsights/pp-dev": "^1.4.7", + "@metricinsights/pp-dev": "^1.5.0", "@stylistic/eslint-plugin": "^5.10.0", "@types/node": "^26.6.4", "@types/react": "^19.3.0", diff --git a/template-react/package.json b/template-react/package.json index 5355039..bf30c1b 100644 --- a/template-react/package.json +++ b/template-react/package.json @@ -22,7 +22,7 @@ "@eslint/compat": "^2.1.1", "@eslint/eslintrc": "^3.3.7", "@eslint/js": "^10.0.1", - "@metricinsights/pp-dev": "^1.4.7", + "@metricinsights/pp-dev": "^1.5.0", "@stylistic/eslint-plugin": "^5.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", diff --git a/template-vanilla-ts/package.json b/template-vanilla-ts/package.json index d7c87c0..e6de9c9 100644 --- a/template-vanilla-ts/package.json +++ b/template-vanilla-ts/package.json @@ -16,7 +16,7 @@ "@eslint/compat": "^2.1.1", "@eslint/eslintrc": "^3.3.7", "@eslint/js": "^10.0.1", - "@metricinsights/pp-dev": "^1.4.7", + "@metricinsights/pp-dev": "^1.5.0", "@stylistic/eslint-plugin": "^5.10.0", "@types/jquery": "^4.0.1", "@typescript-eslint/eslint-plugin": "^8.71.1", diff --git a/template-vanilla/package.json b/template-vanilla/package.json index 40fa2e7..f1d39bc 100644 --- a/template-vanilla/package.json +++ b/template-vanilla/package.json @@ -16,7 +16,7 @@ "@eslint/compat": "^2.1.1", "@eslint/eslintrc": "^3.3.7", "@eslint/js": "^10.0.1", - "@metricinsights/pp-dev": "^1.4.7", + "@metricinsights/pp-dev": "^1.5.0", "@stylistic/eslint-plugin": "^5.10.0", "@types/jquery": "^4.0.1", "eslint": "^10.12.0", From 08d7cd368b6ec37f4e065bd7e82857e341177746 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 13:11:55 +0200 Subject: [PATCH 05/36] fix(templates): keep .env and pp-dev build output out of git pp-dev reads MI_ACCESS_TOKEN from .env / .env. (Vite's loadEnv) and its README tells users to keep the token there, but no template ignored those files: the Vite templates ignored only *.local, the Next templates only .env*.local. The first `git add .` in a new project committed the token. Every _gitignore now ignores .env and .env.*, keeping .env.example. The Next templates also never ignored dist (their next.config distDir and the pp-dev next-build output), dist-zip or backups (the backups pp-dev pulls from MI), unlike the Vite templates. The scaffold tests now check that _gitignore was renamed and that the generated .gitignore of every template lists .env, .env.*, dist, dist-zip and backups. Refs: PP-4792 --- template-nextjs-esm/_gitignore | 11 ++++++++-- template-nextjs-esm/src/assets/img/.gitkeep | 0 .../assets/img}/metricinsights.svg | 0 .../{public => src/assets/img}/next.svg | 0 .../{public => src/assets/img}/pp-dev.svg | 0 .../{public => src/assets/img}/react.svg | 0 template-nextjs/_gitignore | 11 ++++++++-- template-nextjs/src/assets/img/.gitkeep | 0 .../assets/img}/metricinsights.svg | 0 .../{public => src/assets/img}/next.svg | 0 .../{public => src/assets/img}/pp-dev.svg | 0 .../{public => src/assets/img}/react.svg | 0 template-react/_gitignore | 5 +++++ template-vanilla-ts/_gitignore | 5 +++++ template-vanilla/_gitignore | 5 +++++ test/scaffold.spec.mjs | 20 +++++++++++++++++++ 16 files changed, 53 insertions(+), 4 deletions(-) delete mode 100644 template-nextjs-esm/src/assets/img/.gitkeep rename template-nextjs-esm/{public => src/assets/img}/metricinsights.svg (100%) rename template-nextjs-esm/{public => src/assets/img}/next.svg (100%) rename template-nextjs-esm/{public => src/assets/img}/pp-dev.svg (100%) rename template-nextjs-esm/{public => src/assets/img}/react.svg (100%) delete mode 100644 template-nextjs/src/assets/img/.gitkeep rename template-nextjs/{public => src/assets/img}/metricinsights.svg (100%) rename template-nextjs/{public => src/assets/img}/next.svg (100%) rename template-nextjs/{public => src/assets/img}/pp-dev.svg (100%) rename template-nextjs/{public => src/assets/img}/react.svg (100%) diff --git a/template-nextjs-esm/_gitignore b/template-nextjs-esm/_gitignore index fd3dbb5..612e637 100644 --- a/template-nextjs-esm/_gitignore +++ b/template-nextjs-esm/_gitignore @@ -16,6 +16,11 @@ # production /build +# pp-dev: next.config distDir, pp-dev next-build zip, backups pulled from MI +/dist +/dist-zip +/backups + # misc .DS_Store *.pem @@ -25,8 +30,10 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -# local env files -.env*.local +# env files — MI_ACCESS_TOKEN for pp-dev lives here, never commit them +.env +.env.* +!.env.example # vercel .vercel diff --git a/template-nextjs-esm/src/assets/img/.gitkeep b/template-nextjs-esm/src/assets/img/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/template-nextjs-esm/public/metricinsights.svg b/template-nextjs-esm/src/assets/img/metricinsights.svg similarity index 100% rename from template-nextjs-esm/public/metricinsights.svg rename to template-nextjs-esm/src/assets/img/metricinsights.svg diff --git a/template-nextjs-esm/public/next.svg b/template-nextjs-esm/src/assets/img/next.svg similarity index 100% rename from template-nextjs-esm/public/next.svg rename to template-nextjs-esm/src/assets/img/next.svg diff --git a/template-nextjs-esm/public/pp-dev.svg b/template-nextjs-esm/src/assets/img/pp-dev.svg similarity index 100% rename from template-nextjs-esm/public/pp-dev.svg rename to template-nextjs-esm/src/assets/img/pp-dev.svg diff --git a/template-nextjs-esm/public/react.svg b/template-nextjs-esm/src/assets/img/react.svg similarity index 100% rename from template-nextjs-esm/public/react.svg rename to template-nextjs-esm/src/assets/img/react.svg diff --git a/template-nextjs/_gitignore b/template-nextjs/_gitignore index fd3dbb5..612e637 100644 --- a/template-nextjs/_gitignore +++ b/template-nextjs/_gitignore @@ -16,6 +16,11 @@ # production /build +# pp-dev: next.config distDir, pp-dev next-build zip, backups pulled from MI +/dist +/dist-zip +/backups + # misc .DS_Store *.pem @@ -25,8 +30,10 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -# local env files -.env*.local +# env files — MI_ACCESS_TOKEN for pp-dev lives here, never commit them +.env +.env.* +!.env.example # vercel .vercel diff --git a/template-nextjs/src/assets/img/.gitkeep b/template-nextjs/src/assets/img/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/template-nextjs/public/metricinsights.svg b/template-nextjs/src/assets/img/metricinsights.svg similarity index 100% rename from template-nextjs/public/metricinsights.svg rename to template-nextjs/src/assets/img/metricinsights.svg diff --git a/template-nextjs/public/next.svg b/template-nextjs/src/assets/img/next.svg similarity index 100% rename from template-nextjs/public/next.svg rename to template-nextjs/src/assets/img/next.svg diff --git a/template-nextjs/public/pp-dev.svg b/template-nextjs/src/assets/img/pp-dev.svg similarity index 100% rename from template-nextjs/public/pp-dev.svg rename to template-nextjs/src/assets/img/pp-dev.svg diff --git a/template-nextjs/public/react.svg b/template-nextjs/src/assets/img/react.svg similarity index 100% rename from template-nextjs/public/react.svg rename to template-nextjs/src/assets/img/react.svg diff --git a/template-react/_gitignore b/template-react/_gitignore index c1dc08b..6abd018 100644 --- a/template-react/_gitignore +++ b/template-react/_gitignore @@ -14,6 +14,11 @@ backups dist-ssr *.local +# env files — MI_ACCESS_TOKEN for pp-dev lives here, never commit them +.env +.env.* +!.env.example + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/template-vanilla-ts/_gitignore b/template-vanilla-ts/_gitignore index c1dc08b..6abd018 100644 --- a/template-vanilla-ts/_gitignore +++ b/template-vanilla-ts/_gitignore @@ -14,6 +14,11 @@ backups dist-ssr *.local +# env files — MI_ACCESS_TOKEN for pp-dev lives here, never commit them +.env +.env.* +!.env.example + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/template-vanilla/_gitignore b/template-vanilla/_gitignore index c1dc08b..6abd018 100644 --- a/template-vanilla/_gitignore +++ b/template-vanilla/_gitignore @@ -14,6 +14,11 @@ backups dist-ssr *.local +# env files — MI_ACCESS_TOKEN for pp-dev lives here, never commit them +.env +.env.* +!.env.example + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/test/scaffold.spec.mjs b/test/scaffold.spec.mjs index 8bbd72e..e6a29f9 100644 --- a/test/scaffold.spec.mjs +++ b/test/scaffold.spec.mjs @@ -34,6 +34,16 @@ function runNpm(args, options) { return spawn('npm', args, { shell: process.platform === 'win32', ...options }); } +/** Entries of a .gitignore without comments, blanks, CR and the leading "/" of rooted patterns. */ +function readGitignoreEntries(file) { + return fs + .readFileSync(file, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + .map((line) => line.replace(/^\//, '')); +} + const templates = [ { name: 'vanilla', checkFile: 'src/main.js' }, { name: 'vanilla-ts', checkFile: 'src/main.ts' }, @@ -171,6 +181,16 @@ for (const template of templates) { expect(packageJson.name).toBe(packageName); expect(packageJson.dependencies?.['@metricinsights/pp-components']).toBeUndefined(); + + // _gitignore is renamed on copy; the result must keep the pp-dev token file (.env, read by + // pp-dev through Vite's loadEnv) and pp-dev's build output and MI backups out of git. + expect(fs.existsSync(path.join(targetDir, '_gitignore'))).toBeFalsy(); + + const gitignoreEntries = readGitignoreEntries(path.join(targetDir, '.gitignore')); + + for (const entry of ['.env', '.env.*', 'dist', 'dist-zip', 'backups']) { + expect(gitignoreEntries, `.gitignore of ${template.name} lacks "${entry}"`).toContain(entry); + } } finally { fs.rmSync(tmpRoot, { recursive: true, force: true }); } From e137208e5daf3ec3891359cc48910bed71a318de Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 13:11:58 +0200 Subject: [PATCH 06/36] fix(nextjs): import the logos so Next applies the asset prefix The Next templates built image URLs from a hard-coded `ASSETS_PREFIX = /pt/`. That is the production prefix only: under `apiVersion: 7`, withPPDev() serves the dev app at /pl/, so on a fresh project `pp-dev next` answered /pt//pp-dev.svg with 404 and both logos on the start page (and the favicon) were broken. The SVGs move from public/ to src/assets/img/ and are imported as static assets. Next emits them under _next/static/media with the configured assetPrefix, so the same code resolves to /pl//... in development and /pt//... in the exported build; constants.ts no longer needs package.json. Verified on pp-dev 1.5.0: all four images and the favicon are 200 under `next dev` and `pp-dev next`, and `pp-dev next-build` writes /pt//_next/static/media/... into dist/index.html. Refs: PP-4792 --- template-nextjs-esm/src/components/docs/docs.tsx | 13 ++++++++----- template-nextjs-esm/src/components/root-layout.tsx | 4 ++-- template-nextjs-esm/src/constants.ts | 4 ---- template-nextjs-esm/src/pages/index.tsx | 7 ++++--- template-nextjs/src/components/docs/docs.tsx | 13 ++++++++----- template-nextjs/src/components/root-layout.tsx | 4 ++-- template-nextjs/src/constants.ts | 4 ---- template-nextjs/src/pages/index.tsx | 7 ++++--- 8 files changed, 28 insertions(+), 28 deletions(-) diff --git a/template-nextjs-esm/src/components/docs/docs.tsx b/template-nextjs-esm/src/components/docs/docs.tsx index 6a500ba..838f2aa 100644 --- a/template-nextjs-esm/src/components/docs/docs.tsx +++ b/template-nextjs-esm/src/components/docs/docs.tsx @@ -1,6 +1,9 @@ import styles from './docs.module.scss'; import Image from 'next/image'; -import { ASSETS_PREFIX } from '@/constants'; +import ppDevLogo from '@/assets/img/pp-dev.svg'; +import nextLogo from '@/assets/img/next.svg'; +import reactLogo from '@/assets/img/react.svg'; +import metricInsightsLogo from '@/assets/img/metricinsights.svg'; import clsx from 'clsx'; export default function Docs() { @@ -15,7 +18,7 @@ export default function Docs() { > {'PP {'Next.js'} {'React.js'} {'MetricInsights'} PP Dev + Next.js - +
{children}
diff --git a/template-nextjs-esm/src/constants.ts b/template-nextjs-esm/src/constants.ts index ce1d011..9bee524 100644 --- a/template-nextjs-esm/src/constants.ts +++ b/template-nextjs-esm/src/constants.ts @@ -1,5 +1,3 @@ -import packageJson from '../package.json'; - export interface PPVariables { [key: string]: unknown; // LOGO: string; @@ -10,5 +8,3 @@ export const PP_VARIABLES: PPVariables = typeof window !== 'undefined' && typeof window.PP_VARIABLES === 'object' ? (window.PP_VARIABLES as PPVariables) : ({} as PPVariables); - -export const ASSETS_PREFIX = `/pt/${packageJson.name}`; diff --git a/template-nextjs-esm/src/pages/index.tsx b/template-nextjs-esm/src/pages/index.tsx index 1a1371f..5323f16 100644 --- a/template-nextjs-esm/src/pages/index.tsx +++ b/template-nextjs-esm/src/pages/index.tsx @@ -1,6 +1,7 @@ import styles from '@/assets/pages/index.module.scss'; import Image from 'next/image'; -import { ASSETS_PREFIX } from '@/constants'; +import ppDevLogo from '@/assets/img/pp-dev.svg'; +import nextLogo from '@/assets/img/next.svg'; import RootLayout from '@/components/root-layout'; import { useState } from 'react'; import clsx from 'clsx'; @@ -14,7 +15,7 @@ export default function Home() {
PPDev Logo Next.js Logo {'PP {'Next.js'} {'React.js'} {'MetricInsights'} PP Dev + Next.js - +
{children}
diff --git a/template-nextjs/src/constants.ts b/template-nextjs/src/constants.ts index ce1d011..9bee524 100644 --- a/template-nextjs/src/constants.ts +++ b/template-nextjs/src/constants.ts @@ -1,5 +1,3 @@ -import packageJson from '../package.json'; - export interface PPVariables { [key: string]: unknown; // LOGO: string; @@ -10,5 +8,3 @@ export const PP_VARIABLES: PPVariables = typeof window !== 'undefined' && typeof window.PP_VARIABLES === 'object' ? (window.PP_VARIABLES as PPVariables) : ({} as PPVariables); - -export const ASSETS_PREFIX = `/pt/${packageJson.name}`; diff --git a/template-nextjs/src/pages/index.tsx b/template-nextjs/src/pages/index.tsx index 1a1371f..5323f16 100644 --- a/template-nextjs/src/pages/index.tsx +++ b/template-nextjs/src/pages/index.tsx @@ -1,6 +1,7 @@ import styles from '@/assets/pages/index.module.scss'; import Image from 'next/image'; -import { ASSETS_PREFIX } from '@/constants'; +import ppDevLogo from '@/assets/img/pp-dev.svg'; +import nextLogo from '@/assets/img/next.svg'; import RootLayout from '@/components/root-layout'; import { useState } from 'react'; import clsx from 'clsx'; @@ -14,7 +15,7 @@ export default function Home() {
PPDev Logo Next.js Logo Date: Mon, 9 Nov 2026 13:12:01 +0200 Subject: [PATCH 07/36] fix(templates): tsconfig values Next writes on first run, drop a missing file Next 16 rewrote the Next templates' tsconfig.json on the first `pp-dev next`: `jsx` to react-jsx (a "mandatory change") and dist/dev/types/**/*.ts into include (pp-dev's development distDir). A fresh project started with a modified tsconfig. Ship both values; the file is now left alone by `next dev` and `pp-dev next`. template-vanilla-ts/tsconfig.node.json included a vite.config.ts the template does not have. Refs: PP-4792 --- template-nextjs-esm/tsconfig.json | 5 +++-- template-nextjs/tsconfig.json | 5 +++-- template-vanilla-ts/tsconfig.node.json | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/template-nextjs-esm/tsconfig.json b/template-nextjs-esm/tsconfig.json index 77f1698..89eb8f0 100644 --- a/template-nextjs-esm/tsconfig.json +++ b/template-nextjs-esm/tsconfig.json @@ -15,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -34,7 +34,8 @@ "**/*.tsx", ".next/types/**/*.ts", "pp-dev.config.ts", - "dist/types/**/*.ts" + "dist/types/**/*.ts", + "dist/dev/types/**/*.ts" ], "exclude": [ "node_modules" diff --git a/template-nextjs/tsconfig.json b/template-nextjs/tsconfig.json index 77f1698..89eb8f0 100644 --- a/template-nextjs/tsconfig.json +++ b/template-nextjs/tsconfig.json @@ -15,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -34,7 +34,8 @@ "**/*.tsx", ".next/types/**/*.ts", "pp-dev.config.ts", - "dist/types/**/*.ts" + "dist/types/**/*.ts", + "dist/dev/types/**/*.ts" ], "exclude": [ "node_modules" diff --git a/template-vanilla-ts/tsconfig.node.json b/template-vanilla-ts/tsconfig.node.json index 8b94ec0..d07d27b 100644 --- a/template-vanilla-ts/tsconfig.node.json +++ b/template-vanilla-ts/tsconfig.node.json @@ -6,5 +6,5 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, - "include": ["vite.config.ts", "pp-dev.config.ts"] + "include": ["pp-dev.config.ts"] } From 0990e0eba60c5e4f0c731ea50efab6a5937ca322 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 13:12:04 +0200 Subject: [PATCH 08/36] docs(templates): fix stale README text and the app.tsx hint - vanilla README showed an internal instance (640.metricinsights.com) as the mi.url example; use example.metricinsights.com like the others - vanilla, vanilla-ts and react READMEs said "To learn more about Next.js" - the Next READMEs linked the scaffolder at a path in pp-dev-js that no longer exists - the React README's "Expanding the ESLint configuration" described the legacy .eslintrc options; rewritten for the flat config the template ships - the React start page said "Edit src/App.tsx" for src/app.tsx Refs: PP-4792 --- template-nextjs-esm/README.md | 2 +- template-nextjs/README.md | 2 +- template-react/README.md | 19 +++++++++---------- template-react/src/app.tsx | 2 +- template-vanilla-ts/README.md | 2 +- template-vanilla/README.md | 4 ++-- 6 files changed, 15 insertions(+), 16 deletions(-) diff --git a/template-nextjs-esm/README.md b/template-nextjs-esm/README.md index 9562d81..52d9b10 100644 --- a/template-nextjs-esm/README.md +++ b/template-nextjs-esm/README.md @@ -1,6 +1,6 @@ # Next.js (ESM) + PP Dev -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`@metricinsights/create-pp-dev`](https://github.com/mi-examples/pp-dev-js/tree/main/packages/create-pp-dev). +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`@metricinsights/create-pp-dev`](https://github.com/mi-examples/create-pp-dev). This variant uses ECMAScript modules (`"type": "module"` in `package.json`, `import`/`export` in `next.config.js`) instead of CommonJS. Use the `nextjs` template if you need CommonJS instead. diff --git a/template-nextjs/README.md b/template-nextjs/README.md index cb839b8..4a7cc10 100644 --- a/template-nextjs/README.md +++ b/template-nextjs/README.md @@ -1,6 +1,6 @@ # Next.js + PP Dev -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`@metricinsights/create-pp-dev`](https://github.com/mi-examples/pp-dev-js/tree/main/packages/create-pp-dev). +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`@metricinsights/create-pp-dev`](https://github.com/mi-examples/create-pp-dev). ## Getting Started diff --git a/template-react/README.md b/template-react/README.md index 4cbea9f..16d76fe 100644 --- a/template-react/README.md +++ b/template-react/README.md @@ -24,7 +24,7 @@ You can start editing the page by modifying `src/app.tsx`. The page auto-updates ## Learn More -To learn more about Next.js, take a look at the following resources: +To learn more, take a look at the following resources: - [TypeScript Docs](https://www.typescriptlang.org/) - learn about TypeScript features and API. - [React.js Docs](https://react.dev) - learn about React features and API. @@ -33,25 +33,24 @@ To learn more about Next.js, take a look at the following resources: ## Expanding the ESLint configuration -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: +The template ships a flat config (`eslint.config.cjs`) built with `FlatCompat` from `eslint:recommended`, `plugin:@typescript-eslint/recommended` and `plugin:react-hooks/recommended`. If you are developing a production application, we recommend enabling type-aware lint rules: -- Configure the top-level `parserOptions` property like this: +- Point the parser at your `tsconfig` files in `languageOptions`: ```js -export default { - // other rules... +// eslint.config.cjs +languageOptions: { + parser: tsParser, parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', project: ['./tsconfig.json', './tsconfig.node.json'], tsconfigRootDir: __dirname, }, -}; +}, ``` -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` +- Replace `plugin:@typescript-eslint/recommended` in `compat.extends(...)` with `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the same `compat.extends(...)` list ## PP Dev Helper configuration diff --git a/template-react/src/app.tsx b/template-react/src/app.tsx index 4cf79fc..7f595ce 100644 --- a/template-react/src/app.tsx +++ b/template-react/src/app.tsx @@ -21,7 +21,7 @@ function App() {

- Edit src/App.tsx and save to test HMR + Edit src/app.tsx and save to test HMR

Click on the Vite and React logos to learn more

diff --git a/template-vanilla-ts/README.md b/template-vanilla-ts/README.md index eb713d3..7b928c1 100644 --- a/template-vanilla-ts/README.md +++ b/template-vanilla-ts/README.md @@ -20,7 +20,7 @@ You can start editing the page by modifying `src/main.ts` and `index.html`. The ## Learn More -To learn more about Next.js, take a look at the following resources: +To learn more, take a look at the following resources: - [TypeScript Docs](https://www.typescriptlang.org/) - learn about TypeScript features and API. - [PP Dev Documentation](https://www.npmjs.com/package/@metricinsights/pp-dev) - learn about PP Dev features and API. diff --git a/template-vanilla/README.md b/template-vanilla/README.md index 1bdf8be..c38a926 100644 --- a/template-vanilla/README.md +++ b/template-vanilla/README.md @@ -20,7 +20,7 @@ You can start editing the page by modifying `src/main.js` and `index.html`. The ## Learn More -To learn more about Next.js, take a look at the following resources: +To learn more, take a look at the following resources: - [PP Dev Documentation](https://www.npmjs.com/package/@metricinsights/pp-dev) - learn about PP Dev features and API. - [Metric Insights Docs](https://help.metricinsights.com/) - learn about Metric Insights features and API. @@ -38,7 +38,7 @@ export default defineConfig({ /** * Metric Insights instance URL */ - url: 'https://640.metricinsights.com', + url: 'https://example.metricinsights.com', mode: 'embedding', apiVersion: 7, }, From e35bb4878510fe7ac48f1e1621f8da58e0cfa760 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 13:46:58 +0200 Subject: [PATCH 09/36] fix(cli): strict flags, --help/--version, --yes, errors that exit 1 The CLI accepted any unknown flag (`strict: false`), had no --help or --version (both opened the "Project name:" prompt), declared -y/--yes without reading it, and with stdin closed rendered the prompt, took the cancel and exited 0 having created nothing. The target directory was emptied before --template and --package-name were checked, a failed `npm install` still ended in "Project created successfully!", and the registry lookup for pp-components had no timeout and no fallback, so offline it crashed after the project files were written. - parseArgs runs strict; an unknown flag or a missing value exits 1 with the option name and a pointer to --help - --help prints the usage (every flag, the template list, examples); --version prints the package version - --yes asks nothing: the default project name, the files of a non-empty directory are kept, the package name is derived from the directory, packages are installed, no Cursor rules, no pp-components; it needs --template, since that has no default - without a terminal, a question that has no flag to answer it exits 1 and names the flag instead of cancelling itself - --template and --package-name are validated before anything is asked or written - a failed install reports the exit code or spawn error, says how to retry and exits 1; the project files stay - the pp-components version comes from the registry npm is configured with (npm_config_registry), with a 10 s timeout; any failure is a warning and the dependency is written as ^1.0.0 - the package name derived from the target is the directory's own name for ".", relative and absolute paths alike (an absolute path used to be mangled into the suggestion) - init().catch exits 1; the dead customCommand/isYarn1 branch is gone; formatTargetDir strips a trailing backslash too; one packageRoot instead of three resolve() calls - src/types/clack-prompts.d.ts is removed: @clack/prompts ships its own types, the shim made every prompt result `any` Refs: PP-4793 --- src/index.ts | 482 +++++++++++++++++++++-------------- src/types/clack-prompts.d.ts | 1 - 2 files changed, 287 insertions(+), 196 deletions(-) delete mode 100644 src/types/clack-prompts.d.ts diff --git a/src/index.ts b/src/index.ts index 0dee5fc..b3a2ada 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,31 +1,22 @@ -import fs, { mkdirSync } from 'node:fs'; +import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs, styleText } from 'node:util'; import spawn from 'cross-spawn'; import * as p from '@clack/prompts'; -// Positionals stay strings (a numeric project name is not coerced), and boolean flags the user -// didn't pass stay `undefined` — so the interactive prompt is shown for them. `--no-` sets -// false. strict: false keeps unknown flags from aborting the CLI. -const { values: argv, positionals } = parseArgs({ - args: process.argv.slice(2), - options: { - template: { type: 'string', short: 't' }, - 'package-name': { type: 'string' }, - install: { type: 'boolean' }, - 'cursor-rules': { type: 'boolean' }, - 'pp-components': { type: 'boolean' }, - yes: { type: 'boolean', short: 'y' }, - }, - allowPositionals: true, - allowNegative: true, - strict: false, -}); +// dist/index.mjs is bundled into /dist; the templates, the Cursor rules and package.json +// sit next to that directory. +const packageRoot = path.resolve(fileURLToPath(import.meta.url), '../..'); const cwd = process.cwd(); -const stringArg = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined); -const booleanArg = (value: unknown): boolean | undefined => (typeof value === 'boolean' ? value : undefined); +// clack can only ask questions on a terminal. In a pipe or in CI every answer has to come from a +// flag (or from --yes), otherwise the prompt would be cancelled at once and nothing created. +const canPrompt = Boolean(process.stdin.isTTY); + +const PP_COMPONENTS_PACKAGE = '@metricinsights/pp-components'; +const PP_COMPONENTS_FALLBACK_VERSION = '1.0.0'; +const REGISTRY_TIMEOUT_MS = 10_000; type ColorFunc = (str: string | number) => string; @@ -50,7 +41,6 @@ type FrameworkVariant = { name: string; display: string; color: ColorFunc; - customCommand?: string; }; const FRAMEWORKS: Framework[] = [ @@ -102,46 +92,217 @@ const FRAMEWORKS: Framework[] = [ }, ]; -const renameFiles: Record = { +const TEMPLATE_NAMES = FRAMEWORKS.flatMap((f) => f.variants.map((v) => v.name)); + +const renameFiles: Record = { _gitignore: '.gitignore', }; const defaultTargetDir = 'pp-project'; +const CLI_OPTIONS = { + template: { type: 'string', short: 't' }, + 'package-name': { type: 'string' }, + install: { type: 'boolean' }, + 'cursor-rules': { type: 'boolean' }, + 'pp-components': { type: 'boolean' }, + yes: { type: 'boolean', short: 'y' }, + help: { type: 'boolean', short: 'h' }, + version: { type: 'boolean', short: 'v' }, +} as const; + +const USAGE = `Usage: create-pp-dev [project-name] [options] + +Scaffolds a Metric Insights Portal Page project from one of the templates. +Without a flag the CLI asks for the value; with --yes it takes the default instead. + +Options: + -t, --template Template: ${TEMPLATE_NAMES.join(', ')} + --package-name package.json "name" (default: derived from the project name) + --install Install the packages after scaffolding (--no-install skips it) + --cursor-rules Copy the MI Cursor rules into .cursor/rules (--no-cursor-rules skips it) + --pp-components Add ${PP_COMPONENTS_PACKAGE} as a dependency (--no-pp-components skips it) + -y, --yes Ask nothing: keep the files of a non-empty directory, install the + packages, no Cursor rules, no pp-components; needs --template + -h, --help Show this help + -v, --version Show the version + +Examples: + npm create @metricinsights/pp-dev@latest my-pp -- --template react + npm create @metricinsights/pp-dev@latest my-pp -- --template nextjs --no-install -y +`; + +/** Prints the reason and exits with 1. Never returns. */ +function fail(message: string): never { + p.cancel(message); + process.exit(1); +} + +function parseCliArgs() { + try { + return parseArgs({ + args: process.argv.slice(2), + options: CLI_OPTIONS, + allowPositionals: true, + allowNegative: true, + strict: true, + }); + } catch (error) { + // node's message for an unknown option goes on to explain positionals after '--'; the first + // sentence says what matters. + const message = (error instanceof Error ? error.message : String(error)).replace(/\.\s+To specify .*$/s, ''); + + fail(`${message}. Run create-pp-dev --help for the available options.`); + } +} + +/** + * Runs a prompt, or fails when there is no terminal to show it on. `hint` names the flag that + * answers the question without a prompt. + */ +async function ask(hint: string, run: () => Promise): Promise> { + if (!canPrompt) { + fail(`Standard input is not a terminal, so there is nothing to ask: pass ${hint} or --yes (see --help).`); + } + + const result = await run(); + + if (p.isCancel(result)) { + p.cancel('Operation cancelled'); + process.exit(0); + } + + return result as Exclude; +} + +async function chooseTemplate(): Promise { + const framework = await ask('--template ', () => + p.select({ + message: 'Select a framework:', + options: FRAMEWORKS.map((f) => ({ + value: f, + label: f.color(f.display || f.name), + })), + }), + ); + + if (framework.variants.length === 1) { + return framework.variants[0].name; + } + + return ask('--template ', () => + p.select({ + message: 'Select a variant:', + options: framework.variants.map((v) => ({ + value: v.name, + label: v.color(v.display || v.name), + })), + }), + ); +} + +/** + * The newest published version of pp-components, read from the registry npm is configured with. + * A registry that is down, slow or answers something else is a warning, not a failure: the project + * is already on disk by now, and `^1.0.0` is a working floor. + */ +async function latestPpComponentsVersion(): Promise { + const registry = (process.env.npm_config_registry || 'https://registry.npmjs.org').replace(/\/+$/, ''); + const url = `${registry}/${PP_COMPONENTS_PACKAGE}`; + + try { + const response = await fetch(url, { signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS) }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const packument = (await response.json()) as { 'dist-tags'?: { latest?: unknown } }; + const latest = packument['dist-tags']?.latest; + + if (typeof latest !== 'string' || !latest) { + throw new Error('no "latest" dist-tag in the response'); + } + + return latest; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + + p.log.warn( + `Could not read the latest ${PP_COMPONENTS_PACKAGE} version from ${url} (${reason}); ` + + `using ^${PP_COMPONENTS_FALLBACK_VERSION}.`, + ); + + return PP_COMPONENTS_FALLBACK_VERSION; + } +} + async function init() { + const { values: argv, positionals } = parseCliArgs(); + + if (argv.help) { + console.log(USAGE); + + return; + } + + if (argv.version) { + const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8')) as { version: string }; + + console.log(pkg.version); + + return; + } + const argTargetDir = formatTargetDir(positionals[0]); - const argTemplate = stringArg(argv.template); - const argPackageName = stringArg(argv['package-name']); - const shouldInstallPackages = booleanArg(argv.install); - const shouldAddCursorRules = booleanArg(argv['cursor-rules']); - const shouldAddMiComponentsLibrary = booleanArg(argv['pp-components']); + const argTemplate = argv.template; + const argPackageName = argv['package-name']; + const shouldInstallPackages = argv.install; + const shouldAddCursorRules = argv['cursor-rules']; + const shouldAddMiComponentsLibrary = argv['pp-components']; + const assumeDefaults = argv.yes === true; let targetDir = argTargetDir ?? defaultTargetDir; - const getProjectName = () => (targetDir === '.' ? path.basename(path.resolve()) : targetDir); + // The directory's own name, whether the target was given as ".", a relative or an absolute path. + const getProjectName = () => path.basename(path.resolve(cwd, targetDir)); p.intro('Create PP Dev Project'); + // Everything the flags say is checked before anything is asked or written, so a typo can't end + // with an emptied directory and an error. + if (argTemplate !== undefined && !TEMPLATE_NAMES.includes(argTemplate)) { + fail(`Unknown template "${argTemplate}". Available templates: ${TEMPLATE_NAMES.join(', ')}.`); + } + + if (argPackageName !== undefined && !isValidPackageName(argPackageName)) { + fail(`Invalid package.json name "${argPackageName}".`); + } + + if (assumeDefaults && argTemplate === undefined) { + fail(`--yes needs --template . Available templates: ${TEMPLATE_NAMES.join(', ')}.`); + } + try { if (!argTargetDir) { - const projectName = await p.text({ - message: 'Project name:', - initialValue: targetDir, - validate: (value: string | undefined) => { - if (!value?.trim()) { - return 'Project name is required'; - } - - return undefined; - }, - }); - - if (p.isCancel(projectName)) { - p.cancel('Operation cancelled'); + if (assumeDefaults) { + targetDir = defaultTargetDir; + } else { + const projectName = await ask('the project name as the first argument', () => + p.text({ + message: 'Project name:', + initialValue: targetDir, + validate: (value) => { + if (!value?.trim()) { + return 'Project name is required'; + } + + return undefined; + }, + }), + ); - process.exit(0); + targetDir = formatTargetDir(projectName) ?? defaultTargetDir; } - - targetDir = formatTargetDir(projectName) ?? defaultTargetDir; } if (fs.existsSync(targetDir) && !isEmpty(targetDir)) { @@ -150,20 +311,18 @@ async function init() { ? 'Current directory is not empty. Please choose how to proceed:' : `Target directory "${targetDir}" is not empty. Please choose how to proceed:`; - const overwrite = await p.select({ - message, - options: [ - { value: 'yes', label: 'Remove existing files and continue' }, - { value: 'no', label: 'Cancel operation' }, - { value: 'ignore', label: 'Ignore files and continue' }, - ], - }); - - if (p.isCancel(overwrite)) { - p.cancel('Operation cancelled'); - - process.exit(0); - } + const overwrite = assumeDefaults + ? 'ignore' + : await ask('--yes (keeps the existing files)', () => + p.select<'yes' | 'no' | 'ignore'>({ + message, + options: [ + { value: 'yes', label: 'Remove existing files and continue' }, + { value: 'no', label: 'Cancel operation' }, + { value: 'ignore', label: 'Ignore files and continue' }, + ], + }), + ); if (overwrite === 'no') { p.cancel('Operation cancelled'); @@ -174,111 +333,42 @@ async function init() { if (overwrite === 'yes') { emptyDir(targetDir); } + + if (assumeDefaults) { + p.log.info(`Keeping the existing files in ${targetDir === '.' ? 'the current directory' : `"${targetDir}"`}`); + } } - const packageName = argPackageName - ? argPackageName - : await p.text({ + let packageName: string; + + if (argPackageName !== undefined) { + packageName = argPackageName; + } else if (assumeDefaults) { + packageName = toValidPackageName(getProjectName()); + } else { + packageName = await ask('--package-name ', () => + p.text({ message: 'Package name:', initialValue: toValidPackageName(getProjectName()), - validate: (value: string | undefined) => { + validate: (value) => { if (!value || !isValidPackageName(value)) { return 'Invalid package.json name'; } return undefined; }, - }); - - if (p.isCancel(packageName)) { - p.cancel('Operation cancelled'); - - process.exit(0); + }), + ); } if (!isValidPackageName(packageName)) { - p.cancel('Invalid package.json name'); - process.exit(1); + fail(`Invalid package.json name "${packageName}".`); } - let template: string | undefined = argTemplate; - - if (template) { - const isKnownTemplate = FRAMEWORKS.flatMap((f) => f.variants).some((v) => v.name === template); - - if (!isKnownTemplate) { - p.cancel(`Unknown template "${template}"`); - process.exit(1); - } - } else { - const frameworkResult = await p.select({ - message: 'Select a framework:', - options: FRAMEWORKS.map((f) => ({ - value: f, - label: f.color(f.display || f.name), - })), - }); - - if (p.isCancel(frameworkResult)) { - p.cancel('Operation cancelled'); - - process.exit(0); - } - - const framework = frameworkResult as Framework; - - if (framework.variants) { - const variantResult = await p.select({ - message: 'Select a variant:', - options: framework.variants.map((v: FrameworkVariant) => ({ - value: v.name, - label: v.color(v.display || v.name), - })), - }); - - if (p.isCancel(variantResult)) { - p.cancel('Operation cancelled'); - process.exit(0); - } - - template = variantResult as string; - } else { - template = framework.name; - } - } + const template = argTemplate ?? (await chooseTemplate()); const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent); const pkgManager = pkgInfo ? pkgInfo.name : 'npm'; - const isYarn1 = pkgManager === 'yarn' && pkgInfo?.version.startsWith('1.'); - - const { customCommand } = FRAMEWORKS.flatMap((f) => f.variants).find((v) => v.name === template) ?? {}; - - if (customCommand) { - const fullCustomCommand = customCommand - .replace(/^npm create /, () => { - return `${pkgManager} create `; - }) - .replace('@latest', () => (isYarn1 ? '' : '@latest')) - .replace(/^npm exec/, () => { - if (pkgManager === 'pnpm') { - return 'pnpm dlx'; - } - - if (pkgManager === 'yarn' && !isYarn1) { - return 'yarn dlx'; - } - - return 'npm exec'; - }); - - const [command, ...args] = fullCustomCommand.split(' '); - const replacedArgs = args.map((arg) => arg.replace('TARGET_DIR', targetDir)); - const { status } = spawn.sync(command, replacedArgs, { - stdio: 'inherit', - }); - - process.exit(status ?? 0); - } const root = path.resolve(cwd, targetDir); @@ -288,7 +378,7 @@ async function init() { p.log.step('Scaffolding project...'); - const templateDir = path.resolve(fileURLToPath(import.meta.url), '../..', `template-${template}`); + const templateDir = path.join(packageRoot, `template-${template}`); const write = (file: string, content?: string) => { const targetPath = path.join(root, renameFiles[file] ?? file); @@ -308,72 +398,84 @@ async function init() { const pkg = JSON.parse(fs.readFileSync(path.join(templateDir, 'package.json'), 'utf-8')); - pkg.name = packageName || getProjectName(); + pkg.name = packageName; write('package.json', JSON.stringify(pkg, null, 2) + '\n'); const addCursorRules = - typeof shouldAddCursorRules === 'boolean' - ? shouldAddCursorRules - : await p.confirm({ - message: 'Add Cursor rules?', - }); + shouldAddCursorRules ?? + (assumeDefaults + ? false + : await ask('--cursor-rules or --no-cursor-rules', () => + p.confirm({ + message: 'Add Cursor rules?', + }), + )); const cdProjectName = path.relative(cwd, root); if (addCursorRules) { - const ruleFiles = fs.readdirSync(path.resolve(fileURLToPath(import.meta.url), '../..', 'mi-cursor-rules')); + const rulesDir = path.join(packageRoot, 'mi-cursor-rules'); + const ruleFiles = fs.readdirSync(rulesDir); - mkdirSync(path.join(root, '.cursor/rules'), { recursive: true }); + fs.mkdirSync(path.join(root, '.cursor/rules'), { recursive: true }); for (const file of ruleFiles) { - copy( - path.resolve(fileURLToPath(import.meta.url), '../..', 'mi-cursor-rules', file), - path.join(root, '.cursor/rules', file), - ); + copy(path.join(rulesDir, file), path.join(root, '.cursor/rules', file)); } } const addMiComponentsLibrary = - typeof shouldAddMiComponentsLibrary === 'boolean' - ? shouldAddMiComponentsLibrary - : await p.confirm({ - message: `Add @metricinsights/pp-components ` + - `(https://www.npmjs.com/package/@metricinsights/pp-components) library?`, - }); + shouldAddMiComponentsLibrary ?? + (assumeDefaults + ? false + : await ask('--pp-components or --no-pp-components', () => + p.confirm({ + message: `Add ${PP_COMPONENTS_PACKAGE} (https://www.npmjs.com/package/${PP_COMPONENTS_PACKAGE}) library?`, + }), + )); if (addMiComponentsLibrary) { const pkgJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8')); - const latestVersion = await fetch('https://registry.npmjs.org/@metricinsights/pp-components').then((res) => - res.json(), - ); - - const version = latestVersion['dist-tags']?.latest ?? '1.0.0'; + const version = await latestPpComponentsVersion(); if (!pkgJson.dependencies) { pkgJson.dependencies = {}; } - pkgJson.dependencies['@metricinsights/pp-components'] = `^${version}`; + pkgJson.dependencies[PP_COMPONENTS_PACKAGE] = `^${version}`; write('package.json', JSON.stringify(pkgJson, null, 2) + '\n'); } const installPackages = - typeof shouldInstallPackages === 'boolean' - ? shouldInstallPackages - : await p.confirm({ - message: 'Install packages?', - }); + shouldInstallPackages ?? + (assumeDefaults + ? true + : await ask('--install or --no-install', () => + p.confirm({ + message: 'Install packages?', + }), + )); + + const installCommand = `${pkgManager} install`; if (installPackages) { - const installCommand = pkgManager === 'yarn' ? 'yarn install' : `${pkgManager} install`; const [cmd, ...args] = installCommand.split(' '); - - spawn.sync(cmd, args, { + const { status, error } = spawn.sync(cmd, args, { stdio: 'inherit', cwd: root, }); + + if (error || status !== 0) { + const reason = error ? error.message : `exit code ${status ?? 'unknown'}`; + const where = cdProjectName ? ` in ${cdProjectName}` : ''; + + fail( + `"${installCommand}" failed (${reason}). ` + + `The project files are in place; run "${installCommand}"${where} to retry.`, + ); + } } p.outro('Project created successfully! 🎉'); @@ -392,23 +494,11 @@ async function init() { } } - switch (pkgManager) { - case 'yarn': - if (!installPackages) { - console.log(' yarn install'); - } - - console.log(' yarn dev'); - break; - default: - if (!installPackages) { - console.log(` ${pkgManager} install`); - } - - console.log(` ${pkgManager} run dev`); - break; + if (!installPackages) { + console.log(` ${installCommand}`); } + console.log(pkgManager === 'yarn' ? ' yarn dev' : ` ${pkgManager} run dev`); console.log(); } catch (error) { p.cancel('Operation failed'); @@ -419,7 +509,7 @@ async function init() { } export function formatTargetDir(targetDir: string | undefined) { - return targetDir?.trim().replace(/\/+$/g, ''); + return targetDir?.trim().replace(/[\\/]+$/g, ''); } export function copy(src: string, dest: string) { @@ -492,4 +582,6 @@ export function pkgFromUserAgent(userAgent: string | undefined) { init().catch((e) => { console.error(e); + + process.exit(1); }); diff --git a/src/types/clack-prompts.d.ts b/src/types/clack-prompts.d.ts deleted file mode 100644 index 4f62d85..0000000 --- a/src/types/clack-prompts.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module '@clack/prompts'; From 9da48e52d202a70d002dda718c46707e21e36997 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 13:47:00 +0200 Subject: [PATCH 10/36] test: cover the CLI error paths, tag the offline tests, build before the suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new cases: --help, --version, unknown flag, no arguments without a terminal, unknown template and invalid --package-name against a non-empty directory (left untouched), --yes without --template, a non-empty directory without a terminal, "." as target, a scoped package name, --yes defaults, --pp-components against an unreachable registry (^1.0.0 and a warning), a failing npm install (exit 1, files kept) - the registry failures use npm_config_registry=http://127.0.0.1:9 with no fetch retries, so they need no network - tests that never reach the network carry the @offline tag: `npx playwright test --grep @offline` runs 22 of them in under 20 s - the dev-server test and the install test get a 300 s budget; the dev-server test waits for an HTTP answer instead of grepping stdout and asserts that the config placeholders it replaces still exist - one real `npm install` for the option flags instead of four: the cursor-rules × pp-components combinations run with --no-install - `pretest` runs unbuild, so `npm test` always tests the current source - CI retries a failed test once and keeps a trace of a failure Refs: PP-4793 --- package.json | 1 + playwright.config.mjs | 8 + test/scaffold.spec.mjs | 649 ++++++++++++++++++++++++++++++----------- 3 files changed, 483 insertions(+), 175 deletions(-) diff --git a/package.json b/package.json index a1563f4..52e387c 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "prepack": "node scripts/clean-templates.js", "postbuild": "npm pack && node scripts/postbuild.js", "typecheck": "tsc --noEmit", + "pretest": "unbuild", "test": "playwright test" }, "author": "Serhii Shpak", diff --git a/playwright.config.mjs b/playwright.config.mjs index ffe7d03..0d8cdc9 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -5,6 +5,14 @@ export default defineConfig({ testMatch: ['**/*.spec.mjs'], fullyParallel: true, workers: process.env.CI ? '100%' : '25%', + // The tests that need the npm registry can lose to a slow network once; a second try is cheap in + // CI and a flake there blocks every pull request. + retries: process.env.CI ? 1 : 0, reporter: 'list', + // Scaffolding is quick; the tests that install packages or start a dev server set their own + // budget with test.setTimeout(). timeout: 60_000, + use: { + trace: 'retain-on-failure', + }, }); diff --git a/test/scaffold.spec.mjs b/test/scaffold.spec.mjs index e6a29f9..c623b12 100644 --- a/test/scaffold.spec.mjs +++ b/test/scaffold.spec.mjs @@ -9,6 +9,25 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '..'); +const cliEntry = path.join(repoRoot, 'dist', 'index.mjs'); +const packageVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version; + +/** + * Tests tagged @offline never reach the network: `npx playwright test --grep @offline` is the quick + * local run. The others install packages from the registry and take minutes. + */ +const offline = { tag: '@offline' }; + +/** + * A registry nobody listens on. The CLI's pp-components lookup and `npm install` fail fast against + * it, without touching the network, so the failure paths can be tested offline. + */ +const unreachableRegistryEnv = { + npm_config_registry: 'http://127.0.0.1:9', + npm_config_fetch_retries: '0', + npm_config_fetch_retry_mintimeout: '0', + npm_config_fetch_retry_maxtimeout: '0', +}; /** * When tests run via `npm test`, npm sets `npm_execpath` to npm-cli.js. IDE / `npx playwright` @@ -44,33 +63,50 @@ function readGitignoreEntries(file) { .map((line) => line.replace(/^\//, '')); } -const templates = [ - { name: 'vanilla', checkFile: 'src/main.js' }, - { name: 'vanilla-ts', checkFile: 'src/main.ts' }, - { name: 'react', checkFile: 'src/main.tsx' }, - { name: 'nextjs', checkFile: 'src/pages/index.tsx' }, - { name: 'nextjs-esm', checkFile: 'src/pages/index.tsx' }, -]; +function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} -function scaffoldProject({ targetDir, template, packageName, install, cursorRules, ppComponents }) { - const args = ['dist/index.mjs', targetDir, '--template', template, '--package-name', packageName]; +/** Runs `fn` with a fresh temporary directory and removes it afterwards. */ +async function withTmpDir(prefix, fn) { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), `create-pp-dev-${prefix}-`)); - args.push(install ? '--install' : '--no-install'); - args.push(cursorRules ? '--cursor-rules' : '--no-cursor-rules'); - args.push(ppComponents ? '--pp-components' : '--no-pp-components'); + try { + return await fn(tmpRoot); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } +} - return spawnSync(process.execPath, args, { - cwd: repoRoot, +/** Runs the built CLI. stdin is a pipe, as in CI, so every answer has to come from a flag. */ +function runCli(args, { cwd = repoRoot, env = {} } = {}) { + return spawnSync(process.execPath, [cliEntry, ...args], { + cwd, encoding: 'utf8', + env: { ...process.env, ...env }, }); } +function optionFlags({ install, cursorRules, ppComponents }) { + return [ + install ? '--install' : '--no-install', + cursorRules ? '--cursor-rules' : '--no-cursor-rules', + ppComponents ? '--pp-components' : '--no-pp-components', + ]; +} + +function scaffoldProject({ targetDir, template, packageName, cwd, env, ...options }) { + const args = [targetDir, '--template', template, '--package-name', packageName, ...optionFlags(options)]; + + return runCli(args, { cwd, env }); +} + /** * Same CLI entry as `npm create @metricinsights/pp-dev` (npm wires create-* to this package). * Uses `npm exec --package=file:…` because `npm create …@file:` is not supported for scoped * initializers on some npm versions. */ -function runNpmCreateFromLocalPackage({ targetDir, template, packageName, install, cursorRules, ppComponents }) { +function runNpmCreateFromLocalPackage({ targetDir, template, packageName, ...options }) { const pkgUrl = pathToFileURL(repoRoot).href; const args = [ 'exec', @@ -83,9 +119,7 @@ function runNpmCreateFromLocalPackage({ targetDir, template, packageName, instal template, '--package-name', packageName, - install ? '--install' : '--no-install', - cursorRules ? '--cursor-rules' : '--no-cursor-rules', - ppComponents ? '--pp-components' : '--no-pp-components', + ...optionFlags(options), ]; return runNpmSync(args, { @@ -94,46 +128,65 @@ function runNpmCreateFromLocalPackage({ targetDir, template, packageName, instal }); } -function waitForDevServerReady(child, timeoutMs = 60_000) { +/** + * Resolves once the dev server answers HTTP on `url` (any status counts), rejects when the child + * exits first or the budget runs out. The server's output is kept for the error message. + */ +function waitForHttp(child, url, timeoutMs) { return new Promise((resolve, reject) => { - let stdout = ''; - let stderr = ''; + let output = ''; + let exited = false; + let finished = false; + + const onOutput = (chunk) => { + output += chunk.toString(); + }; + + const finish = (error) => { + if (finished) { + return; + } - const done = (error) => { + finished = true; clearTimeout(timer); - child.stdout?.off('data', onStdout); - child.stderr?.off('data', onStderr); + clearInterval(poll); + child.stdout?.off('data', onOutput); + child.stderr?.off('data', onOutput); child.off('exit', onExit); if (error) { reject(error); } else { - resolve({ stdout, stderr }); + resolve(output); } }; - const onStdout = (chunk) => { - stdout += chunk.toString(); - - if (/local:|localhost|http:\/\/|https:\/\//i.test(stdout)) { - done(); - } - }; - - const onStderr = (chunk) => { - stderr += chunk.toString(); - }; - const onExit = (code) => { - done(new Error(`dev server exited early with code ${code}\nstdout:${stdout}\nstderr:${stderr}`)); + exited = true; + finish(new Error(`dev server exited early with code ${code}\noutput:\n${output}`)); }; - child.stdout?.on('data', onStdout); - child.stderr?.on('data', onStderr); + child.stdout?.on('data', onOutput); + child.stderr?.on('data', onOutput); child.on('exit', onExit); + const poll = setInterval(async () => { + if (exited || finished) { + return; + } + + try { + const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(2_000) }); + + await response.arrayBuffer(); + finish(); + } catch { + // not listening yet + } + }, 500); + const timer = setTimeout(() => { - done(new Error(`timed out waiting for dev server\nstdout:${stdout}\nstderr:${stderr}`)); + finish(new Error(`timed out waiting for the dev server at ${url}\noutput:\n${output}`)); }, timeoutMs); }); } @@ -152,181 +205,427 @@ function stopProcessTree(pid) { } } -for (const template of templates) { - test(`scaffolds ${template.name} project in non-interactive mode`, async () => { - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'create-pp-dev-e2e-')); - const packageName = `sample-${template.name}`; - const targetDir = path.join(tmpRoot, packageName); +const templates = [ + { name: 'vanilla', checkFile: 'src/main.js' }, + { name: 'vanilla-ts', checkFile: 'src/main.ts' }, + { name: 'react', checkFile: 'src/main.tsx' }, + { name: 'nextjs', checkFile: 'src/pages/index.tsx' }, + { name: 'nextjs-esm', checkFile: 'src/pages/index.tsx' }, +]; - const run = scaffoldProject({ - targetDir, - template: template.name, - packageName, - install: false, - cursorRules: false, - ppComponents: false, +const templateNames = templates.map((t) => t.name); + +test.describe('usage and version', () => { + test('--help prints the usage with every flag and template and exits 0', offline, async () => { + const run = runCli(['--help']); + + expect(run.status, run.stderr).toBe(0); + expect(run.stdout).toContain('Usage: create-pp-dev'); + + for (const flag of [ + '--template', + '--package-name', + '--install', + '--cursor-rules', + '--pp-components', + '--yes', + '--help', + '--version', + ]) { + expect(run.stdout, `usage lacks ${flag}`).toContain(flag); + } + + for (const name of templateNames) { + expect(run.stdout, `usage lacks the ${name} template`).toContain(name); + } + }); + + test('--version prints the package version and exits 0', offline, async () => { + const run = runCli(['--version']); + + expect(run.status, run.stderr).toBe(0); + expect(run.stdout.trim()).toBe(packageVersion); + }); + + test('an unknown flag exits 1 with a hint and creates nothing', offline, async () => { + await withTmpDir('unknown-flag', async (tmpRoot) => { + const run = runCli(['proj', '--template', 'vanilla', '--bogus-flag'], { cwd: tmpRoot }); + + expect(run.status).toBe(1); + expect(run.stdout + run.stderr).toContain('--bogus-flag'); + expect(run.stdout + run.stderr).toContain('--help'); + expect(fs.readdirSync(tmpRoot)).toEqual([]); }); + }); - try { - expect(run.status, `stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(0); + test('without a terminal and without arguments it exits 1 instead of prompting', offline, async () => { + await withTmpDir('no-tty', async (tmpRoot) => { + const run = runCli([], { cwd: tmpRoot }); - const packageJsonPath = path.join(targetDir, 'package.json'); - const templateSpecificFile = path.join(targetDir, template.checkFile); + expect(run.status).toBe(1); + expect(run.stdout + run.stderr).toContain('--yes'); + expect(fs.readdirSync(tmpRoot)).toEqual([]); + }); + }); +}); - expect(fs.existsSync(packageJsonPath)).toBeTruthy(); - expect(fs.existsSync(templateSpecificFile)).toBeTruthy(); - expect(fs.existsSync(path.join(targetDir, '.cursor'))).toBeFalsy(); +test.describe('argument validation happens before anything is written', () => { + const existingFile = 'keep-me.txt'; - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + function nonEmptyTarget(tmpRoot) { + const targetDir = path.join(tmpRoot, 'existing'); - expect(packageJson.name).toBe(packageName); - expect(packageJson.dependencies?.['@metricinsights/pp-components']).toBeUndefined(); + fs.mkdirSync(targetDir); + fs.writeFileSync(path.join(targetDir, existingFile), 'do not delete'); - // _gitignore is renamed on copy; the result must keep the pp-dev token file (.env, read by - // pp-dev through Vite's loadEnv) and pp-dev's build output and MI backups out of git. - expect(fs.existsSync(path.join(targetDir, '_gitignore'))).toBeFalsy(); + return targetDir; + } - const gitignoreEntries = readGitignoreEntries(path.join(targetDir, '.gitignore')); + test('an unknown template exits 1 and leaves a non-empty directory untouched', offline, async () => { + await withTmpDir('bad-template', async (tmpRoot) => { + const targetDir = nonEmptyTarget(tmpRoot); + const run = runCli([targetDir, '--template', 'bogus', '--package-name', 'existing', '-y']); - for (const entry of ['.env', '.env.*', 'dist', 'dist-zip', 'backups']) { - expect(gitignoreEntries, `.gitignore of ${template.name} lacks "${entry}"`).toContain(entry); - } - } finally { - fs.rmSync(tmpRoot, { recursive: true, force: true }); - } + expect(run.status).toBe(1); + expect(run.stdout + run.stderr).toContain('Unknown template "bogus"'); + expect(fs.readdirSync(targetDir)).toEqual([existingFile]); + }); }); -} -for (const template of templates) { - test(`npm create (npm exec) + npm install for ${template.name} template`, async () => { - test.setTimeout(300_000); + test('an invalid --package-name exits 1 and leaves a non-empty directory untouched', offline, async () => { + await withTmpDir('bad-name', async (tmpRoot) => { + const targetDir = nonEmptyTarget(tmpRoot); + const run = runCli([targetDir, '--template', 'vanilla', '--package-name', 'Bad Name', '-y']); - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'create-pp-dev-npm-flow-')); - const packageName = `npm-flow-${template.name}`; - const targetDir = path.join(tmpRoot, packageName); + expect(run.status).toBe(1); + expect(run.stdout + run.stderr).toContain('Invalid package.json name'); + expect(fs.readdirSync(targetDir)).toEqual([existingFile]); + }); + }); - const run = runNpmCreateFromLocalPackage({ - targetDir, - template: template.name, - packageName, - install: false, - cursorRules: false, - ppComponents: false, + test('--yes without --template exits 1 and names the flag', offline, async () => { + await withTmpDir('yes-no-template', async (tmpRoot) => { + const run = runCli(['proj', '-y', '--no-install'], { cwd: tmpRoot }); + + expect(run.status).toBe(1); + expect(run.stdout + run.stderr).toContain('--template'); + expect(fs.readdirSync(tmpRoot)).toEqual([]); }); + }); - try { - expect(run.status, `create stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(0); + test('a non-empty directory without a terminal exits 1 and keeps its files', offline, async () => { + await withTmpDir('non-empty-no-tty', async (tmpRoot) => { + const targetDir = nonEmptyTarget(tmpRoot); + const run = runCli([targetDir, '--template', 'vanilla', '--package-name', 'existing', '--no-install']); - const templateSpecificFile = path.join(targetDir, template.checkFile); + expect(run.status).toBe(1); + expect(run.stdout + run.stderr).toContain('--yes'); + expect(fs.readdirSync(targetDir)).toEqual([existingFile]); + }); + }); +}); - expect(fs.existsSync(templateSpecificFile)).toBeTruthy(); +test.describe('scaffolding', () => { + for (const template of templates) { + test(`scaffolds ${template.name} project in non-interactive mode`, offline, async () => { + await withTmpDir('e2e', async (tmpRoot) => { + const packageName = `sample-${template.name}`; + const targetDir = path.join(tmpRoot, packageName); - const installRun = runNpmSync(['install', '--no-audit', '--fund=false'], { - cwd: targetDir, - encoding: 'utf8', + const run = scaffoldProject({ + targetDir, + template: template.name, + packageName, + install: false, + cursorRules: false, + ppComponents: false, + }); + + expect(run.status, `stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(0); + + const packageJsonPath = path.join(targetDir, 'package.json'); + const templateSpecificFile = path.join(targetDir, template.checkFile); + + expect(fs.existsSync(packageJsonPath)).toBeTruthy(); + expect(fs.existsSync(templateSpecificFile)).toBeTruthy(); + expect(fs.existsSync(path.join(targetDir, '.cursor'))).toBeFalsy(); + + const packageJson = readJson(packageJsonPath); + + expect(packageJson.name).toBe(packageName); + expect(packageJson.dependencies?.['@metricinsights/pp-components']).toBeUndefined(); + + // _gitignore is renamed on copy; the result must keep the pp-dev token file (.env, read by + // pp-dev through Vite's loadEnv) and pp-dev's build output and MI backups out of git. + expect(fs.existsSync(path.join(targetDir, '_gitignore'))).toBeFalsy(); + + const gitignoreEntries = readGitignoreEntries(path.join(targetDir, '.gitignore')); + + for (const entry of ['.env', '.env.*', 'dist', 'dist-zip', 'backups']) { + expect(gitignoreEntries, `.gitignore of ${template.name} lacks "${entry}"`).toContain(entry); + } }); + }); + } - expect(installRun.status, `npm install stderr: ${installRun.stderr}\nstdout: ${installRun.stdout}`).toBe(0); - expect(fs.existsSync(path.join(targetDir, 'node_modules'))).toBeTruthy(); - } finally { - fs.rmSync(tmpRoot, { recursive: true, force: true }); - } - }); -} + test('scaffolds into the current directory with "." and names it after the directory', offline, async () => { + await withTmpDir('dot', async (tmpRoot) => { + const projectDir = path.join(tmpRoot, 'my-portal-page'); -const optionCombinations = [true, false].flatMap((install) => - [true, false].flatMap((cursorRules) => [true, false].map((ppComponents) => ({ install, cursorRules, ppComponents }))), -); - -for (const options of optionCombinations) { - const testTitle = [ - 'scaffolds vanilla with options', - `install=${options.install},`, - `cursorRules=${options.cursorRules},`, - `ppComponents=${options.ppComponents}`, - ].join(' '); - - test(testTitle, async () => { - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'create-pp-dev-options-')); - const packageName = - `sample-options-${options.install ? 'i1' : 'i0'}-` + - `${options.cursorRules ? 'c1' : 'c0'}-` + - `${options.ppComponents ? 'p1' : 'p0'}`; - const targetDir = path.join(tmpRoot, packageName); + fs.mkdirSync(projectDir); - const run = scaffoldProject({ - targetDir, - template: 'vanilla', - packageName, - ...options, + const run = runCli(['.', '--template', 'vanilla-ts', '-y', '--no-install'], { cwd: projectDir }); + + expect(run.status, `stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(0); + expect(fs.existsSync(path.join(projectDir, 'src/main.ts'))).toBeTruthy(); + expect(readJson(path.join(projectDir, 'package.json')).name).toBe('my-portal-page'); + // no `cd` into a directory the user is already in + expect(run.stdout).not.toMatch(/^\s+cd /m); }); + }); + + test('keeps a scoped --package-name as given', offline, async () => { + await withTmpDir('scoped', async (tmpRoot) => { + const targetDir = path.join(tmpRoot, 'scoped'); + const run = scaffoldProject({ + targetDir, + template: 'vanilla', + packageName: '@my-org/portal-page', + install: false, + cursorRules: false, + ppComponents: false, + }); - try { expect(run.status, `stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(0); + expect(readJson(path.join(targetDir, 'package.json')).name).toBe('@my-org/portal-page'); + }); + }); - const packageJsonPath = path.join(targetDir, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + test('--yes keeps the files of a non-empty directory and takes the defaults', offline, async () => { + await withTmpDir('yes-defaults', async (tmpRoot) => { + const targetDir = path.join(tmpRoot, 'existing'); - expect(packageJson.name).toBe(packageName); - expect(fs.existsSync(path.join(targetDir, '.cursor'))).toBe(options.cursorRules); - expect(Boolean(packageJson.dependencies?.['@metricinsights/pp-components'])).toBe(options.ppComponents); + fs.mkdirSync(targetDir); + fs.writeFileSync(path.join(targetDir, 'notes.md'), 'kept'); - if (options.ppComponents) { - expect(packageJson.dependencies['@metricinsights/pp-components']).toMatch(/^\^[1-9]\d*\./); - } + // --no-install: the default of --yes is to install, which needs the registry + const run = runCli([targetDir, '--template', 'react', '-y', '--no-install']); - expect(fs.existsSync(path.join(targetDir, 'node_modules'))).toBe(options.install); - } finally { - fs.rmSync(tmpRoot, { recursive: true, force: true }); - } - }); -} + expect(run.status, `stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(0); + expect(fs.readFileSync(path.join(targetDir, 'notes.md'), 'utf8')).toBe('kept'); + expect(fs.existsSync(path.join(targetDir, 'src/main.tsx'))).toBeTruthy(); -test('runs dev server for scaffolded vanilla project', async () => { - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'create-pp-dev-devserver-')); - const packageName = 'sample-vanilla-server'; - const targetDir = path.join(tmpRoot, packageName); + const packageJson = readJson(path.join(targetDir, 'package.json')); - const scaffold = scaffoldProject({ - targetDir, - template: 'vanilla', - packageName, - install: false, - cursorRules: false, - ppComponents: false, + expect(packageJson.name).toBe('existing'); + expect(packageJson.dependencies?.['@metricinsights/pp-components']).toBeUndefined(); + expect(fs.existsSync(path.join(targetDir, '.cursor'))).toBeFalsy(); + expect(fs.existsSync(path.join(targetDir, 'node_modules'))).toBeFalsy(); + }); }); - expect(scaffold.status, `stderr: ${scaffold.stderr}\nstdout: ${scaffold.stdout}`).toBe(0); + const optionCombinations = [true, false].flatMap((cursorRules) => + [true, false].map((ppComponents) => ({ cursorRules, ppComponents })), + ); - // pp-dev 1.0 requires mi.url whenever mi.mode is "embedding" or app.type is "template" — fill in - // the placeholder the template ships commented out, same as a user would before their first `dev`. - const configPath = path.join(targetDir, 'pp-dev.config.js'); - const configSource = fs.readFileSync(configPath, 'utf8'); + for (const options of optionCombinations) { + const testTitle = + `scaffolds vanilla with options cursorRules=${options.cursorRules}, ` + `ppComponents=${options.ppComponents}`; + + test(testTitle, offline, async () => { + await withTmpDir('options', async (tmpRoot) => { + const packageName = `sample-options-${options.cursorRules ? 'c1' : 'c0'}-${options.ppComponents ? 'p1' : 'p0'}`; + const targetDir = path.join(tmpRoot, packageName); + + const run = scaffoldProject({ + targetDir, + template: 'vanilla', + packageName, + install: false, + ...options, + // pp-components is resolved from the registry; an unreachable one still yields a project + env: options.ppComponents ? unreachableRegistryEnv : {}, + }); + + expect(run.status, `stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(0); + + const packageJson = readJson(path.join(targetDir, 'package.json')); + + expect(packageJson.name).toBe(packageName); + expect(fs.existsSync(path.join(targetDir, '.cursor'))).toBe(options.cursorRules); + expect(Boolean(packageJson.dependencies?.['@metricinsights/pp-components'])).toBe(options.ppComponents); + expect(fs.existsSync(path.join(targetDir, 'node_modules'))).toBeFalsy(); + + if (options.cursorRules) { + expect(fs.readdirSync(path.join(targetDir, '.cursor/rules')).length).toBeGreaterThan(0); + } + }); + }); + } - fs.writeFileSync( - configPath, - configSource - .replace("// url: 'https://example.metricinsights.com',", "url: 'https://example.metricinsights.com',") - .replace('// id: 1,', 'id: 1,'), - ); + test('--pp-components falls back to ^1.0.0 with a warning when the registry is unreachable', offline, async () => { + await withTmpDir('registry-down', async (tmpRoot) => { + const targetDir = path.join(tmpRoot, 'offline-components'); + const run = scaffoldProject({ + targetDir, + template: 'vanilla', + packageName: 'offline-components', + install: false, + cursorRules: false, + ppComponents: true, + env: unreachableRegistryEnv, + }); - const install = runNpmSync(['install', '--no-audit', '--fund=false'], { - cwd: targetDir, - encoding: 'utf8', + expect(run.status, `stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(0); + expect(readJson(path.join(targetDir, 'package.json')).dependencies['@metricinsights/pp-components']).toBe( + '^1.0.0', + ); + expect(run.stdout + run.stderr).toContain('127.0.0.1:9'); + expect(run.stdout).toContain('Project created successfully'); + }); }); +}); - expect(install.error, `install spawn error: ${install.error?.message ?? 'none'}`).toBeUndefined(); - expect(install.status, `stderr: ${install.stderr}\nstdout: ${install.stdout}`).toBe(0); +test.describe('package installation', () => { + test('a failing install exits 1, keeps the files and says how to retry', offline, async () => { + test.setTimeout(120_000); + + await withTmpDir('install-fails', async (tmpRoot) => { + const targetDir = path.join(tmpRoot, 'install-fails'); + const run = scaffoldProject({ + targetDir, + template: 'vanilla', + packageName: 'install-fails', + install: true, + cursorRules: false, + ppComponents: false, + env: unreachableRegistryEnv, + }); - const devServer = runNpm(['run', 'dev', '--', '--host', '127.0.0.1', '--port', '4173'], { - cwd: targetDir, - stdio: ['ignore', 'pipe', 'pipe'], + expect(run.status, `stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(1); + expect(run.stdout + run.stderr).toContain('"npm install" failed'); + expect(run.stdout + run.stderr).toContain('to retry'); + expect(run.stdout).not.toContain('Project created successfully'); + expect(fs.existsSync(path.join(targetDir, 'package.json'))).toBeTruthy(); + expect(fs.existsSync(path.join(targetDir, 'src/main.js'))).toBeTruthy(); + }); }); - try { - await waitForDevServerReady(devServer); - } finally { - stopProcessTree(devServer.pid); - fs.rmSync(tmpRoot, { recursive: true, force: true }); + test('--install installs the packages of a scaffolded vanilla project', async () => { + test.setTimeout(300_000); + + await withTmpDir('install', async (tmpRoot) => { + const targetDir = path.join(tmpRoot, 'installed'); + const run = scaffoldProject({ + targetDir, + template: 'vanilla', + packageName: 'installed', + install: true, + cursorRules: true, + ppComponents: true, + }); + + expect(run.status, `stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(0); + expect(fs.existsSync(path.join(targetDir, 'node_modules/@metricinsights/pp-dev/package.json'))).toBeTruthy(); + expect( + fs.existsSync(path.join(targetDir, 'node_modules/@metricinsights/pp-components/package.json')), + ).toBeTruthy(); + expect(readJson(path.join(targetDir, 'package.json')).dependencies['@metricinsights/pp-components']).toMatch( + /^\^[1-9]\d*\./, + ); + expect(fs.existsSync(path.join(targetDir, '.cursor/rules'))).toBeTruthy(); + }); + }); +}); + +test.describe('npm create flow', () => { + for (const template of templates) { + test(`npm create (npm exec) + npm install for ${template.name} template`, async () => { + test.setTimeout(300_000); + + await withTmpDir('npm-flow', async (tmpRoot) => { + const packageName = `npm-flow-${template.name}`; + const targetDir = path.join(tmpRoot, packageName); + + const run = runNpmCreateFromLocalPackage({ + targetDir, + template: template.name, + packageName, + install: false, + cursorRules: false, + ppComponents: false, + }); + + expect(run.status, `create stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(0); + + const templateSpecificFile = path.join(targetDir, template.checkFile); + + expect(fs.existsSync(templateSpecificFile)).toBeTruthy(); + + const installRun = runNpmSync(['install', '--no-audit', '--fund=false'], { + cwd: targetDir, + encoding: 'utf8', + }); + + expect(installRun.status, `npm install stderr: ${installRun.stderr}\nstdout: ${installRun.stdout}`).toBe(0); + expect(fs.existsSync(path.join(targetDir, 'node_modules'))).toBeTruthy(); + }); + }); } }); + +test('runs dev server for scaffolded vanilla project', async () => { + // scaffold + npm install + server start; the install alone takes minutes on a slow registry + test.setTimeout(300_000); + + await withTmpDir('devserver', async (tmpRoot) => { + const packageName = 'sample-vanilla-server'; + const targetDir = path.join(tmpRoot, packageName); + const port = 4173; + + const scaffold = scaffoldProject({ + targetDir, + template: 'vanilla', + packageName, + install: false, + cursorRules: false, + ppComponents: false, + }); + + expect(scaffold.status, `stderr: ${scaffold.stderr}\nstdout: ${scaffold.stdout}`).toBe(0); + + // pp-dev requires mi.url whenever mi.mode is "embedding" or app.type is "template" — fill in the + // placeholders the template ships commented out, same as a user would before their first `dev`. + const configPath = path.join(targetDir, 'pp-dev.config.js'); + const configSource = fs.readFileSync(configPath, 'utf8'); + const placeholders = ["// url: 'https://example.metricinsights.com',", '// id: 1,']; + + for (const placeholder of placeholders) { + expect(configSource, `the vanilla pp-dev.config.js no longer contains ${placeholder}`).toContain(placeholder); + } + + fs.writeFileSync( + configPath, + placeholders.reduce((source, placeholder) => source.replace(placeholder, placeholder.slice(3)), configSource), + ); + + const install = runNpmSync(['install', '--no-audit', '--fund=false'], { + cwd: targetDir, + encoding: 'utf8', + }); + + expect(install.error, `install spawn error: ${install.error?.message ?? 'none'}`).toBeUndefined(); + expect(install.status, `stderr: ${install.stderr}\nstdout: ${install.stdout}`).toBe(0); + + const devServer = runNpm(['run', 'dev', '--', '--host', '127.0.0.1', '--port', String(port)], { + cwd: targetDir, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + try { + await waitForHttp(devServer, `http://127.0.0.1:${port}/`, 60_000); + } finally { + stopProcessTree(devServer.pid); + } + }); +}); From 2c01ce3cb316a0a5fa2c8a12cbe94924def6b249 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 14:11:40 +0200 Subject: [PATCH 11/36] fix(cli): preflight every missing flag, --yes keeps files for real, roots keep their separator Three review findings on the previous commit: - Without a terminal, a missing --cursor-rules, --pp-components or --install flag was noticed only when its prompt came up, after the scaffold was on disk; a retry with the flag then met a non-empty directory. Every flag that would be asked for is now checked up front and the message names all of them. A non-empty target without a terminal says which directory and what to do about it. - --yes "kept" the files of a non-empty directory but still overwrote any that the template also has (package.json, .gitignore, src/...). With nobody to ask, a collision with a top-level template entry now stops the run before anything is written and lists the files. - formatTargetDir stripped the trailing separator from a filesystem root: "C:\" became the drive-relative "C:", which resolves to the current directory. Roots keep their separator; an empty target falls back to the default name. Refs: PP-4793 --- src/index.ts | 85 +++++++++++++++++++++++++++++++++++++----- test/scaffold.spec.mjs | 78 +++++++++++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index b3a2ada..9029634 100644 --- a/src/index.ts +++ b/src/index.ts @@ -122,8 +122,9 @@ Options: --install Install the packages after scaffolding (--no-install skips it) --cursor-rules Copy the MI Cursor rules into .cursor/rules (--no-cursor-rules skips it) --pp-components Add ${PP_COMPONENTS_PACKAGE} as a dependency (--no-pp-components skips it) - -y, --yes Ask nothing: keep the files of a non-empty directory, install the - packages, no Cursor rules, no pp-components; needs --template + -y, --yes Ask nothing: install the packages, no Cursor rules, no pp-components; + a non-empty directory is accepted when no file would be overwritten; + needs --template -h, --help Show this help -v, --version Show the version @@ -282,6 +283,43 @@ async function init() { fail(`--yes needs --template . Available templates: ${TEMPLATE_NAMES.join(', ')}.`); } + // Without a terminal nothing can be asked later either: name every missing flag now, before + // anything is written, instead of failing on the first one after the scaffold is on disk. + if (!canPrompt && !assumeDefaults) { + const missing: string[] = []; + + if (argTargetDir === undefined) { + missing.push('the project name as the first argument'); + } + + if (argTemplate === undefined) { + missing.push('--template '); + } + + if (argPackageName === undefined) { + missing.push('--package-name '); + } + + if (shouldAddCursorRules === undefined) { + missing.push('--cursor-rules or --no-cursor-rules'); + } + + if (shouldAddMiComponentsLibrary === undefined) { + missing.push('--pp-components or --no-pp-components'); + } + + if (shouldInstallPackages === undefined) { + missing.push('--install or --no-install'); + } + + if (missing.length > 0) { + fail( + `Standard input is not a terminal, so there is nothing to ask: pass ${missing.join(', ')} ` + + 'or --yes (see --help).', + ); + } + } + try { if (!argTargetDir) { if (assumeDefaults) { @@ -306,16 +344,20 @@ async function init() { } if (fs.existsSync(targetDir) && !isEmpty(targetDir)) { - const message = - targetDir === '.' - ? 'Current directory is not empty. Please choose how to proceed:' - : `Target directory "${targetDir}" is not empty. Please choose how to proceed:`; + const where = targetDir === '.' ? 'The current directory' : `Target directory "${targetDir}"`; + + if (!canPrompt && !assumeDefaults) { + fail( + `${where} is not empty and there is no terminal to ask what to do with its files: ` + + 'use an empty directory, or pass --yes to keep them (refused if the template would overwrite one).', + ); + } const overwrite = assumeDefaults ? 'ignore' - : await ask('--yes (keeps the existing files)', () => + : await ask('--yes', () => p.select<'yes' | 'no' | 'ignore'>({ - message, + message: `${where} is not empty. Please choose how to proceed:`, options: [ { value: 'yes', label: 'Remove existing files and continue' }, { value: 'no', label: 'Cancel operation' }, @@ -335,6 +377,21 @@ async function init() { } if (assumeDefaults) { + // "Keep the files" has to mean it: with nobody to ask, a template file that would land on an + // existing one stops the run before anything is written. + const templateDir = path.join(packageRoot, `template-${argTemplate}`); + const collisions = fs + .readdirSync(templateDir) + .map((file) => renameFiles[file] ?? file) + .filter((name) => fs.existsSync(path.join(targetDir, name))); + + if (collisions.length > 0) { + fail( + `${where} already contains ${collisions.join(', ')}, which the ${argTemplate} template would ` + + 'overwrite. Use an empty directory, or run without --yes to choose.', + ); + } + p.log.info(`Keeping the existing files in ${targetDir === '.' ? 'the current directory' : `"${targetDir}"`}`); } } @@ -509,7 +566,17 @@ async function init() { } export function formatTargetDir(targetDir: string | undefined) { - return targetDir?.trim().replace(/[\\/]+$/g, ''); + const trimmed = targetDir?.trim(); + + if (!trimmed) { + return undefined; + } + + // Trailing separators go ("my-pp/" -> "my-pp"), but a filesystem root stays what it is: "C:\" + // without its backslash is a drive-relative path, "/" without its slash is nothing at all. + const stripped = trimmed.replace(/[\\/]+$/, ''); + + return stripped === '' || /^[A-Za-z]:$/.test(stripped) ? trimmed : stripped; } export function copy(src: string, dest: string) { diff --git a/test/scaffold.spec.mjs b/test/scaffold.spec.mjs index c623b12..521f3d9 100644 --- a/test/scaffold.spec.mjs +++ b/test/scaffold.spec.mjs @@ -316,13 +316,89 @@ test.describe('argument validation happens before anything is written', () => { test('a non-empty directory without a terminal exits 1 and keeps its files', offline, async () => { await withTmpDir('non-empty-no-tty', async (tmpRoot) => { const targetDir = nonEmptyTarget(tmpRoot); - const run = runCli([targetDir, '--template', 'vanilla', '--package-name', 'existing', '--no-install']); + const run = runCli([ + targetDir, + '--template', + 'vanilla', + '--package-name', + 'existing', + '--no-install', + '--no-cursor-rules', + '--no-pp-components', + ]); expect(run.status).toBe(1); + expect(run.stdout + run.stderr).toContain(`"${targetDir}" is not empty`); expect(run.stdout + run.stderr).toContain('--yes'); expect(fs.readdirSync(targetDir)).toEqual([existingFile]); }); }); + + test('without a terminal every missing flag is named before anything is written', offline, async () => { + await withTmpDir('missing-flags', async (tmpRoot) => { + const run = runCli(['proj', '--template', 'vanilla', '--package-name', 'proj', '--no-install'], { cwd: tmpRoot }); + + expect(run.status).toBe(1); + expect(run.stdout + run.stderr).toContain('--cursor-rules'); + expect(run.stdout + run.stderr).toContain('--pp-components'); + expect(fs.readdirSync(tmpRoot)).toEqual([]); + }); + }); + + test('--yes refuses a directory where the template would overwrite a file', offline, async () => { + await withTmpDir('yes-collision', async (tmpRoot) => { + const targetDir = nonEmptyTarget(tmpRoot); + + fs.writeFileSync(path.join(targetDir, 'package.json'), '{}'); + + const run = runCli([targetDir, '--template', 'vanilla', '-y', '--no-install']); + + expect(run.status).toBe(1); + expect(run.stdout + run.stderr).toContain('package.json'); + expect(fs.readdirSync(targetDir).sort()).toEqual([existingFile, 'package.json']); + expect(fs.readFileSync(path.join(targetDir, 'package.json'), 'utf8')).toBe('{}'); + }); + }); + + test('a filesystem root as target keeps its trailing separator', offline, async () => { + // "C:\\" stripped to "C:" would be a drive-relative path and resolve to the current directory + const root = path.parse(os.tmpdir()).root; + const run = runCli([ + root, + '--template', + 'vanilla', + '--package-name', + 'root', + '--no-install', + '--no-cursor-rules', + '--no-pp-components', + ]); + + expect(run.status).toBe(1); + expect(run.stdout + run.stderr).toContain(`"${root}" is not empty`); + }); + + test('a trailing path separator on the target is stripped', offline, async () => { + await withTmpDir('trailing-sep', async (tmpRoot) => { + const targetDir = path.join(tmpRoot, 'sub'); + const run = scaffoldProject({ + targetDir: targetDir + path.sep, + template: 'vanilla', + packageName: 'sub', + install: false, + cursorRules: false, + ppComponents: false, + }); + + expect( + run.status, + `stderr: ${run.stderr} +stdout: ${run.stdout}`, + ).toBe(0); + expect(readJson(path.join(targetDir, 'package.json')).name).toBe('sub'); + expect(run.stdout).toContain(`cd ${path.relative(repoRoot, targetDir)}`); + }); + }); }); test.describe('scaffolding', () => { From 8ee89b9f2543f7cef4293376e06b1e8bdfbf3ba5 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 14:06:02 +0200 Subject: [PATCH 12/36] chore: license field, format:check, build script cleanup - package.json said "license": "ISC" while LICENSE is MIT (added in 1.1.4); npm showed ISC - `format:check` runs prettier --check; `.github/` (generated callers, Dependabot config) and `docs/` are ignored, and endOfLine is "auto" because the checkout is CRLF on Windows and LF in git - build.config.ts: the `prompts` alias pointed at a package that was replaced by @clack/prompts long ago, and inlineDependencies had no effect (runtime dependencies stay external); both removed - scripts/postbuild.js packs the tarball itself (`npm pack --json`), removes the tarballs of earlier builds first and names the copy from npm's answer instead of picking the newest .tgz by mtime; it runs clean-templates.js before packing with scripts off, because prepack output would land in the JSON - scripts/clean-templates.js explains why it exists: `files` whitelists template-*/** and a root .npmignore cannot exclude below it, so prepack is what keeps a template's node_modules out of the package Refs: PP-4794 --- .prettierignore | 4 +++ .prettierrc.cjs | 2 ++ build.config.ts | 6 ----- package.json | 7 ++--- scripts/clean-templates.js | 4 +++ scripts/postbuild.js | 54 ++++++++++++++++++++++---------------- 6 files changed, 46 insertions(+), 31 deletions(-) diff --git a/.prettierignore b/.prettierignore index 8c5ebb3..d65c74a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,7 @@ template-* *.tgz package-lock.json CHANGELOG.md +# analysis notes in prose, not code +docs +# generated by mi-examples-workflows and Dependabot; formatted by their generators +.github diff --git a/.prettierrc.cjs b/.prettierrc.cjs index b0648ab..bb03bf0 100644 --- a/.prettierrc.cjs +++ b/.prettierrc.cjs @@ -4,4 +4,6 @@ module.exports = { singleQuote: true, trailingComma: 'all', printWidth: 120, + // the checkout is CRLF on Windows (core.autocrlf) and LF in git; check the style, not the line ending + endOfLine: 'auto', }; diff --git a/build.config.ts b/build.config.ts index 6447354..e2c8969 100644 --- a/build.config.ts +++ b/build.config.ts @@ -4,15 +4,9 @@ export default defineBuildConfig({ entries: ['src/index'], clean: true, rollup: { - inlineDependencies: true, esbuild: { target: 'node24', minify: true, }, }, - alias: { - // we can always use non-transpiled code since we support node 24+ - prompts: 'prompts/lib/index.js', - }, - hooks: {}, }); diff --git a/package.json b/package.json index 52e387c..153e78c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@metricinsights/create-pp-dev", "version": "1.1.7", "type": "module", - "description": "Portal Page Templates repository", + "description": "Scaffolds a Metric Insights Portal Page project from a template", "bin": { "create-pp-dev": "index.js" }, @@ -34,8 +34,9 @@ "lint": "eslint . --report-unused-disable-directives --max-warnings 0", "lint:fix": "eslint . --report-unused-disable-directives --max-warnings 0 --fix", "format": "prettier --write .", + "format:check": "prettier --check .", "prepack": "node scripts/clean-templates.js", - "postbuild": "npm pack && node scripts/postbuild.js", + "postbuild": "node scripts/postbuild.js", "typecheck": "tsc --noEmit", "pretest": "unbuild", "test": "playwright test" @@ -49,7 +50,7 @@ "url": "https://github.com/mi-examples/create-pp-dev/issues" }, "homepage": "https://github.com/mi-examples/create-pp-dev#readme", - "license": "ISC", + "license": "MIT", "devDependencies": { "@eslint/compat": "^2.1.1", "@eslint/eslintrc": "^3.3.7", diff --git a/scripts/clean-templates.js b/scripts/clean-templates.js index 76dcaff..4dbeb69 100644 --- a/scripts/clean-templates.js +++ b/scripts/clean-templates.js @@ -1,3 +1,7 @@ +// Runs as `prepack`, before every `npm pack` / `npm publish`. It is the only thing keeping a +// template's node_modules and package-lock.json out of the published package: `files` in +// package.json whitelists `template-*/**`, and a root .npmignore cannot exclude anything below a +// whitelisted directory. Both appear whenever a template was installed in place for a manual test. import { readdirSync, rmSync, existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; diff --git a/scripts/postbuild.js b/scripts/postbuild.js index ad1ac51..1a36940 100644 --- a/scripts/postbuild.js +++ b/scripts/postbuild.js @@ -1,35 +1,45 @@ -import { readFileSync, copyFileSync, readdirSync, statSync } from 'fs'; +// Runs after `npm run build` (unbuild): packs the package and keeps a copy of the tarball as +// -latest.tgz, so a fixed file name can be installed from (npm i ../metricinsights-create-pp-dev-latest.tgz). +// Tarballs of earlier builds are removed first, so the repository root holds the current version only. +import { copyFileSync, readdirSync, readFileSync, rmSync } from 'fs'; +import { execFileSync } from 'child_process'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..'); -// Read package name from package.json const pkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8')); +// @metricinsights/create-pp-dev -> metricinsights-create-pp-dev, as npm pack names the tarball const packName = pkg.name.replace(/^@/, '').replace(/\//g, '-'); -// Find latest .tgz file matching the package -const tgzFiles = readdirSync(rootDir) - .filter((f) => f.endsWith('.tgz') && f.startsWith(packName)) - .map((f) => ({ - name: f, - path: join(rootDir, f), - mtime: statSync(join(rootDir, f)).mtime, - })) - .sort((a, b) => b.mtime - a.mtime); - -if (tgzFiles.length === 0) { - console.error("No pack file found. Run 'npm pack' first."); - process.exit(1); +for (const file of readdirSync(rootDir)) { + if (file.startsWith(`${packName}-`) && file.endsWith('.tgz')) { + rmSync(join(rootDir, file)); + console.log(`Removed ${file}`); + } } -const latest = tgzFiles[0]; -const latestName = latest.name; +// Under an npm script, npm_execpath points at npm-cli.js; run that with the current node so the +// same npm packs the package. Outside npm (node scripts/postbuild.js) fall back to npm on PATH. +const [command, ...prefix] = process.env.npm_execpath + ? [process.execPath, process.env.npm_execpath] + : [process.platform === 'win32' ? 'npm.cmd' : 'npm']; +const useShell = process.platform === 'win32' && !process.env.npm_execpath; -// Replace version with "latest" in filename (e.g. pkg-1.0.0.tgz -> pkg-latest.tgz) -const latestNameNew = latestName.replace(/-[\d.]+(-[a-z0-9.-]+)?\.tgz$/, '-latest.tgz'); -const destPath = join(rootDir, latestNameNew); +// prepack (clean-templates.js) prints to stdout, where `npm pack --json` prints its result too, so +// run the cleanup here and pack with the scripts off; `npm publish` in CI still runs prepack. +execFileSync(process.execPath, [join(__dirname, 'clean-templates.js')], { cwd: rootDir, stdio: 'inherit' }); -copyFileSync(latest.path, destPath); -console.log(`Copied ${latestName} -> ${latestNameNew}`); +const output = execFileSync(command, [...prefix, 'pack', '--json', '--ignore-scripts'], { + cwd: rootDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + shell: useShell, +}); + +const [packed] = JSON.parse(output); +const latestName = `${packName}-latest.tgz`; + +copyFileSync(join(rootDir, packed.filename), join(rootDir, latestName)); +console.log(`Packed ${packed.filename} (${packed.entryCount} files, ${packed.size} B) -> ${latestName}`); From 89133e284c4c0db722e4995e2847933925deebf7 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 14:06:04 +0200 Subject: [PATCH 13/36] docs: README for the CLI as it is, CONTRIBUTING.md and CLAUDE.md README: Node.js 24 (as engines says, not 22); every flag with its default under --yes; the non-interactive behaviour (no prompt without a terminal, exit 1 before anything is written); what tells nextjs from nextjs-esm and the Vite templates' embedding mode from the others' standalone mode; where the token goes; the development commands. .cursorrules described semantic-release and claimed perf doesn't release; .cursor/rules/create-release.mdc prescribed a manual tag on origin/main, which the shared release workflow creates itself. Both are replaced by CONTRIBUTING.md (commit convention, release process, pull requests) and CLAUDE.md (project map and conventions), as in pp-dev. Refs: PP-4794 --- .cursor/rules/create-release.mdc | 37 ------------- .cursorrules | 95 -------------------------------- CLAUDE.md | 51 +++++++++++++++++ CONTRIBUTING.md | 75 +++++++++++++++++++++++++ README.md | 79 ++++++++++++++++++++------ 5 files changed, 187 insertions(+), 150 deletions(-) delete mode 100644 .cursor/rules/create-release.mdc delete mode 100644 .cursorrules create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md diff --git a/.cursor/rules/create-release.mdc b/.cursor/rules/create-release.mdc deleted file mode 100644 index 60f2141..0000000 --- a/.cursor/rules/create-release.mdc +++ /dev/null @@ -1,37 +0,0 @@ ---- -description: Create release tag from latest origin/main with version from package.json (without beta suffix) -alwaysApply: false ---- - -# Create Release Workflow - -When the user asks to **Create release** (or "create tag", "release tag"): - -## Steps - -1. **Read version from package.json** – Get the `version` field (e.g. `0.5.5-beta.2`). - -2. **Strip beta suffix** – Remove `-beta.*`, `-alpha.*`, `-rc.*` etc. Examples: - - `0.5.5-beta.2` → `0.5.5` - - `1.0.0-rc.1` → `1.0.0` - - `2.3.4` → `2.3.4` (unchanged) - -3. **Tag name** – Use `v` + stripped version: `v0.5.5`. - -4. **Create tag on latest origin/main** – Run: - ```bash - git fetch origin main - git tag v origin/main -m "Release v" - ``` - Example: `git tag v0.5.5 origin/main -m "Release v0.5.5"` - -5. **Push tag** (if user wants to publish): - ```bash - git push origin v - ``` - -## Notes - -- Tag is created from `origin/main`, not from the current branch. -- Do not push the tag unless the user explicitly requests it. -- Confirm the version and tag name with the user before creating/pushing. diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index fff0160..0000000 --- a/.cursorrules +++ /dev/null @@ -1,95 +0,0 @@ -# create-pp-dev - Cursor Rules - -## Commit Message Guidelines - -### Format: `(): ` -- Use imperative mood ("add" not "added") -- Don't capitalize first letter -- No period at the end -- Keep subject concise (50 chars or less) - -### Commit Types (choose one): -- **`feat`** - A new feature (triggers MINOR version bump) -- **`fix`** - A bug fix (triggers PATCH version bump) -- **`docs`** - Documentation only changes (NO version bump) -- **`style`** - Code style changes, formatting, etc (NO version bump) -- **`refactor`** - Code refactoring, no functional changes (NO version bump) -- **`perf`** - Performance improvements (NO version bump) -- **`test`** - Adding or updating tests (NO version bump) -- **`chore`** - Maintenance tasks, dependencies, etc (NO version bump) -- **`ci`** - CI/CD configuration changes (NO version bump) -- **`build`** - Build system or external dependency changes (NO version bump) - -### Scopes (optional, but recommended): -- **`templates`** - Template-related changes -- **`react`** - React template changes -- **`nextjs`** - Next.js template changes -- **`vanilla`** - Vanilla template changes -- **`cli`** - CLI-related changes -- **`deps`** - Dependency updates -- **`docs`** - Documentation changes -- **`test`** - Testing changes -- **`config`** - Configuration changes - -### Examples: -```bash -feat(templates): add new Vue.js template -fix(cli): resolve project initialization issue -docs(readme): update installation instructions -style: format code according to prettier rules -refactor(cli): extract common validation functions -chore(deps): update all dependencies to latest versions -ci(github): add semantic-release workflow -feat(react): add TypeScript strict mode support -fix(nextjs): resolve build configuration issue -``` - -### Breaking Changes: -```bash -feat(templates): change default project structure - -BREAKING CHANGE: Templates now use different folder structure. -Existing projects may need manual migration. -``` - -### Body and Footer Guidelines: -- **Body**: Explain WHAT and WHY, not HOW -- **Breaking Changes**: Start footer with "BREAKING CHANGE: " - -### Important Notes: -- Only `feat`, `fix`, and `BREAKING CHANGE` trigger version bumps -- Use scopes to indicate which area is affected -- Be descriptive but concise -- Reference related issues when possible -- Follow the [Conventional Commits](https://www.conventionalcommits.org/) specification - -## General Development Guidelines - -### Code Quality: -- Always aim for clean, maintainable architecture -- Focus on areas of the code relevant to the task -- Do not touch code unrelated to the task -- Avoid making major changes to working patterns unless explicitly instructed -- Think about what other methods and areas might be affected by changes - -### Semantic Release: -- This project uses semantic-release for automated versioning -- All releases go through Pull Requests (no direct pushes to main) -- Commit messages determine version bumps automatically -- Follow conventional commit format strictly for proper releases - -### Project Structure: -- `src/` - CLI source code -- `template-react/` - React + Vite template -- `template-nextjs/` - Next.js template -- `template-vanilla/` - Vanilla JavaScript template -- `template-vanilla-ts/` - Vanilla TypeScript template -- `mi-cursor-rules/` - Cursor rules for generated projects -- `dist/` - Build output - -### Template Guidelines: -- Keep templates minimal but functional -- Include proper TypeScript configurations -- Ensure pp-dev dependency is up to date -- Test templates after changes - diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0906f70 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# create-pp-dev — Claude Code Instructions + +## What this project is + +`@metricinsights/create-pp-dev` is the scaffolder behind `npm create @metricinsights/pp-dev`: a small CLI that copies one of five templates into a new directory, rewrites its `package.json` name and optionally copies the MI Cursor rules, adds `@metricinsights/pp-components` and runs the install. Every template depends on `@metricinsights/pp-dev`, the local development framework for Metric Insights Portal Pages. + +## Essential commands + +```bash +npm run build # unbuild src/index.ts -> dist/index.mjs, then pack to -latest.tgz +npm run lint # eslint (the templates are linted by their own configs, not here) +npm run typecheck # tsc --noEmit +npm run format:check # prettier --check (templates, CHANGELOG and docs are ignored) +npm test # builds, then the Playwright suite: scaffolds and installs the templates (minutes) +npx playwright test --grep @offline # the quick subset: no network, under half a minute +``` + +The templates have no lockfiles in the repository (`.gitignore`, `scripts/clean-templates.js`). To audit one, copy its `package.json` to a temporary directory, run `npm install --package-lock-only` there and `npm audit`. + +## Layout + +``` +index.js — bin: imports dist/index.mjs +src/index.ts — the whole CLI: parseArgs, prompts (@clack/prompts), copy, install +scripts/clean-templates.js — prepack: removes template-*/node_modules and lockfiles (see the comment there) +scripts/postbuild.js — packs the tarball and keeps -latest.tgz +template-nextjs/ — Next.js (CommonJS next.config.js), pp-dev next +template-nextjs-esm/ — Next.js ("type": "module") +template-react/ — React + Vite +template-vanilla/ — Vite, JavaScript +template-vanilla-ts/ — Vite, TypeScript +mi-cursor-rules/ — copied into a new project's .cursor/rules on request +test/scaffold.spec.mjs — Playwright as a test runner (no browser): runs dist/index.mjs and npm +.github/ — generated shared-workflow callers; settings in mi-examples-workflows.json +``` + +Template conventions: `_gitignore` is renamed to `.gitignore` on copy (npm would drop a `.gitignore` from the package); `package.json#name` is replaced with the project name; the pp-dev config ships with `mi.url` and `app.id` commented out. + +## Key conventions + +- **Every template ships to users.** A change to a template's dependencies or config is a `fix` and should be released soon after merging; keep the five templates on the same `@metricinsights/pp-dev` range and the same ESLint/Prettier rules. +- **The CLI must work without a terminal.** Every prompt has a flag; with stdin not a TTY and no flag, exit 1 with a hint. `--yes` takes the defaults. Validate the flags before touching the disk. +- **Tests run the built CLI.** `pretest` runs unbuild; tests that need the registry are not tagged `@offline`, failures of the registry are simulated with `npm_config_registry=http://127.0.0.1:9`. +- **Line endings.** The checkout uses `core.autocrlf=true` on Windows: files are CRLF in the working tree and LF in git. Scripts that edit files must not assume LF. +- Change only the code the task needs; check what else uses the code you change. + +## Commits, PRs, releases + +- Commit messages follow Conventional Commits, `(): ` — types, scopes and the release process are in `CONTRIBUTING.md`. `fix`, `perf` and `revert` release a patch, `feat` a minor. +- PR descriptions follow `.cursor/rules/pr-message-format.mdc` (🚀 features, 🔧 fixes, 🔐 security, 🧪 tests, 🧹 chore). Copilot review is requested automatically. +- Releases use the shared release flow (betas from `develop`, production through `release/vX.Y.Z → main`); tags and `CHANGELOG.md` are written by the workflow. Never create a tag by hand. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d8c0426 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing to create-pp-dev + +## Commit Message Convention + +We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification. The commit messages decide the version of the next release and become its changelog. + +### Commit Message Format + +``` +(): + +[optional body] + +[optional footer] +``` + +- imperative mood ("add", not "added"), lower-case start, no period at the end; +- the body says what and why, not how; +- a breaking change gets a `!` after the type/scope or a `BREAKING CHANGE:` footer. + +### Types + +- `feat`: a new feature (a new template, a new CLI flag) +- `fix`: a bug fix; also a dependency bump in a template or in the CLI's runtime dependencies, since that ships to every new project +- `docs`: documentation only +- `style`: formatting, no change in meaning +- `refactor`: a code change that neither fixes a bug nor adds a feature +- `perf`: a performance improvement +- `test`: adding or correcting tests +- `chore`: tooling, development dependencies, repository upkeep +- `ci`: the GitHub workflows and Dependabot configuration + +### Scopes + +- `cli` — `src/index.ts` +- `templates` — a change across templates; `react`, `nextjs`, `vanilla`, `vanilla-ts` for one of them +- `deps` — dependency updates +- `rules` — `mi-cursor-rules/` +- `test`, `docs`, `release` + +### Examples + +``` +fix(cli): report a failed npm install and exit 1 +fix(deps): bump @metricinsights/pp-dev to ^1.5.0 in the templates +feat(templates): add a Vue template +docs(readme): document the non-interactive flags +test: cover the CLI error paths +``` + +## Release Process + +Releases use the shared workflows from [mi-examples-workflows](https://github.com/mi-examples/mi-examples-workflows) ([release flow](https://github.com/mi-examples/mi-examples-workflows/blob/main/docs/workflows.md#release-workflows)). The caller is [`.github/workflows/release.yml`](.github/workflows/release.yml). + +- **Betas.** Every push to `develop` with releasable commits publishes `X.Y.Z-beta.N` to npm under the `beta` dist-tag, with a GitHub prerelease. Try one with `npm create @metricinsights/pp-dev@beta`. +- **Production releases.** + 1. Run **Actions → Release → Run workflow**. It opens a release pull request `release/vX.Y.Z → main` with the version bump and the new `CHANGELOG.md` entry. + 2. Review and edit the entry in the pull request, then merge it (merge commit, not squash). + 3. Merging publishes to npm under `latest` and creates the tag and the GitHub release. It also opens the back-merge pull request into `develop`. +- **Versions** come from the commit messages (see above): + - `feat` → minor; + - `fix`, `perf` and `revert` → patch; + - `!` or a `BREAKING CHANGE:` footer → major; + - other types don't release. +- **Tags and `CHANGELOG.md`** are written by the workflow. Don't create tags by hand and don't edit the changelog outside a release pull request. +- **Publishing** uses npm Trusted Publishing (OIDC) from `release.yml` in the `npm-publish` environment. No npm token is needed or stored. Don't rename `release.yml`: the trusted publisher is registered for that filename. +- **`main` ahead of `develop`.** After a production release the back-merge pull request `main → develop` opens automatically. Until it is merged, the "Main ahead check" fails pull requests into `develop` and re-runs them once `develop` contains `main` again. +- **CI and release callers** are generated from `.github/mi-examples-workflows.json`. To change a CI setting, edit that file and run `npx github:mi-examples/mi-examples-workflows`; don't edit the callers by hand. + +## Pull Requests + +- Branch from `develop`; pull requests go into `develop`. Only release pull requests go into `main`. +- The PR description follows `.cursor/rules/pr-message-format.mdc` (summary, key changes, testing, included commits). +- Copilot review is requested automatically by the organisation's ruleset. +- Dependency updates to the templates ship in every new project: a template bump is a `fix(deps)` and should be released soon after it is merged. diff --git a/README.md b/README.md index 896921e..a9b1e5e 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,16 @@ ## Overview -`@metricinsights/create-pp-dev` is a CLI tool for scaffolding new MetricInsights Portal Page projects. It provides various templates to help you start quickly with Portal Page development. +`@metricinsights/create-pp-dev` is a CLI tool for scaffolding new Metric Insights Portal Page projects. It copies one of the templates below into a new directory, names the project and, on request, adds the MI Cursor rules and the [@metricinsights/pp-components](https://www.npmjs.com/package/@metricinsights/pp-components) library and installs the packages. Every template runs on [@metricinsights/pp-dev](https://www.npmjs.com/package/@metricinsights/pp-dev), the local development framework and build tool for Portal Pages. ## Requirements -- [Node.js](https://nodejs.org/en/) version 22+ -- npm, yarn, or pnpm package manager +- [Node.js](https://nodejs.org/en/) 24 or newer (the templates require it too) +- npm 10 or newer, yarn, or pnpm ## Usage -### Basic Usage +### Interactive ```bash # Using npm @@ -26,9 +26,11 @@ yarn create @metricinsights/pp-dev pnpm create @metricinsights/pp-dev ``` -### Advanced Usage +The CLI asks for the project name, the package name, the framework and its variant, and then whether to add the Cursor rules, add `@metricinsights/pp-components` and install the packages. -You can directly specify the project name and template via command line options: +### With arguments + +Every question has a flag. A flag that is passed is not asked; `--yes` answers the rest with the defaults. ```bash # npm 7+, extra double-dash is needed: @@ -47,23 +49,49 @@ To scaffold in the current directory, use `.` as the project name: npm create @metricinsights/pp-dev@latest . -- --template react ``` +| Flag | Answers | Default with `--yes` | +| ---------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------- | +| `[project-name]` | the directory to create (`.` for the current one) | `pp-project` | +| `-t, --template ` | the template (see below) | none: `--yes` needs `--template` | +| `--package-name ` | `name` in the project's `package.json` | derived from the directory name | +| `--install` / `--no-install` | run the package manager's install after scaffolding | install | +| `--cursor-rules` / `--no-cursor-rules` | copy the MI Cursor rules into `.cursor/rules` | no | +| `--pp-components` / `--no-pp-components` | add `@metricinsights/pp-components` as a dependency | no | +| `-y, --yes` | ask nothing: take the defaults above; a non-empty target directory keeps its files | — | +| `-h, --help` | print the usage | | +| `-v, --version` | print the version | | + +The package manager is the one that runs the CLI (`npm create` → npm, `yarn create` → yarn, `pnpm create` → pnpm). + +#### Non-interactive use (CI, scripts) + +Without a terminal the CLI never prompts: a question that no flag answers ends the run with exit code 1 and the flag to pass. Pass every flag, or `--yes` plus `--template`: + +```bash +npm create @metricinsights/pp-dev@latest my-pp -- --template nextjs --yes --no-install +``` + +An unknown flag, an unknown template or an invalid package name also exit with 1, before anything is written. A failed install exits with 1 and leaves the scaffolded files in place. + ## Available Templates -| Template | Description | -| ------------ | --------------------------------------- | -| `vanilla` | Basic Portal Page with vanilla JavaScript | -| `vanilla-ts` | Basic Portal Page with TypeScript | -| `react` | Portal Page with React | -| `nextjs` | Portal Page with Next.js (CommonJS) | -| `nextjs-esm` | Portal Page with Next.js (ES Modules) | +| Template | Description | +| ------------ | ------------------------------------------------------------------------------------------------------------- | +| `vanilla` | Portal Page with vanilla JavaScript on Vite | +| `vanilla-ts` | Portal Page with TypeScript on Vite | +| `react` | Portal Page with React and TypeScript on Vite | +| `nextjs` | Portal Page with Next.js (pages router, static export) in a CommonJS project: `next.config.js` uses `require` | +| `nextjs-esm` | The same Next.js project with `"type": "module"`: `next.config.js` uses `import`/`export` | + +Pick `nextjs-esm` for a new project unless something in your toolchain still needs CommonJS; the two templates are otherwise identical. The Vite templates ship `mi.mode: 'embedding'` (the page is rendered inside the MI navigation), the React and Next.js templates `mi.mode: 'standalone'` with the MI top bar bundled in; both can be changed in `pp-dev.config.*`. ## Interactive Features -During project setup, the CLI will prompt you with optional features: +During project setup, the CLI offers optional features: -- **Cursor Rules** - Add AI-assisted development rules for [Cursor IDE](https://cursor.sh/). These rules help the AI understand MetricInsights Portal Page conventions. +- **Cursor Rules** - Add AI-assisted development rules for [Cursor IDE](https://cursor.sh/). These rules help the AI understand Metric Insights Portal Page conventions and the MI REST API. -- **@metricinsights/pp-components** - Optionally include the [@metricinsights/pp-components](https://www.npmjs.com/package/@metricinsights/pp-components) library with pre-built UI components for Portal Pages. +- **@metricinsights/pp-components** - Optionally include the [@metricinsights/pp-components](https://www.npmjs.com/package/@metricinsights/pp-components) library with pre-built UI components for Portal Pages. The version comes from the npm registry the package manager is configured with; if the registry cannot be reached, `^1.0.0` is written and a warning printed. - **Auto-install packages** - Choose to automatically run package installation after scaffolding. @@ -77,10 +105,23 @@ npm install npm run dev ``` -Follow the instructions in the generated project's README for more details. +Before the first `npm run dev`, set `mi.url` and `app.id` in `pp-dev.config.*` and put your personal access token into `.env` as `MI_ACCESS_TOKEN` (the generated `.gitignore` keeps `.env` out of git). Follow the instructions in the generated project's README for more details. For more information about developing Portal Pages, see the [@metricinsights/pp-dev documentation](https://www.npmjs.com/package/@metricinsights/pp-dev). +## Development + +```bash +npm run build # build dist/index.mjs and pack the tarball +npm run lint +npm run typecheck +npm run format:check +npm test # builds, then scaffolds and installs every template (minutes) +npx playwright test --grep @offline # the quick subset without network +``` + +Commit conventions and the release process are in [CONTRIBUTING.md](CONTRIBUTING.md). + ## Releases Releases use the shared workflows from [mi-examples-workflows](https://github.com/mi-examples/mi-examples-workflows) ([release flow](https://github.com/mi-examples/mi-examples-workflows/blob/main/docs/workflows.md#release-workflows)): @@ -88,7 +129,9 @@ Releases use the shared workflows from [mi-examples-workflows](https://github.co - every push to `develop` with releasable commits publishes `X.Y.Z-beta.N` under the `beta` dist-tag (`npm create @metricinsights/pp-dev@beta`); - **Actions → Release → Run workflow** opens a release pull request into `main`; merging it publishes `latest` and opens the back-merge into `develop`; - versions come from [Conventional Commits](https://www.conventionalcommits.org/) (`feat` → minor; `fix`, `perf`, `revert` → patch; `!` or `BREAKING CHANGE:` → major); +- `CHANGELOG.md` and the tags are written by the release workflow (from 1.1.8 on; older entries were written by semantic-release or by hand); - publishing uses npm Trusted Publishing (OIDC) from `.github/workflows/release.yml`, so don't rename that file. + ## License -ISC +MIT From 95d8250e4f8d6ec0ce0a2594fe4486b93f67ab00 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 14:06:05 +0200 Subject: [PATCH 14/36] fix(rules): pp-dev 1.5 summary in mi-custom-apps-dev.mdc The rule copied into new projects' .cursor/rules carried a copy of the pp-dev README from around 1.0: --logLevel levels that don't exist, inspector.maxMemory at 1 GiB (it is 100 MB), no next-build, no users, server.allowedHosts, sync.maxBackups / buildTimeoutMs or devPanel, and a migration guide from the pre-pp-dev helper. Replaced by a summary of pp-dev 1.5 (scripts, commands, config sections with defaults, validation, environment, Next.js base paths, dev server) that points at the pp-dev README as the reference. The MI variables section stays. Refs: PP-4794 --- mi-cursor-rules/mi-custom-apps-dev.mdc | 291 ++++--------------------- 1 file changed, 37 insertions(+), 254 deletions(-) diff --git a/mi-cursor-rules/mi-custom-apps-dev.mdc b/mi-cursor-rules/mi-custom-apps-dev.mdc index 5ad293b..24414dd 100644 --- a/mi-cursor-rules/mi-custom-apps-dev.mdc +++ b/mi-cursor-rules/mi-custom-apps-dev.mdc @@ -5,293 +5,76 @@ alwaysApply: true --- # Developing custom Apps in Metric Insights -The Metric Insights platform can be used for the deploymen of custom Apps (also called Portal Pages) -Local development of these Portal Pages requires the pp-dev-helper and knowledge of App (Portal Page) Variables (a feature within the Metric Insights platform) +The Metric Insights platform can be used for the deployment of custom Apps (also called Portal Pages). +Local development of these Portal Pages requires pp-dev and knowledge of App (Portal Page) Variables (a feature within the Metric Insights platform). -# pp-dev documentation +# pp-dev: local development of a Portal Page -The PP Dev Helper is a development framework and build tool for Metric Insights' Portal Pages, designed to make the -lives of Portal Page (App) developers easier: +[`@metricinsights/pp-dev`](https://www.npmjs.com/package/@metricinsights/pp-dev) is the development framework and build tool for Portal Pages (custom Apps). It runs the page locally (Vite, or Next.js through `pp-dev next`), proxies API requests to a Metric Insights instance with the developer's token, loads the page's template variables, syncs the built template back to MI and zips the build for upload. This is a summary for pp-dev 1.5; the reference is the package README, https://github.com/mi-examples/pp-dev#readme, which wins when the two disagree. -- build your Portal Page -- run/test locally with API requests proxied to a Metric Insights server -- a lot more (to be documented soon!) +## Scripts and commands -pp-dev is based on [Vite](https://vitejs.dev/). +Projects created with `npm create @metricinsights/pp-dev` already have the scripts: -## Usage +| Script | Vite templates (`vanilla`, `vanilla-ts`, `react`) | Next.js templates | +| --------- | ------------------------------------------------------------ | ------------------- | +| `dev` | `pp-dev` | `pp-dev next` | +| `build` | `pp-dev build` (TypeScript templates: `tsc && pp-dev build`) | `pp-dev next-build` | +| `preview` | `pp-dev preview` (react) | — | -Package installation +CLI commands: `pp-dev [root]` (aliases `dev`, `serve`), `pp-dev next [root]` (`next-serve`, `next-dev`), `pp-dev build [root]`, `pp-dev next-build [root]`, `pp-dev preview [root]`, `pp-dev optimize [root]`, `pp-dev migrate [config]`, `pp-dev changelog [old] [new]`, `pp-dev generate-icon-font [source] [destination]`. -```shell -$ npm i @metricinsights/pp-dev -``` - -### Define Configuration - -You'll need to create a file with the name `pp-dev.config` and extension `js` or -`cjs` (if you define type `module` in package.json), or `ts` if you want to use TypeScript, or `json`. - -Alternatively, you can define configuration in your `package.json` via the `pp-dev` key - -Configuration is a grouped object (`PPDevConfig`) with sections for `mi`, `app`, `proxy`, `build`, `sync`, and -`inspector`. Use the `defineConfig()` helper for full TypeScript intellisense. +Global options: `-c, --config `, `--base `, `-l, --logLevel `, `--clearScreen`, `--mode `. Dev-server options: `--host [host]` (a bare `--host` listens on all addresses), `--port ` (default `3000`); `--open [path]` and `--strictPort` apply to `pp-dev` only. `pp-dev --help` and `pp-dev --help` list everything. -Generic configuration examples: - -- JavaScript - -```javascript -// pp-dev.config.js - -const { defineConfig } = require('@metricinsights/pp-dev'); - -module.exports = defineConfig({ - mi: { - url: 'https://mi.company.com', - mode: 'standalone', - }, - app: { - id: 1, - }, -}); -``` +## Configuration -- TypeScript +The config lives in `pp-dev.config.ts` (or `.js`, `.cjs`, `.mjs`, `.json`), or under the `pp-dev` key of `package.json`. Use `defineConfig()` for typing. The config is grouped: ```typescript // pp-dev.config.ts - import { defineConfig } from '@metricinsights/pp-dev'; export default defineConfig({ mi: { url: 'https://mi.company.com', mode: 'standalone', + apiVersion: 7, }, app: { id: 1, + type: 'template', }, }); ``` -- JSON as `pp-dev.config.json` - -```json -{ - "mi": { - "url": "https://mi.company.com", - "mode": "standalone" - }, - "app": { - "id": 1 - } -} -``` - -- JSON as `package.json` - -```json -{ - "name": "", - "version": "1.0.0", - "scripts": {}, - "pp-dev": { - "mi": { - "url": "https://mi.company.com", - "mode": "standalone" - }, - "app": { - "id": 1 - } - } -} -``` - -> Migrating from an older, flat config (`backendBaseURL`, `portalPageId`, `miHudLess`, `v7Features`, ...)? Run -> `npx @metricinsights/pp-dev migrate` to convert it to the grouped format automatically (writes a `.bak` backup -> first; use `--dry-run` to preview). - -## [Next.js](https://nextjs.org/) Configuration - -To use the PP Dev Helper with Next.js: - -1. Add pp-dev config to your root directory. - -2. Change your `dev` script in `package.json` to `pp-dev next`. - -3. Finally, wrap your next.config with a `withPPDev` function. - -```javascript -// next.config.js - -const { withPPDev } = require('@metricinsights/pp-dev'); - -module.exports = withPPDev({ - // your next config -}); -``` - -### Vite configuration - -If you need to change something in the build you can define a `vite.config` file. -More details [Vite Confighere](https://vitejs.dev/config/) - -### Configuration API description - -#### `mi` — Metric Insights connection - -| Field | Type | Default | Description | -| --- | --- | --- | --- | -| `mi.url` | `string` | `MI_BACKEND_URL` env | MI instance base URL, used for proxying requests to the MI backend | -| `mi.token` | `string` | `MI_ACCESS_TOKEN` env | Personal access token for authentication | -| `mi.mode` | `'standalone' \| 'embedding'` | `'standalone'` | `standalone` hides the MI navigation bar (recommended for React); `embedding` keeps the MI wrapper around the page | -| `mi.include` | `'top-bar' \| 'shared-components'` | — | Bundle MI shared/top-bar assets into the build. Only valid when `mi.mode` is `'standalone'` | -| `mi.apiVersion` | `6 \| 7` | `7` | MI API/routing version to target | - -#### `app` — Portal Page identity - -| Field | Type | Default | Description | -| --- | --- | --- | --- | -| `app.id` | `number` | — | Portal Page ID, used to fetch Portal Page Variable values. Required when `app.type` is `'template'`, or when `app.type` is `'page'` and `mi.mode` is `'standalone'` | -| `app.type` | `'template' \| 'page'` | `'template'` | `template` loads Template Variables for `app.id`; `page` disables Template Variables transformation (for a Portal Page without a template) | -| `app.name` | `string` | `package.json#name` | Internal asset name for URLs and the build ZIP — usually left unset | - -#### `proxy` — dev-server proxy - -| Field | Type | Default | Description | -| --- | --- | --- | --- | -| `proxy.cache` | `boolean` | `true` | Enables proxy cache for requests to the MI backend | -| `proxy.cacheTtl` | `number` (ms) | `600000` | Proxy cache TTL | -| `proxy.tls.allowSelfSigned` | `boolean` | `false` | Disables SSL certificate validation for proxy requests. Useful for self-signed certificates | - -#### `build` — build output - -| Field | Type | Default | Description | -| --- | --- | --- | --- | -| `build.outDir` | `string` | `'dist'` | Output directory for the build | -| `build.zip` | `boolean \| { fileName?, outDir?, inDir? }` | `true` | Zips the build output. Object form customizes the output filename/directory (filename supports `[name]` as a placeholder for the template name) | -| `build.versionFile` | `boolean \| { enabled?, fileNameTemplate? }` | `true` | Writes a VERSION file with SHA256 hashes into the build | -| `build.imageOptimisations` | `boolean \| object` | `true` | Enables image optimization for the build. Object form is passed to [vite-plugin-image-optimizer](https://www.npmjs.com/package/vite-plugin-image-optimizer#plugin-options) | - -#### `sync` — asset sync / backups - -| Field | Type | Default | Description | -| --- | --- | --- | --- | -| `sync.backupsDir` | `string` | `'backups'` | Directory for asset backups pulled from the MI server | - -#### `inspector` — Request Inspector - -| Field | Type | Default | Description | -| --- | --- | --- | --- | -| `inspector.enabled` | `boolean` | `true` | Enables the request inspector web UI at `/@pp-dev/inspector` | -| `inspector.maxMemory` | `number` (bytes) | `1073741824` | Max total captured request/response body memory before oldest entries are evicted | -| `inspector.captureLimit` | `number` (bytes) | `10485760` | Max body size captured per request/response; larger bodies are stored truncated | - -`mi.apiVersion: 7` changes the default local development path from `/pt/` to `/pl/` (avoids conflicts with the Metric Insights v7 toolbar) and switches Code Sync to the v7 URLs (available on Metric Insights v7.1.0+ instances). - -### CLI API description - -`pp-dev help` - show CLI's help - -Global options: - -- `-c, --config ` - define the path to the configuration file. Default is `pp-dev.config.js` -- `--base ` - public base path (default: `/`) -- `-l, --logLevel ` - define the log level. Default is `info`. Available options: `trace`, `debug`, `info`, `warn`, `error`, `silent` -- `--clearScreen` - clear the screen before logging -- `--mode ` - define the environment mode. Default is `development`. Available options: `development`, `production`, `test` - -#### `pp-dev` - -Aliases: `pp-dev dev`, `pp-dev serve` - -Runs application in development mode with hot-reload. Also, its proxies requests to the MI server. - -Available options and arguments: - -- `[root]` - define the root directory for the application. Default is `.` -- `--host ` - define the host for the application. Default is `localhost` -- `--port ` - define the port for the application. Default is `3000` -- `--open [path]` - open the application in the default browser. You can define the path to open the browser with the specific page -- `--strictPort` - enable strict port checking. If the port is already in use, the application will exit with an error - -#### `pp-dev next` - -Aliases: `pp-dev next-server`, `pp-dev next-dev` - -Runs the Next.js application in development mode with hot-reload. Also, its proxies requests to the MI server. - -Available options and arguments: - -- `[root]` - define the root directory for the application. Default is `.` -- `--port ` - define the port for the application. Default is `3000` -- `--host ` - define the host for the application. Default is `localhost` - -#### `pp-dev build` - -Runs the application build. Will create `dist` and `dist-zip` folders. `dist` folder contains unzipped build files. `dist-zip` contains file `.zip` with files from the `dist` folder - -Available options and arguments: - -- `[root]` - define the root directory for the application. Default is `.` -- `--target ` - transpile target. Default is `modules`. -- `--outDir ` - define the output directory for the build. Default is `dist` -- `--assetsDir ` - directory under outDir to place assets in Default is `assets` -- `--changelog [assetsFile]` - create a changelog file. You can define the path to the assets file or set `true` to use the default path. Default is `true`. Default path is `backups/.zip` where `` is the latest backup file - -#### `pp-dev changelog [oldAssetPath] [newAssetPath]` - -Create a changelog file. You can define the path to the previous and current assets files. If you don't define the path to the previous assets and current assets file, the CLI will try to get it from options. If you don't define the path to the current assets file, the CLI will throw an error. - -Available options and arguments: - -- `[oldAssetPath]` - define the path to the current assets file. Can accept path to the folder or path to the zip file -- `[newAssetPath]` - define the path to the previous assets file. Can accept path to the folder or path to the zip file -- `--oldAssetsPath ` - define the path to the previous assets file. Can accept path to the folder or path to the zip file -- `--newAssetsPath ` - define the path to the current assets file. Can accept path to the folder or path to the zip file -- `--destination ` - define the destination path for the changelog file. Default is `.` -- `--filename ` - define the filename for the changelog file. Default is `CHANGELOG.html` - -#### `pp-dev generate-icon-font [source] [destination]` - -Generate an icon font from the source folder. You can define the path to the source folder and the destination folder. If you don't define the path to the source folder, the CLI will try to get it from options. If you don't define the path to the destination folder, the CLI will throw an error. - -Available options and arguments: +| Section | Fields (default) | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mi` | `url`; `token` (`MI_ACCESS_TOKEN` env; keep it in `.env`, never in the config); `mode: 'standalone' \| 'embedding'` (`standalone` hides the MI navigation, `embedding` keeps it); `include: 'top-bar' \| 'shared-components'` (standalone only); `apiVersion: 6 \| 7` (`7`) | +| `app` | `id` (the Portal Page id, required for `type: 'template'`); `type: 'template' \| 'page'` (`template` syncs back to MI); `name` (`package.json#name`) | +| `proxy` | `cache` (`true`); `cacheTtl` (`600000` ms); `tls.allowSelfSigned` (`false`, MI requests only) | +| `build` | `outDir` (`'dist'`); `zip` (`true` or `{ fileName, outDir, inDir }`); `versionFile` (`true`); `imageOptimisations` (`true`) | +| `sync` | `backupsDir` (`'backups'`); `maxBackups` (`20`, `false` keeps all); `buildTimeoutMs` (`600000`) | +| `inspector` | `enabled` (`true`); `maxMemory` (`104857600`, 100 MB); `captureLimit` (`10485760`, 10 MB) | +| `devPanel` | `position` (`'bottom-right'`); `hidden` (`false`); `autoHide` (`false`) | +| `users` | `persist` (`false`): keep the users added with an API token between runs, in `~/.pp-dev/users/` | +| `server` | `allowedHosts` (`[]`): host names the dev server answers besides `localhost`, `*.localhost` and IP addresses, for tunnels; `true` answers any host, avoid it | -- `[source]` - define the path to the source folder with SVG icons -- `[destination]` - define the path to the destination folder for the generated icon font and styles -- `--source ` - define the path to the source folder with SVG icons -- `--destination ` - define the path to the destination folder for the generated icon font and styles -- `--fontName ` - define the font name. Default is `icon-font` +Validation: `mi.include` needs `mi.mode: 'standalone'`; `mi.url` is required when `mi.mode` is `embedding` or `app.type` is `template`; `app.id` is required for `app.type: 'template'` (and for `page` in `standalone` mode). -## Migration guide from old Portal Page Helper to new +Environment: `MI_ACCESS_TOKEN` (default for `mi.token`), `PP_DEV_DIST_ZIP`, `PP_DEV_DIST_ZIP_DIR`, `PP_DEV_DIST_ZIP_FILENAME`, `PP_DEV_VERSION_MANIFEST`, `PP_DEV_VERSION_FILE_TEMPLATE`. The `.env*` files of the project root are loaded (variables prefixed `MI_`); the generated `.gitignore` keeps them out of git. -1. Initialize npm in your portal page repository (if you have `package.json` file in PP folder, you can skip this step): +A config in the 0.x flat format (`backendBaseURL`, `portalPageId`, `miHudLess`, `v7Features`, ...) is converted with `npx @metricinsights/pp-dev migrate` (`--dry-run` to preview; a `.bak` copy is written). - Go to portal page folder and run command. This command will create `package.json` file in your folder +## Next.js - ```shell - $ npm init - ``` +`next.config.js` wraps the Next config with `withPPDev()` (`require` in a CommonJS project, `import` in an ESM one) and sets `output: 'export'`, `distDir: 'dist'` and `images.unoptimized: true`. `pp-dev next` serves the app under `/pl//` in development (API v7) and the export is served by MI under `/pt//`: import static assets in the code (Next applies the right `assetPrefix`) instead of hard-coding either prefix. `pp-dev next` restarts its server when `pp-dev.config.*`, `next.config.*` or `.env*` change. -2. Install this package by this command: - ```shell - $ npm i @metricinsights/pp-dev - ``` -3. Create two scripts in `package.json` script section. +## Dev server - - `start` script: `"start": "pp-dev"` - - `build` script: `"build": "pp-dev build"` +- **Dev panel** in the page: sync the built template to MI (keeps up to `sync.maxBackups` backups of the page in `backupsDir`), open the Variables Editor (`/@pp-dev/variables-editor`) and the Request Inspector (`/@pp-dev/inspector`), work as other MI users (each saved user gets its own host, `.localhost:`). +- The dev server and its own routes answer `localhost`, `*.localhost` and IP hosts only; a tunnel's host name goes into `server.allowedHosts`. +- Personal access tokens authenticate to MI as the developer: keep them in `.env`, bind the server to `localhost` unless another device needs it. -4. Change all paths to the file in index.html to the absolute path. - If you have a path like `/pt/main.js` this must be changed to `/main.js`. - Also, you may need to add `type="module"` to every script that is added by the `script` tag with the `src` property. - Actually would be good to have only one `script` tag with the `src` tag. - Every other JS file will be imported with construction like this `import helper from './helpers';` - - - # Working with Variables Variables must be created on the Metric Insights server tied to App development. Variables are instantiated in the React application via the index.html file specifically From 15405110bdfb63039e836bf756ee60d74bb7b4c0 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 14:06:07 +0200 Subject: [PATCH 15/36] test: the README documents every flag --help lists Refs: PP-4794 --- test/scaffold.spec.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/scaffold.spec.mjs b/test/scaffold.spec.mjs index 521f3d9..7e7e0c4 100644 --- a/test/scaffold.spec.mjs +++ b/test/scaffold.spec.mjs @@ -247,6 +247,18 @@ test.describe('usage and version', () => { expect(run.stdout.trim()).toBe(packageVersion); }); + test('the README documents every flag of --help', offline, async () => { + const usage = runCli(['--help']).stdout; + const flags = [...new Set(usage.match(/--[a-z][a-z-]*/g))]; + const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8'); + + expect(flags.length).toBeGreaterThan(5); + + for (const flag of flags) { + expect(readme, `README.md does not mention ${flag}`).toContain(flag); + } + }); + test('an unknown flag exits 1 with a hint and creates nothing', offline, async () => { await withTmpDir('unknown-flag', async (tmpRoot) => { const run = runCli(['proj', '--template', 'vanilla', '--bogus-flag'], { cwd: tmpRoot }); From 4ed82f61cc1e8bc9f29f40aeb19bd81ae5aa9a1d Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 14:13:04 +0200 Subject: [PATCH 16/36] docs(readme): --yes accepts a non-empty directory only without collisions Follows the --yes change in 2c01ce3 (PP-4793). Refs: PP-4794 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a9b1e5e..0f00dfc 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ npm create @metricinsights/pp-dev@latest . -- --template react | `--install` / `--no-install` | run the package manager's install after scaffolding | install | | `--cursor-rules` / `--no-cursor-rules` | copy the MI Cursor rules into `.cursor/rules` | no | | `--pp-components` / `--no-pp-components` | add `@metricinsights/pp-components` as a dependency | no | -| `-y, --yes` | ask nothing: take the defaults above; a non-empty target directory keeps its files | — | +| `-y, --yes` | ask nothing: take the defaults above; a non-empty target directory is accepted only when the template would overwrite none of its files | — | | `-h, --help` | print the usage | | | `-v, --version` | print the version | | From f6a0072358f59ff2ac265bc2e8338a99ae8c62c2 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 14:14:14 +0200 Subject: [PATCH 17/36] style(readme): prettier formatting of the flags table Refs: PP-4794 --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0f00dfc..f8c723d 100644 --- a/README.md +++ b/README.md @@ -49,17 +49,17 @@ To scaffold in the current directory, use `.` as the project name: npm create @metricinsights/pp-dev@latest . -- --template react ``` -| Flag | Answers | Default with `--yes` | -| ---------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------- | -| `[project-name]` | the directory to create (`.` for the current one) | `pp-project` | -| `-t, --template ` | the template (see below) | none: `--yes` needs `--template` | -| `--package-name ` | `name` in the project's `package.json` | derived from the directory name | -| `--install` / `--no-install` | run the package manager's install after scaffolding | install | -| `--cursor-rules` / `--no-cursor-rules` | copy the MI Cursor rules into `.cursor/rules` | no | -| `--pp-components` / `--no-pp-components` | add `@metricinsights/pp-components` as a dependency | no | -| `-y, --yes` | ask nothing: take the defaults above; a non-empty target directory is accepted only when the template would overwrite none of its files | — | -| `-h, --help` | print the usage | | -| `-v, --version` | print the version | | +| Flag | Answers | Default with `--yes` | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| `[project-name]` | the directory to create (`.` for the current one) | `pp-project` | +| `-t, --template ` | the template (see below) | none: `--yes` needs `--template` | +| `--package-name ` | `name` in the project's `package.json` | derived from the directory name | +| `--install` / `--no-install` | run the package manager's install after scaffolding | install | +| `--cursor-rules` / `--no-cursor-rules` | copy the MI Cursor rules into `.cursor/rules` | no | +| `--pp-components` / `--no-pp-components` | add `@metricinsights/pp-components` as a dependency | no | +| `-y, --yes` | ask nothing: take the defaults above; a non-empty target directory is accepted only when the template would overwrite none of its files | — | +| `-h, --help` | print the usage | | +| `-v, --version` | print the version | | The package manager is the one that runs the CLI (`npm create` → npm, `yarn create` → yarn, `pnpm create` → pnpm). From 7323804fd38e4bf70e7049b6816754af8eb9ea26 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 14:20:24 +0200 Subject: [PATCH 18/36] fix(build): postbuild reuses npm_execpath only when it is npm yarn and pnpm set npm_execpath too, to their own CLI; running that with npm's `pack --json --ignore-scripts` fails (other options, another output shape). postbuild.js now takes npm_execpath only when it ends in npm-cli.js, otherwise the npm that ships with the running node, and npm from PATH as the last resort. The Playwright helpers that run npm had the same assumption and get the same guard. Test: postbuild packs with npm when npm_execpath points at a yarn.js. Refs: PP-4794 --- scripts/postbuild.js | 29 +++++++++++++++++++++++------ test/scaffold.spec.mjs | 32 ++++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/scripts/postbuild.js b/scripts/postbuild.js index 1a36940..ce6e453 100644 --- a/scripts/postbuild.js +++ b/scripts/postbuild.js @@ -1,7 +1,7 @@ // Runs after `npm run build` (unbuild): packs the package and keeps a copy of the tarball as // -latest.tgz, so a fixed file name can be installed from (npm i ../metricinsights-create-pp-dev-latest.tgz). // Tarballs of earlier builds are removed first, so the repository root holds the current version only. -import { copyFileSync, readdirSync, readFileSync, rmSync } from 'fs'; +import { copyFileSync, existsSync, readdirSync, readFileSync, rmSync } from 'fs'; import { execFileSync } from 'child_process'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; @@ -21,11 +21,28 @@ for (const file of readdirSync(rootDir)) { } // Under an npm script, npm_execpath points at npm-cli.js; run that with the current node so the -// same npm packs the package. Outside npm (node scripts/postbuild.js) fall back to npm on PATH. -const [command, ...prefix] = process.env.npm_execpath - ? [process.execPath, process.env.npm_execpath] - : [process.platform === 'win32' ? 'npm.cmd' : 'npm']; -const useShell = process.platform === 'win32' && !process.env.npm_execpath; +// same npm packs the package. Under yarn or pnpm it points at their CLI, whose `pack` takes other +// options and prints another shape, so then (and outside any package manager) use the npm that +// ships with the running node, or npm from PATH as the last resort. +function findNpmCli() { + const fromEnv = process.env.npm_execpath; + + if (fromEnv && /npm-cli\.js$/.test(fromEnv)) { + return fromEnv; + } + + const nodeDir = dirname(process.execPath); + const candidates = [ + join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'), // Windows installs, nvm-windows + join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), // Linux, macOS, nvm + ]; + + return candidates.find((candidate) => existsSync(candidate)); +} + +const npmCli = findNpmCli(); +const [command, ...prefix] = npmCli ? [process.execPath, npmCli] : [process.platform === 'win32' ? 'npm.cmd' : 'npm']; +const useShell = process.platform === 'win32' && !npmCli; // prepack (clean-templates.js) prints to stdout, where `npm pack --json` prints its result too, so // run the cleanup here and pack with the scripts off; `npm publish` in CI still runs prepack. diff --git a/test/scaffold.spec.mjs b/test/scaffold.spec.mjs index 7e7e0c4..326039b 100644 --- a/test/scaffold.spec.mjs +++ b/test/scaffold.spec.mjs @@ -30,11 +30,14 @@ const unreachableRegistryEnv = { }; /** - * When tests run via `npm test`, npm sets `npm_execpath` to npm-cli.js. IDE / `npx playwright` - * does not — fall back to `npm` on PATH (needs shell on Windows for npm.cmd). + * When tests run via `npm test`, npm sets `npm_execpath` to npm-cli.js. Under yarn or pnpm it is + * their CLI instead, and IDE / `npx playwright` sets nothing — fall back to `npm` on PATH then + * (needs shell on Windows for npm.cmd). */ +const npmExecPath = /npm-cli\.js$/.test(process.env.npm_execpath ?? '') ? process.env.npm_execpath : undefined; + function runNpmSync(args, options) { - const npmExec = process.env.npm_execpath; + const npmExec = npmExecPath; if (npmExec) { return spawnSync(process.execPath, [npmExec, ...args], options); @@ -44,7 +47,7 @@ function runNpmSync(args, options) { } function runNpm(args, options) { - const npmExec = process.env.npm_execpath; + const npmExec = npmExecPath; if (npmExec) { return spawn(process.execPath, [npmExec, ...args], options); @@ -626,6 +629,27 @@ test.describe('package installation', () => { }); }); +test.describe('build scripts', () => { + test('postbuild packs with npm even when another package manager runs the build', offline, async () => { + test.setTimeout(120_000); + + // yarn and pnpm also set npm_execpath, to their own CLI; postbuild must not hand npm's arguments to it + const run = spawnSync(process.execPath, [path.join(repoRoot, 'scripts', 'postbuild.js')], { + cwd: repoRoot, + encoding: 'utf8', + env: { ...process.env, npm_execpath: path.join(repoRoot, 'node_modules', 'not-installed', 'yarn.js') }, + }); + + expect( + run.status, + `stderr: ${run.stderr} +stdout: ${run.stdout}`, + ).toBe(0); + expect(run.stdout).toMatch(/Packed metricinsights-create-pp-dev-\d.*-latest\.tgz/); + expect(fs.existsSync(path.join(repoRoot, 'metricinsights-create-pp-dev-latest.tgz'))).toBeTruthy(); + }); +}); + test.describe('npm create flow', () => { for (const template of templates) { test(`npm create (npm exec) + npm install for ${template.name} template`, async () => { From e43dd84c86b782a65fa3bea8871c0556fa59afbd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:23:45 +0000 Subject: [PATCH 19/36] fix(deps): bump the templates-major group across 1 directory with 9 updates Updates the requirements on [react](https://github.com/react/react/tree/HEAD/packages/react), [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom), [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser), [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react), [eslint](https://github.com/eslint/eslint), [globals](https://github.com/sindresorhus/globals), [prettier](https://github.com/prettier/prettier) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) to permit the latest version. Updates `react` to 19.3.1 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.3.1/packages/react) Updates `react-dom` to 19.3.1 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.3.1/packages/react-dom) Updates `@typescript-eslint/eslint-plugin` to 8.72.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.72.1/packages/eslint-plugin) Updates `@typescript-eslint/parser` to 8.72.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.72.1/packages/parser) Updates `@vitejs/plugin-react` from 6.3.0 to 6.2.1 - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.2.1/packages/plugin-react) Updates `eslint` to 10.14.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.12.0...v10.14.0) Updates `globals` to 17.14.0 - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.13.0...v17.14.0) Updates `prettier` to 3.9.10 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.9.9...3.9.10) Updates `vite` from 8.3.7 to 8.3.6 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.3.6/packages/vite) --- updated-dependencies: - dependency-name: "@typescript-eslint/eslint-plugin" dependency-version: 8.72.1 dependency-type: direct:development dependency-group: templates-major - dependency-name: "@typescript-eslint/parser" dependency-version: 8.72.1 dependency-type: direct:development dependency-group: templates-major - dependency-name: "@vitejs/plugin-react" dependency-version: 6.2.1 dependency-type: direct:development dependency-group: templates-major - dependency-name: eslint dependency-version: 10.14.0 dependency-type: direct:development dependency-group: templates-major - dependency-name: globals dependency-version: 17.14.0 dependency-type: direct:development dependency-group: templates-major - dependency-name: prettier dependency-version: 3.9.10 dependency-type: direct:development dependency-group: templates-major - dependency-name: react dependency-version: 19.3.1 dependency-type: direct:production dependency-group: templates-major - dependency-name: react-dom dependency-version: 19.3.1 dependency-type: direct:production dependency-group: templates-major - dependency-name: vite dependency-version: 8.3.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: templates-major ... Signed-off-by: dependabot[bot] --- template-react/package.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/template-react/package.json b/template-react/package.json index bf30c1b..d234b26 100644 --- a/template-react/package.json +++ b/template-react/package.json @@ -15,8 +15,8 @@ }, "dependencies": { "clsx": "^2.1.1", - "react": "^19.3.0", - "react-dom": "^19.3.0" + "react": "^19.3.1", + "react-dom": "^19.3.1" }, "devDependencies": { "@eslint/compat": "^2.1.1", @@ -26,15 +26,15 @@ "@stylistic/eslint-plugin": "^5.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", - "@typescript-eslint/eslint-plugin": "^8.71.1", - "@typescript-eslint/parser": "^8.71.1", - "@vitejs/plugin-react": "^6.1.2", - "eslint": "^10.12.0", + "@typescript-eslint/eslint-plugin": "^8.72.1", + "@typescript-eslint/parser": "^8.72.1", + "@vitejs/plugin-react": "^6.2.1", + "eslint": "^10.14.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.7", - "globals": "^17.13.0", - "prettier": "^3.9.9", + "globals": "^17.14.0", + "prettier": "^3.9.10", "typescript": "^6.0.3", - "vite": "^8.3.4" + "vite": "^8.3.6" } } From 6614f1485a0cebf1a388f512a7c1c9231c3d23bc Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 16:30:40 +0200 Subject: [PATCH 20/36] fix(cli): hide registry credentials, --yes checks Cursor rules too, dev-server test on a free port Second Copilot round on #359/#360: - The warning for an unreachable registry printed npm_config_registry verbatim, so a URL with basic-auth userinfo would land its password in the terminal or a CI log. The URL is shown without userinfo, and the same redaction is applied to the error text. - With --yes --cursor-rules, the collision check looked at the template entries only, so an existing .cursor/rules/*.mdc was overwritten despite the promise to keep the files. The requested rule destinations are part of the check now. - The dev-server test polled the fixed port 4173 and accepted any response, so an unrelated service on that port made it pass. It takes a free port, starts pp-dev with --strictPort and waits for the 302 to /pl// that only the scaffolded app answers with. Regression tests: "the registry warning does not repeat credentials from the registry URL" and "--yes --cursor-rules refuses a directory with a Cursor rule it would overwrite". --- src/index.ts | 50 +++++++++++++++-------- test/scaffold.spec.mjs | 91 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 117 insertions(+), 24 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9029634..9569a8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import * as p from '@clack/prompts'; // dist/index.mjs is bundled into /dist; the templates, the Cursor rules and package.json // sit next to that directory. const packageRoot = path.resolve(fileURLToPath(import.meta.url), '../..'); +const cursorRulesDir = path.join(packageRoot, 'mi-cursor-rules'); const cwd = process.cwd(); // clack can only ask questions on a terminal. In a pipe or in CI every answer has to come from a @@ -202,6 +203,20 @@ async function chooseTemplate(): Promise { ); } +/** The URL without its userinfo, for messages: `https://user:secret@host/x` becomes `https://host/x`. */ +function withoutCredentials(url: string): string { + try { + const parsed = new URL(url); + + parsed.username = ''; + parsed.password = ''; + + return parsed.href.replace(/\/+$/, ''); + } catch { + return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/i, '$1'); + } +} + /** * The newest published version of pp-components, read from the registry npm is configured with. * A registry that is down, slow or answers something else is a warning, not a failure: the project @@ -227,11 +242,14 @@ async function latestPpComponentsVersion(): Promise { return latest; } catch (error) { - const reason = error instanceof Error ? error.message : String(error); + // A registry URL may carry basic-auth userinfo (https://user:secret@registry.example), which must + // not end up in a terminal or a CI log, neither on its own nor quoted inside the error. + const shownRegistry = withoutCredentials(registry); + const reason = (error instanceof Error ? error.message : String(error)).replaceAll(registry, shownRegistry); p.log.warn( - `Could not read the latest ${PP_COMPONENTS_PACKAGE} version from ${url} (${reason}); ` + - `using ^${PP_COMPONENTS_FALLBACK_VERSION}.`, + `Could not read the latest ${PP_COMPONENTS_PACKAGE} version from ${shownRegistry}/${PP_COMPONENTS_PACKAGE} ` + + `(${reason}); using ^${PP_COMPONENTS_FALLBACK_VERSION}.`, ); return PP_COMPONENTS_FALLBACK_VERSION; @@ -377,18 +395,21 @@ async function init() { } if (assumeDefaults) { - // "Keep the files" has to mean it: with nobody to ask, a template file that would land on an - // existing one stops the run before anything is written. + // "Keep the files" has to mean it: with nobody to ask, a template file or a Cursor rule that + // would land on an existing one stops the run before anything is written. const templateDir = path.join(packageRoot, `template-${argTemplate}`); - const collisions = fs - .readdirSync(templateDir) - .map((file) => renameFiles[file] ?? file) - .filter((name) => fs.existsSync(path.join(targetDir, name))); + const wouldWrite = fs.readdirSync(templateDir).map((file) => renameFiles[file] ?? file); + + if (shouldAddCursorRules) { + wouldWrite.push(...fs.readdirSync(cursorRulesDir).map((file) => `.cursor/rules/${file}`)); + } + + const collisions = wouldWrite.filter((name) => fs.existsSync(path.join(targetDir, name))); if (collisions.length > 0) { fail( - `${where} already contains ${collisions.join(', ')}, which the ${argTemplate} template would ` + - 'overwrite. Use an empty directory, or run without --yes to choose.', + `${where} already contains ${collisions.join(', ')}, which this run would overwrite. ` + + 'Use an empty directory, or run without --yes to choose.', ); } @@ -472,13 +493,10 @@ async function init() { const cdProjectName = path.relative(cwd, root); if (addCursorRules) { - const rulesDir = path.join(packageRoot, 'mi-cursor-rules'); - const ruleFiles = fs.readdirSync(rulesDir); - fs.mkdirSync(path.join(root, '.cursor/rules'), { recursive: true }); - for (const file of ruleFiles) { - copy(path.join(rulesDir, file), path.join(root, '.cursor/rules', file)); + for (const file of fs.readdirSync(cursorRulesDir)) { + copy(path.join(cursorRulesDir, file), path.join(root, '.cursor/rules', file)); } } diff --git a/test/scaffold.spec.mjs b/test/scaffold.spec.mjs index 521f3d9..547caa8 100644 --- a/test/scaffold.spec.mjs +++ b/test/scaffold.spec.mjs @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import fs from 'node:fs'; +import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -129,12 +130,32 @@ function runNpmCreateFromLocalPackage({ targetDir, template, packageName, ...opt } /** - * Resolves once the dev server answers HTTP on `url` (any status counts), rejects when the child - * exits first or the budget runs out. The server's output is kept for the error message. + * A TCP port nothing listens on at this moment. The server is then started with --strictPort, so + * losing the port to another process in between fails the test instead of passing it by accident. */ -function waitForHttp(child, url, timeoutMs) { +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + + server.close(() => resolve(port)); + }); + }); +} + +/** + * Resolves once a request to `url` gets a response that `isReady` accepts, rejects when the child + * exits first or the budget runs out. A response from something else listening on the port keeps + * the polling going. The server's output is kept for the error message. + */ +function waitForHttp(child, url, isReady, timeoutMs) { return new Promise((resolve, reject) => { let output = ''; + let lastResponse = 'none'; let exited = false; let finished = false; @@ -179,14 +200,22 @@ function waitForHttp(child, url, timeoutMs) { const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(2_000) }); await response.arrayBuffer(); - finish(); + lastResponse = `HTTP ${response.status}, location: ${response.headers.get('location') ?? 'none'}`; + + if (isReady(response)) { + finish(); + } } catch { // not listening yet } }, 500); const timer = setTimeout(() => { - finish(new Error(`timed out waiting for the dev server at ${url}\noutput:\n${output}`)); + finish( + new Error( + `timed out waiting for the dev server at ${url} (last response: ${lastResponse})\noutput:\n${output}`, + ), + ); }, timeoutMs); }); } @@ -360,6 +389,23 @@ test.describe('argument validation happens before anything is written', () => { }); }); + test('--yes --cursor-rules refuses a directory with a Cursor rule it would overwrite', offline, async () => { + await withTmpDir('yes-rule-collision', async (tmpRoot) => { + const targetDir = nonEmptyTarget(tmpRoot); + const ruleFile = path.join(targetDir, '.cursor', 'rules', 'mi-custom-apps-dev.mdc'); + + fs.mkdirSync(path.dirname(ruleFile), { recursive: true }); + fs.writeFileSync(ruleFile, 'customized'); + + const run = runCli([targetDir, '--template', 'vanilla', '-y', '--cursor-rules', '--no-install']); + + expect(run.status).toBe(1); + expect(run.stdout + run.stderr).toContain('.cursor/rules/mi-custom-apps-dev.mdc'); + expect(fs.readdirSync(targetDir).sort()).toEqual(['.cursor', existingFile]); + expect(fs.readFileSync(ruleFile, 'utf8')).toBe('customized'); + }); + }); + test('a filesystem root as target keeps its trailing separator', offline, async () => { // "C:\\" stripped to "C:" would be a drive-relative path and resolve to the current directory const root = path.parse(os.tmpdir()).root; @@ -560,6 +606,30 @@ test.describe('scaffolding', () => { expect(run.stdout).toContain('Project created successfully'); }); }); + + test('the registry warning does not repeat credentials from the registry URL', offline, async () => { + await withTmpDir('registry-creds', async (tmpRoot) => { + const targetDir = path.join(tmpRoot, 'redacted-registry'); + const run = scaffoldProject({ + targetDir, + template: 'vanilla', + packageName: 'redacted-registry', + install: false, + cursorRules: false, + ppComponents: true, + env: { ...unreachableRegistryEnv, npm_config_registry: 'http://user:s3cret-token@127.0.0.1:9' }, + }); + const output = run.stdout + run.stderr; + + expect(run.status, `stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(0); + expect(output).toContain('http://127.0.0.1:9/@metricinsights/pp-components'); + expect(output).not.toContain('s3cret-token'); + expect(output).not.toContain('user:'); + expect(readJson(path.join(targetDir, 'package.json')).dependencies['@metricinsights/pp-components']).toBe( + '^1.0.0', + ); + }); + }); }); test.describe('package installation', () => { @@ -657,7 +727,7 @@ test('runs dev server for scaffolded vanilla project', async () => { await withTmpDir('devserver', async (tmpRoot) => { const packageName = 'sample-vanilla-server'; const targetDir = path.join(tmpRoot, packageName); - const port = 4173; + const port = await freePort(); const scaffold = scaffoldProject({ targetDir, @@ -693,13 +763,18 @@ test('runs dev server for scaffolded vanilla project', async () => { expect(install.error, `install spawn error: ${install.error?.message ?? 'none'}`).toBeUndefined(); expect(install.status, `stderr: ${install.stderr}\nstdout: ${install.stdout}`).toBe(0); - const devServer = runNpm(['run', 'dev', '--', '--host', '127.0.0.1', '--port', String(port)], { + const devServer = runNpm(['run', 'dev', '--', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { cwd: targetDir, stdio: ['ignore', 'pipe', 'pipe'], }); + // pp-dev answers "/" with a redirect to the page's own base, /pl// — that is what + // tells this server apart from anything else that could answer on the port. + const isScaffoldedApp = (response) => + response.status === 302 && (response.headers.get('location') ?? '').includes(`/${packageName}/`); + try { - await waitForHttp(devServer, `http://127.0.0.1:${port}/`, 60_000); + await waitForHttp(devServer, `http://127.0.0.1:${port}/`, isScaffoldedApp, 60_000); } finally { stopProcessTree(devServer.pid); } From 6084c145021a9eae8f618d8a25c6152174440e3c Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 16:31:24 +0200 Subject: [PATCH 21/36] docs(readme): --yes also refuses to overwrite existing Cursor rules --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f8c723d..409980b 100644 --- a/README.md +++ b/README.md @@ -49,17 +49,17 @@ To scaffold in the current directory, use `.` as the project name: npm create @metricinsights/pp-dev@latest . -- --template react ``` -| Flag | Answers | Default with `--yes` | -| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | -| `[project-name]` | the directory to create (`.` for the current one) | `pp-project` | -| `-t, --template ` | the template (see below) | none: `--yes` needs `--template` | -| `--package-name ` | `name` in the project's `package.json` | derived from the directory name | -| `--install` / `--no-install` | run the package manager's install after scaffolding | install | -| `--cursor-rules` / `--no-cursor-rules` | copy the MI Cursor rules into `.cursor/rules` | no | -| `--pp-components` / `--no-pp-components` | add `@metricinsights/pp-components` as a dependency | no | -| `-y, --yes` | ask nothing: take the defaults above; a non-empty target directory is accepted only when the template would overwrite none of its files | — | -| `-h, --help` | print the usage | | -| `-v, --version` | print the version | | +| Flag | Answers | Default with `--yes` | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| `[project-name]` | the directory to create (`.` for the current one) | `pp-project` | +| `-t, --template ` | the template (see below) | none: `--yes` needs `--template` | +| `--package-name ` | `name` in the project's `package.json` | derived from the directory name | +| `--install` / `--no-install` | run the package manager's install after scaffolding | install | +| `--cursor-rules` / `--no-cursor-rules` | copy the MI Cursor rules into `.cursor/rules` | no | +| `--pp-components` / `--no-pp-components` | add `@metricinsights/pp-components` as a dependency | no | +| `-y, --yes` | ask nothing: take the defaults above; a non-empty target directory is accepted only when nothing in it would be overwritten (template files, Cursor rules) | — | +| `-h, --help` | print the usage | | +| `-v, --version` | print the version | | The package manager is the one that runs the CLI (`npm create` → npm, `yarn create` → yarn, `pnpm create` → pnpm). From 7be8414ed2da6fcba434bf7c5b3dc0a6645adc48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:46:44 +0000 Subject: [PATCH 22/36] chore(deps-dev): bump @typescript-eslint/eslint-plugin Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.71.1 to 8.72.1. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.72.1/packages/eslint-plugin) --- updated-dependencies: - dependency-name: "@typescript-eslint/eslint-plugin" dependency-version: 8.71.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 108 +++++++++++++++++++++++----------------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index 21c2502..374e710 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1557,17 +1557,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", - "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz", + "integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.71.1", - "@typescript-eslint/type-utils": "8.71.1", - "@typescript-eslint/utils": "8.71.1", - "@typescript-eslint/visitor-keys": "8.71.1", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/type-utils": "8.72.1", + "@typescript-eslint/utils": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1580,7 +1580,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.71.1", + "@typescript-eslint/parser": "^8.72.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1596,16 +1596,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", - "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz", + "integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.71.1", - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/typescript-estree": "8.71.1", - "@typescript-eslint/visitor-keys": "8.71.1", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", "debug": "^4.4.3" }, "engines": { @@ -1621,14 +1621,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", - "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz", + "integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.71.1", - "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/tsconfig-utils": "^8.72.1", + "@typescript-eslint/types": "^8.72.1", "debug": "^4.4.3" }, "engines": { @@ -1643,14 +1643,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", - "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz", + "integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/visitor-keys": "8.71.1" + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1661,9 +1661,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", - "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz", + "integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==", "dev": true, "license": "MIT", "engines": { @@ -1678,15 +1678,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", - "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz", + "integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/typescript-estree": "8.71.1", - "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/utils": "8.72.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1703,9 +1703,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", - "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz", + "integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==", "dev": true, "license": "MIT", "engines": { @@ -1717,16 +1717,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", - "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz", + "integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.71.1", - "@typescript-eslint/tsconfig-utils": "8.71.1", - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/visitor-keys": "8.71.1", + "@typescript-eslint/project-service": "8.72.1", + "@typescript-eslint/tsconfig-utils": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1784,16 +1784,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", - "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz", + "integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.71.1", - "@typescript-eslint/types": "8.71.1", - "@typescript-eslint/typescript-estree": "8.71.1" + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1808,13 +1808,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", - "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz", + "integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/types": "8.72.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { From 0608a788b383cc828fa9db78d21c64af9411e097 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:56:17 +0000 Subject: [PATCH 23/36] chore(deps-dev): bump prettier from 3.9.9 to 3.9.10 Bumps [prettier](https://github.com/prettier/prettier) from 3.9.9 to 3.9.10. - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.9.9...3.9.10) --- updated-dependencies: - dependency-name: prettier dependency-version: 3.9.10 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 21c2502..91b3537 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "@metricinsights/create-pp-dev", "version": "1.1.7", - "license": "ISC", + "license": "MIT", "dependencies": { "@clack/prompts": "^1.8.1", "cross-spawn": "^7.0.6" @@ -26,7 +26,7 @@ "@typescript-eslint/parser": "^8.71.1", "eslint": "^10.12.0", "globals": "^17.13.0", - "prettier": "^3.9.9", + "prettier": "^3.9.10", "unbuild": "^3.6.1" }, "engines": { @@ -4059,9 +4059,9 @@ } }, "node_modules/prettier": { - "version": "3.9.9", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", - "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 153e78c..741acbc 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "@typescript-eslint/parser": "^8.71.1", "eslint": "^10.12.0", "globals": "^17.13.0", - "prettier": "^3.9.9", + "prettier": "^3.9.10", "unbuild": "^3.6.1" }, "dependencies": { From 322a4179f5e6fe9732a120c017bc22fe98de2288 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 18:05:59 +0200 Subject: [PATCH 24/36] ci: audit the package and every template with npm run audit:all The templates keep no lockfile, so the shared CI audit covered the root package only and a vulnerable pin in a template (next 16.3.8, four critical alerts on main since October) surfaced as Dependabot alerts alone. scripts/audit-all.mjs runs npm audit for the root and for each template, resolving the template in a temporary directory with npm install --package-lock-only, and fails on a high or critical advisory that the shared audit-allowlist.json does not cover (read from AUDIT_ALLOWLIST_URL, which the shared workflows set, else from main). It is the audit-command of both the pull-request CI and the weekly dependency audit; the callers are regenerated. scripts/npm-cli.js holds the npm lookup that postbuild.js had (npm_execpath only when it is npm-cli.js, else the npm next to node, else PATH), shared by both scripts. --- .github/mi-examples-workflows.json | 6 +- .github/workflows/ci.yml | 1 + .github/workflows/dependency-audit.yml | 2 + CLAUDE.md | 2 +- README.md | 1 + package.json | 1 + scripts/audit-all.mjs | 244 +++++++++++++++++++++++++ scripts/npm-cli.js | 38 ++++ scripts/postbuild.js | 32 +--- 9 files changed, 298 insertions(+), 29 deletions(-) create mode 100644 scripts/audit-all.mjs create mode 100644 scripts/npm-cli.js diff --git a/.github/mi-examples-workflows.json b/.github/mi-examples-workflows.json index 92128b9..81cff51 100644 --- a/.github/mi-examples-workflows.json +++ b/.github/mi-examples-workflows.json @@ -8,7 +8,11 @@ ], "inputs": { "ci": { - "dist-dir": "dist" + "dist-dir": "dist", + "audit-command": "npm run audit:all" + }, + "dependency-audit": { + "audit-command": "npm run audit:all" } } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbadc9d..00489ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,4 @@ jobs: uses: mi-examples/mi-examples-workflows/.github/workflows/node-ci.yml@75e92a129ce7adbf059e73e83ce9e14222e7c7a2 # v0.10.0 with: dist-dir: "dist" + audit-command: "npm run audit:all" diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml index 184e8eb..1da0c6e 100644 --- a/.github/workflows/dependency-audit.yml +++ b/.github/workflows/dependency-audit.yml @@ -21,3 +21,5 @@ jobs: permissions: contents: read uses: mi-examples/mi-examples-workflows/.github/workflows/dependency-audit.yml@75e92a129ce7adbf059e73e83ce9e14222e7c7a2 # v0.10.0 + with: + audit-command: "npm run audit:all" diff --git a/CLAUDE.md b/CLAUDE.md index 0906f70..ec38723 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ npm test # builds, then the Playwright suite: scaffolds and instal npx playwright test --grep @offline # the quick subset: no network, under half a minute ``` -The templates have no lockfiles in the repository (`.gitignore`, `scripts/clean-templates.js`). To audit one, copy its `package.json` to a temporary directory, run `npm install --package-lock-only` there and `npm audit`. +The templates have no lockfiles in the repository (`.gitignore`, `scripts/clean-templates.js`), so a plain `npm audit` covers the package itself only. `npm run audit:all` audits the root and every template (each template is resolved in a temporary directory with `npm install --package-lock-only`); the shared CI runs it on pull requests and weekly. A high or critical advisory without a fix goes into the shared `audit-allowlist.json` in mi-examples-workflows with a reason and an `until` date, never into this repository. ## Layout diff --git a/README.md b/README.md index 409980b..1c840be 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ npm run build # build dist/index.mjs and pack the tarball npm run lint npm run typecheck npm run format:check +npm run audit:all # npm audit for the package and every template (each resolved in a temp dir) npm test # builds, then scaffolds and installs every template (minutes) npx playwright test --grep @offline # the quick subset without network ``` diff --git a/package.json b/package.json index 153e78c..8a36f11 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "prepack": "node scripts/clean-templates.js", "postbuild": "node scripts/postbuild.js", "typecheck": "tsc --noEmit", + "audit:all": "node scripts/audit-all.mjs", "pretest": "unbuild", "test": "playwright test" }, diff --git a/scripts/audit-all.mjs b/scripts/audit-all.mjs new file mode 100644 index 0000000..525d2fc --- /dev/null +++ b/scripts/audit-all.mjs @@ -0,0 +1,244 @@ +/** + * Runs `npm audit --json` for the package itself and for every template, and fails (exit 1) when + * any of them has a high or critical vulnerability that is not allowlisted. `npm run audit:all`. + * + * The templates keep no lockfile in the repository (.gitignore, scripts/clean-templates.js), and + * npm audit needs one, so each template's package.json is copied to a temporary directory where + * `npm install --package-lock-only` resolves the tree that a scaffolded project would get today. + * That is also why a template can fail from one day to the next without a change here: the + * advisory is new, or a transitive dependency moved. + * + * The allowlist is the shared one in mi-examples-workflows (audit-allowlist.json), read on every + * run: from AUDIT_ALLOWLIST_URL, which the shared CI audit sets, else from its main branch. It + * holds advisories with no upstream fix that cannot be reached, each with a reason and an `until` + * date after which it stops applying. Add or remove entries there, not here. If the list cannot be + * loaded, nothing is allowlisted. AUDIT_ALLOWLIST_URL=none audits without it. + */ +import { copyFileSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { npmCommand } from './npm-cli.js'; + +const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..'); + +const DEFAULT_ALLOWLIST_URL = + 'https://raw.githubusercontent.com/mi-examples/mi-examples-workflows/main/audit-allowlist.json'; +const FAILING_SEVERITIES = new Set(['high', 'critical']); +const MAX_BUFFER = 64 * 1024 * 1024; + +/** Loads the shared allowlist: GHSA id -> reason, for the entries that have not expired. */ +async function loadAllowlist() { + const source = process.env.AUDIT_ALLOWLIST_URL || DEFAULT_ALLOWLIST_URL; + + if (source === 'none') { + return new Map(); + } + + try { + let text; + + if (/^https?:\/\//.test(source)) { + const response = await fetch(source, { signal: AbortSignal.timeout(15_000) }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + text = await response.text(); + } else { + text = readFileSync(source, 'utf8'); + } + + // An entry applies through its `until` day, in UTC. + const today = new Date().toISOString().slice(0, 10); + const allowlist = new Map(); + + for (const entry of JSON.parse(text).entries ?? []) { + const valid = + /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/.test(entry?.id ?? '') && + typeof entry.reason === 'string' && + entry.reason.trim() !== '' && + /^\d{4}-\d{2}-\d{2}$/.test(entry.until ?? ''); + + if (!valid) { + console.warn(`⚠ Ignoring an invalid audit allowlist entry: ${JSON.stringify(entry)}`); + } else if (entry.until < today) { + console.warn(`✗ The audit allowlist entry for ${entry.id} expired on ${entry.until}, so it applies no more.`); + } else { + allowlist.set(entry.id, `${entry.reason.trim()} (until ${entry.until})`); + } + } + + console.log(`Audit allowlist: ${source} (${allowlist.size} active)`); + + return allowlist; + } catch (error) { + console.warn(`⚠ Could not load the audit allowlist from ${source} (${error.message}); nothing is allowlisted.`); + + return new Map(); + } +} + +function runNpm(args, cwd) { + const { command, args: fullArgs, shell } = npmCommand(args); + + return spawnSync(command, fullArgs, { cwd, encoding: 'utf8', shell, maxBuffer: MAX_BUFFER }); +} + +/** The GHSA id in an advisory URL, as GitHub writes it (GHSA-jmr9-qjv8-65gv). */ +function ghsaIdFromUrl(url) { + const match = /GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}/i.exec(url ?? ''); + + return match ? match[0] : null; +} + +/** The advisories a vulnerable package ultimately stems from, through the `via` chain. */ +function resolveGhsaIds(vulnerabilities, name, seen) { + if (seen.has(name)) { + return []; + } + + seen.add(name); + + const entry = vulnerabilities[name]; + + if (!entry) { + return [`UNKNOWN:${name}`]; + } + + const ids = []; + + for (const via of entry.via) { + if (typeof via === 'string') { + ids.push(...resolveGhsaIds(vulnerabilities, via, seen)); + } else { + ids.push(ghsaIdFromUrl(via.url) ?? `UNKNOWN:${via.title ?? name}`); + } + } + + return ids; +} + +/** + * `npm audit --json` in `cwd`, checked against the allowlist. npm exits non-zero whenever it finds + * anything, so only the report decides. + */ +function auditDirectory(cwd, allowlist) { + const spawned = runNpm(['audit', '--json'], cwd); + + if (spawned.error || !spawned.stdout) { + console.error(spawned.error ?? spawned.stderr ?? 'npm audit produced no output'); + + return false; + } + + let report; + + try { + report = JSON.parse(spawned.stdout); + } catch (error) { + console.error(`Could not parse the npm audit output: ${error.message}\n${spawned.stdout}`); + + return false; + } + + if (report.error) { + console.error(`npm audit failed: ${report.error.summary ?? report.error.code ?? JSON.stringify(report.error)}`); + + return false; + } + + const vulnerabilities = report.vulnerabilities ?? {}; + const counts = report.metadata?.vulnerabilities ?? {}; + let unresolved = 0; + + for (const [name, entry] of Object.entries(vulnerabilities)) { + if (!FAILING_SEVERITIES.has(entry.severity)) { + continue; + } + + const ghsaIds = [...new Set(resolveGhsaIds(vulnerabilities, name, new Set()))]; + const unallowlisted = ghsaIds.filter((id) => !allowlist.has(id)); + + if (unallowlisted.length === 0) { + const reasons = ghsaIds.map((id) => `${id} — ${allowlist.get(id)}`).join('; '); + + console.log(` ⚠ ${name} (${entry.severity}) — ALLOWLISTED: ${reasons}`); + continue; + } + + unresolved += 1; + console.log(` ✗ ${name} (${entry.severity}) — ${unallowlisted.join(', ')}`); + } + + const lower = ['moderate', 'low', 'info'] + .filter((severity) => counts[severity] > 0) + .map((severity) => `${counts[severity]} ${severity}`) + .join(', '); + + if (unresolved === 0) { + console.log(` ✓ no unresolved high/critical vulnerabilities${lower ? ` (${lower}, not failing)` : ''}`); + } + + return unresolved === 0; +} + +/** The package itself: its own package-lock.json is in the repository. */ +function auditRoot(allowlist) { + return auditDirectory(rootDir, allowlist); +} + +/** A template: resolve its tree from package.json alone, in a temporary directory. */ +function auditTemplate(template, allowlist) { + const tmp = mkdtempSync(join(tmpdir(), `create-pp-dev-audit-${template}-`)); + + try { + copyFileSync(join(rootDir, template, 'package.json'), join(tmp, 'package.json')); + + const install = runNpm( + ['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--fund=false', '--loglevel=error'], + tmp, + ); + + if (install.error || install.status !== 0) { + const reason = install.error?.message ?? install.stderr; + + console.error(`npm install --package-lock-only failed for ${template}:\n${reason}`); + + return false; + } + + return auditDirectory(tmp, allowlist); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + +const allowlist = await loadAllowlist(); +const templates = readdirSync(rootDir) + .filter((entry) => entry.startsWith('template-')) + .sort(); +const bar = '='.repeat(60); +const results = []; + +for (const { label, run } of [ + { label: 'root', run: () => auditRoot(allowlist) }, + ...templates.map((template) => ({ label: template, run: () => auditTemplate(template, allowlist) })), +]) { + console.log(`\n${bar}\n npm audit — ${label}\n${bar}\n`); + results.push({ label, ok: run() }); +} + +console.log(`\n${bar}\n Audit summary\n${bar}`); + +for (const { label, ok } of results) { + console.log(` ${ok ? '✓' : '✗'} ${label}: ${ok ? 'ok' : 'failed'}`); +} + +console.log(''); + +if (results.some(({ ok }) => !ok)) { + process.exit(1); +} diff --git a/scripts/npm-cli.js b/scripts/npm-cli.js new file mode 100644 index 0000000..d78b0e1 --- /dev/null +++ b/scripts/npm-cli.js @@ -0,0 +1,38 @@ +// How to start npm from a script, so the build scripts do not spawn `npm` through a shell. +// +// Under an npm script, npm_execpath points at npm-cli.js; run that with the current node so the +// same npm is used. Under yarn or pnpm it points at their CLI, whose commands take other options +// and print another shape, so then (and outside any package manager) use the npm that ships with +// the running node, or npm from PATH as the last resort (which needs a shell on Windows, npm.cmd). +import { existsSync } from 'fs'; +import { dirname, join } from 'path'; + +function findNpmCli() { + const fromEnv = process.env.npm_execpath; + + if (fromEnv && /npm-cli\.js$/.test(fromEnv)) { + return fromEnv; + } + + const nodeDir = dirname(process.execPath); + const candidates = [ + join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'), // Windows installs, nvm-windows + join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), // Linux, macOS, nvm + ]; + + return candidates.find((candidate) => existsSync(candidate)); +} + +/** + * The command, leading arguments and `shell` option that start npm; `args` are appended. + * Usage: `const { command, args, shell } = npmCommand(['pack', '--json']);` + */ +export function npmCommand(args) { + const npmCli = findNpmCli(); + + if (npmCli) { + return { command: process.execPath, args: [npmCli, ...args], shell: false }; + } + + return { command: process.platform === 'win32' ? 'npm.cmd' : 'npm', args, shell: process.platform === 'win32' }; +} diff --git a/scripts/postbuild.js b/scripts/postbuild.js index ce6e453..469c0aa 100644 --- a/scripts/postbuild.js +++ b/scripts/postbuild.js @@ -1,10 +1,11 @@ // Runs after `npm run build` (unbuild): packs the package and keeps a copy of the tarball as // -latest.tgz, so a fixed file name can be installed from (npm i ../metricinsights-create-pp-dev-latest.tgz). // Tarballs of earlier builds are removed first, so the repository root holds the current version only. -import { copyFileSync, existsSync, readdirSync, readFileSync, rmSync } from 'fs'; +import { copyFileSync, readdirSync, readFileSync, rmSync } from 'fs'; import { execFileSync } from 'child_process'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; +import { npmCommand } from './npm-cli.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..'); @@ -20,39 +21,16 @@ for (const file of readdirSync(rootDir)) { } } -// Under an npm script, npm_execpath points at npm-cli.js; run that with the current node so the -// same npm packs the package. Under yarn or pnpm it points at their CLI, whose `pack` takes other -// options and prints another shape, so then (and outside any package manager) use the npm that -// ships with the running node, or npm from PATH as the last resort. -function findNpmCli() { - const fromEnv = process.env.npm_execpath; - - if (fromEnv && /npm-cli\.js$/.test(fromEnv)) { - return fromEnv; - } - - const nodeDir = dirname(process.execPath); - const candidates = [ - join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'), // Windows installs, nvm-windows - join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), // Linux, macOS, nvm - ]; - - return candidates.find((candidate) => existsSync(candidate)); -} - -const npmCli = findNpmCli(); -const [command, ...prefix] = npmCli ? [process.execPath, npmCli] : [process.platform === 'win32' ? 'npm.cmd' : 'npm']; -const useShell = process.platform === 'win32' && !npmCli; - // prepack (clean-templates.js) prints to stdout, where `npm pack --json` prints its result too, so // run the cleanup here and pack with the scripts off; `npm publish` in CI still runs prepack. execFileSync(process.execPath, [join(__dirname, 'clean-templates.js')], { cwd: rootDir, stdio: 'inherit' }); -const output = execFileSync(command, [...prefix, 'pack', '--json', '--ignore-scripts'], { +const pack = npmCommand(['pack', '--json', '--ignore-scripts']); +const output = execFileSync(pack.command, pack.args, { cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], - shell: useShell, + shell: pack.shell, }); const [packed] = JSON.parse(output); From ceeba324c25b3b6ccbab106c27f451ad0f60c9f4 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 18:06:01 +0200 Subject: [PATCH 25/36] ci(dependabot): one group for the template updates The split into templates (minor, patch) and templates-major did not hold: with versioning-strategy increase, Dependabot filed nine minor and patch bumps of template-react under templates-major (#356). One group for everything but pp-dev, which keeps its own. --- .github/dependabot.yml | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fea2838..9b3ff31 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -21,12 +21,13 @@ updates: # The project templates. Every dependency in them ships in each scaffolded # project, so an update is a fix and releases a patch. Ranges are raised to - # the new version. Updates arrive as at most three pull requests for all + # the new version. Updates arrive as at most two pull requests for all # templates together (a dependency joins the first group it matches): - # - pp-dev on its own; - # - every other minor and patch update; - # - every other major update, separately, since those can need changes to - # the templates. + # - pp-dev on its own, since its bump is usually made by hand with the + # release that needs it; + # - everything else in one group. A split by update type did not hold: + # with ranges raised rather than versions pinned, Dependabot filed + # minor and patch bumps under the "major" group (#356). - package-ecosystem: 'npm' directories: ['/template-*'] target-branch: 'develop' @@ -41,10 +42,6 @@ updates: patterns: ['@metricinsights/pp-dev'] templates: patterns: ['*'] - update-types: ['minor', 'patch'] - templates-major: - patterns: ['*'] - update-types: ['major'] ignore: # TypeScript 7 conflicts with the typescript-eslint peer range # (>=4.8.4 <6.1.0) in the templates and here. Remove this once From 03919d12c45e972e36534a63b63c2a1a4014f6c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:13:09 +0000 Subject: [PATCH 26/36] chore(deps-dev): bump eslint from 10.12.0 to 10.14.0 Bumps [eslint](https://github.com/eslint/eslint) from 10.12.0 to 10.14.0. - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.12.0...v10.14.0) --- updated-dependencies: - dependency-name: eslint dependency-version: 10.13.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index e779303..67bde93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2506,9 +2506,9 @@ } }, "node_modules/eslint": { - "version": "10.12.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", - "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "version": "10.14.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz", + "integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==", "dev": true, "license": "MIT", "workspaces": [ @@ -2521,7 +2521,7 @@ "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", - "@humanfs/node": "^0.16.6", + "@humanfs/node": "^0.16.8", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", @@ -2542,7 +2542,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.5", + "minimatch": "^10.2.6", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, From 292fc38ec52d3ab84b73a78641f5a208f79c4349 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 18:30:14 +0200 Subject: [PATCH 27/36] fix(deps): override minimatch and glob to the versions that close their advisories npm audit at every severity, for the package and the five templates, found two advisories under the moderate count: minimatch < 11.0.1 (GHSA-g3hv-75wg-m249, denial of service through globparts amplification) in the eslint / typescript-eslint / glob chain, and glob <= 14.0.1 (GHSA-3g3m-2fjr-6pw4, AbortSignal cancellation bypass) behind pp-dev's svgtofont, through svgicons2svgfont and cacache. No dependency asks for the fixed versions yet, so `overrides` pin them: minimatch ^11.0.4 in the root, minimatch and glob ^14.0.2 in every template, as pp-dev does in its own package. A vanilla project scaffolded with the overrides installs, lints and builds, and npm audit reports nothing. The root's remaining findings are three low ones from deepmerge through unbuild, which has no fixed version. CLAUDE.md says what the overrides are for and when to remove them; Dependabot does not maintain them. --- CLAUDE.md | 2 + package-lock.json | 170 ++++++------------------------- package.json | 3 + template-nextjs-esm/package.json | 4 + template-nextjs/package.json | 4 + template-react/package.json | 4 + template-vanilla-ts/package.json | 4 + template-vanilla/package.json | 4 + 8 files changed, 56 insertions(+), 139 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ec38723..ec68d52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,8 @@ npx playwright test --grep @offline # the quick subset: no network, under half The templates have no lockfiles in the repository (`.gitignore`, `scripts/clean-templates.js`), so a plain `npm audit` covers the package itself only. `npm run audit:all` audits the root and every template (each template is resolved in a temporary directory with `npm install --package-lock-only`); the shared CI runs it on pull requests and weekly. A high or critical advisory without a fix goes into the shared `audit-allowlist.json` in mi-examples-workflows with a reason and an `until` date, never into this repository. +`overrides` in the root and in every template `package.json` pin transitive packages whose fixed version no dependency asks for yet: `minimatch` (GHSA-g3hv-75wg-m249, fixed in 11.0.1; eslint, typescript-eslint and glob still ask for 10.x) and, in the templates, `glob` (GHSA-3g3m-2fjr-6pw4, fixed in 14.0.2; svgicons2svgfont and cacache behind pp-dev ask for 10–13). A scaffolded project carries them. Dependabot does not maintain overrides, so when a template dependency moves on, remove the entry and check that `npm run audit:all` stays clean. + ## Layout ``` diff --git a/package-lock.json b/package-lock.json index e779303..8fdb915 100644 --- a/package-lock.json +++ b/package-lock.json @@ -639,45 +639,6 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@eslint/config-helpers": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", @@ -852,6 +813,13 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/nonrecursive-glob-match": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.1.0.tgz", + "integrity": "sha512-GPm/Dbvk5RLmhRnxXIa7nTpK0xtl4E0wj0MslsuNiJ1FIROU+2+9lgUC/dgF7Ey4QCWhfEsgxilMRMCdHPfDfw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1744,45 +1712,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.72.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz", @@ -1923,11 +1852,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/baseline-browser-mapping": { "version": "2.11.27", @@ -1950,14 +1882,16 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -2079,13 +2013,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/confbox": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", @@ -2596,29 +2523,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", @@ -2650,22 +2554,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -3216,16 +3104,20 @@ "license": "CC0-1.0" }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.5.tgz", + "integrity": "sha512-snPlVppFcYWfLLFQGhDxUyBcHNtMlWmn3jtj7pEaO+DP8pqL0IvAzXhMYmn1z9ASNtgmwLBdFxSf4tMUB0OTKA==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "@isaacs/nonrecursive-glob-match": "^1.1.0", + "brace-expansion": "^5.0.12" }, "engines": { - "node": "*" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/mkdist": { diff --git a/package.json b/package.json index eb7a441..46bbecd 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,9 @@ "prettier": "^3.9.10", "unbuild": "^3.6.1" }, + "overrides": { + "minimatch": "^11.0.4" + }, "dependencies": { "@clack/prompts": "^1.8.1", "cross-spawn": "^7.0.6" diff --git a/template-nextjs-esm/package.json b/template-nextjs-esm/package.json index f061f9d..51e46d7 100644 --- a/template-nextjs-esm/package.json +++ b/template-nextjs-esm/package.json @@ -29,5 +29,9 @@ "eslint-config-next": "16.4.3", "prettier": "^3.9.9", "typescript": "^6.0.3" + }, + "overrides": { + "glob": "^14.0.2", + "minimatch": "^11.0.4" } } diff --git a/template-nextjs/package.json b/template-nextjs/package.json index 3815a21..dd1088a 100644 --- a/template-nextjs/package.json +++ b/template-nextjs/package.json @@ -28,5 +28,9 @@ "eslint-config-next": "16.4.3", "prettier": "^3.9.9", "typescript": "^6.0.3" + }, + "overrides": { + "glob": "^14.0.2", + "minimatch": "^11.0.4" } } diff --git a/template-react/package.json b/template-react/package.json index d234b26..78776cc 100644 --- a/template-react/package.json +++ b/template-react/package.json @@ -36,5 +36,9 @@ "prettier": "^3.9.10", "typescript": "^6.0.3", "vite": "^8.3.6" + }, + "overrides": { + "glob": "^14.0.2", + "minimatch": "^11.0.4" } } diff --git a/template-vanilla-ts/package.json b/template-vanilla-ts/package.json index e6de9c9..2b216f2 100644 --- a/template-vanilla-ts/package.json +++ b/template-vanilla-ts/package.json @@ -25,5 +25,9 @@ "globals": "^17.13.0", "prettier": "^3.9.9", "typescript": "^6.0.3" + }, + "overrides": { + "glob": "^14.0.2", + "minimatch": "^11.0.4" } } diff --git a/template-vanilla/package.json b/template-vanilla/package.json index f1d39bc..ef49ebf 100644 --- a/template-vanilla/package.json +++ b/template-vanilla/package.json @@ -22,5 +22,9 @@ "eslint": "^10.12.0", "globals": "^17.13.0", "prettier": "^3.9.9" + }, + "overrides": { + "glob": "^14.0.2", + "minimatch": "^11.0.4" } } From 1bf92a878bcc06d53427adc1fe80668d9dac1e19 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Mon, 9 Nov 2026 18:34:09 +0200 Subject: [PATCH 28/36] fix(audit): accept an allowlist entry only when its until names a real day The allowlist parser checked the shape of `until` only, so an entry with `until: "2026-99-99"` sorted after today and stayed active, suppressing the findings it names with an expiry nobody can reach. The parser lives in scripts/audit-allowlist.js now and takes an entry only when `until` is a calendar day that exists (2026-02-30 and 2026-99-99 are not), as the shared audit does. Tests (@offline): a valid entry applies; it applies through its until day and expires after it; an until that is not a real day is rejected; an entry without a GHSA id or reason is ignored; an empty list allowlists nothing. --- scripts/audit-all.mjs | 23 ++++---------- scripts/audit-allowlist.js | 42 ++++++++++++++++++++++++++ test/scaffold.spec.mjs | 61 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 scripts/audit-allowlist.js diff --git a/scripts/audit-all.mjs b/scripts/audit-all.mjs index 525d2fc..b0009be 100644 --- a/scripts/audit-all.mjs +++ b/scripts/audit-all.mjs @@ -19,6 +19,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { parseAllowlist } from './audit-allowlist.js'; import { npmCommand } from './npm-cli.js'; const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..'); @@ -51,24 +52,10 @@ async function loadAllowlist() { text = readFileSync(source, 'utf8'); } - // An entry applies through its `until` day, in UTC. - const today = new Date().toISOString().slice(0, 10); - const allowlist = new Map(); - - for (const entry of JSON.parse(text).entries ?? []) { - const valid = - /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/.test(entry?.id ?? '') && - typeof entry.reason === 'string' && - entry.reason.trim() !== '' && - /^\d{4}-\d{2}-\d{2}$/.test(entry.until ?? ''); - - if (!valid) { - console.warn(`⚠ Ignoring an invalid audit allowlist entry: ${JSON.stringify(entry)}`); - } else if (entry.until < today) { - console.warn(`✗ The audit allowlist entry for ${entry.id} expired on ${entry.until}, so it applies no more.`); - } else { - allowlist.set(entry.id, `${entry.reason.trim()} (until ${entry.until})`); - } + const { allowlist, warnings } = parseAllowlist(text); + + for (const warning of warnings) { + console.warn(warning); } console.log(`Audit allowlist: ${source} (${allowlist.size} active)`); diff --git a/scripts/audit-allowlist.js b/scripts/audit-allowlist.js new file mode 100644 index 0000000..dbc2a6b --- /dev/null +++ b/scripts/audit-allowlist.js @@ -0,0 +1,42 @@ +// The shared audit allowlist (audit-allowlist.json in mi-examples-workflows), parsed the way the +// shared audit parses it: an entry counts only with a GHSA id, a reason and an `until` that is a +// real calendar day; an entry whose `until` day has passed is reported and no longer applies. +const GHSA_ID = /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/; + +/** `YYYY-MM-DD` that names a day that exists: 2026-02-30 and 2026-99-99 do not. */ +function isCalendarDay(value) { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return false; + } + + const date = new Date(`${value}T00:00:00Z`); + + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +/** + * Parses the allowlist JSON. Returns the active entries as a Map from GHSA id to the reason (with + * its expiry), and the warnings to print for entries that were ignored or have expired. + * `today` is a `YYYY-MM-DD` in UTC; an entry applies through its `until` day. + */ +export function parseAllowlist(text, today = new Date().toISOString().slice(0, 10)) { + const allowlist = new Map(); + const warnings = []; + + for (const entry of JSON.parse(text).entries ?? []) { + const id = entry?.id ?? ''; + const until = entry?.until ?? ''; + const valid = + GHSA_ID.test(id) && typeof entry.reason === 'string' && entry.reason.trim() !== '' && isCalendarDay(until); + + if (!valid) { + warnings.push(`⚠ Ignoring an invalid audit allowlist entry: ${JSON.stringify(entry)}`); + } else if (until < today) { + warnings.push(`✗ The audit allowlist entry for ${id} expired on ${until}, so it applies no more.`); + } else { + allowlist.set(id, `${entry.reason.trim()} (until ${until})`); + } + } + + return { allowlist, warnings }; +} diff --git a/test/scaffold.spec.mjs b/test/scaffold.spec.mjs index 512660f..2934c74 100644 --- a/test/scaffold.spec.mjs +++ b/test/scaffold.spec.mjs @@ -699,6 +699,67 @@ test.describe('package installation', () => { }); }); +test.describe('audit allowlist', () => { + const allowlistModule = pathToFileURL(path.join(repoRoot, 'scripts', 'audit-allowlist.js')).href; + const entry = (fields) => ({ + id: 'GHSA-aaaa-bbbb-cccc', + reason: 'cannot be reached', + until: '2030-01-01', + ...fields, + }); + const parse = async (entries, today = '2026-11-09') => { + const { parseAllowlist } = await import(allowlistModule); + + return parseAllowlist(JSON.stringify({ entries }), today); + }; + + test('an entry with a GHSA id, a reason and a future day applies', offline, async () => { + const { allowlist, warnings } = await parse([entry({})]); + + expect([...allowlist.keys()]).toEqual(['GHSA-aaaa-bbbb-cccc']); + expect(allowlist.get('GHSA-aaaa-bbbb-cccc')).toBe('cannot be reached (until 2030-01-01)'); + expect(warnings).toEqual([]); + }); + + test('an entry applies through its until day and expires after it', offline, async () => { + const onTheDay = await parse([entry({ until: '2026-11-09' })]); + const dayAfter = await parse([entry({ until: '2026-11-08' })]); + + expect(onTheDay.allowlist.size).toBe(1); + expect(dayAfter.allowlist.size).toBe(0); + expect(dayAfter.warnings.join('\n')).toContain('expired on 2026-11-08'); + }); + + test('an until that is not a real day is rejected, not kept active', offline, async () => { + // "2026-99-99" passes a format check and sorts after today; it must not suppress anything + for (const until of ['2026-99-99', '2026-02-30', '2026-1-1', 'tomorrow', 20261231, undefined]) { + const { allowlist, warnings } = await parse([entry({ until })]); + + expect(allowlist.size, `until=${String(until)}`).toBe(0); + expect(warnings.join('\n'), `until=${String(until)}`).toContain('invalid audit allowlist entry'); + } + }); + + test('an entry without a GHSA id or without a reason is ignored', offline, async () => { + const { allowlist, warnings } = await parse([ + entry({ id: 'CVE-2026-0001' }), + entry({ reason: ' ' }), + entry({ reason: undefined }), + entry({ id: 'GHSA-dddd-eeee-ffff' }), + ]); + + expect([...allowlist.keys()]).toEqual(['GHSA-dddd-eeee-ffff']); + expect(warnings).toHaveLength(3); + }); + + test('a list without entries allowlists nothing', offline, async () => { + const { allowlist, warnings } = await parse(undefined); + + expect(allowlist.size).toBe(0); + expect(warnings).toEqual([]); + }); +}); + test.describe('build scripts', () => { test('postbuild packs with npm even when another package manager runs the build', offline, async () => { test.setTimeout(120_000); From 33268670887206222e274ab7b21bf9aa1d053b1c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:34:58 +0000 Subject: [PATCH 29/36] chore(deps-dev): bump @playwright/test from 1.64.1 to 1.65.0 Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.64.1 to 1.65.0. - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](https://github.com/microsoft/playwright/compare/v1.64.1...v1.65.0) --- updated-dependencies: - dependency-name: "@playwright/test" dependency-version: 1.65.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 67bde93..01ce48d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -947,13 +947,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", - "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.64.1" + "playwright": "1.65.0" }, "bin": { "playwright": "cli.js" @@ -3504,13 +3504,13 @@ } }, "node_modules/playwright": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", - "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.64.1" + "playwright-core": "1.65.0" }, "bin": { "playwright": "cli.js" @@ -3520,9 +3520,9 @@ } }, "node_modules/playwright-core": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", - "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", "dev": true, "license": "Apache-2.0", "bin": { From ba37199b99fc33857d0fd1ae3a5ba526470e8aff Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Tue, 10 Nov 2026 08:32:04 +0200 Subject: [PATCH 30/36] fix(deps): pin glob and minimatch for yarn and pnpm too The templates' `overrides` are read by npm only. The CLI installs with the package manager that runs it, and with pnpm 10 or yarn 1.22 a scaffolded project still resolved glob 10.5 / 11.1 and minimatch 10.2 (pnpm audit: 4 moderate, yarn audit: 6 moderate). Each template now repeats the pins in `resolutions`, which yarn and pnpm read and npm ignores; with it both resolve glob 14.0.4 and minimatch 11.0.5 and their audits report nothing, in all five templates. Tests (@offline): every template has the same entries in `resolutions` and `overrides`, and a scaffolded project carries both fields. CLAUDE.md says the two are changed together. --- CLAUDE.md | 2 +- template-nextjs-esm/package.json | 4 ++++ template-nextjs/package.json | 4 ++++ template-react/package.json | 4 ++++ template-vanilla-ts/package.json | 4 ++++ template-vanilla/package.json | 4 ++++ test/scaffold.spec.mjs | 41 ++++++++++++++++++++++++++++++++ 7 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index ec68d52..8939720 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ npx playwright test --grep @offline # the quick subset: no network, under half The templates have no lockfiles in the repository (`.gitignore`, `scripts/clean-templates.js`), so a plain `npm audit` covers the package itself only. `npm run audit:all` audits the root and every template (each template is resolved in a temporary directory with `npm install --package-lock-only`); the shared CI runs it on pull requests and weekly. A high or critical advisory without a fix goes into the shared `audit-allowlist.json` in mi-examples-workflows with a reason and an `until` date, never into this repository. -`overrides` in the root and in every template `package.json` pin transitive packages whose fixed version no dependency asks for yet: `minimatch` (GHSA-g3hv-75wg-m249, fixed in 11.0.1; eslint, typescript-eslint and glob still ask for 10.x) and, in the templates, `glob` (GHSA-3g3m-2fjr-6pw4, fixed in 14.0.2; svgicons2svgfont and cacache behind pp-dev ask for 10–13). A scaffolded project carries them. Dependabot does not maintain overrides, so when a template dependency moves on, remove the entry and check that `npm run audit:all` stays clean. +`overrides` in the root and in every template `package.json` pin transitive packages whose fixed version no dependency asks for yet: `minimatch` (GHSA-g3hv-75wg-m249, fixed in 11.0.1; eslint, typescript-eslint and glob still ask for 10.x) and, in the templates, `glob` (GHSA-3g3m-2fjr-6pw4, fixed in 14.0.2; svgicons2svgfont and cacache behind pp-dev ask for 10–13). A scaffolded project carries them. In the templates every pin is written twice, with the same value: `overrides` is what npm reads, `resolutions` is what yarn and pnpm read (each ignores the other field; checked with pnpm 10 and yarn 1.22, which resolve glob 10/11 and minimatch 10 without `resolutions`). Change both together; a test compares them. Dependabot maintains neither, so when a template dependency moves on, remove the entry from both and check that `npm run audit:all` stays clean. ## Layout diff --git a/template-nextjs-esm/package.json b/template-nextjs-esm/package.json index 51e46d7..0db0637 100644 --- a/template-nextjs-esm/package.json +++ b/template-nextjs-esm/package.json @@ -33,5 +33,9 @@ "overrides": { "glob": "^14.0.2", "minimatch": "^11.0.4" + }, + "resolutions": { + "glob": "^14.0.2", + "minimatch": "^11.0.4" } } diff --git a/template-nextjs/package.json b/template-nextjs/package.json index dd1088a..923ae89 100644 --- a/template-nextjs/package.json +++ b/template-nextjs/package.json @@ -32,5 +32,9 @@ "overrides": { "glob": "^14.0.2", "minimatch": "^11.0.4" + }, + "resolutions": { + "glob": "^14.0.2", + "minimatch": "^11.0.4" } } diff --git a/template-react/package.json b/template-react/package.json index 78776cc..56909d5 100644 --- a/template-react/package.json +++ b/template-react/package.json @@ -40,5 +40,9 @@ "overrides": { "glob": "^14.0.2", "minimatch": "^11.0.4" + }, + "resolutions": { + "glob": "^14.0.2", + "minimatch": "^11.0.4" } } diff --git a/template-vanilla-ts/package.json b/template-vanilla-ts/package.json index 2b216f2..686af23 100644 --- a/template-vanilla-ts/package.json +++ b/template-vanilla-ts/package.json @@ -29,5 +29,9 @@ "overrides": { "glob": "^14.0.2", "minimatch": "^11.0.4" + }, + "resolutions": { + "glob": "^14.0.2", + "minimatch": "^11.0.4" } } diff --git a/template-vanilla/package.json b/template-vanilla/package.json index ef49ebf..d2c029b 100644 --- a/template-vanilla/package.json +++ b/template-vanilla/package.json @@ -26,5 +26,9 @@ "overrides": { "glob": "^14.0.2", "minimatch": "^11.0.4" + }, + "resolutions": { + "glob": "^14.0.2", + "minimatch": "^11.0.4" } } diff --git a/test/scaffold.spec.mjs b/test/scaffold.spec.mjs index 2934c74..f744b5d 100644 --- a/test/scaffold.spec.mjs +++ b/test/scaffold.spec.mjs @@ -699,6 +699,47 @@ test.describe('package installation', () => { }); }); +test.describe('template dependency pins', () => { + const templates = fs + .readdirSync(repoRoot) + .filter((name) => name.startsWith('template-')) + .sort(); + + // npm reads `overrides`; yarn and pnpm ignore it and read `resolutions`. A pin in one field only + // closes an advisory for some package managers and leaves it open for the others. + for (const template of templates) { + test(`${template} pins the same versions for npm and for yarn / pnpm`, offline, () => { + const pkg = readJson(path.join(repoRoot, template, 'package.json')); + + expect(pkg.resolutions ?? {}, 'resolutions (yarn, pnpm) must mirror overrides (npm)').toEqual( + pkg.overrides ?? {}, + ); + }); + } + + test('a scaffolded project carries both fields', offline, async () => { + await withTmpDir('pins', async (tmpRoot) => { + const targetDir = path.join(tmpRoot, 'pinned'); + const run = scaffoldProject({ + targetDir, + template: 'vanilla', + packageName: 'pinned', + install: false, + cursorRules: false, + ppComponents: false, + }); + + expect(run.status, `stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(0); + + const template = readJson(path.join(repoRoot, 'template-vanilla', 'package.json')); + const scaffolded = readJson(path.join(targetDir, 'package.json')); + + expect(scaffolded.overrides).toEqual(template.overrides); + expect(scaffolded.resolutions).toEqual(template.resolutions); + }); + }); +}); + test.describe('audit allowlist', () => { const allowlistModule = pathToFileURL(path.join(repoRoot, 'scripts', 'audit-allowlist.js')).href; const entry = (fields) => ({ From 66acce06c9258fd60eabd32546880d7cb3487b7d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:34:39 +0000 Subject: [PATCH 31/36] chore(deps-dev): bump globals from 17.13.0 to 17.14.0 Bumps [globals](https://github.com/sindresorhus/globals) from 17.13.0 to 17.14.0. - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.13.0...v17.14.0) --- updated-dependencies: - dependency-name: globals dependency-version: 17.14.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b311c42..50aa418 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2806,9 +2806,9 @@ } }, "node_modules/globals": { - "version": "17.13.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", - "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "version": "17.14.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.14.0.tgz", + "integrity": "sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==", "dev": true, "license": "MIT", "engines": { From 28b56d1024093e26582b002d09c97b14a2dd4b67 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:36:26 +0000 Subject: [PATCH 32/36] fix(deps): bump the templates group across 2 directories with 2 updates Bumps the templates group with 2 updates in the /template-nextjs directory: [next](https://github.com/vercel/next.js) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next). Bumps the templates group with 2 updates in the /template-nextjs-esm directory: [next](https://github.com/vercel/next.js) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next). Updates `next` from 16.4.3 to 16.4.5 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v16.4.3...v16.4.5) Updates `eslint-config-next` from 16.4.3 to 16.4.5 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/commits/v16.4.5/packages/eslint-config-next) Updates `next` from 16.4.3 to 16.4.5 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v16.4.3...v16.4.5) Updates `eslint-config-next` from 16.4.3 to 16.4.5 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/commits/v16.4.5/packages/eslint-config-next) Updates `next` from 16.4.3 to 16.4.5 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v16.4.3...v16.4.5) Updates `eslint-config-next` from 16.4.3 to 16.4.5 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/commits/v16.4.5/packages/eslint-config-next) Updates `next` from 16.4.3 to 16.4.5 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v16.4.3...v16.4.5) Updates `eslint-config-next` from 16.4.3 to 16.4.5 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/commits/v16.4.5/packages/eslint-config-next) --- updated-dependencies: - dependency-name: eslint-config-next dependency-version: 16.4.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: templates - dependency-name: eslint-config-next dependency-version: 16.4.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: templates - dependency-name: next dependency-version: 16.4.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: templates - dependency-name: next dependency-version: 16.4.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: templates ... Signed-off-by: dependabot[bot] --- template-nextjs-esm/package.json | 4 ++-- template-nextjs/package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/template-nextjs-esm/package.json b/template-nextjs-esm/package.json index 0db0637..f98149b 100644 --- a/template-nextjs-esm/package.json +++ b/template-nextjs-esm/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "clsx": "^2.1.1", - "next": "16.4.3", + "next": "16.4.5", "react": "^19.3.0", "react-dom": "^19.3.0" }, @@ -26,7 +26,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "eslint": "^10.12.0", - "eslint-config-next": "16.4.3", + "eslint-config-next": "16.4.5", "prettier": "^3.9.9", "typescript": "^6.0.3" }, diff --git a/template-nextjs/package.json b/template-nextjs/package.json index 923ae89..bb5b402 100644 --- a/template-nextjs/package.json +++ b/template-nextjs/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "clsx": "^2.1.1", - "next": "16.4.3", + "next": "16.4.5", "react": "^19.3.0", "react-dom": "^19.3.0" }, @@ -25,7 +25,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "eslint": "^10.12.0", - "eslint-config-next": "16.4.3", + "eslint-config-next": "16.4.5", "prettier": "^3.9.9", "typescript": "^6.0.3" }, From 827fccd20ecdb593e807b2b76d058881d9fa980a Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Tue, 10 Nov 2026 13:59:55 +0200 Subject: [PATCH 33/36] fix(deps): bump @metricinsights/pp-dev to ^1.5.1 in the templates, drop the glob pin pp-dev 1.5.1 made svgtofont an optional peer dependency, and svgtofont was the only way glob 10 and 11 (GHSA-3g3m-2fjr-6pw4) reached a scaffolded project. On ^1.5.1 neither glob nor svgtofont is in the resolved tree under npm, pnpm or yarn, so the glob entry leaves `overrides` and `resolutions`. The minimatch pin stays: it comes through eslint and typescript-eslint. The shipped Cursor rule says that `pp-dev generate-icon-font` needs svgtofont in the project; CLAUDE.md describes the pins that are left. --- CLAUDE.md | 2 +- mi-cursor-rules/mi-custom-apps-dev.mdc | 2 +- template-nextjs-esm/package.json | 4 +--- template-nextjs/package.json | 4 +--- template-react/package.json | 4 +--- template-vanilla-ts/package.json | 4 +--- template-vanilla/package.json | 4 +--- 7 files changed, 7 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8939720..79394e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ npx playwright test --grep @offline # the quick subset: no network, under half The templates have no lockfiles in the repository (`.gitignore`, `scripts/clean-templates.js`), so a plain `npm audit` covers the package itself only. `npm run audit:all` audits the root and every template (each template is resolved in a temporary directory with `npm install --package-lock-only`); the shared CI runs it on pull requests and weekly. A high or critical advisory without a fix goes into the shared `audit-allowlist.json` in mi-examples-workflows with a reason and an `until` date, never into this repository. -`overrides` in the root and in every template `package.json` pin transitive packages whose fixed version no dependency asks for yet: `minimatch` (GHSA-g3hv-75wg-m249, fixed in 11.0.1; eslint, typescript-eslint and glob still ask for 10.x) and, in the templates, `glob` (GHSA-3g3m-2fjr-6pw4, fixed in 14.0.2; svgicons2svgfont and cacache behind pp-dev ask for 10–13). A scaffolded project carries them. In the templates every pin is written twice, with the same value: `overrides` is what npm reads, `resolutions` is what yarn and pnpm read (each ignores the other field; checked with pnpm 10 and yarn 1.22, which resolve glob 10/11 and minimatch 10 without `resolutions`). Change both together; a test compares them. Dependabot maintains neither, so when a template dependency moves on, remove the entry from both and check that `npm run audit:all` stays clean. +`overrides` in the root and in every template `package.json` pin transitive packages whose fixed version no dependency asks for yet: `minimatch` (GHSA-g3hv-75wg-m249, fixed in 11.0.1; eslint and typescript-eslint still ask for 10.x). A scaffolded project carries them. The templates used to pin `glob` as well (GHSA-3g3m-2fjr-6pw4, through `svgtofont` behind pp-dev); pp-dev 1.5.1 made `svgtofont` an optional peer dependency, so that chain is gone and the pin with it. In the templates every pin is written twice, with the same value: `overrides` is what npm reads, `resolutions` is what yarn and pnpm read (each ignores the other field; checked with pnpm 10 and yarn 1.22, which resolve minimatch 10 without `resolutions`). Change both together; a test compares them. Dependabot maintains neither, so when a template dependency moves on, remove the entry from both and check that `npm run audit:all` stays clean. ## Layout diff --git a/mi-cursor-rules/mi-custom-apps-dev.mdc b/mi-cursor-rules/mi-custom-apps-dev.mdc index 24414dd..4ade68e 100644 --- a/mi-cursor-rules/mi-custom-apps-dev.mdc +++ b/mi-cursor-rules/mi-custom-apps-dev.mdc @@ -22,7 +22,7 @@ Projects created with `npm create @metricinsights/pp-dev` already have the scrip | `build` | `pp-dev build` (TypeScript templates: `tsc && pp-dev build`) | `pp-dev next-build` | | `preview` | `pp-dev preview` (react) | — | -CLI commands: `pp-dev [root]` (aliases `dev`, `serve`), `pp-dev next [root]` (`next-serve`, `next-dev`), `pp-dev build [root]`, `pp-dev next-build [root]`, `pp-dev preview [root]`, `pp-dev optimize [root]`, `pp-dev migrate [config]`, `pp-dev changelog [old] [new]`, `pp-dev generate-icon-font [source] [destination]`. +CLI commands: `pp-dev [root]` (aliases `dev`, `serve`), `pp-dev next [root]` (`next-serve`, `next-dev`), `pp-dev build [root]`, `pp-dev next-build [root]`, `pp-dev preview [root]`, `pp-dev optimize [root]`, `pp-dev migrate [config]`, `pp-dev changelog [old] [new]`, `pp-dev generate-icon-font [source] [destination]` (needs `svgtofont` in the project since pp-dev 1.5.1: `npm install --save-dev svgtofont`). Global options: `-c, --config `, `--base `, `-l, --logLevel `, `--clearScreen`, `--mode `. Dev-server options: `--host [host]` (a bare `--host` listens on all addresses), `--port ` (default `3000`); `--open [path]` and `--strictPort` apply to `pp-dev` only. `pp-dev --help` and `pp-dev --help` list everything. diff --git a/template-nextjs-esm/package.json b/template-nextjs-esm/package.json index 0db0637..28147b8 100644 --- a/template-nextjs-esm/package.json +++ b/template-nextjs-esm/package.json @@ -20,7 +20,7 @@ "react-dom": "^19.3.0" }, "devDependencies": { - "@metricinsights/pp-dev": "^1.5.0", + "@metricinsights/pp-dev": "^1.5.1", "@stylistic/eslint-plugin": "^5.10.0", "@types/node": "^26.6.4", "@types/react": "^19.3.0", @@ -31,11 +31,9 @@ "typescript": "^6.0.3" }, "overrides": { - "glob": "^14.0.2", "minimatch": "^11.0.4" }, "resolutions": { - "glob": "^14.0.2", "minimatch": "^11.0.4" } } diff --git a/template-nextjs/package.json b/template-nextjs/package.json index 923ae89..2fe5bc6 100644 --- a/template-nextjs/package.json +++ b/template-nextjs/package.json @@ -19,7 +19,7 @@ "react-dom": "^19.3.0" }, "devDependencies": { - "@metricinsights/pp-dev": "^1.5.0", + "@metricinsights/pp-dev": "^1.5.1", "@stylistic/eslint-plugin": "^5.10.0", "@types/node": "^26.6.4", "@types/react": "^19.3.0", @@ -30,11 +30,9 @@ "typescript": "^6.0.3" }, "overrides": { - "glob": "^14.0.2", "minimatch": "^11.0.4" }, "resolutions": { - "glob": "^14.0.2", "minimatch": "^11.0.4" } } diff --git a/template-react/package.json b/template-react/package.json index 56909d5..4fc6822 100644 --- a/template-react/package.json +++ b/template-react/package.json @@ -22,7 +22,7 @@ "@eslint/compat": "^2.1.1", "@eslint/eslintrc": "^3.3.7", "@eslint/js": "^10.0.1", - "@metricinsights/pp-dev": "^1.5.0", + "@metricinsights/pp-dev": "^1.5.1", "@stylistic/eslint-plugin": "^5.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", @@ -38,11 +38,9 @@ "vite": "^8.3.6" }, "overrides": { - "glob": "^14.0.2", "minimatch": "^11.0.4" }, "resolutions": { - "glob": "^14.0.2", "minimatch": "^11.0.4" } } diff --git a/template-vanilla-ts/package.json b/template-vanilla-ts/package.json index 686af23..0052bb6 100644 --- a/template-vanilla-ts/package.json +++ b/template-vanilla-ts/package.json @@ -16,7 +16,7 @@ "@eslint/compat": "^2.1.1", "@eslint/eslintrc": "^3.3.7", "@eslint/js": "^10.0.1", - "@metricinsights/pp-dev": "^1.5.0", + "@metricinsights/pp-dev": "^1.5.1", "@stylistic/eslint-plugin": "^5.10.0", "@types/jquery": "^4.0.1", "@typescript-eslint/eslint-plugin": "^8.71.1", @@ -27,11 +27,9 @@ "typescript": "^6.0.3" }, "overrides": { - "glob": "^14.0.2", "minimatch": "^11.0.4" }, "resolutions": { - "glob": "^14.0.2", "minimatch": "^11.0.4" } } diff --git a/template-vanilla/package.json b/template-vanilla/package.json index d2c029b..fefe888 100644 --- a/template-vanilla/package.json +++ b/template-vanilla/package.json @@ -16,7 +16,7 @@ "@eslint/compat": "^2.1.1", "@eslint/eslintrc": "^3.3.7", "@eslint/js": "^10.0.1", - "@metricinsights/pp-dev": "^1.5.0", + "@metricinsights/pp-dev": "^1.5.1", "@stylistic/eslint-plugin": "^5.10.0", "@types/jquery": "^4.0.1", "eslint": "^10.12.0", @@ -24,11 +24,9 @@ "prettier": "^3.9.9" }, "overrides": { - "glob": "^14.0.2", "minimatch": "^11.0.4" }, "resolutions": { - "glob": "^14.0.2", "minimatch": "^11.0.4" } } From c09d5f045a887f3a607e87699e2894223d564c1c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:48:31 +0000 Subject: [PATCH 34/36] fix(deps): bump the templates-major group across 4 directories with 8 updates Updates the requirements on [react](https://github.com/react/react/tree/HEAD/packages/react), [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom), [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [eslint](https://github.com/eslint/eslint), [prettier](https://github.com/prettier/prettier), [globals](https://github.com/sindresorhus/globals), [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) and [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) to permit the latest version. Updates `react` to 19.3.1 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.3.1/packages/react) Updates `react-dom` to 19.3.1 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.3.1/packages/react-dom) Updates `@types/node` to 26.6.6 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `eslint` to 10.14.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.12.0...v10.14.0) Updates `prettier` to 3.9.10 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.9.9...3.9.10) Updates `react` to 19.3.1 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.3.1/packages/react) Updates `react-dom` to 19.3.1 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.3.1/packages/react-dom) Updates `@types/node` to 26.6.6 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `eslint` to 10.14.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.12.0...v10.14.0) Updates `prettier` to 3.9.10 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.9.9...3.9.10) Updates `eslint` to 10.14.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.12.0...v10.14.0) Updates `prettier` to 3.9.10 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.9.9...3.9.10) Updates `globals` to 17.14.0 - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.13.0...v17.14.0) Updates `eslint` to 10.14.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.12.0...v10.14.0) Updates `prettier` to 3.9.10 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.9.9...3.9.10) Updates `globals` to 17.14.0 - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.13.0...v17.14.0) Updates `@typescript-eslint/eslint-plugin` from 8.72.2 to 8.72.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.72.1/packages/eslint-plugin) Updates `@typescript-eslint/parser` from 8.72.2 to 8.72.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.72.1/packages/parser) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 26.6.6 dependency-type: direct:development dependency-group: templates-major - dependency-name: "@types/node" dependency-version: 26.6.6 dependency-type: direct:development dependency-group: templates-major - dependency-name: "@typescript-eslint/eslint-plugin" dependency-version: 8.72.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: templates-major - dependency-name: "@typescript-eslint/parser" dependency-version: 8.72.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: templates-major - dependency-name: eslint dependency-version: 10.14.0 dependency-type: direct:development dependency-group: templates-major - dependency-name: eslint dependency-version: 10.14.0 dependency-type: direct:development dependency-group: templates-major - dependency-name: eslint dependency-version: 10.14.0 dependency-type: direct:development dependency-group: templates-major - dependency-name: eslint dependency-version: 10.14.0 dependency-type: direct:development dependency-group: templates-major - dependency-name: globals dependency-version: 17.14.0 dependency-type: direct:development dependency-group: templates-major - dependency-name: globals dependency-version: 17.14.0 dependency-type: direct:development dependency-group: templates-major - dependency-name: prettier dependency-version: 3.9.10 dependency-type: direct:development dependency-group: templates-major - dependency-name: prettier dependency-version: 3.9.10 dependency-type: direct:development dependency-group: templates-major - dependency-name: prettier dependency-version: 3.9.10 dependency-type: direct:development dependency-group: templates-major - dependency-name: prettier dependency-version: 3.9.10 dependency-type: direct:development dependency-group: templates-major - dependency-name: react dependency-version: 19.3.1 dependency-type: direct:production dependency-group: templates-major - dependency-name: react dependency-version: 19.3.1 dependency-type: direct:production dependency-group: templates-major - dependency-name: react-dom dependency-version: 19.3.1 dependency-type: direct:production dependency-group: templates-major - dependency-name: react-dom dependency-version: 19.3.1 dependency-type: direct:production dependency-group: templates-major ... Signed-off-by: dependabot[bot] --- template-nextjs-esm/package.json | 10 +++++----- template-nextjs/package.json | 10 +++++----- template-vanilla-ts/package.json | 10 +++++----- template-vanilla/package.json | 6 +++--- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/template-nextjs-esm/package.json b/template-nextjs-esm/package.json index 7468c1e..cae62ff 100644 --- a/template-nextjs-esm/package.json +++ b/template-nextjs-esm/package.json @@ -16,18 +16,18 @@ "dependencies": { "clsx": "^2.1.1", "next": "16.4.5", - "react": "^19.3.0", - "react-dom": "^19.3.0" + "react": "^19.3.1", + "react-dom": "^19.3.1" }, "devDependencies": { "@metricinsights/pp-dev": "^1.5.1", "@stylistic/eslint-plugin": "^5.10.0", - "@types/node": "^26.6.4", + "@types/node": "^26.6.6", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", - "eslint": "^10.12.0", + "eslint": "^10.14.0", "eslint-config-next": "16.4.5", - "prettier": "^3.9.9", + "prettier": "^3.9.10", "typescript": "^6.0.3" }, "overrides": { diff --git a/template-nextjs/package.json b/template-nextjs/package.json index a5d5834..c0b28d9 100644 --- a/template-nextjs/package.json +++ b/template-nextjs/package.json @@ -15,18 +15,18 @@ "dependencies": { "clsx": "^2.1.1", "next": "16.4.5", - "react": "^19.3.0", - "react-dom": "^19.3.0" + "react": "^19.3.1", + "react-dom": "^19.3.1" }, "devDependencies": { "@metricinsights/pp-dev": "^1.5.1", "@stylistic/eslint-plugin": "^5.10.0", - "@types/node": "^26.6.4", + "@types/node": "^26.6.6", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", - "eslint": "^10.12.0", + "eslint": "^10.14.0", "eslint-config-next": "16.4.5", - "prettier": "^3.9.9", + "prettier": "^3.9.10", "typescript": "^6.0.3" }, "overrides": { diff --git a/template-vanilla-ts/package.json b/template-vanilla-ts/package.json index 0052bb6..ecb134d 100644 --- a/template-vanilla-ts/package.json +++ b/template-vanilla-ts/package.json @@ -19,11 +19,11 @@ "@metricinsights/pp-dev": "^1.5.1", "@stylistic/eslint-plugin": "^5.10.0", "@types/jquery": "^4.0.1", - "@typescript-eslint/eslint-plugin": "^8.71.1", - "@typescript-eslint/parser": "^8.71.1", - "eslint": "^10.12.0", - "globals": "^17.13.0", - "prettier": "^3.9.9", + "@typescript-eslint/eslint-plugin": "^8.72.1", + "@typescript-eslint/parser": "^8.72.1", + "eslint": "^10.14.0", + "globals": "^17.14.0", + "prettier": "^3.9.10", "typescript": "^6.0.3" }, "overrides": { diff --git a/template-vanilla/package.json b/template-vanilla/package.json index fefe888..18397d8 100644 --- a/template-vanilla/package.json +++ b/template-vanilla/package.json @@ -19,9 +19,9 @@ "@metricinsights/pp-dev": "^1.5.1", "@stylistic/eslint-plugin": "^5.10.0", "@types/jquery": "^4.0.1", - "eslint": "^10.12.0", - "globals": "^17.13.0", - "prettier": "^3.9.9" + "eslint": "^10.14.0", + "globals": "^17.14.0", + "prettier": "^3.9.10" }, "overrides": { "minimatch": "^11.0.4" From c91aa052b74c10fea6aea3749f6feb1b20ea2524 Mon Sep 17 00:00:00 2001 From: "mi-examples-workflows-bot[bot]" <333657910+mi-examples-workflows-bot[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:52:46 +0000 Subject: [PATCH 35/36] chore(release): v1.1.8 --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9b06d0..b6156fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## [1.1.8](https://github.com/mi-examples/create-pp-dev/compare/v1.1.7...v1.1.8) (2026-11-10) + +### Features + +- Added `--help` to print usage with every flag, the template list and examples, and `--version` to print the package version. +- `--yes` now works and asks nothing: it uses the default project name, derives the package name from the directory, installs packages and skips Cursor rules and `@metricinsights/pp-components`. It requires `--template`. +- The CLI can run without a terminal. It never prompts, and if any required flag is missing it exits with code 1 before writing anything, listing all of the missing flags. + +### Bug fixes + +- Unknown flags and flags missing a value now exit with code 1 and point to `--help`. Previously they were silently accepted. +- An unknown template or an invalid `--package-name` is now reported before the target directory is touched, so existing files are no longer deleted. +- A failed package install now exits with code 1 instead of reporting "Project created successfully!", and the scaffolded files are kept. +- With `--yes`, a non-empty target directory is only accepted when nothing in it would be overwritten, including template files and existing `.cursor/rules/*.mdc` files; otherwise the run stops and lists the conflicting files. +- Adding `@metricinsights/pp-components` no longer crashes when the registry is unreachable: the lookup times out, writes `^1.0.0` and prints a warning. +- The registry warning and error text no longer print credentials embedded in the registry URL. +- A filesystem root such as `C:\` as the target is no longer turned into the current directory. +- The generated `.gitignore` now ignores `.env` and `.env.*` (keeping `.env.example`), so `MI_ACCESS_TOKEN` is no longer committed by accident; the Next.js templates also ignore `dist`, `dist-zip` and `backups`. +- The Next.js templates now import their logos and favicon as static assets, so they load under both `pp-dev next` and the exported build instead of returning 404 in development. +- The Next.js templates ship the `tsconfig.json` values Next 16 writes on first run, so a new project's `tsconfig.json` is no longer modified by `pp-dev next`. +- The `vanilla-ts` template's `tsconfig.node.json` no longer references a `vite.config.ts` that doesn't exist. + +### Other changes + +- The Next.js templates now use `next` and `eslint-config-next` 16.4.5, which fixes two critical remote code execution advisories and a high-severity denial-of-service advisory present in 16.3.8. +- The templates now require `@metricinsights/pp-dev` `^1.5.1`. With this version, `pp-dev generate-icon-font` needs `svgtofont` installed in the project (`npm install --save-dev svgtofont`). +- The templates pin `minimatch` in both `overrides` and `resolutions` to clear a denial-of-service advisory with npm, yarn and pnpm alike. +- Updated the template dependencies, including `react` and `react-dom` 19.3.1, `vite`, `@vitejs/plugin-react`, `eslint`, `prettier`, `globals` and `typescript-eslint`. +- The MI Cursor rule copied into new projects now describes `pp-dev` 1.5 (commands, config sections with defaults, Next.js base paths and dev server) instead of an outdated copy of its README. +- The package's `license` field now correctly says MIT. + ## [1.1.7](https://github.com/mi-examples/create-pp-dev/compare/v1.1.6...v1.1.7) (2026-10-22) ### Bug fixes diff --git a/package-lock.json b/package-lock.json index 51f2c7a..1fb2913 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@metricinsights/create-pp-dev", - "version": "1.1.7", + "version": "1.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@metricinsights/create-pp-dev", - "version": "1.1.7", + "version": "1.1.8", "license": "MIT", "dependencies": { "@clack/prompts": "^1.8.1", diff --git a/package.json b/package.json index 46bbecd..52500b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metricinsights/create-pp-dev", - "version": "1.1.7", + "version": "1.1.8", "type": "module", "description": "Scaffolds a Metric Insights Portal Page project from a template", "bin": { From 314f252f6a6cf189bd739bda63c6a1cbf977f78c Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Tue, 10 Nov 2026 15:56:30 +0200 Subject: [PATCH 36/36] chore(release): v1.2.0 instead of v1.1.8 The release adds --help, --version, a working --yes and the run without a terminal, so it is a minor release. The commits were typed fix, which made the workflow plan a patch. --- CHANGELOG.md | 2 +- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6156fc..86fafae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [1.1.8](https://github.com/mi-examples/create-pp-dev/compare/v1.1.7...v1.1.8) (2026-11-10) +## [1.2.0](https://github.com/mi-examples/create-pp-dev/compare/v1.1.7...v1.2.0) (2026-11-10) ### Features diff --git a/README.md b/README.md index 1c840be..cfaf87a 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ Releases use the shared workflows from [mi-examples-workflows](https://github.co - every push to `develop` with releasable commits publishes `X.Y.Z-beta.N` under the `beta` dist-tag (`npm create @metricinsights/pp-dev@beta`); - **Actions → Release → Run workflow** opens a release pull request into `main`; merging it publishes `latest` and opens the back-merge into `develop`; - versions come from [Conventional Commits](https://www.conventionalcommits.org/) (`feat` → minor; `fix`, `perf`, `revert` → patch; `!` or `BREAKING CHANGE:` → major); -- `CHANGELOG.md` and the tags are written by the release workflow (from 1.1.8 on; older entries were written by semantic-release or by hand); +- `CHANGELOG.md` and the tags are written by the release workflow (from 1.2.0 on; older entries were written by semantic-release or by hand); - publishing uses npm Trusted Publishing (OIDC) from `.github/workflows/release.yml`, so don't rename that file. ## License diff --git a/package-lock.json b/package-lock.json index 1fb2913..68f99d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@metricinsights/create-pp-dev", - "version": "1.1.8", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@metricinsights/create-pp-dev", - "version": "1.1.8", + "version": "1.2.0", "license": "MIT", "dependencies": { "@clack/prompts": "^1.8.1", diff --git a/package.json b/package.json index 52500b5..edfb562 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metricinsights/create-pp-dev", - "version": "1.1.8", + "version": "1.2.0", "type": "module", "description": "Scaffolds a Metric Insights Portal Page project from a template", "bin": {