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/.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 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/.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/CHANGELOG.md b/CHANGELOG.md index f9b06d0..86fafae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## [1.2.0](https://github.com/mi-examples/create-pp-dev/compare/v1.1.7...v1.2.0) (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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..79394e0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# 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`), 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 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 + +``` +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..cfaf87a 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 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). + +#### 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,24 @@ 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 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 +``` + +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 +130,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.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 -ISC +MIT 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/mi-cursor-rules/mi-custom-apps-dev.mdc b/mi-cursor-rules/mi-custom-apps-dev.mdc index 5ad293b..4ade68e 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]` (needs `svgtofont` in the project since pp-dev 1.5.1: `npm install --save-dev svgtofont`). -```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 diff --git a/package-lock.json b/package-lock.json index 21c2502..68f99d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { "name": "@metricinsights/create-pp-dev", - "version": "1.1.7", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@metricinsights/create-pp-dev", - "version": "1.1.7", - "license": "ISC", + "version": "1.2.0", + "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": { @@ -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", @@ -947,13 +915,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.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.63.0" + "playwright": "1.65.0" }, "bin": { "playwright": "cli.js" @@ -1557,17 +1525,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 +1548,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 +1564,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 +1589,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 +1611,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 +1629,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 +1646,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 +1671,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 +1685,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", @@ -1744,56 +1712,17 @@ "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.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 +1737,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": { @@ -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", @@ -2506,9 +2433,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 +2448,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 +2469,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" }, @@ -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", @@ -2918,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": { @@ -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": { @@ -3504,13 +3396,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.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.63.0" + "playwright-core": "1.65.0" }, "bin": { "playwright": "cli.js" @@ -3520,9 +3412,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.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": { @@ -4059,9 +3951,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 a1563f4..edfb562 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@metricinsights/create-pp-dev", - "version": "1.1.7", + "version": "1.2.0", "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,9 +34,12 @@ "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", + "audit:all": "node scripts/audit-all.mjs", + "pretest": "unbuild", "test": "playwright test" }, "author": "Serhii Shpak", @@ -48,7 +51,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", @@ -60,9 +63,12 @@ "@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" }, + "overrides": { + "minimatch": "^11.0.4" + }, "dependencies": { "@clack/prompts": "^1.8.1", "cross-spawn": "^7.0.6" 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/scripts/audit-all.mjs b/scripts/audit-all.mjs new file mode 100644 index 0000000..b0009be --- /dev/null +++ b/scripts/audit-all.mjs @@ -0,0 +1,231 @@ +/** + * 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 { parseAllowlist } from './audit-allowlist.js'; +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'); + } + + const { allowlist, warnings } = parseAllowlist(text); + + for (const warning of warnings) { + console.warn(warning); + } + + 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/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/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/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 ad1ac51..469c0aa 100644 --- a/scripts/postbuild.js +++ b/scripts/postbuild.js @@ -1,35 +1,40 @@ -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'; +import { npmCommand } from './npm-cli.js'; 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; +// 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 pack = npmCommand(['pack', '--json', '--ignore-scripts']); +const output = execFileSync(pack.command, pack.args, { + cwd: rootDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + shell: pack.shell, +}); -// 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); +const [packed] = JSON.parse(output); +const latestName = `${packName}-latest.tgz`; -copyFileSync(latest.path, destPath); -console.log(`Copied ${latestName} -> ${latestNameNew}`); +copyFileSync(join(rootDir, packed.filename), join(rootDir, latestName)); +console.log(`Packed ${packed.filename} (${packed.entryCount} files, ${packed.size} B) -> ${latestName}`); diff --git a/src/index.ts b/src/index.ts index 0dee5fc..9569a8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,31 +1,23 @@ -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 cursorRulesDir = path.join(packageRoot, 'mi-cursor-rules'); 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 +42,6 @@ type FrameworkVariant = { name: string; display: string; color: ColorFunc; - customCommand?: string; }; const FRAMEWORKS: Framework[] = [ @@ -102,69 +93,297 @@ 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: 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 + +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 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 + * 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) { + // 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 ${shownRegistry}/${PP_COMPONENTS_PACKAGE} ` + + `(${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(', ')}.`); + } + + // 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) { - 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)) { - 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 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'); + const where = targetDir === '.' ? 'The current directory' : `Target directory "${targetDir}"`; - process.exit(0); + 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', () => + p.select<'yes' | 'no' | 'ignore'>({ + message: `${where} is not empty. Please choose how to proceed:`, + 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 +393,60 @@ async function init() { if (overwrite === 'yes') { emptyDir(targetDir); } + + if (assumeDefaults) { + // "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 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 this run 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}"`}`); + } } - 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 +456,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 +476,81 @@ 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')); + fs.mkdirSync(path.join(root, '.cursor/rules'), { recursive: true }); - 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), - ); + for (const file of fs.readdirSync(cursorRulesDir)) { + copy(path.join(cursorRulesDir, 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 +569,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 +584,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) { @@ -492,4 +667,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'; 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-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/package.json b/template-nextjs-esm/package.json index 37bb91e..cae62ff 100644 --- a/template-nextjs-esm/package.json +++ b/template-nextjs-esm/package.json @@ -15,19 +15,25 @@ }, "dependencies": { "clsx": "^2.1.1", - "next": "16.3.8", - "react": "^19.3.0", - "react-dom": "^19.3.0" + "next": "16.4.5", + "react": "^19.3.1", + "react-dom": "^19.3.1" }, "devDependencies": { - "@metricinsights/pp-dev": "^1.4.7", + "@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-config-next": "16.3.8", - "prettier": "^3.9.9", + "eslint": "^10.14.0", + "eslint-config-next": "16.4.5", + "prettier": "^3.9.10", "typescript": "^6.0.3" + }, + "overrides": { + "minimatch": "^11.0.4" + }, + "resolutions": { + "minimatch": "^11.0.4" } } 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-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() {

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-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-ts/package.json b/template-vanilla-ts/package.json index d7c87c0..ecb134d 100644 --- a/template-vanilla-ts/package.json +++ b/template-vanilla-ts/package.json @@ -16,14 +16,20 @@ "@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.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": { + "minimatch": "^11.0.4" + }, + "resolutions": { + "minimatch": "^11.0.4" } } 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"] } 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, }, 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/template-vanilla/package.json b/template-vanilla/package.json index 40fa2e7..18397d8 100644 --- a/template-vanilla/package.json +++ b/template-vanilla/package.json @@ -16,11 +16,17 @@ "@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.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" + }, + "resolutions": { + "minimatch": "^11.0.4" } } diff --git a/test/scaffold.spec.mjs b/test/scaffold.spec.mjs index 8bbd72e..f744b5d 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'; @@ -9,13 +10,35 @@ 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; /** - * 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). + * 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. 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); @@ -25,7 +48,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); @@ -34,33 +57,60 @@ function runNpm(args, options) { return spawn('npm', args, { shell: process.platform === 'win32', ...options }); } -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' }, -]; +/** 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(/^\//, '')); +} -function scaffoldProject({ targetDir, template, packageName, install, cursorRules, ppComponents }) { - const args = ['dist/index.mjs', targetDir, '--template', template, '--package-name', packageName]; +function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} - args.push(install ? '--install' : '--no-install'); - args.push(cursorRules ? '--cursor-rules' : '--no-cursor-rules'); - args.push(ppComponents ? '--pp-components' : '--no-pp-components'); +/** 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}-`)); - return spawnSync(process.execPath, args, { - cwd: repoRoot, + try { + return await fn(tmpRoot); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } +} + +/** 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', @@ -73,9 +123,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, { @@ -84,46 +132,93 @@ function runNpmCreateFromLocalPackage({ targetDir, template, packageName, instal }); } -function waitForDevServerReady(child, timeoutMs = 60_000) { +/** + * 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 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 stdout = ''; - let stderr = ''; + let output = ''; + let lastResponse = 'none'; + let exited = false; + let finished = false; - const done = (error) => { + const onOutput = (chunk) => { + output += chunk.toString(); + }; + + const finish = (error) => { + if (finished) { + return; + } + + 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(); + const onExit = (code) => { + exited = true; + finish(new Error(`dev server exited early with code ${code}\noutput:\n${output}`)); + }; - if (/local:|localhost|http:\/\/|https:\/\//i.test(stdout)) { - done(); + child.stdout?.on('data', onOutput); + child.stderr?.on('data', onOutput); + child.on('exit', onExit); + + const poll = setInterval(async () => { + if (exited || finished) { + return; } - }; - const onStderr = (chunk) => { - stderr += chunk.toString(); - }; + try { + const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(2_000) }); - const onExit = (code) => { - done(new Error(`dev server exited early with code ${code}\nstdout:${stdout}\nstderr:${stderr}`)); - }; + await response.arrayBuffer(); + lastResponse = `HTTP ${response.status}, location: ${response.headers.get('location') ?? 'none'}`; - child.stdout?.on('data', onStdout); - child.stderr?.on('data', onStderr); - child.on('exit', onExit); + if (isReady(response)) { + 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} (last response: ${lastResponse})\noutput:\n${output}`, + ), + ); }, timeoutMs); }); } @@ -142,171 +237,684 @@ 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('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 }); + + 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([]); }); + }); + + 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 }); + + expect(run.status).toBe(1); + expect(run.stdout + run.stderr).toContain('--yes'); + expect(fs.readdirSync(tmpRoot)).toEqual([]); + }); + }); +}); + +test.describe('argument validation happens before anything is written', () => { + const existingFile = 'keep-me.txt'; + + function nonEmptyTarget(tmpRoot) { + const targetDir = path.join(tmpRoot, 'existing'); + + fs.mkdirSync(targetDir); + fs.writeFileSync(path.join(targetDir, existingFile), 'do not delete'); + + return targetDir; + } + + 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']); + + expect(run.status).toBe(1); + expect(run.stdout + run.stderr).toContain('Unknown template "bogus"'); + expect(fs.readdirSync(targetDir)).toEqual([existingFile]); + }); + }); + + 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']); + + expect(run.status).toBe(1); + expect(run.stdout + run.stderr).toContain('Invalid package.json name'); + expect(fs.readdirSync(targetDir)).toEqual([existingFile]); + }); + }); + + 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([]); + }); + }); + + 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', + '--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('--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; + 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', () => { + 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 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); + } + }); + }); + } + + 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'); + + fs.mkdirSync(projectDir); + + const run = runCli(['.', '--template', 'vanilla-ts', '-y', '--no-install'], { cwd: projectDir }); - try { 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); + }); + }); - const packageJsonPath = path.join(targetDir, 'package.json'); - const templateSpecificFile = path.join(targetDir, template.checkFile); + 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, + }); - expect(fs.existsSync(packageJsonPath)).toBeTruthy(); - expect(fs.existsSync(templateSpecificFile)).toBeTruthy(); - expect(fs.existsSync(path.join(targetDir, '.cursor'))).toBeFalsy(); + expect(run.status, `stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(0); + expect(readJson(path.join(targetDir, 'package.json')).name).toBe('@my-org/portal-page'); + }); + }); + + 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'); + + fs.mkdirSync(targetDir); + fs.writeFileSync(path.join(targetDir, 'notes.md'), 'kept'); + + // --no-install: the default of --yes is to install, which needs the registry + const run = runCli([targetDir, '--template', 'react', '-y', '--no-install']); + + 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(); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const packageJson = readJson(path.join(targetDir, 'package.json')); - expect(packageJson.name).toBe(packageName); + expect(packageJson.name).toBe('existing'); expect(packageJson.dependencies?.['@metricinsights/pp-components']).toBeUndefined(); - } finally { - fs.rmSync(tmpRoot, { recursive: true, force: true }); - } + expect(fs.existsSync(path.join(targetDir, '.cursor'))).toBeFalsy(); + expect(fs.existsSync(path.join(targetDir, 'node_modules'))).toBeFalsy(); + }); }); -} - -for (const template of templates) { - test(`npm create (npm exec) + npm install for ${template.name} template`, async () => { - test.setTimeout(300_000); - 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); + const optionCombinations = [true, false].flatMap((cursorRules) => + [true, false].map((ppComponents) => ({ cursorRules, ppComponents })), + ); - const run = runNpmCreateFromLocalPackage({ - targetDir, - template: template.name, - packageName, - install: false, - cursorRules: false, - ppComponents: false, + 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); + } + }); }); + } - try { - expect(run.status, `create stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(0); + 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 templateSpecificFile = path.join(targetDir, template.checkFile); + 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(fs.existsSync(templateSpecificFile)).toBeTruthy(); + 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; - const installRun = runNpmSync(['install', '--no-audit', '--fund=false'], { - cwd: targetDir, - encoding: 'utf8', + 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', () => { + 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, }); - 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 }); - } + 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(); + }); }); -} -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); + test('--install installs the packages of a scaffolded vanilla project', async () => { + test.setTimeout(300_000); - const run = scaffoldProject({ - targetDir, - template: 'vanilla', - packageName, - ...options, + 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('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, + }); - try { expect(run.status, `stderr: ${run.stderr}\nstdout: ${run.stdout}`).toBe(0); - const packageJsonPath = path.join(targetDir, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const template = readJson(path.join(repoRoot, 'template-vanilla', 'package.json')); + const scaffolded = 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(scaffolded.overrides).toEqual(template.overrides); + expect(scaffolded.resolutions).toEqual(template.resolutions); + }); + }); +}); - if (options.ppComponents) { - expect(packageJson.dependencies['@metricinsights/pp-components']).toMatch(/^\^[1-9]\d*\./); - } +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); - expect(fs.existsSync(path.join(targetDir, 'node_modules'))).toBe(options.install); - } finally { - fs.rmSync(tmpRoot, { recursive: true, force: true }); - } + 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('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); + 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' })]); - const scaffold = scaffoldProject({ - targetDir, - template: 'vanilla', - packageName, - install: false, - cursorRules: false, - ppComponents: false, + expect(onTheDay.allowlist.size).toBe(1); + expect(dayAfter.allowlist.size).toBe(0); + expect(dayAfter.warnings.join('\n')).toContain('expired on 2026-11-08'); }); - expect(scaffold.status, `stderr: ${scaffold.stderr}\nstdout: ${scaffold.stdout}`).toBe(0); + 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 })]); - // 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'); + expect(allowlist.size, `until=${String(until)}`).toBe(0); + expect(warnings.join('\n'), `until=${String(until)}`).toContain('invalid audit allowlist entry'); + } + }); - fs.writeFileSync( - configPath, - configSource - .replace("// url: 'https://example.metricinsights.com',", "url: 'https://example.metricinsights.com',") - .replace('// id: 1,', 'id: 1,'), - ); + 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' }), + ]); - const install = runNpmSync(['install', '--no-audit', '--fund=false'], { - cwd: targetDir, - encoding: 'utf8', + expect([...allowlist.keys()]).toEqual(['GHSA-dddd-eeee-ffff']); + expect(warnings).toHaveLength(3); }); - expect(install.error, `install spawn error: ${install.error?.message ?? 'none'}`).toBeUndefined(); - expect(install.status, `stderr: ${install.stderr}\nstdout: ${install.stdout}`).toBe(0); + test('a list without entries allowlists nothing', offline, async () => { + const { allowlist, warnings } = await parse(undefined); - const devServer = runNpm(['run', 'dev', '--', '--host', '127.0.0.1', '--port', '4173'], { - cwd: targetDir, - stdio: ['ignore', 'pipe', 'pipe'], + expect(allowlist.size).toBe(0); + expect(warnings).toEqual([]); }); +}); - try { - await waitForDevServerReady(devServer); - } finally { - stopProcessTree(devServer.pid); - fs.rmSync(tmpRoot, { recursive: true, force: true }); +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 () => { + 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 = await freePort(); + + 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), '--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}/`, isScaffoldedApp, 60_000); + } finally { + stopProcessTree(devServer.pid); + } + }); +});