From 8d801ac9dd2923e226b6b7c15430ffc9067efe5a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 00:14:24 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(cli):=20=E2=9C=A8=20add=20first-class?= =?UTF-8?q?=20VitNode=20CLI=20for=20dev,=20build,=20start,=20plugins=20and?= =?UTF-8?q?=20database?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the existing `vitnode` binary in @vitnode/core into the main developer interface: - `vitnode dev` / `build` / `start` adapt to the folder they run in (app, API app, or plugin package), drive Vite through its JavaScript API, and keep the existing plugin-package behaviour of `build` and `dev`. - `vitnode build` reports output files with raw/gzip sizes, per-category size severity, grouped warnings, `--analyze` (per-package breakdown from Rolldown chunk metadata) and a comparison with the previous build by logical chunk identity (snapshot in node_modules/.cache/vitnode). - `vitnode plugin create | list | validate` generate the canonical plugin, list configured/workspace plugins via the app's real config loader, and validate plugins through VitNode's own plugin, route and API loaders. - `vitnode db generate | migrate | push | status` reuse the existing migration lock, in-process migrator and seed, and preview changes from drizzle-kit's structured `--explain --output json` output. `db push` refuses production unless `--force` and confirms data loss explicitly. - Centralised terminal UI (colors, symbols, spinners, tables, plain mode), typed CLI errors with one error boundary and meaningful exit codes, and non-interactive/CI detection. - Legacy `db:prepare`, `migrate` and `i18n:*` commands keep working. - Docs: new dev/cli section (overview, build, plugins, database, CI). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XoLwCe46nQZD8LHRwayaax --- apps/api/package.json | 6 +- apps/web/content/docs/dev/cli/build.mdx | 187 ++++++ apps/web/content/docs/dev/cli/ci.mdx | 86 +++ apps/web/content/docs/dev/cli/database.mdx | 170 ++++++ apps/web/content/docs/dev/cli/index.mdx | 182 ++++++ apps/web/content/docs/dev/cli/meta.json | 6 + apps/web/content/docs/dev/cli/plugins.mdx | 192 ++++++ apps/web/content/docs/dev/database/index.mdx | 9 +- apps/web/content/docs/dev/meta.json | 1 + apps/web/package.json | 6 +- .../src/create/create-package-json.ts | 26 +- packages/vitnode/package.json | 2 + packages/vitnode/scripts/build.ts | 18 - .../vitnode/scripts/cli-arguments.test.ts | 386 ------------ packages/vitnode/scripts/cli-arguments.ts | 204 ------- .../scripts/cli/builder/analysis.test.ts | 165 +++++ .../vitnode/scripts/cli/builder/analysis.ts | 97 +++ .../vitnode/scripts/cli/builder/app-build.ts | 174 ++++++ .../vitnode/scripts/cli/builder/collector.ts | 170 ++++++ .../scripts/cli/builder/compiler-build.ts | 156 +++++ .../scripts/cli/builder/error-context.ts | 73 +++ .../scripts/cli/builder/measure.test.ts | 110 ++++ .../vitnode/scripts/cli/builder/measure.ts | 108 ++++ .../scripts/cli/builder/output-files.test.ts | 137 +++++ .../scripts/cli/builder/output-files.ts | 175 ++++++ .../scripts/cli/builder/package-owner.ts | 104 ++++ .../vitnode/scripts/cli/builder/report.ts | 268 ++++++++ .../scripts/cli/builder/size-severity.test.ts | 45 ++ .../scripts/cli/builder/size-severity.ts | 53 ++ .../scripts/cli/builder/snapshot.test.ts | 165 +++++ .../vitnode/scripts/cli/builder/snapshot.ts | 196 ++++++ .../vitnode/scripts/cli/builder/warnings.ts | 114 ++++ .../scripts/cli/commands/build.test.ts | 449 ++++++++++++++ .../vitnode/scripts/cli/commands/build.ts | 168 +++++ .../vitnode/scripts/cli/commands/db.test.ts | 515 ++++++++++++++++ packages/vitnode/scripts/cli/commands/db.ts | 469 ++++++++++++++ .../vitnode/scripts/cli/commands/dev.test.ts | 391 ++++++++++++ packages/vitnode/scripts/cli/commands/dev.ts | 124 ++++ .../vitnode/scripts/cli/commands/legacy.ts | 96 +++ .../scripts/cli/commands/plugin-create.ts | 147 +++++ .../scripts/cli/commands/plugin-list.ts | 69 +++ .../scripts/cli/commands/plugin-validate.ts | 149 +++++ .../scripts/cli/commands/plugin.test.ts | 574 ++++++++++++++++++ .../scripts/cli/commands/start.test.ts | 197 ++++++ .../vitnode/scripts/cli/commands/start.ts | 116 ++++ packages/vitnode/scripts/cli/context.ts | 76 +++ packages/vitnode/scripts/cli/db/database.ts | 186 ++++++ packages/vitnode/scripts/cli/db/db.test.ts | 192 ++++++ .../vitnode/scripts/cli/db/migration-state.ts | 108 ++++ packages/vitnode/scripts/cli/db/prepare.ts | 114 ++++ packages/vitnode/scripts/cli/db/statements.ts | 128 ++++ packages/vitnode/scripts/cli/dev/open-url.ts | 24 + .../vitnode/scripts/cli/dev/request-log.ts | 66 ++ packages/vitnode/scripts/cli/dev/vite-dev.ts | 189 ++++++ packages/vitnode/scripts/cli/dev/watchers.ts | 111 ++++ packages/vitnode/scripts/cli/errors.ts | 95 +++ packages/vitnode/scripts/cli/help.ts | 63 ++ packages/vitnode/scripts/cli/index.test.ts | 171 ++++++ packages/vitnode/scripts/cli/index.ts | 80 +++ .../vitnode/scripts/cli/plugins/create.ts | 159 +++++ .../vitnode/scripts/cli/plugins/discover.ts | 187 ++++++ .../scripts/cli/plugins/naming.test.ts | 79 +++ .../vitnode/scripts/cli/plugins/naming.ts | 125 ++++ .../vitnode/scripts/cli/plugins/template.ts | 471 ++++++++++++++ .../vitnode/scripts/cli/plugins/validate.ts | 420 +++++++++++++ .../scripts/cli/plugins/workspace.test.ts | 63 ++ .../vitnode/scripts/cli/plugins/workspace.ts | 79 +++ packages/vitnode/scripts/cli/program.ts | 230 +++++++ .../vitnode/scripts/cli/project/packages.ts | 125 ++++ .../vitnode/scripts/cli/project/processes.ts | 170 ++++++ .../vitnode/scripts/cli/project/project.ts | 138 +++++ packages/vitnode/scripts/cli/report-error.ts | 83 +++ .../vitnode/scripts/cli/start/server-entry.ts | 67 ++ .../scripts/cli/start/wait-for-port.ts | 48 ++ packages/vitnode/scripts/cli/testing.ts | 132 ++++ .../vitnode/scripts/cli/ui/capture-output.ts | 69 +++ packages/vitnode/scripts/cli/ui/colors.ts | 56 ++ .../vitnode/scripts/cli/ui/format.test.ts | 70 +++ packages/vitnode/scripts/cli/ui/format.ts | 59 ++ packages/vitnode/scripts/cli/ui/prompts.ts | 106 ++++ packages/vitnode/scripts/cli/ui/symbols.ts | 56 ++ packages/vitnode/scripts/cli/ui/table.ts | 61 ++ .../vitnode/scripts/cli/ui/terminal.test.ts | 102 ++++ packages/vitnode/scripts/cli/ui/terminal.ts | 99 +++ packages/vitnode/scripts/cli/ui/ui.test.ts | 153 +++++ packages/vitnode/scripts/cli/ui/ui.ts | 284 +++++++++ packages/vitnode/scripts/cli/version.ts | 33 + .../scripts/database-bootstrap.test.ts | 85 ++- packages/vitnode/scripts/dev.ts | 71 --- packages/vitnode/scripts/get-config.ts | 25 +- .../vitnode/scripts/no-route-copier.test.ts | 7 +- packages/vitnode/scripts/prepare-database.ts | 239 +++++--- .../scripts/run-interactive-shell-command.ts | 25 - packages/vitnode/scripts/scripts.ts | 168 +---- packages/vitnode/scripts/spawn-command.ts | 15 - .../src/framework/vite/plugin-routes.ts | 2 +- pnpm-lock.yaml | 6 + 97 files changed, 12248 insertions(+), 977 deletions(-) create mode 100644 apps/web/content/docs/dev/cli/build.mdx create mode 100644 apps/web/content/docs/dev/cli/ci.mdx create mode 100644 apps/web/content/docs/dev/cli/database.mdx create mode 100644 apps/web/content/docs/dev/cli/index.mdx create mode 100644 apps/web/content/docs/dev/cli/meta.json create mode 100644 apps/web/content/docs/dev/cli/plugins.mdx delete mode 100644 packages/vitnode/scripts/build.ts delete mode 100644 packages/vitnode/scripts/cli-arguments.test.ts delete mode 100644 packages/vitnode/scripts/cli-arguments.ts create mode 100644 packages/vitnode/scripts/cli/builder/analysis.test.ts create mode 100644 packages/vitnode/scripts/cli/builder/analysis.ts create mode 100644 packages/vitnode/scripts/cli/builder/app-build.ts create mode 100644 packages/vitnode/scripts/cli/builder/collector.ts create mode 100644 packages/vitnode/scripts/cli/builder/compiler-build.ts create mode 100644 packages/vitnode/scripts/cli/builder/error-context.ts create mode 100644 packages/vitnode/scripts/cli/builder/measure.test.ts create mode 100644 packages/vitnode/scripts/cli/builder/measure.ts create mode 100644 packages/vitnode/scripts/cli/builder/output-files.test.ts create mode 100644 packages/vitnode/scripts/cli/builder/output-files.ts create mode 100644 packages/vitnode/scripts/cli/builder/package-owner.ts create mode 100644 packages/vitnode/scripts/cli/builder/report.ts create mode 100644 packages/vitnode/scripts/cli/builder/size-severity.test.ts create mode 100644 packages/vitnode/scripts/cli/builder/size-severity.ts create mode 100644 packages/vitnode/scripts/cli/builder/snapshot.test.ts create mode 100644 packages/vitnode/scripts/cli/builder/snapshot.ts create mode 100644 packages/vitnode/scripts/cli/builder/warnings.ts create mode 100644 packages/vitnode/scripts/cli/commands/build.test.ts create mode 100644 packages/vitnode/scripts/cli/commands/build.ts create mode 100644 packages/vitnode/scripts/cli/commands/db.test.ts create mode 100644 packages/vitnode/scripts/cli/commands/db.ts create mode 100644 packages/vitnode/scripts/cli/commands/dev.test.ts create mode 100644 packages/vitnode/scripts/cli/commands/dev.ts create mode 100644 packages/vitnode/scripts/cli/commands/legacy.ts create mode 100644 packages/vitnode/scripts/cli/commands/plugin-create.ts create mode 100644 packages/vitnode/scripts/cli/commands/plugin-list.ts create mode 100644 packages/vitnode/scripts/cli/commands/plugin-validate.ts create mode 100644 packages/vitnode/scripts/cli/commands/plugin.test.ts create mode 100644 packages/vitnode/scripts/cli/commands/start.test.ts create mode 100644 packages/vitnode/scripts/cli/commands/start.ts create mode 100644 packages/vitnode/scripts/cli/context.ts create mode 100644 packages/vitnode/scripts/cli/db/database.ts create mode 100644 packages/vitnode/scripts/cli/db/db.test.ts create mode 100644 packages/vitnode/scripts/cli/db/migration-state.ts create mode 100644 packages/vitnode/scripts/cli/db/prepare.ts create mode 100644 packages/vitnode/scripts/cli/db/statements.ts create mode 100644 packages/vitnode/scripts/cli/dev/open-url.ts create mode 100644 packages/vitnode/scripts/cli/dev/request-log.ts create mode 100644 packages/vitnode/scripts/cli/dev/vite-dev.ts create mode 100644 packages/vitnode/scripts/cli/dev/watchers.ts create mode 100644 packages/vitnode/scripts/cli/errors.ts create mode 100644 packages/vitnode/scripts/cli/help.ts create mode 100644 packages/vitnode/scripts/cli/index.test.ts create mode 100644 packages/vitnode/scripts/cli/index.ts create mode 100644 packages/vitnode/scripts/cli/plugins/create.ts create mode 100644 packages/vitnode/scripts/cli/plugins/discover.ts create mode 100644 packages/vitnode/scripts/cli/plugins/naming.test.ts create mode 100644 packages/vitnode/scripts/cli/plugins/naming.ts create mode 100644 packages/vitnode/scripts/cli/plugins/template.ts create mode 100644 packages/vitnode/scripts/cli/plugins/validate.ts create mode 100644 packages/vitnode/scripts/cli/plugins/workspace.test.ts create mode 100644 packages/vitnode/scripts/cli/plugins/workspace.ts create mode 100644 packages/vitnode/scripts/cli/program.ts create mode 100644 packages/vitnode/scripts/cli/project/packages.ts create mode 100644 packages/vitnode/scripts/cli/project/processes.ts create mode 100644 packages/vitnode/scripts/cli/project/project.ts create mode 100644 packages/vitnode/scripts/cli/report-error.ts create mode 100644 packages/vitnode/scripts/cli/start/server-entry.ts create mode 100644 packages/vitnode/scripts/cli/start/wait-for-port.ts create mode 100644 packages/vitnode/scripts/cli/testing.ts create mode 100644 packages/vitnode/scripts/cli/ui/capture-output.ts create mode 100644 packages/vitnode/scripts/cli/ui/colors.ts create mode 100644 packages/vitnode/scripts/cli/ui/format.test.ts create mode 100644 packages/vitnode/scripts/cli/ui/format.ts create mode 100644 packages/vitnode/scripts/cli/ui/prompts.ts create mode 100644 packages/vitnode/scripts/cli/ui/symbols.ts create mode 100644 packages/vitnode/scripts/cli/ui/table.ts create mode 100644 packages/vitnode/scripts/cli/ui/terminal.test.ts create mode 100644 packages/vitnode/scripts/cli/ui/terminal.ts create mode 100644 packages/vitnode/scripts/cli/ui/ui.test.ts create mode 100644 packages/vitnode/scripts/cli/ui/ui.ts create mode 100644 packages/vitnode/scripts/cli/version.ts delete mode 100644 packages/vitnode/scripts/dev.ts delete mode 100644 packages/vitnode/scripts/run-interactive-shell-command.ts delete mode 100644 packages/vitnode/scripts/spawn-command.ts diff --git a/apps/api/package.json b/apps/api/package.json index fa5165265..197f3b3b6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,10 +7,10 @@ "db:prepare": "vitnode db:prepare", "db:migrate": "vitnode migrate", "docker:dev": "docker compose -f ./docker-compose.yml -p vitnode-dev-dun up -d", - "dev": "vitnode db:prepare && tsx watch src/index.ts", + "dev": "vitnode dev", "dev:email": "email dev --dir src/emails", - "build": "tsc && tsc-alias -p tsconfig.json", - "start": "node dist/index.js", + "build": "vitnode build", + "start": "vitnode start", "drizzle-kit": "drizzle-kit", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/apps/web/content/docs/dev/cli/build.mdx b/apps/web/content/docs/dev/cli/build.mdx new file mode 100644 index 000000000..4c88ec482 --- /dev/null +++ b/apps/web/content/docs/dev/cli/build.mdx @@ -0,0 +1,187 @@ +--- +title: Build for production +description: Build a VitNode app with vitnode build and read its bundle sizes, size warnings, bundle analysis and changes since the previous build. +icon: Package +--- + +`vitnode build` runs your app's real Vite production build, then reports every file it wrote with raw and gzip sizes, warns about oversized client bundles, and compares the result with your previous build. + +```bash +vitnode build +``` + +```txt +◆ VitNode + Production build + + ✓ Configuration loaded, plugin routes generated 1.5s + ✓ Building client 21.7s + ✓ Building server 12.8s + ✓ Packaging server (Nitro) 21.9s + ✓ Measuring output 1.1s + +Output + +Client JS .output/public + File Size gzip + ───────────────────────────────────────────────── + ▲ assets/custom-emoji-BPTfd_f5.js 820.6 kB 163.2 kB + ! assets/editor-CUh_DSIT.js 319.4 kB 101.0 kB + ✓ assets/search-DxtCMbN7.js 223.9 kB 69.1 kB + ✓ assets/index-B6Na7-xY.js 82.4 kB 26.9 kB + … 4721 more not shown (--verbose lists all) - 4731 files, 18.1 MB, gzip 4.5 MB + +Server .output/server + File Size + ────────────────────────── + ✓ index.mjs 1.2 MB + + ✓ Built in 1m 6s +``` + +The steps are the build environments your app really has - for a TanStack Start app with Nitro: client, server, then Nitro's server output. Plugin routes and registries are generated by VitNode's Vite plugin while the configuration loads. + +In a plugin package, `vitnode build` compiles the package into `dist` instead. In an API app, it runs `tsc` and `tsc-alias`. + +## Read the sizes + +| Column | Meaning | +| ------ | ------------------------------------------------------------------- | +| Size | The file on disk, after minification | +| gzip | What a browser downloads with gzip - client JavaScript and CSS only | +| brotli | Brotli at quality 11, with `--analyze`, for the largest files | + +Sizes use decimal units (`1 kB` = 1000 bytes), like Vite and browser dev tools. Server files are never compressed - nobody downloads them. + +## Size colors and thresholds + +Each file gets a symbol and a color. Client JavaScript is held to the strictest budget, because every visitor downloads, parses and runs it. A server bundle is read once at boot, so its thresholds are much looser. + +| File type | `✓` good | `✓` normal | `!` warning | `▲` large | +| --------- | -------- | ---------- | ----------- | --------- | +| Client JS | ≤ 100 kB | ≤ 250 kB | ≤ 500 kB | > 500 kB | +| CSS | ≤ 50 kB | ≤ 100 kB | ≤ 250 kB | > 250 kB | +| Assets | ≤ 100 kB | ≤ 500 kB | ≤ 1 MB | > 1 MB | +| Server | ≤ 1 MB | ≤ 5 MB | ≤ 20 MB | > 20 MB | + +The `large` line for client JavaScript is Vite's own 500 kB chunk warning. + +## Build warnings + +Oversized client files get a short, grouped warning with what to try: + +```txt +Warnings +▲ admin-D2kL8aQ1.js is larger than the recommended 500.0 kB for a client chunk. + assets/admin-D2kL8aQ1.js 612.4 kB + Consider: + • @tiptap/core makes up 41.2% of admin-D2kL8aQ1.js - import it with import() where it is needed + • load heavy libraries with dynamic import() where they are used + • give pages their own chunk: component: lazy(() => import(...)) in routes.ts + • lazy-load heavy UI such as editors and dialogs with React.lazy + Suspense +``` + +Client chunks warn from 250 kB; CSS and assets only when they are large. Server files never warn. + + + Size warnings are recommendations. A build only fails when the build itself + fails. + + +## Compare with the previous build + +From the second build on, `vitnode build` shows what changed: + +```txt +Bundle changes + File Size Change + ──────────────────────────────────────────────────── + assets/editor-H2kq9ZZ1.js 428.6 kB +86.1 kB +25.1% ▲ + assets/admin-Dk2L8aQ1.js 612.4 kB -12.8 kB -2.0% + assets/new-page-Ab12cdEf.js 4.1 kB +4.1 kB new + Client JS total 18.1 MB (+77.4 kB) +``` + +`▲` marks a file that grew by more than 10% and more than 10 kB. Changes under 1 kB are left out. + +Files are matched by what they contain, not by name, so a new content hash does not break the comparison: + +- an entry chunk is matched by the module it is built from (`src/pages/editor.tsx`) +- a shared chunk by its name and the module that makes up most of it +- an asset by its source file + +The previous sizes live in `node_modules/.cache/vitnode/build-snapshot.json`. Nothing is written to your repository. A missing or unreadable snapshot only skips the comparison - it never fails the build. + + + A shared chunk with no entry module is matched by its name and largest module. + If a refactor changes which module dominates it, it shows up as one file + removed and one added. + + +## Analyze a bundle + +```bash +vitnode build --analyze +``` + +```txt +Bundle analysis + + assets/custom-emoji-BPTfd_f5.js 820.6 kB + @tiptap/extension-emoji ≈ 422.3 kB 51.5% + @tiptap/core ≈ 120.7 kB 14.7% + prosemirror-view ≈ 117.9 kB 14.4% + application code ≈ 50.8 kB 6.2% + other ≈ 57.9 kB 7.1% +``` + +`--analyze` breaks the five largest client chunks down by package, and names a dominating package in the warnings. It uses the module sizes the bundler records for every chunk. Those are measured before minification, so the percentages are exact and the sizes (`≈`) are that share of the file on disk. Workspace packages show by name; your app's own files show as `application code`. + +The analysis is printed in the terminal - nothing opens in a browser. + +## Output modes + +| Command | Use it for | +| ------------------------- | ------------------------------------------------------------ | +| `vitnode build` | Your terminal: symbols, colors, spinners when interactive | +| `vitnode build --plain` | Logs and CI: `[OK]`/`[WARN]` lines, no color, no animation | +| `vitnode build --verbose` | Debugging: Vite's own log, every file, and full stack traces | + +```txt title="vitnode build --plain" +VitNode - Production build +[OK] Building client (21.7s) +[OK] Building server (12.8s) +[WARN] custom-emoji-BPTfd_f5.js is larger than the recommended 500.0 kB for a client chunk. +[OK] Built in 1m 6s +``` + +Without `--verbose`, messages that Vite and its plugins print are held back, so they do not break the progress lines. The CLI counts them for you, and prints them if the build fails. + +## When a build fails + +```txt +✖ Build failed + Plugin @vitnode/blog + File plugins/blog/src/pages/post.tsx:42:9 + Step vite:oxc + + Missing export: title +``` + +`Plugin` appears when the failing file belongs to a VitNode plugin, `File` and `Step` when the bundler reported them. The original message is always shown as-is. Run with `--verbose` for the full stack trace. + +## Learn more + + + + + + diff --git a/apps/web/content/docs/dev/cli/ci.mdx b/apps/web/content/docs/dev/cli/ci.mdx new file mode 100644 index 000000000..6b84bfa28 --- /dev/null +++ b/apps/web/content/docs/dev/cli/ci.mdx @@ -0,0 +1,86 @@ +--- +title: Use the CLI in CI +description: Validate plugins, build with readable logs and apply migrations from GitHub Actions, Docker and other CI pipelines with the VitNode CLI. +icon: Workflow +--- + +```bash +pnpm vitnode plugin validate +pnpm vitnode build --plain +pnpm vitnode db migrate --yes +``` + +Run them in your app's folder. Every command exits with a non-zero code when it fails, so a pipeline stops at the first problem. + +## GitHub Actions + +```yaml title=".github/workflows/build.yml" +name: Build + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build:plugins + - run: pnpm --filter web exec vitnode plugin validate + - run: pnpm --filter web exec vitnode build --plain +``` + +Run `vitnode` in the app folder (`--filter web` above), so it finds the app's config. + +## Migrate during deployment + +```bash +POSTGRES_URL=postgresql://... pnpm vitnode db migrate --yes +pnpm vitnode start +``` + +Run migrations once per deployment, before the new version starts. `--yes` is required: without an interactive terminal, the CLI never waits for an answer. + +## Non-interactive behavior + +The CLI detects where it runs. Under CI (`CI`, `GITHUB_ACTIONS` and similar), with piped output, or when stdin is not a terminal: + +- no spinners, animations or cursor movement +- no prompts - a command that needs confirmation exits with code `2` and names the flag to pass +- colors only when `FORCE_COLOR` is set; `NO_COLOR` always turns them off + +`--plain` goes further and gives stable, prefixed lines for log parsers: + +```txt +VitNode - Production build +[OK] Building client (21.7s) +[OK] Building server (12.8s) +[WARN] admin-D2kL8aQ1.js is larger than the recommended 500.0 kB for a client chunk. +[OK] Built in 1m 6s +``` + +Bundle size warnings never fail a build. The bundle comparison reads the previous sizes from `node_modules/.cache/vitnode` - cache that folder between runs to compare builds in CI. + +## Exit codes + +| Code | Meaning | +| ---- | -------------------------------------------------------------------------- | +| `0` | Success | +| `1` | Failed: build error, invalid plugin, migration error, unreachable database | +| `2` | Wrong command line, or a required `--yes` was not passed | + +## Learn more + + + + + diff --git a/apps/web/content/docs/dev/cli/database.mdx b/apps/web/content/docs/dev/cli/database.mdx new file mode 100644 index 000000000..61e413123 --- /dev/null +++ b/apps/web/content/docs/dev/cli/database.mdx @@ -0,0 +1,170 @@ +--- +title: Database commands +description: Generate and apply database migrations, check migration status, and push schema changes in development with the VitNode CLI. +icon: Database +--- + +```bash +vitnode db generate # write a migration for your schema changes +vitnode db migrate # apply pending migrations +vitnode db status # check the connection and what is pending +vitnode db push # sync the schema directly - development only +``` + +Run them in the app that owns the database - the one with `drizzle.config.ts`. They use your app's own Drizzle configuration and connection. + +## Generate, migrate or push? + +| Command | Changes | Use it | +| ------------- | ----------------------------- | ---------------------------------------- | +| `db generate` | Writes a migration file | After changing tables, before committing | +| `db migrate` | Applies migration files | Everywhere: development, CI, production | +| `db push` | Changes the database directly | Quick experiments in development | + +Migrations are files you commit, so every database gets the same changes in the same order. `db push` skips the files - useful while you experiment, never for a shared or production database. + +## Generate a migration + +```bash +vitnode db generate --name notifications +``` + +```txt +◆ VitNode + Database + + ✓ Checking migration history 2.7s + ✓ Comparing schema with the last migration 3.1s + +Schema changes detected + + table notifications + + table notification_preferences + + column users.notification_count + + ✓ Generating migration 3.1s + + ✓ 20261005233216_notifications +``` + +The changes come from drizzle-kit's own plan, so they are exactly what the migration contains. If drizzle-kit has to ask whether a column was renamed, the command hands the terminal over to it. In a non-interactive terminal it stops instead of guessing. + + + Migrations are generated from the plugins' build output. After changing a + plugin's tables, run `vitnode build` in the plugin, then `vitnode db + generate`. + + +## Apply migrations + +```bash +vitnode db migrate +``` + +```txt +◆ VitNode + Database migration + + ✓ Connecting to the database 26ms + +Pending migrations + ○ 20261004081732_add_core_notifications + ○ 20261004112416_allow_null_notification_digest_schedule + +◇ Apply 2 migrations? Yes + ✓ Applying migrations 840ms + ✓ 20261004081732_add_core_notifications + ✓ 20261004112416_allow_null_notification_digest_schedule + + ✓ Database is up to date. +``` + +`db migrate` applies migrations in one transaction, then seeds the data every VitNode installation needs (languages, roles, permissions). A lock makes two processes wait for each other instead of racing. If a migration fails, nothing is applied and the error shows Postgres' own details. + +Without an interactive terminal, confirm with `--yes`: + +```bash +vitnode db migrate --yes +``` + +Without `--yes`, a non-interactive run exits with code `2` instead of waiting. + +## Check the status + +```bash +vitnode db status +``` + +```txt +◆ VitNode + Database + + Provider PostgreSQL + Database vitnode @ localhost:5432 + Status ● connected + +Migrations + Applied 64 + Pending 1 + +Pending + ○ 20261005233216_notifications +``` + +`db status` only reads. It connects with your app's configuration - the status is never guessed from the config alone - and exits with code `1` when the database is unreachable. It also warns about an applied migration whose file changed since it was applied. + +## Push the schema (development) + +```bash +vitnode db push +``` + +```txt +◆ VitNode + Push database schema + + ! Direct schema synchronization is intended for development. + +Changes + - table zz_drafts + + index notifications.notifications_user_id_idx + +Data loss + ! table zz_drafts (non empty) + +◇ Apply 2 changes, including 1 that deletes data? No +``` + +`db push` shows every change before applying it, and names each change that deletes data. Deleting data always needs a separate confirmation - from a script, pass `--accept-data-loss` together with `--yes`. + + + When `NODE_ENV` is `production`, `vitnode db push` stops before connecting. + Use `db generate` and `db migrate` instead. `--force` overrides the check, if + you really mean to push to that database. + + +## Options + +| Command | Option | Effect | +| ------------- | -------------------- | ----------------------------------------------- | +| `db generate` | `--name ` | Name the migration folder | +| `db migrate` | `-y, --yes` | Apply without asking | +| `db push` | `-y, --yes` | Apply without asking | +| `db push` | `--accept-data-loss` | Allow changes that delete data without a prompt | +| `db push` | `--force` | Allow pushing when `NODE_ENV` is `production` | + +All of them also accept `--plain` and `--verbose`. + +## Learn more + + + + + diff --git a/apps/web/content/docs/dev/cli/index.mdx b/apps/web/content/docs/dev/cli/index.mdx new file mode 100644 index 000000000..05df9270b --- /dev/null +++ b/apps/web/content/docs/dev/cli/index.mdx @@ -0,0 +1,182 @@ +--- +title: VitNode CLI +description: The vitnode command runs, builds and starts your app, creates and validates plugins, and manages database migrations. +icon: SquareTerminal +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { TypeTable } from 'fumadocs-ui/components/type-table' + +`vitnode` is the command you run VitNode with. It ships with `@vitnode/core`, so every VitNode app and plugin already has it. + + + +```bash tab="bun" +bun run vitnode dev +``` + +```bash tab="pnpm" +pnpm vitnode dev +``` + +```bash tab="npm" +npx vitnode dev +``` + + + +Generated projects call it from their `package.json` scripts, so `pnpm dev`, `pnpm build` and `pnpm start` already go through it. + +## Commands + +| Command | What it does | +| ------------------------- | --------------------------------------------- | +| `vitnode dev` | Start the development environment | +| `vitnode build` | Build for production and report bundle sizes | +| `vitnode start` | Start the production server | +| `vitnode plugin create` | Create a plugin from the official template | +| `vitnode plugin list` | List the plugins your app uses | +| `vitnode plugin validate` | Check plugins the way your app will load them | +| `vitnode db generate` | Generate a migration from schema changes | +| `vitnode db migrate` | Apply pending migrations | +| `vitnode db push` | Push the schema directly (development only) | +| `vitnode db status` | Show the connection and migration status | + +Run `vitnode` alone for a short overview, or `vitnode --help` for a command's options. + +## What the folder decides + +`dev`, `build` and `start` work on the project in the current folder: + +| Folder | `vitnode dev` | `vitnode build` | `vitnode start` | +| ------------------------------------------------- | ------------------------------------ | --------------------------------------- | -------------------------- | +| App (`vite.config.ts`) | Database bootstrap, then Vite | Vite production build + size report | `.output/server/index.mjs` | +| API app (`src/vitnode.api.config.ts`) | Database bootstrap, then `tsx watch` | `tsc` + `tsc-alias` | `dist/index.js` | +| Plugin package (`tsconfig.build.json` + `.swcrc`) | Compilers in watch mode | Types, JavaScript and aliases to `dist` | - | + +The database bootstrap runs only in an app that owns the schema - one with a `drizzle.config.ts`. It generates a migration for schema changes, applies pending migrations and seeds initial data, just like `vitnode db:prepare`. + +## Start development + +```bash +vitnode dev +``` + +```txt +◆ VitNode + Development + + ✓ Loading configuration 31ms + ✓ Database connected up to date + ✓ 2 plugins loaded + ✓ Starting dev server 2.6s + + Web http://localhost:3000 + AdminCP http://localhost:3000/admin + API http://localhost:3000/api +──────────────────────────────────────── +GET / 200 180ms +GET /api/@vitnode/core/users/session 200 6ms +HMR src/routes/_main/index.tsx +``` + +The URLs are the ones Vite actually bound. The port is `--port`, then `PORT`, then `server.port` in `vite.config.ts`, then `3000`. + +In an interactive terminal, type a shortcut and press Enter: + +| Key | Action | +| --- | ------------- | +| `o` | Open the site | +| `a` | Open AdminCP | +| `r` | Restart | +| `c` | Clear | +| `q` | Quit | + +Ctrl+C closes the server and every watcher it started. + +## Start the production server + +```bash +vitnode build +vitnode start +``` + +```txt +◆ VitNode + Production + + ● Running http://localhost:3000 +``` + +`vitnode start` runs the server your build produced with Node - it is not a second server. "Running" appears only once the server accepts connections. `--port` and `--host` set `PORT` and `HOST`, the variables the server already reads. A build for a hosting platform (for example the `vercel` Nitro preset) is refused, because that platform runs it. + +## Output options + + + +Colors follow `NO_COLOR` and `FORCE_COLOR`. In CI, piped output and other non-interactive terminals, the CLI never shows spinners or prompts - see [CI](/docs/dev/cli/ci). + +## Exit codes + +| Code | Meaning | +| ----- | ----------------------------------------------------------------------- | +| `0` | Success | +| `1` | The command failed: a build error, invalid plugin, unreachable database | +| `2` | The command line is wrong, or a confirmation flag is missing | +| `130` | Cancelled with Ctrl+C at a prompt | + +## Vite, Drizzle Kit and TanStack Start + +The CLI drives these tools - through Vite's JavaScript API, your project's own `drizzle-kit`, and your `vite.config.ts` - instead of replacing them. You can still run `vite` or `drizzle-kit` directly; `vitnode` adds the database bootstrap, plugin checks and readable output around them. + +## Older commands + +These still work for existing projects and deployment scripts: + +| Command | Same as | +| -------------------- | ---------------------------------------------------- | +| `vitnode db:prepare` | Generate, migrate and seed - what `vitnode dev` runs | +| `vitnode migrate` | `vitnode db:prepare`; `--generate` only generates | +| `vitnode i18n:*` | See [Internationalization](/docs/dev/i18n) | + +## Learn more + + + + + + + diff --git a/apps/web/content/docs/dev/cli/meta.json b/apps/web/content/docs/dev/cli/meta.json new file mode 100644 index 000000000..322ddfd9b --- /dev/null +++ b/apps/web/content/docs/dev/cli/meta.json @@ -0,0 +1,6 @@ +{ + "title": "CLI", + "description": "Run, build, start, create plugins and manage migrations with the vitnode command.", + "icon": "SquareTerminal", + "pages": ["index", "build", "plugins", "database", "ci"] +} diff --git a/apps/web/content/docs/dev/cli/plugins.mdx b/apps/web/content/docs/dev/cli/plugins.mdx new file mode 100644 index 000000000..f07b41bc5 --- /dev/null +++ b/apps/web/content/docs/dev/cli/plugins.mdx @@ -0,0 +1,192 @@ +--- +title: Create and validate plugins +description: Create a VitNode plugin with vitnode plugin create, list the plugins an app uses, and validate them before a build or in CI. +icon: Puzzle +--- + +import { File, Files, Folder } from 'fumadocs-ui/components/files' + +```bash +vitnode plugin create blog +vitnode plugin list +vitnode plugin validate blog +``` + +## Create a plugin + +```bash +vitnode plugin create blog +``` + +```txt +◆ VitNode + Create plugin + +◇ Package name @acme/blog +◇ Description Blogging for VitNode + + Creating plugin... + ✓ Package created + ✓ Plugin definition + ✓ Required structure + ✓ Translations + ✓ Tests + ✓ Documentation + +Created + plugins/blog +``` + +Every plugin starts from the same official structure - there are no features to pick. You only answer what cannot be derived: + +- **Package name** - the plugin id. It defaults to your workspace's plugin scope (`@acme/blog` when your plugins are `@acme/*`), otherwise `vitnode-plugin-blog`. +- **Description** - one line for `package.json`. + +Run `vitnode plugin create` without a name to be asked for it. To skip every question, pass the values: + +```bash +vitnode plugin create blog --package-name @acme/blog --description "Blogging for VitNode" --yes +``` + +### What you get + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +The plugin is small but complete: a page at `/blog` whose loader calls the plugin's own `hello` API endpoint through the [fetcher](/docs/dev/fetcher), its strings in `en.json`, and a Vitest test for the endpoint. + +### Use it in your app + +1. Add `"@acme/blog": "workspace:*"` to your app's dependencies and install. +2. Register `blogPlugin()` from `@acme/blog/config` in `vitnode.config.ts`. +3. Register `blogApiPlugin()` from `@acme/blog/config.api` in `vitnode.api.config.ts`. +4. Build it: `cd plugins/blog && vitnode build` (or `vitnode dev` to rebuild on every change). + +### Name rules + +| Rule | Example | +| ----------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| Lowercase letters, digits and single dashes | `event-calendar` | +| Starts with a letter, under 50 characters | `forum2` | +| Not one of core's routes: `admin`, `api`, `core`, `discover`, `files`, `login`, `notifications`, `register`, `search`, `users`, `vitnode` | - | + +The package name must be a valid npm name and must not be `@vitnode/core`. Creation stops before writing anything if the folder already exists, or a workspace package already has that name - a plugin id must be unique. + +## List plugins + +```bash +vitnode plugin list +``` + +```txt +◆ VitNode + Plugins + + Plugin Version Source + ────────────────────────────────────────────── + @vitnode/blog 2.0.0-canary.13 workspace + @acme/search 1.1.2 package + @acme/forum (not configured) 0.1.0 workspace + + 2 plugins configured in apps/web +``` + +The list comes from your app's `vitnode.config.ts`, read by the same loader the app's build uses. Workspace plugins the app does not use yet are listed as `not configured`. + +| Source | Meaning | +| ----------- | ---------------------------- | +| `workspace` | A package in this repository | +| `package` | Installed from a registry | + +`--verbose` adds each plugin's path and description. Run the command from an app, or from anywhere in its workspace. + +## Validate plugins + +```bash +vitnode plugin validate # every plugin - or the one you are in +vitnode plugin validate blog # by name, package name or folder +``` + +```txt + @vitnode/blog plugins/blog + ✓ Package 2.0.0-canary.13 + ✓ Plugin definition + ✓ API definition + ✓ Routes 3 routes + ✓ Translations en + ✓ AdminCP navigation + ✓ Database schema 6 tables + + ✓ 1 plugin valid +``` + +Validation loads the plugin's build output - the files your app imports - through VitNode's own loaders. A check runs only when the plugin has that part. + +| Check | Fails when | +| ------------------ | ----------------------------------------------------------------------------------------------------------- | +| Package | `name` is not a valid plugin id, `type` is not `module`, or `exports` does not expose `dist` | +| Build output | `dist/src/config.js` is missing - build the plugin first | +| Plugin definition | `config.tsx` exports no `buildPlugin()` factory, or its `pluginId` is not the package name | +| API definition | `buildApiPlugin()` rejects the configuration, or its `pluginId` does not match | +| Routes | The route tree does not compile, or a `lazy(() => import(...))` page file is missing | +| Translations | A locale file is unreadable, lacks the plugin's top-level key, or a route's `messages` namespace is missing | +| AdminCP navigation | A nav item requires a permission the plugin does not register in `permissionStaff.admin` | +| Database schema | A schema module fails to load, or two modules declare the same table | + +A failure says what is wrong and where: + +```txt + ✖ AdminCP navigation + AdminCP navigation item /admin/blog requires the permission posts.can_manage, + but @acme/blog does not register it in buildApiPlugin({ permissionStaff: { admin } }). + dist/src/admin/nav.js + +✖ 1 plugin failed validation: @acme/blog +``` + +`vitnode plugin validate` exits with code `1` when any plugin is invalid, so it can gate a CI job. + +## Learn more + + + + + diff --git a/apps/web/content/docs/dev/database/index.mdx b/apps/web/content/docs/dev/database/index.mdx index 0c1b3dfa5..74b44ff68 100644 --- a/apps/web/content/docs/dev/database/index.mdx +++ b/apps/web/content/docs/dev/database/index.mdx @@ -77,11 +77,14 @@ npm run build:plugins && npm run db:migrate ## Migration Commands +Run these with the [VitNode CLI](/docs/dev/cli/database) in the app that owns the database: + | Command | Action | | :--- | :--- | -| `pnpm db:generate` | Inspects schema diffs and writes timestamped SQL migrations | -| `pnpm db:migrate` | Applies pending migrations against the target database | -| `pnpm db:studio` | Opens Drizzle Studio to inspect and edit database rows visually | +| `vitnode db generate` | Shows schema changes and writes a timestamped SQL migration | +| `vitnode db migrate` | Applies pending migrations against the target database | +| `vitnode db status` | Checks the connection and lists pending migrations | +| `vitnode db push` | Syncs the schema directly - development only | If your table represents an editorial entity (articles, FAQs, products), consider [Content Engine](/docs/dev/content-engine/defining-a-content-type) to generate your Drizzle table, API routes, and AdminCP screens from one definition. diff --git a/apps/web/content/docs/dev/meta.json b/apps/web/content/docs/dev/meta.json index 2203ede44..f0d283f7a 100644 --- a/apps/web/content/docs/dev/meta.json +++ b/apps/web/content/docs/dev/meta.json @@ -7,6 +7,7 @@ "---Start here---", "index", "setup", + "cli", "deployments", "architecture", "configuration", diff --git a/apps/web/package.json b/apps/web/package.json index 77d910fd0..ea8dd00e8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,13 +4,13 @@ "private": true, "type": "module", "scripts": { - "dev": "pnpm --filter api db:prepare && vite dev --port 3000", - "build": "vite build", + "dev": "pnpm --filter api db:prepare && vitnode dev --port 3000", + "build": "vitnode build", "preview": "vite preview", "lint": "eslint .", "format": "prettier --write . && eslint --fix", "check": "prettier --check .", - "start": "node .output/server/index.mjs", + "start": "vitnode start", "typecheck": "tsc --noEmit", "lint:fix": "eslint . --fix" }, diff --git a/packages/create-vitnode-app/src/create/create-package-json.ts b/packages/create-vitnode-app/src/create/create-package-json.ts index bd6627bdf..a3ac9c54d 100644 --- a/packages/create-vitnode-app/src/create/create-package-json.ts +++ b/packages/create-vitnode-app/src/create/create-package-json.ts @@ -119,9 +119,9 @@ export const apiScripts = ( start: "NODE_ENV=production bun run src/index.ts", } : { - dev: "vitnode db:prepare && tsx watch src/index.ts", - build: "tsc && tsc-alias -p tsconfig.json", - start: "node dist/index.js", + dev: "vitnode dev", + build: "vitnode build", + start: "vitnode start", }), "dev:email": "email dev --dir src/emails", ...i18nScripts, @@ -134,14 +134,14 @@ export const apiScripts = ( /** * The single app: a TanStack Start site with the Hono API mounted inside it. * - * `vite` rather than `next`, and `start` runs Nitro's own server output rather - * than a framework CLI: a Start build emits `.output/server/index.mjs`, which is - * a plain Node entry point and needs nothing installed to run. + * `dev`, `build` and `start` are the VitNode CLI, which drives Vite through its + * JavaScript API and starts the plain Node entry a Start build emits + * (`.output/server/index.mjs`) - nothing beyond Node is needed to run it. * * **This shape owns a database.** It ships `drizzle.config.ts`, a `migrations/` * directory and `vitnode.api.config.ts`, and it serves `/api/*` from its own * process - so it is the schema's owner as much as a standalone API app is, and - * `dev` waits for the bootstrap before Vite starts. + * `vitnode dev` runs the database bootstrap before Vite starts. * * That line went missing in Stage 17 and it is the regression this file was * fixed for. The reasoning at the time was correct about the half it was looking @@ -164,10 +164,10 @@ export const singleAppScripts = ( ) => ({ "db:migrate": "vitnode migrate", "db:prepare": "vitnode db:prepare", - dev: "vitnode db:prepare && vite dev --port 3000", + dev: "vitnode dev", "dev:email": "email dev --dir src/emails", - build: "vite build", - start: "node .output/server/index.mjs", + build: "vitnode build", + start: "vitnode start", ...i18nScripts, ...withIf(eslint, eslintScripts), ...withIf(docker, { "docker:dev": dockerDevScript(appName) }), @@ -175,9 +175,9 @@ export const singleAppScripts = ( }); export const webScripts = (eslint: boolean) => ({ - dev: "vite dev --port 3000", - build: "vite build", - start: "node .output/server/index.mjs", + dev: "vitnode dev", + build: "vitnode build", + start: "vitnode start", ...i18nScripts, ...withIf(eslint, eslintScripts), }); diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 8af60d5f2..230161d93 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -249,6 +249,7 @@ "class-variance-authority": "^0.7.1", "cmdk": "^1.1.1", "cn": "^0.4.0", + "commander": "^15.0.0", "cron-parser": "^5.10.1", "d3-shape": "^3.2.0", "date-fns": "^4.4.0", @@ -257,6 +258,7 @@ "input-otp": "^1.5.0", "jiti": "^2.7.0", "motion": "^13.4.6", + "picocolors": "^1.1.1", "postgres": "^3.4.9", "rate-limiter-flexible": "^11.2.1", "react-colorful": "^5.8.1", diff --git a/packages/vitnode/scripts/build.ts b/packages/vitnode/scripts/build.ts deleted file mode 100644 index f2eb79806..000000000 --- a/packages/vitnode/scripts/build.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { runInteractiveShellCommand } from "./run-interactive-shell-command.js"; -import { writePluginApiRegistry } from "./write-plugin-api-registry.js"; - -export const buildPlugin = async () => { - writePluginApiRegistry(); - await runInteractiveShellCommand("tsc", ["-p", "tsconfig.build.json"]); - await runInteractiveShellCommand("swc", [ - "src", - "-d", - "dist", - "--config-file", - ".swcrc", - // Carries locale JSON (and other assets) into `dist`, next to the compiled - // `locales/index.js` barrel that imports it. - "--copy-files", - ]); - await runInteractiveShellCommand("tsc-alias", ["-p", "tsconfig.build.json"]); -}; diff --git a/packages/vitnode/scripts/cli-arguments.test.ts b/packages/vitnode/scripts/cli-arguments.test.ts deleted file mode 100644 index 6799c86f5..000000000 --- a/packages/vitnode/scripts/cli-arguments.test.ts +++ /dev/null @@ -1,386 +0,0 @@ -// @vitest-environment node -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; - -import type { CliCommandName } from "./cli-arguments.js"; - -import { - cliCommandNames, - COMMAND_ARGUMENTS, - parseCliArguments, -} from "./cli-arguments.js"; - -const scriptsRoot = import.meta.dirname; - -const codeOf = (file: string): string => - readFileSync(join(scriptsRoot, file), "utf8") - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\/\/.*$/gm, ""); - -/** The refusal message, or `null` when the invocation was accepted. */ -const refusal = (...argv: string[]): null | string => { - const parsed = parseCliArguments(argv); - - return parsed.ok ? null : parsed.message; -}; - -/** The accepted parse, or `null` when it was refused. */ -const accepted = ( - ...argv: string[] -): null | { args: readonly string[]; command: CliCommandName } => { - const parsed = parseCliArguments(argv); - - return parsed.ok ? { args: parsed.args, command: parsed.command } : null; -}; - -describe("the contract covers every command the CLI dispatches", () => { - const entryPoint = codeOf("scripts.ts"); - const dispatched = [...entryPoint.matchAll(/case "([^"]+)":/g)] - .map(match => match[1]) - .sort(); - - it("lists the nine commands", () => { - expect(cliCommandNames()).toEqual([ - "build", - "db:prepare", - "dev", - "i18n:check", - "i18n:create", - "i18n:delete", - "i18n:update", - "i18n:update:ai", - "migrate", - ]); - }); - - it("dispatches every command it validates, and validates every one it dispatches", () => { - expect(dispatched).toEqual(cliCommandNames()); - }); - - it("gives every command a usage line", () => { - for (const name of cliCommandNames()) { - expect(COMMAND_ARGUMENTS[name].usage).toMatch( - new RegExp(`^vitnode ${name.replaceAll(":", ":")}`), - ); - } - }); - - /** - * A value-taking flag has to be one of the command's flags, or the value rule - * would never be reached and `--model` would be refused as unknown. - */ - it("declares no value flag it does not also allow", () => { - for (const name of cliCommandNames()) { - const spec = COMMAND_ARGUMENTS[name]; - - for (const flag of spec.valueFlags ?? []) { - expect(spec.flags).toContain(flag); - } - } - }); -}); - -describe("valid invocations are accepted, and stay accepted", () => { - it.each([ - ["build"], - ["db:prepare"], - ["dev"], - ["migrate"], - ["migrate", "--generate"], - ["i18n:check"], - ["i18n:check", "--ci"], - ["i18n:create"], - ["i18n:delete"], - ["i18n:update"], - ["i18n:update:ai"], - ])("%s %s", (...argv) => { - expect(refusal(...argv)).toBeNull(); - }); - - it("parses the command and hands the arguments through unchanged", () => { - expect(accepted("migrate", "--generate")).toEqual({ - args: ["--generate"], - command: "migrate", - }); - expect(accepted("i18n:check", "--ci")).toEqual({ - args: ["--ci"], - command: "i18n:check", - }); - expect(accepted("migrate")).toEqual({ args: [], command: "migrate" }); - }); - - /** - * The three commands that parse their own `argv`, and the reason the contract - * is not "no arguments" for all nine. - * - * `i18n:create` reads `[code, ...nameParts]` and joins the tail into a name, - * `i18n:delete` reads one code, and `i18n:update:ai` takes locale codes plus - * `--model` / `--concurrency`. All of it is existing, documented, scriptable - * behaviour - anything supplied skips a prompt, which is what lets these run - * on a non-interactive stdin - so validation must not take it away. - */ - it.each([ - ["i18n:create", "pl"], - ["i18n:create", "pl", "Polski"], - ["i18n:create", "pt-BR", "Português", "do", "Brasil"], - ["i18n:delete", "pl"], - ["i18n:update:ai", "pl"], - ["i18n:update:ai", "pl", "de"], - ["i18n:update:ai", "--model", "gpt-5"], - ["i18n:update:ai", "--model=gpt-5"], - ["i18n:update:ai", "pl", "--model", "gpt-5", "--concurrency", "4"], - ["i18n:update:ai", "--concurrency=4"], - ])("%s %s %s %s", (...argv) => { - expect(refusal(...argv)).toBeNull(); - }); -}); - -describe("an unknown flag is refused", () => { - it.each([ - ["build", "--foo"], - ["db:prepare", "--foo"], - ["dev", "--foo"], - ["migrate", "--foo"], - ["i18n:check", "--foo"], - ["i18n:create", "--foo"], - ["i18n:delete", "--foo"], - ["i18n:update", "--foo"], - ["i18n:update:ai", "--foo"], - ])("%s %s", (...argv) => { - expect(refusal(...argv)).not.toBeNull(); - }); - - it("names the argument, the command and the flags that would have worked", () => { - const message = refusal("migrate", "--foo"); - - expect(message).toContain('"--foo"'); - expect(message).toContain('"migrate"'); - expect(message).toContain("--generate"); - }); - - it("says plainly when a command accepts no arguments at all", () => { - const message = refusal("db:prepare", "--generate"); - - expect(message).toContain('"db:prepare"'); - expect(message).toContain("accepts no arguments"); - }); - - it("refuses a short flag rather than counting it as a positional", () => { - expect(refusal("i18n:delete", "-x")).not.toBeNull(); - }); - - /** - * Flags belong to commands, not to the CLI. A whitelist shared across - * commands would make both of these legal ways to ask for nothing. - */ - it.each([ - ["migrate", "--ci"], - ["i18n:check", "--generate"], - ["i18n:update:ai", "--ci"], - ["i18n:check", "--model", "gpt-5"], - ])("%s %s is not another command's flag", (...argv) => { - expect(refusal(...argv)).not.toBeNull(); - }); -}); - -describe("extra positional arguments are refused", () => { - it.each([ - ["build", "hello"], - ["db:prepare", "anything"], - ["dev", "anything"], - ["migrate", "foo"], - ["migrate", "--generate", "extra"], - ["i18n:check", "foo"], - ["i18n:check", "--ci", "foo"], - ["i18n:update", "foo"], - ["i18n:delete", "pl", "de"], - ])("%s %s %s", (...argv) => { - expect(refusal(...argv)).not.toBeNull(); - }); - - it("counts a consumed flag value as a value, not a positional", () => { - // `4` belongs to `--concurrency`. Counting it as a locale code would refuse - // an invocation the implementation has always accepted. - expect(refusal("i18n:update:ai", "--concurrency", "4")).toBeNull(); - }); -}); - -describe("repeated and malformed flags are refused", () => { - it.each([ - ["migrate", "--generate", "--generate"], - ["migrate", "--generate", "--foo"], - ["i18n:check", "--ci", "--ci"], - ["i18n:check", "--ci", "--foo"], - ["i18n:update:ai", "--model", "a", "--model", "b"], - ])("%s %s %s %s %s", (...argv) => { - expect(refusal(...argv)).not.toBeNull(); - }); - - it("names the repeated flag", () => { - expect(refusal("migrate", "--generate", "--generate")).toContain( - "repeated", - ); - }); - - it("refuses a value flag with no value", () => { - expect(refusal("i18n:update:ai", "--model")).toContain("needs a value"); - expect(refusal("i18n:update:ai", "--model=")).toContain("needs a value"); - expect( - refusal("i18n:update:ai", "--model", "--concurrency", "4"), - ).toContain("needs a value"); - }); - - it("refuses a value on a switch that takes none", () => { - expect(refusal("migrate", "--generate=yes")).toContain("takes no value"); - expect(refusal("i18n:check", "--ci=true")).toContain("takes no value"); - }); - - it("checks every argument, not only the first", () => { - // The bug this guards: returning as soon as one flag validates leaves the - // rest of the list unread. `--model=gpt-5` carries its own value and so is - // the one flag with nothing to consume. - expect(refusal("i18n:update:ai", "--model=gpt-5", "--foo")).not.toBeNull(); - expect( - refusal("i18n:update:ai", "--model", "gpt-5", "--foo"), - ).not.toBeNull(); - }); -}); - -describe("a missing or unknown command is refused", () => { - it("refuses no command at all", () => { - const message = refusal(); - - expect(message).toContain("No command given"); - // `undefined` must not read as a command name in the message. - expect(message).not.toContain("undefined"); - }); - - it("refuses an empty command", () => { - expect(refusal("")).toContain("No command given"); - }); - - it("refuses an unknown command and says what exists", () => { - const message = refusal("wat"); - - expect(message).toContain('"wat"'); - expect(message).toContain("db:prepare"); - }); - - /** - * Never part of this CLI's contract, and this task is validation rather than - * feature expansion - so they are refused like any other unknown command. - */ - it.each(["--help", "-h", "--version", "-v"])("refuses %s", spelling => { - expect(refusal(spelling)).not.toBeNull(); - }); - - it("refuses a command spelled as a flag's value", () => { - expect(refusal("--generate")).not.toBeNull(); - }); - - it("does not accept a command by prototype inheritance", () => { - // `Object.hasOwn`, not `name in table`: `toString` and `constructor` are on - // every object's prototype and are not commands. - expect(refusal("toString")).toContain("Command not found"); - expect(refusal("constructor")).toContain("Command not found"); - }); -}); - -/** - * The semantic regression this whole change exists for. - * - * `migrate --generat` must not become `migrate`. The old entry point compared - * one string to `"--generate"`, and a typo failed that comparison and fell - * through to the full bootstrap - generate, apply *and seed*, against whatever - * `POSTGRES_URL` names. Somebody who meant "write the migration files" got a - * migrated and seeded database. - * - * `i18n:check --cii` is the same failure with a quieter cost: `isCi` was false, - * so a CI job that had asked for a hard failure got a soft report and exit 0. - */ -describe("a mistyped flag never becomes a different command", () => { - it("refuses `migrate --generat` rather than running a full migration", () => { - const parsed = parseCliArguments(["migrate", "--generat"]); - - expect(parsed.ok).toBe(false); - // And the refusal is not quietly equivalent to the bare command. - expect(parsed).not.toEqual(parseCliArguments(["migrate"])); - }); - - it.each(["--generat", "--generate-", "--Generate", "-generate", "generate"])( - "refuses `migrate %s`", - spelling => { - expect(refusal("migrate", spelling)).not.toBeNull(); - }, - ); - - it("refuses `i18n:check --cii` rather than running a non-CI check", () => { - const parsed = parseCliArguments(["i18n:check", "--cii"]); - - expect(parsed.ok).toBe(false); - expect(parsed).not.toEqual(parseCliArguments(["i18n:check"])); - }); - - it.each(["--cii", "--c", "--CI", "-ci", "ci"])( - "refuses `i18n:check %s`", - spelling => { - expect(refusal("i18n:check", spelling)).not.toBeNull(); - }, - ); - - /** - * The accepted spelling is the only one that turns the behaviour on, which is - * what the entry point reads. Stated here so the pairing cannot drift: if - * `args` ever carried an unvalidated token, this is the assertion that fails. - */ - it("only ever hands the exact spelling through", () => { - expect(accepted("migrate", "--generate")?.args).toEqual(["--generate"]); - expect(accepted("migrate")?.args).toEqual([]); - expect(accepted("i18n:check", "--ci")?.args).toEqual(["--ci"]); - expect(accepted("i18n:check")?.args).toEqual([]); - }); -}); - -/** - * That the parser is in front of the dispatch, read off the entry point. - * - * Three assertions and no spawned process. The property is positional - "the - * check happens before the work" - and it is the one thing a call to - * `parseCliArguments` cannot demonstrate about its own caller. - */ -describe("the entry point validates before it dispatches", () => { - const withoutComments = codeOf("scripts.ts"); - - it("calls the parser, and does so above the switch", () => { - const parse = withoutComments.indexOf("parseCliArguments("); - const guard = withoutComments.indexOf("!parsed.ok"); - const dispatch = withoutComments.indexOf("switch (command)"); - - expect(parse).toBeGreaterThan(-1); - expect(guard).toBeGreaterThan(parse); - expect(dispatch).toBeGreaterThan(guard); - }); - - /** - * The old reads, both gone. `process.argv[3]` as "the flag" is what let every - * argument after the third be ignored, and it is the shape a future edit is - * most likely to reintroduce. - */ - it("reads the whole argv rather than one indexed argument", () => { - expect(withoutComments).toContain("process.argv.slice(2)"); - expect(withoutComments).not.toMatch(/process\.argv\[\d]/); - }); - - /** - * No command implementation may be called with a raw argument. The commands - * that need one read the validated list; the three that parse their own - * `argv` are simply never started on an `argv` the parser refused. - */ - it("passes no unvalidated argument to a command", () => { - expect(withoutComments).toContain('args.includes("--ci")'); - expect(withoutComments).toContain('args.includes("--generate")'); - expect(withoutComments).not.toMatch(/i18nCheck\(\s*flag\s*\)/); - expect(withoutComments).not.toMatch(/flag\s*===\s*"--generate"/); - }); -}); diff --git a/packages/vitnode/scripts/cli-arguments.ts b/packages/vitnode/scripts/cli-arguments.ts deleted file mode 100644 index c7b417e5f..000000000 --- a/packages/vitnode/scripts/cli-arguments.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** Every command the CLI dispatches on. */ -export type CliCommandName = - | "build" - | "db:prepare" - | "dev" - | "i18n:check" - | "i18n:create" - | "i18n:delete" - | "i18n:update" - | "i18n:update:ai" - | "migrate"; - -export interface CliCommandArguments { - flags: readonly string[]; - - positional: "any" | number; - /** One line of usage, printed with every refusal. */ - usage: string; - - valueFlags?: readonly string[]; -} - -export const COMMAND_ARGUMENTS: Record = { - build: { flags: [], positional: 0, usage: "vitnode build" }, - "db:prepare": { flags: [], positional: 0, usage: "vitnode db:prepare" }, - dev: { flags: [], positional: 0, usage: "vitnode dev" }, - "i18n:check": { - flags: ["--ci"], - positional: 0, - usage: "vitnode i18n:check [--ci]", - }, - "i18n:create": { - flags: [], - positional: "any", - usage: 'vitnode i18n:create ""', - }, - "i18n:delete": { - flags: [], - positional: 1, - usage: "vitnode i18n:delete ", - }, - "i18n:update": { flags: [], positional: 0, usage: "vitnode i18n:update" }, - "i18n:update:ai": { - flags: ["--concurrency", "--model"], - positional: "any", - usage: - "vitnode i18n:update:ai [code...] [--model ] [--concurrency ]", - valueFlags: ["--concurrency", "--model"], - }, - migrate: { - flags: ["--generate"], - positional: 0, - usage: "vitnode migrate [--generate]", - }, -}; - -/** The command names, sorted, for the message an unknown one prints. */ -export const cliCommandNames = (): CliCommandName[] => - (Object.keys(COMMAND_ARGUMENTS) as CliCommandName[]).sort(); - -/** Whether `vitnode ` is a command at all. */ -export const isCliCommandName = ( - name: string | undefined, -): name is CliCommandName => - name !== undefined && Object.hasOwn(COMMAND_ARGUMENTS, name); - -export type ParsedCli = - | { args: readonly string[]; command: CliCommandName; ok: true } - | { message: string; ok: false }; - -const quoted = (values: readonly string[]): string => - values.map(value => `"${value}"`).join(", "); - -/** `--model=gpt-5` as `["--model", "gpt-5"]`; a plain flag as `[flag]`. */ -const splitFlag = (arg: string): [string, string | undefined] => { - const equals = arg.indexOf("="); - - return equals === -1 - ? [arg, undefined] - : [arg.slice(0, equals), arg.slice(equals + 1)]; -}; - -const allowedSentence = (spec: CliCommandArguments): string => - spec.flags.length === 0 - ? "It accepts no flags." - : `Allowed: ${[...spec.flags].sort().join(", ")}.`; - -/** - * The first thing wrong with `args`, or `null`. - * - * First rather than all of them, because the list is short and the first - * problem is the one that explains the rest: `migrate --generat --generat` is - * one typo, not two findings. - * - * A token is treated as a flag when it starts with `-`, so `vitnode build -x` - * is refused as an unknown flag rather than counted as a positional. No command - * here has a negative-number argument for that to get in the way of. - */ -const firstProblem = ( - command: CliCommandName, - spec: CliCommandArguments, - args: readonly string[], -): null | string => { - const where = `for command "${command}"`; - const usage = `Usage: ${spec.usage}`; - - // The six commands that read nothing at all. Said as one sentence, because - // "allowed flags: none, allowed positionals: none" is the same fact twice. - if (spec.flags.length === 0 && spec.positional === 0) { - return args.length === 0 - ? null - : `Command "${command}" accepts no arguments, but got ${quoted(args)}. ${usage}`; - } - - const valueFlags = spec.valueFlags ?? []; - const seen = new Set(); - let positional = 0; - - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - - if (!arg.startsWith("-")) { - positional += 1; - continue; - } - - const [flag, inlineValue] = splitFlag(arg); - - if (!spec.flags.includes(flag)) { - return `Invalid argument "${arg}" ${where}. ${allowedSentence(spec)} ${usage}`; - } - - // Repetition rather than "at most one flag": every flag here is a switch or - // a single setting, so a second `--generate` is as meaningless as a second - // `--model`, and saying so names the flag instead of counting tokens. - if (seen.has(flag)) { - return `Argument "${flag}" is repeated ${where}. ${usage}`; - } - seen.add(flag); - - if (!valueFlags.includes(flag)) { - if (inlineValue !== undefined) { - return `Argument "${flag}" ${where} takes no value. ${usage}`; - } - continue; - } - - if (inlineValue !== undefined) { - if (inlineValue === "") { - return `Argument "${flag}" ${where} needs a value. ${usage}`; - } - - // `--model=gpt-5` carries its own value, so nothing is consumed and the - // scan continues - returning here would leave every later token unchecked. - continue; - } - - const next = args[index + 1]; - - if (next === undefined || next.startsWith("-")) { - return `Argument "${flag}" ${where} needs a value. ${usage}`; - } - - // Consumed as the flag's value, so it is not one of the positionals below. - index += 1; - } - - if (spec.positional !== "any" && positional > spec.positional) { - return spec.positional === 0 - ? `Command "${command}" accepts no positional arguments, but got ${String(positional)}. ${usage}` - : `Command "${command}" accepts at most ${String(spec.positional)} argument, but got ${String(positional)}. ${usage}`; - } - - return null; -}; - -/** - * `process.argv.slice(2)`, checked. - * - * Pure: it returns a refusal rather than printing or exiting, so every case - * below can be stated as an assertion instead of a spawned process. - * - * `--help` and `--version` are not commands and are refused as such. They have - * never been part of this CLI's contract, and inventing them here would be a - * feature rather than the validation this is. - */ -export const parseCliArguments = (argv: readonly string[]): ParsedCli => { - const [name, ...args] = argv; - const available = `Available commands: ${cliCommandNames().join(", ")}.`; - - if (name === undefined || name === "") { - return { message: `No command given. ${available}`, ok: false }; - } - - if (!isCliCommandName(name)) { - return { message: `Command not found: "${name}". ${available}`, ok: false }; - } - - const problem = firstProblem(name, COMMAND_ARGUMENTS[name], args); - - return problem === null - ? { args, command: name, ok: true } - : { message: problem, ok: false }; -}; diff --git a/packages/vitnode/scripts/cli/builder/analysis.test.ts b/packages/vitnode/scripts/cli/builder/analysis.test.ts new file mode 100644 index 000000000..f02d29390 --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/analysis.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment node +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { ChunkModule, MeasuredFile } from "./output-files"; + +import { analyzeChunks, dominantPackage } from "./analysis"; +import { + APPLICATION_CODE, + createPackageOwner, + packageNameFromPath, + VIRTUAL_MODULES, +} from "./package-owner"; + +const chunk = ( + fileName: string, + size: number, + modules: ChunkModule[], +): MeasuredFile => ({ + absolutePath: `/app/${fileName}`, + brotli: null, + category: "client-js", + consumer: "client", + displayPath: fileName, + environment: "client", + fileName, + gzip: null, + isEntry: false, + key: fileName, + modules, + size, + type: "chunk", +}); + +const ownerByPrefix = (id: string) => id.split("/")[0]; + +describe("analyzeChunks", () => { + it("splits a chunk into packages by their share of the code", () => { + const [analysis] = + analyzeChunks( + [ + chunk("editor.js", 400_000, [ + { id: "@tiptap/core/a", renderedLength: 300 }, + { id: "@tiptap/core/b", renderedLength: 100 }, + { id: "prosemirror-model/x", renderedLength: 100 }, + ]), + ], + { owner: ownerByPrefix }, + ) ?? []; + + expect(analysis.contributions).toEqual([ + { label: "@tiptap", renderedLength: 400, share: 0.8, size: 320_000 }, + { + label: "prosemirror-model", + renderedLength: 100, + share: 0.2, + size: 80_000, + }, + ]); + expect(analysis.other).toBeNull(); + }); + + it("groups everything past the top contributors as other", () => { + const modules = ["a", "b", "c", "d"].map((name, index) => ({ + id: `${name}/index.js`, + renderedLength: 100 - index, + })); + const [analysis] = + analyzeChunks([chunk("x.js", 1000, modules)], { + contributors: 2, + owner: ownerByPrefix, + }) ?? []; + + expect(analysis.contributions.map(c => c.label)).toEqual(["a", "b"]); + expect(analysis.other).toMatchObject({ + label: "other", + renderedLength: 195, + }); + }); + + it("breaks down the largest chunks first", () => { + const result = analyzeChunks( + [ + chunk("small.js", 10, [{ id: "a/x", renderedLength: 1 }]), + chunk("big.js", 999, [{ id: "a/x", renderedLength: 1 }]), + ], + { chunks: 1, owner: ownerByPrefix }, + ); + + expect(result?.map(analysis => analysis.file.fileName)).toEqual(["big.js"]); + }); + + it("is unavailable when the bundler reported no modules", () => { + expect( + analyzeChunks([chunk("a.js", 1000, [])], { owner: ownerByPrefix }), + ).toBeNull(); + }); + + it("names the package that dominates a chunk, but never the app's own code", () => { + const [vendor] = + analyzeChunks( + [ + chunk("v.js", 100, [ + { id: "react-scan/x", renderedLength: 80 }, + { id: "preact/x", renderedLength: 20 }, + ]), + ], + { owner: ownerByPrefix }, + ) ?? []; + const [own] = + analyzeChunks( + [chunk("o.js", 100, [{ id: "src/x", renderedLength: 1 }])], + { owner: () => APPLICATION_CODE }, + ) ?? []; + + expect(dominantPackage(vendor)?.label).toBe("react-scan"); + expect(dominantPackage(own)).toBeNull(); + }); +}); + +describe("package ownership of modules", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "vitnode-owner-")); + }); + + afterEach(() => { + rmSync(root, { force: true, recursive: true }); + }); + + it.each([ + ["/app/node_modules/react/index.js", "react"], + ["/app/node_modules/@tiptap/core/dist/index.js", "@tiptap/core"], + [ + "/r/node_modules/.pnpm/react-dom@19.3.0/node_modules/react-dom/cjs/x.js", + "react-dom", + ], + [String.raw`C:\app\node_modules\@scope\pkg\index.js`, "@scope/pkg"], + ["/app/src/main.tsx", null], + ])("reads the package of %s", (path, expected) => { + expect(packageNameFromPath(path)).toBe(expected); + }); + + it("labels the project's own files, workspace packages and virtual modules", () => { + const app = join(root, "apps", "web"); + const plugin = join(root, "plugins", "blog"); + mkdirSync(join(app, "src"), { recursive: true }); + mkdirSync(join(plugin, "dist"), { recursive: true }); + writeFileSync(join(app, "package.json"), JSON.stringify({ name: "web" })); + writeFileSync( + join(plugin, "package.json"), + JSON.stringify({ name: "@acme/blog" }), + ); + + const owner = createPackageOwner(app); + + expect(owner(join(app, "src", "main.tsx"))).toBe(APPLICATION_CODE); + expect(owner(join(plugin, "dist", "routes.js"))).toBe("@acme/blog"); + expect(owner("\0vite/preload-helper")).toBe(VIRTUAL_MODULES); + expect(owner.rootOf(join(plugin, "dist", "routes.js"))).toBe(plugin); + }); +}); diff --git a/packages/vitnode/scripts/cli/builder/analysis.ts b/packages/vitnode/scripts/cli/builder/analysis.ts new file mode 100644 index 000000000..16b9f29ba --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/analysis.ts @@ -0,0 +1,97 @@ +import type { MeasuredFile } from "./output-files"; + +export interface Contribution { + /** A package name, "application code" or "virtual modules". */ + label: string; + renderedLength: number; + /** Share of the chunk's code, 0..1. */ + share: number; + /** `share` of the chunk's size on disk - an estimate, see below. */ + size: number; +} + +export interface ChunkAnalysis { + contributions: Contribution[]; + file: MeasuredFile; + /** Everything outside the top contributors, or `null` when nothing is. */ + other: Contribution | null; +} + +export interface AnalyzeOptions { + /** How many chunks to break down. */ + chunks?: number; + /** How many contributors to name per chunk before "other". */ + contributors?: number; + owner: (moduleId: string) => string; +} + +/** + * What each package contributes to the largest client chunks. + * + * Built from the bundler's own metadata: every chunk lists its modules with + * their `renderedLength` - the bytes each one contributed after tree-shaking, + * before minification. Those lengths give each package's *share*; the size + * shown is that share of the chunk's real size on disk, because minification + * shrinks every module and is not reported per module. Shares are exact, + * sizes are a close estimate. + * + * Returns `null` when the bundler reported no module metadata at all, so the + * caller can say the analysis is unavailable rather than print empty tables. + */ +export const analyzeChunks = ( + files: readonly MeasuredFile[], + { chunks = 5, contributors = 6, owner }: AnalyzeOptions, +): ChunkAnalysis[] | null => { + const candidates = files + .filter(file => file.category === "client-js" && file.type === "chunk") + .sort((a, b) => b.size - a.size); + + if (!candidates.some(file => file.modules.length > 0)) return null; + + return candidates + .filter(file => file.modules.length > 0) + .slice(0, chunks) + .map(file => { + const byOwner = new Map(); + for (const module of file.modules) { + const label = owner(module.id); + byOwner.set(label, (byOwner.get(label) ?? 0) + module.renderedLength); + } + + const total = [...byOwner.values()].reduce((sum, n) => sum + n, 0); + const toContribution = ( + label: string, + renderedLength: number, + ): Contribution => { + const share = total === 0 ? 0 : renderedLength / total; + + return { label, renderedLength, share, size: share * file.size }; + }; + + const sorted = [...byOwner.entries()] + .map(([label, length]) => toContribution(label, length)) + .sort((a, b) => b.renderedLength - a.renderedLength); + + const top = sorted.slice(0, contributors); + const rest = sorted.slice(contributors); + const restLength = rest.reduce((sum, c) => sum + c.renderedLength, 0); + + return { + contributions: top, + file, + other: rest.length === 0 ? null : toContribution("other", restLength), + }; + }); +}; + +/** The single largest third-party package in a chunk, if it dominates it. */ +export const dominantPackage = ( + analysis: ChunkAnalysis, + minimumShare = 0.3, +): Contribution | null => + analysis.contributions.find( + c => + c.share >= minimumShare && + c.label !== "application code" && + c.label !== "virtual modules", + ) ?? null; diff --git a/packages/vitnode/scripts/cli/builder/app-build.ts b/packages/vitnode/scripts/cli/builder/app-build.ts new file mode 100644 index 000000000..e47a54e59 --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/app-build.ts @@ -0,0 +1,174 @@ +import type { InlineConfig, Logger } from "vite"; + +import type { Project } from "../project/project"; +import type { OutputCapture } from "../ui/capture-output"; +import type { TaskHandle, Ui } from "../ui/ui"; +import type { EnvironmentInfo } from "./collector"; +import type { MeasuredFile } from "./output-files"; + +import { isPluginPackage } from "../plugins/discover"; +import { importFromProject, readPackageJson } from "../project/packages"; +import { captureProcessOutput } from "../ui/capture-output"; +import { createBuildCollector } from "./collector"; +import { describeBuildError } from "./error-context"; +import { measureFiles } from "./measure"; +import { createPackageOwner } from "./package-owner"; + +/** The slice of Vite's JavaScript API a build needs. */ +export interface ViteBuildApi { + createBuilder: (config: InlineConfig) => Promise<{ + buildApp: () => Promise; + config: { plugins: readonly { name: string }[] }; + }>; +} + +export interface AppBuildOptions { + analyze: boolean; + /** Overridable so tests can stand in for console interception. */ + captureOutput?: () => OutputCapture; + loadVite?: () => Promise; + project: Project; + ui: Ui; +} + +export interface AppBuildResult { + /** Warnings Vite and its plugins logged, held back unless `--verbose`. */ + bundlerWarnings: string[]; + /** Environments that wrote what ships, in build order. */ + environments: string[]; + files: MeasuredFile[]; +} + +const ENVIRONMENT_LABELS: Record = { + client: "Building client", + nitro: "Packaging server (Nitro)", + ssr: "Building server", +}; + +export const labelForEnvironment = ({ consumer, name }: EnvironmentInfo) => + ENVIRONMENT_LABELS[name] ?? + `Building ${name}${consumer === "server" ? " (server)" : ""}`; + +/** + * A logger for Vite that keeps warnings instead of printing them, so they can + * be counted after the progress lines rather than tearing through them. + */ +const createQuietLogger = (warnings: string[]): Logger => { + const warned = new Set(); + + return { + clearScreen: () => undefined, + error: message => { + warnings.push(message); + }, + hasErrorLogged: () => false, + hasWarned: false, + info: () => undefined, + warn: message => { + warnings.push(message); + }, + warnOnce: message => { + if (warned.has(message)) return; + warned.add(message); + warnings.push(message); + }, + }; +}; + +/** + * `vite build` for a VitNode app, through Vite's own JavaScript API. + * + * The app's `vite.config.ts` is loaded exactly as `vite build` loads it - same + * plugins, same environments, same `builder.buildApp` - with one observer + * added, so the progress shown is the real sequence of environments this app + * builds (for a TanStack Start + Nitro app: client, SSR, then Nitro's server + * output). Generating plugin routes and registries happens inside that config, + * in VitNode's own Vite plugin, which is why it is part of "Loading + * configuration" rather than a step of its own. + */ +export const runAppBuild = async ({ + analyze, + captureOutput = captureProcessOutput, + loadVite = async () => importFromProject(project.root, "vite"), + project, + ui, +}: AppBuildOptions): Promise => { + const vite = await loadVite(); + const tasks = new Map(); + const bundlerWarnings: string[] = []; + + const collector = createBuildCollector(project.root, { + onEnd: (environment, error) => { + const task = tasks.get(environment.name); + if (error === undefined) task?.succeed(); + else task?.fail(); + }, + onStart: environment => { + tasks.set(environment.name, ui.task(labelForEnvironment(environment))); + }, + }); + + const capture = ui.verbose ? null : captureOutput(); + const configTask = ui.task("Loading configuration"); + + try { + const builder = await vite.createBuilder({ + build: { reportCompressedSize: false }, + configFile: project.viteConfig ?? undefined, + customLogger: ui.verbose ? undefined : createQuietLogger(bundlerWarnings), + logLevel: ui.verbose ? "info" : "warn", + mode: "production", + plugins: [collector.plugin], + root: project.root, + }); + const generatesRoutes = builder.config.plugins.some( + plugin => plugin.name === "vitnode:plugin-routes", + ); + configTask.succeed( + generatesRoutes + ? "Configuration loaded, plugin routes generated" + : "Configuration loaded", + ); + + await builder.buildApp(); + } catch (error) { + configTask.fail(); + tasks.forEach(task => { + task.fail(); + }); + const captured = capture?.restore() ?? ""; + + const owner = createPackageOwner(project.root); + throw describeBuildError(error, { + output: captured, + pluginOf: file => { + const dir = owner.rootOf(file); + + return dir === null || dir === project.root || !isPluginPackage(dir) + ? null + : (readPackageJson(dir)?.name ?? null); + }, + root: project.root, + }); + } finally { + capture?.restore(); + } + + const shipped = [...collector.environments.values()].filter( + environment => !environment.intermediate, + ); + + const files = await ui.runTask("Measuring output", async () => + measureFiles( + shipped.flatMap(environment => environment.files), + // Enough for every group the report lists without --verbose. + { brotli: analyze ? 25 : 0 }, + ), + ); + + return { + bundlerWarnings, + environments: shipped.map(environment => environment.name), + files, + }; +}; diff --git a/packages/vitnode/scripts/cli/builder/collector.ts b/packages/vitnode/scripts/cli/builder/collector.ts new file mode 100644 index 000000000..7ad9f48ef --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/collector.ts @@ -0,0 +1,170 @@ +import type { Plugin } from "vite"; + +import { dirname, isAbsolute, join, relative, sep } from "node:path"; + +import type { BuildOutputFile, ChunkModule, Consumer } from "./output-files"; + +import { toDisplayPath } from "../ui/format"; +import { + categorize, + dedupeKeys, + isReportedFile, + logicalKey, +} from "./output-files"; + +export interface EnvironmentInfo { + consumer: Consumer; + name: string; +} + +export interface EnvironmentRecord extends EnvironmentInfo { + files: BuildOutputFile[]; + /** + * Written somewhere under `node_modules` - an intermediate step another + * environment consumes (TanStack Start's SSR bundle, which Nitro then + * packages), not something that ships. + */ + intermediate: boolean; + outDir: null | string; +} + +export interface CollectorEvents { + onEnd: (environment: EnvironmentInfo, error?: unknown) => void; + onStart: (environment: EnvironmentInfo) => void; +} + +/** The parts of a Rolldown/Rollup output entry the report reads. */ +interface OutputEntry { + facadeModuleId?: null | string; + fileName: string; + isEntry?: boolean; + modules?: Record; + name?: string; + originalFileNames?: readonly string[]; + type: "asset" | "chunk"; +} + +interface HookContext { + environment?: { config: { consumer?: Consumer }; name: string }; +} + +const infoOf = (context: HookContext): EnvironmentInfo => ({ + consumer: context.environment?.config.consumer ?? "client", + name: context.environment?.name ?? "client", +}); + +const isUnderNodeModules = (dir: string) => + dir.split(sep).includes("node_modules") || dir.includes("/node_modules/"); + +/** + * A Vite plugin that watches the build instead of changing it. + * + * Added to the app's own Vite config for the duration of `vitnode build`. It + * reports when each environment starts and finishes - which is what the + * progress lines are made of, so they name the environments the app really + * has - and records every file each one wrote together with the module + * metadata the bundler already computed. Nothing is parsed back out of the + * output. + */ +export const createBuildCollector = (root: string, events: CollectorEvents) => { + const environments = new Map(); + const ended = new Set(); + + const recordFor = (info: EnvironmentInfo): EnvironmentRecord => { + const existing = environments.get(info.name); + if (existing !== undefined) return existing; + + const record: EnvironmentRecord = { + ...info, + files: [], + intermediate: false, + outDir: null, + }; + environments.set(info.name, record); + + return record; + }; + + const end = (info: EnvironmentInfo, error?: unknown) => { + if (ended.has(info.name)) return; + ended.add(info.name); + events.onEnd(info, error); + }; + + const plugin: Plugin = { + // Report on the bundle exactly as the app's plugins left it. + enforce: "post", + name: "vitnode:build-report", + + buildStart(this: HookContext) { + const info = infoOf(this); + recordFor(info); + ended.delete(info.name); + events.onStart(info); + }, + + buildEnd(this: HookContext, error?: Error) { + if (error !== undefined) end(infoOf(this), error); + }, + + writeBundle( + this: HookContext, + options: { dir?: string; file?: string }, + bundle: Record, + ) { + const info = infoOf(this); + const record = recordFor(info); + const outDir = + options.dir ?? (options.file ? dirname(options.file) : root); + const absoluteDir = isAbsolute(outDir) ? outDir : join(root, outDir); + + record.outDir = absoluteDir; + record.intermediate = isUnderNodeModules(relative(root, absoluteDir)); + + const files = Object.values(bundle) + .filter(entry => isReportedFile(entry.fileName)) + .map((entry): BuildOutputFile => { + const modules: ChunkModule[] = + entry.type === "chunk" + ? Object.entries(entry.modules ?? {}).map(([id, module]) => ({ + id, + renderedLength: module.renderedLength, + })) + : []; + const absolutePath = join(absoluteDir, entry.fileName); + + return { + absolutePath, + category: categorize(entry.fileName, info.consumer), + consumer: info.consumer, + displayPath: toDisplayPath(relative(root, absolutePath)), + environment: info.name, + fileName: toDisplayPath(entry.fileName), + isEntry: entry.isEntry === true, + key: logicalKey( + { + environment: info.name, + facadeModuleId: entry.facadeModuleId, + fileName: entry.fileName, + modules, + name: entry.name, + originalFileNames: entry.originalFileNames, + type: entry.type, + }, + root, + ), + modules, + type: entry.type, + }; + }); + + record.files = dedupeKeys([...record.files, ...files]); + }, + + closeBundle(this: HookContext) { + end(infoOf(this)); + }, + }; + + return { environments, plugin }; +}; diff --git a/packages/vitnode/scripts/cli/builder/compiler-build.ts b/packages/vitnode/scripts/cli/builder/compiler-build.ts new file mode 100644 index 000000000..afa677265 --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/compiler-build.ts @@ -0,0 +1,156 @@ +import { readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +import type { Project } from "../project/project"; +import type { Ui } from "../ui/ui"; +import type { MeasuredFile } from "./output-files"; + +import { writePluginApiRegistry } from "../../write-plugin-api-registry"; +import { RuntimeError } from "../errors"; +import { resolveBin } from "../project/packages"; +import { runProcess } from "../project/processes"; +import { toDisplayPath } from "../ui/format"; + +export interface CompilerStep { + args: string[]; + /** The package whose executable runs this step, e.g. `typescript`. */ + bin: { name: string; package: string }; + label: string; +} + +/** + * A plugin package's build: the three compilers it has always used, in order. + * + * Types first (`tsc` emits declarations only), then the JavaScript (`swc`, + * which also copies locale JSON into `dist`), then `tsc-alias` rewriting the + * `@/` imports both of them left behind. + */ +export const packageBuildSteps = (): CompilerStep[] => [ + { + args: ["-p", "tsconfig.build.json"], + bin: { name: "tsc", package: "typescript" }, + label: "Emitting type declarations", + }, + { + args: ["src", "-d", "dist", "--config-file", ".swcrc", "--copy-files"], + bin: { name: "swc", package: "@swc/cli" }, + label: "Compiling sources", + }, + { + args: ["-p", "tsconfig.build.json"], + bin: { name: "tsc-alias", package: "tsc-alias" }, + label: "Rewriting path aliases", + }, +]; + +/** A standalone API app's build: `tsc`, then `tsc-alias`. */ +export const apiBuildSteps = (): CompilerStep[] => [ + { + args: ["-p", "tsconfig.json"], + bin: { name: "tsc", package: "typescript" }, + label: "Compiling TypeScript", + }, + { + args: ["-p", "tsconfig.json"], + bin: { name: "tsc-alias", package: "tsc-alias" }, + label: "Rewriting path aliases", + }, +]; + +export type StepRunner = ( + step: CompilerStep, + project: Project, +) => Promise<{ code: number; output: string }>; + +export const runStepWithNode: StepRunner = async (step, project) => + runProcess({ + args: [ + resolveBin(project.root, step.bin.package, step.bin.name), + ...step.args, + ], + capture: true, + command: process.execPath, + cwd: project.root, + }); + +export const runCompilerSteps = async ({ + project, + runStep = runStepWithNode, + steps, + ui, +}: { + project: Project; + runStep?: StepRunner; + steps: readonly CompilerStep[]; + ui: Ui; +}): Promise => { + for (const step of steps) { + const task = ui.task(step.label); + const { code, output } = await runStep(step, project); + + if (ui.verbose && output.trim() !== "") ui.line(output.trimEnd()); + + if (code !== 0) { + task.fail(); + throw new RuntimeError( + `Build failed: ${step.bin.name} exited with code ${String(code)}`, + { + output: ui.verbose ? undefined : output, + }, + ); + } + task.succeed(); + } +}; + +/** Writes `types/api-registry.gen.d.ts` before `tsc` needs it. */ +export const prepareRegistryStep = (project: Project, ui: Ui) => { + if (writePluginApiRegistry(project.root)) { + ui.success("API registry generated"); + } +}; + +/** Every file under `dir`, as measured output, for the compiler builds. */ +export const listOutput = (project: Project, dir: string): MeasuredFile[] => { + const root = join(project.root, dir); + const files: MeasuredFile[] = []; + + const walk = (current: string) => { + let entries: string[]; + try { + entries = readdirSync(current); + } catch { + return; + } + for (const entry of entries) { + const path = join(current, entry); + const stats = statSync(path); + if (stats.isDirectory()) { + walk(path); + continue; + } + if (entry.endsWith(".map")) continue; + + const fileName = toDisplayPath(relative(root, path)); + files.push({ + absolutePath: path, + brotli: null, + category: "server", + consumer: "server", + displayPath: toDisplayPath(relative(project.root, path)), + environment: "dist", + fileName, + gzip: null, + isEntry: fileName === "index.js" || fileName === "src/index.js", + key: `dist:${fileName}`, + modules: [], + size: stats.size, + type: "chunk", + }); + } + }; + + walk(root); + + return files; +}; diff --git a/packages/vitnode/scripts/cli/builder/error-context.ts b/packages/vitnode/scripts/cli/builder/error-context.ts new file mode 100644 index 000000000..6a72d8279 --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/error-context.ts @@ -0,0 +1,73 @@ +import { isAbsolute, relative } from "node:path"; + +import { errorMessage, RuntimeError } from "../errors"; +import { toDisplayPath } from "../ui/format"; + +/** The fields Rollup, Rolldown and Vite attach to a build error. */ +interface BundlerError { + code?: string; + frame?: string; + id?: string; + loc?: { column?: number; file?: string; line?: number }; + plugin?: string; +} + +export interface BuildErrorContextOptions { + /** What the build printed before it failed, shown under the error. */ + output?: string; + /** The VitNode plugin a file belongs to, or `null`. */ + pluginOf: (file: string) => null | string; + root: string; +} + +const firstLines = (text: string, count: number) => + text.trim().split(/\r?\n/).slice(0, count); + +/** + * Turns a failed build into a `RuntimeError` that says where it failed. + * + * Only adds what the error itself carries: the file and position the bundler + * reported, the bundler plugin that threw, and - when that file is inside a + * configured VitNode plugin - the plugin's name. Nothing is guessed from the + * message. The original error stays the `cause`, so `--verbose` still prints + * its full stack. + */ +export const describeBuildError = ( + error: unknown, + { output, pluginOf, root }: BuildErrorContextOptions, +): RuntimeError => { + const bundler = ( + typeof error === "object" && error !== null ? error : {} + ) as BundlerError; + const file = bundler.loc?.file ?? bundler.id; + const details: string[] = []; + + if (file !== undefined && isAbsolute(file.split("?")[0] ?? file)) { + const path = file.split("?")[0] ?? file; + const plugin = pluginOf(path); + if (plugin !== null) details.push(`Plugin ${plugin}`); + + const position = + bundler.loc?.line === undefined + ? "" + : `:${String(bundler.loc.line)}${bundler.loc.column === undefined ? "" : `:${String(bundler.loc.column)}`}`; + details.push(`File ${toDisplayPath(relative(root, path))}${position}`); + } + + if (bundler.plugin !== undefined) details.push(`Step ${bundler.plugin}`); + + const message = firstLines(errorMessage(error), 12); + if (details.length > 0) details.push(""); + details.push(...message); + + if (bundler.frame !== undefined) { + details.push("", ...firstLines(bundler.frame, 12)); + } + + return new RuntimeError("Build failed", { + cause: error, + details, + hint: "Run with --verbose for the full stack trace and the bundler's own log.", + output: output?.trim() === "" ? undefined : output, + }); +}; diff --git a/packages/vitnode/scripts/cli/builder/measure.test.ts b/packages/vitnode/scripts/cli/builder/measure.test.ts new file mode 100644 index 000000000..4d7b9bb1b --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/measure.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment node +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { BuildOutputFile, FileCategory } from "./output-files"; + +import { brotliSize, gzipSize, measureFiles } from "./measure"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "vitnode-measure-")); +}); + +afterEach(() => { + rmSync(dir, { force: true, recursive: true }); +}); + +const fileOf = ( + name: string, + content: string, + category: FileCategory, +): BuildOutputFile => { + const absolutePath = join(dir, name); + writeFileSync(absolutePath, content); + + return { + absolutePath, + category, + consumer: category === "server" ? "server" : "client", + displayPath: name, + environment: category === "server" ? "ssr" : "client", + fileName: name, + isEntry: false, + key: name, + modules: [], + type: "chunk", + }; +}; + +describe("measureFiles", () => { + it("reads the raw size from disk and compresses client scripts", async () => { + const script = "export const value = 'vitnode';\n".repeat(500); + const [measured] = await measureFiles( + [fileOf("a.js", script, "client-js")], + { + brotli: 0, + }, + ); + + expect(measured.size).toBe(Buffer.byteLength(script)); + expect(measured.gzip).toBeGreaterThan(0); + expect(measured.gzip).toBeLessThan(measured.size); + expect(measured.brotli).toBeNull(); + }); + + it("does not compress server output", async () => { + const [measured] = await measureFiles( + [fileOf("index.mjs", "x".repeat(5000), "server")], + { + brotli: 25, + }, + ); + + expect(measured.size).toBe(5000); + expect(measured.gzip).toBeNull(); + expect(measured.brotli).toBeNull(); + }); + + it("measures an empty file as zero everywhere", async () => { + const [measured] = await measureFiles( + [fileOf("empty.js", "", "client-js")], + { + brotli: 1, + }, + ); + + expect(measured).toMatchObject({ brotli: 0, gzip: 0, size: 0 }); + }); + + it("computes brotli only for the largest files when asked", async () => { + const files = [ + fileOf( + "big.js", + "a".repeat(20_000) + Math.random().toString(), + "client-js", + ), + fileOf("small.js", "b".repeat(100), "client-js"), + ]; + const measured = await measureFiles(files, { brotli: 1 }); + + expect( + measured.find(file => file.fileName === "big.js")?.brotli, + ).toBeGreaterThan(0); + expect( + measured.find(file => file.fileName === "small.js")?.brotli, + ).toBeNull(); + }); +}); + +describe("compression helpers", () => { + it("compress repetitive content well", async () => { + const content = Buffer.from("vitnode ".repeat(10_000)); + + expect(await gzipSize(content)).toBeLessThan(content.length / 10); + expect(await brotliSize(content)).toBeLessThan(content.length / 10); + }); +}); diff --git a/packages/vitnode/scripts/cli/builder/measure.ts b/packages/vitnode/scripts/cli/builder/measure.ts new file mode 100644 index 000000000..7b0808c65 --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/measure.ts @@ -0,0 +1,108 @@ +import { readFile, stat } from "node:fs/promises"; +import { availableParallelism } from "node:os"; +import { promisify } from "node:util"; +import { brotliCompress, constants, gzip } from "node:zlib"; + +import type { BuildOutputFile, MeasuredFile } from "./output-files"; + +const gzipAsync = promisify(gzip); +const brotliAsync = promisify(brotliCompress); + +export const gzipSize = async (content: Buffer): Promise => + content.length === 0 ? 0 : (await gzipAsync(content)).length; + +/** Quality 11 - what a CDN serving precompressed static assets uses. */ +export const brotliSize = async (content: Buffer): Promise => + content.length === 0 + ? 0 + : ( + await brotliAsync(content, { + params: { + [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, + [constants.BROTLI_PARAM_SIZE_HINT]: content.length, + }, + }) + ).length; + +/** What the browser downloads compressed: scripts and styles. */ +const isCompressible = (file: BuildOutputFile) => + file.category === "client-js" || file.category === "css"; + +/** Runs `work` over `items` with at most `limit` in flight. */ +export const mapWithConcurrency = async ( + items: readonly T[], + limit: number, + work: (item: T) => Promise, +): Promise => { + const results = new Array(items.length); + let next = 0; + + const worker = async () => { + while (next < items.length) { + const index = next; + next += 1; + results[index] = await work(items[index]); + } + }; + + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, worker), + ); + + return results; +}; + +export interface MeasureOptions { + /** + * How many of the largest scripts and styles also get a brotli size. + * Brotli at quality 11 is an order of magnitude slower than gzip, so it is + * computed on request and only for the files a report actually shows. + */ + brotli: number; +} + +/** + * Sizes every file as it is on disk. + * + * Read from disk rather than from the bundle in memory, because a later + * environment (Nitro) or plugin may still rewrite what the bundler emitted, and + * the report should describe what ships. Only client scripts and styles are + * compressed: a server bundle is never sent over the wire, and gzipping + * megabytes of it would only slow the build report down. + */ +export const measureFiles = async ( + files: readonly BuildOutputFile[], + { brotli }: MeasureOptions, +): Promise => { + const concurrency = Math.max(2, availableParallelism()); + const sized = await mapWithConcurrency(files, concurrency, async file => ({ + file, + size: (await stat(file.absolutePath)).size, + })); + + const withBrotli = new Set( + sized + .filter(({ file }) => isCompressible(file)) + .sort((a, b) => b.size - a.size) + .slice(0, brotli) + .map(({ file }) => file), + ); + + return mapWithConcurrency( + sized, + concurrency, + async ({ file, size }): Promise => { + if (!isCompressible(file)) + return { ...file, brotli: null, gzip: null, size }; + + const content = await readFile(file.absolutePath); + + return { + ...file, + brotli: withBrotli.has(file) ? await brotliSize(content) : null, + gzip: await gzipSize(content), + size, + }; + }, + ); +}; diff --git a/packages/vitnode/scripts/cli/builder/output-files.test.ts b/packages/vitnode/scripts/cli/builder/output-files.test.ts new file mode 100644 index 000000000..9809ca5d9 --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/output-files.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + categorize, + dedupeKeys, + isReportedFile, + logicalKey, + stripHash, +} from "./output-files"; + +const root = "/app"; + +describe("categorize", () => { + it.each([ + ["assets/index-C8Fb2aaa.js", "client", "client-js"], + ["assets/index-K1F3aaaa.css", "client", "css"], + ["assets/logo-Ab12cdEf.png", "client", "asset"], + ["assets/font-Ab12cdEf.woff2", "client", "asset"], + ["manifest.webmanifest", "client", "other"], + ["index.mjs", "server", "server"], + ["styles.css", "server", "server"], + ] as const)("%s from the %s is %s", (fileName, consumer, expected) => { + expect(categorize(fileName, consumer)).toBe(expected); + }); + + it("never reports source maps", () => { + expect(isReportedFile("assets/index.js.map")).toBe(false); + expect(isReportedFile("assets/index.js")).toBe(true); + }); +}); + +describe("stripHash", () => { + it.each([ + ["assets/index-C8Fb2xQ1.js", "assets/index.js"], + ["assets/custom-emoji-BPTfd_f5.js", "assets/custom-emoji.js"], + ["assets/styles-_Fdh3UXR.css", "assets/styles.css"], + ["assets/editor.F9aK2bQ3.js", "assets/editor.js"], + ])("strips the content hash from %s", (fileName, expected) => { + expect(stripHash(fileName)).toBe(expected); + }); + + it("leaves a plain eight-letter word alone", () => { + expect(stripHash("assets/use-provider.js")).toBe("assets/use-provider.js"); + }); +}); + +describe("logicalKey", () => { + it("identifies an entry chunk by the module it fronts, whatever its hash", () => { + const build = (fileName: string) => + logicalKey( + { + environment: "client", + facadeModuleId: "/app/src/pages/editor.tsx", + fileName, + name: "editor", + type: "chunk", + }, + root, + ); + + expect(build("assets/editor-F9aK2bQ3.js")).toBe( + "client:src/pages/editor.tsx", + ); + expect(build("assets/editor-H2kq9ZZ1.js")).toBe( + build("assets/editor-F9aK2bQ3.js"), + ); + }); + + it("identifies a shared chunk by its name and largest module", () => { + expect( + logicalKey( + { + environment: "client", + facadeModuleId: null, + fileName: "assets/index-AAAAAAA1.js", + modules: [ + { id: "/app/node_modules/react-dom/index.js", renderedLength: 900 }, + { id: "/app/src/a.ts", renderedLength: 10 }, + ], + name: "index", + type: "chunk", + }, + root, + ), + ).toBe("client:index@node_modules/react-dom/index.js"); + }); + + it("identifies an asset by its source file", () => { + expect( + logicalKey( + { + environment: "client", + fileName: "assets/logo-Ab12cdEf.png", + originalFileNames: ["src/assets/logo.png"], + type: "asset", + }, + root, + ), + ).toBe("client:src/assets/logo.png"); + }); + + it("falls back to the file name without its hash", () => { + expect( + logicalKey( + { + environment: "client", + fileName: "assets/x-Ab12cdEf.css", + type: "asset", + }, + root, + ), + ).toBe("client:assets/x.css"); + }); + + it("is machine-independent: Windows paths become forward slashes", () => { + expect( + logicalKey( + { + environment: "client", + facadeModuleId: "C:\\app\\src\\main.tsx", + fileName: "main.js", + type: "chunk", + }, + "C:\\app", + ), + ).not.toContain("\\"); + }); + + it("makes repeated keys unique without merging them", () => { + expect( + dedupeKeys([{ key: "a" }, { key: "a" }, { key: "b" }]).map( + file => file.key, + ), + ).toEqual(["a", "a#2", "b"]); + }); +}); diff --git a/packages/vitnode/scripts/cli/builder/output-files.ts b/packages/vitnode/scripts/cli/builder/output-files.ts new file mode 100644 index 000000000..1f9edae14 --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/output-files.ts @@ -0,0 +1,175 @@ +import { isAbsolute, relative } from "node:path"; + +import { toDisplayPath } from "../ui/format"; + +export type FileCategory = "asset" | "client-js" | "css" | "other" | "server"; + +export type Consumer = "client" | "server"; + +export interface ChunkModule { + id: string; + renderedLength: number; +} + +/** + * One file a build wrote, with what the bundler knew about it. + * + * `key` is the file's *logical* identity - stable across builds even though + * `fileName` carries a content hash - and is what the previous-build + * comparison matches on. + */ +export interface BuildOutputFile { + absolutePath: string; + category: FileCategory; + consumer: Consumer; + /** Path relative to the project root, forward slashes, for display. */ + displayPath: string; + environment: string; + /** Path inside the environment's output directory. */ + fileName: string; + isEntry: boolean; + key: string; + modules: ChunkModule[]; + type: "asset" | "chunk"; +} + +export interface MeasuredFile extends BuildOutputFile { + brotli: null | number; + gzip: null | number; + size: number; +} + +const ASSET_EXTENSIONS = new Set([ + ".avif", + ".bmp", + ".eot", + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".mp3", + ".mp4", + ".otf", + ".png", + ".svg", + ".ttf", + ".wasm", + ".webm", + ".webp", + ".woff", + ".woff2", +]); + +const extensionOf = (fileName: string): string => { + const match = /\.[^./\\]+$/.exec(fileName); + + return match === null ? "" : match[0].toLowerCase(); +}; + +/** Source maps are debugging aids, not output anyone downloads. */ +export const isReportedFile = (fileName: string): boolean => + extensionOf(fileName) !== ".map"; + +export const categorize = ( + fileName: string, + consumer: Consumer, +): FileCategory => { + if (consumer === "server") return "server"; + + const extension = extensionOf(fileName); + if (extension === ".js" || extension === ".mjs") return "client-js"; + if (extension === ".css") return "css"; + if (ASSET_EXTENSIONS.has(extension)) return "asset"; + + return "other"; +}; + +/** + * A content hash Rolldown/Rollup put into a file name: 8 URL-safe base64 + * characters before the extension. At least one digit, capital or `_` is + * required so that an ordinary eight-letter word (`-provider.js`) is not taken + * for one. + */ +const HASH_PATTERN = /[-.]([A-Za-z0-9_-]{8})(?=\.[A-Za-z0-9]+$)/; + +export const stripHash = (fileName: string): string => { + const match = HASH_PATTERN.exec(fileName); + if (match === null || !/[0-9A-Z_]/.test(match[1])) return fileName; + + return fileName.slice(0, match.index) + fileName.slice(match.index + 9); +}; + +const isVirtual = (id: string) => + id.startsWith("\0") || id.startsWith("virtual:"); + +/** A module id as a stable, machine-independent string. */ +export const relativeModuleId = (id: string, root: string): string => { + const withoutQuery = id.split("?")[0] ?? id; + if (isVirtual(withoutQuery) || !isAbsolute(withoutQuery)) { + return toDisplayPath(withoutQuery.replace(/^\0/, "")); + } + + return toDisplayPath(relative(root, withoutQuery)); +}; + +export interface LogicalKeyInput { + environment: string; + facadeModuleId?: null | string; + fileName: string; + modules?: readonly ChunkModule[]; + name?: string; + originalFileNames?: readonly string[]; + type: "asset" | "chunk"; +} + +/** + * The identity of a file across builds. + * + * - An entry chunk is the module it is the facade for. + * - A shared chunk has no facade, so it is its name plus the module that makes + * up most of it: the name alone repeats (`index`, `dist`), the dominant + * module rarely does and survives small edits. + * - An asset is the source file it was emitted from. + * - Anything else falls back to its file name without the hash. + * + * Paths are relative to the project root, so a snapshot taken on one machine + * compares cleanly on another. + */ +export const logicalKey = (input: LogicalKeyInput, root: string): string => { + const prefix = `${input.environment}:`; + + if (input.type === "chunk") { + if (input.facadeModuleId != null && !isVirtual(input.facadeModuleId)) { + return prefix + relativeModuleId(input.facadeModuleId, root); + } + + const dominant = [...(input.modules ?? [])].sort( + (a, b) => b.renderedLength - a.renderedLength, + )[0]; + + if (input.name !== undefined && dominant !== undefined) { + return `${prefix}${input.name}@${relativeModuleId(dominant.id, root)}`; + } + } + + const original = input.originalFileNames?.[0]; + if (input.type === "asset" && original !== undefined) { + return prefix + toDisplayPath(original); + } + + return prefix + toDisplayPath(stripHash(input.fileName)); +}; + +/** Makes keys unique, in output order, so two identical keys cannot merge. */ +export const dedupeKeys = (files: T[]): T[] => { + const seen = new Map(); + + return files.map(file => { + const count = seen.get(file.key) ?? 0; + seen.set(file.key, count + 1); + + return count === 0 + ? file + : { ...file, key: `${file.key}#${String(count + 1)}` }; + }); +}; diff --git a/packages/vitnode/scripts/cli/builder/package-owner.ts b/packages/vitnode/scripts/cli/builder/package-owner.ts new file mode 100644 index 000000000..6b4283058 --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/package-owner.ts @@ -0,0 +1,104 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +export const APPLICATION_CODE = "application code"; +export const VIRTUAL_MODULES = "virtual modules"; + +/** + * `@scope/name` or `name` from the last `node_modules/` segment of a path - + * the last one, because pnpm nests the real package inside + * `node_modules/.pnpm//node_modules/`. + */ +export const packageNameFromPath = (path: string): null | string => { + const normalized = path.replaceAll("\\", "/"); + const marker = "/node_modules/"; + const index = normalized.lastIndexOf(marker); + if (index === -1) return null; + + const [first, second] = normalized.slice(index + marker.length).split("/"); + if (first === undefined || first === "") return null; + + return first.startsWith("@") && second !== undefined + ? `${first}/${second}` + : first; +}; + +export interface PackageOwner { + /** The project root itself is reported as {@link APPLICATION_CODE}. */ + (moduleId: string): string; + /** The package directory a module belongs to, or `null`. */ + rootOf: (moduleId: string) => null | string; +} + +/** + * Which package a bundled module came from. + * + * `node_modules` paths answer directly. Everything else - the app's own files, + * and workspace packages a pnpm link resolved outside `node_modules` - is + * answered by the nearest `package.json`, cached per directory so a chunk of a + * thousand modules costs a handful of file reads. + */ +export const createPackageOwner = (projectRoot: string): PackageOwner => { + const root = resolve(projectRoot); + const cache = new Map(); + + const nearestPackage = (start: string) => { + const visited: string[] = []; + let current = start; + let found: null | { dir: string; name: string } = null; + + for (;;) { + const cached = cache.get(current); + if (cached !== undefined) { + found = cached; + break; + } + visited.push(current); + + const manifest = join(current, "package.json"); + if (existsSync(manifest)) { + try { + const { name } = JSON.parse(readFileSync(manifest, "utf8")) as { + name?: string; + }; + found = { dir: current, name: name ?? current }; + } catch { + found = { dir: current, name: current }; + } + break; + } + + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + + visited.forEach(dir => cache.set(dir, found)); + + return found; + }; + + const lookup = (moduleId: string) => { + const path = moduleId.split("?")[0] ?? moduleId; + if (path.startsWith("\0") || !isAbsolute(path)) return null; + + return nearestPackage(dirname(path)); + }; + + const owner = ((moduleId: string): string => { + const path = moduleId.split("?")[0] ?? moduleId; + if (path.startsWith("\0") || !isAbsolute(path)) return VIRTUAL_MODULES; + + const fromNodeModules = packageNameFromPath(path); + if (fromNodeModules !== null) return fromNodeModules; + + const found = lookup(moduleId); + if (found === null || found.dir === root) return APPLICATION_CODE; + + return found.name; + }) as PackageOwner; + + owner.rootOf = moduleId => lookup(moduleId)?.dir ?? null; + + return owner; +}; diff --git a/packages/vitnode/scripts/cli/builder/report.ts b/packages/vitnode/scripts/cli/builder/report.ts new file mode 100644 index 000000000..92073d54e --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/report.ts @@ -0,0 +1,268 @@ +import type { Ui } from "../ui/ui"; +import type { ChunkAnalysis } from "./analysis"; +import type { FileCategory, MeasuredFile } from "./output-files"; +import type { SizeSeverity } from "./size-severity"; +import type { SnapshotComparison } from "./snapshot"; +import type { BuildWarning } from "./warnings"; + +import { padEnd, padStart } from "../ui/colors"; +import { + formatByteDelta, + formatBytes, + formatPercent, + formatPercentDelta, + plural, +} from "../ui/format"; +import { classifySize } from "./size-severity"; +import { isNotableGrowth } from "./snapshot"; + +const GROUPS: { category: FileCategory; limit: number; title: string }[] = [ + { category: "client-js", limit: 10, title: "Client JS" }, + { category: "css", limit: 5, title: "CSS" }, + { category: "asset", limit: 5, title: "Assets" }, + { category: "other", limit: 5, title: "Other" }, + { category: "server", limit: 5, title: "Server" }, +]; + +const sum = (values: readonly number[]) => + values.reduce((total, value) => total + value, 0); + +/** Severity as a symbol and a color, so neither is the only signal. */ +export const severityStyle = (ui: Ui, severity: SizeSeverity) => { + const { colors, symbols } = ui; + + switch (severity) { + case "good": + return { paint: colors.success, symbol: colors.success(symbols.success) }; + case "large": + return { paint: colors.error, symbol: colors.error(symbols.large) }; + case "normal": + return { + paint: (text: string) => text, + symbol: colors.muted(symbols.success), + }; + case "warning": + return { paint: colors.warning, symbol: colors.warning(symbols.warning) }; + } +}; + +const outDirOf = (files: readonly MeasuredFile[]) => { + const [first] = files; + if (first === undefined) return ""; + + return first.displayPath.slice( + 0, + first.displayPath.length - first.fileName.length - 1, + ); +}; + +const renderGroup = ( + ui: Ui, + title: string, + files: readonly MeasuredFile[], + limit: number, + showBrotli: boolean, +) => { + if (files.length === 0) return; + + const sorted = [...files].sort((a, b) => b.size - a.size); + // A server bundle is reported by its entry and its largest files: the + // thousands of route chunks in it are never downloaded by anyone. + const shown = ui.verbose + ? sorted + : files[0].category === "server" + ? [ + ...sorted.filter(file => file.isEntry), + ...sorted.filter(file => !file.isEntry), + ].slice(0, limit) + : sorted.slice(0, limit); + const compressed = files.some(file => file.gzip !== null); + + ui.section(`${title} ${ui.colors.muted(outDirOf(files))}`); + ui.table({ + columns: [ + { header: "File" }, + { align: "right", header: "Size" }, + ...(compressed ? [{ align: "right" as const, header: "gzip" }] : []), + ...(compressed && showBrotli + ? [{ align: "right" as const, header: "brotli" }] + : []), + ], + rows: shown.map(file => { + const style = severityStyle(ui, classifySize(file.category, file.size)); + + return [ + `${style.symbol} ${file.fileName}`, + style.paint(formatBytes(file.size)), + ...(compressed + ? [ui.colors.muted(file.gzip === null ? "-" : formatBytes(file.gzip))] + : []), + ...(compressed && showBrotli + ? [ + ui.colors.muted( + file.brotli === null ? "-" : formatBytes(file.brotli), + ), + ] + : []), + ]; + }), + }); + + const hidden = files.length - shown.length; + const totals = [ + `${plural(files.length, "file")}, ${formatBytes(sum(files.map(f => f.size)))}`, + ...(compressed + ? [`gzip ${formatBytes(sum(files.map(f => f.gzip ?? 0)))}`] + : []), + ].join(", "); + + ui.note( + hidden > 0 + ? `… ${String(hidden)} more not shown (--verbose lists all) - ${totals}` + : `Total: ${totals}`, + ); +}; + +export const renderOutput = ( + ui: Ui, + files: readonly MeasuredFile[], + { brotli }: { brotli: boolean }, +) => { + for (const group of GROUPS) { + renderGroup( + ui, + group.title, + files.filter(file => file.category === group.category), + group.limit, + brotli, + ); + } +}; + +export const renderComparison = (ui: Ui, comparison: SnapshotComparison) => { + const rows = [ + ...comparison.changed, + ...comparison.added, + ...comparison.removed, + ] + .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)) + .slice(0, ui.verbose ? undefined : 10); + + ui.section("Bundle changes"); + + if (rows.length === 0) { + ui.note("No size changes since the previous build."); + + return; + } + + ui.table({ + columns: [ + { header: "File" }, + { align: "right", header: "Size" }, + { align: "right", header: "Change" }, + { align: "right", header: "" }, + { header: "" }, + ], + rows: rows.map(change => { + const paint = change.delta > 0 ? ui.colors.warning : ui.colors.success; + const status = + change.before === 0 + ? ui.colors.muted("new") + : change.after === 0 + ? ui.colors.muted("removed") + : isNotableGrowth(change) + ? ui.colors.error(ui.symbols.large) + : ""; + + return [ + change.file, + formatBytes(change.after), + paint(formatByteDelta(change.delta)), + change.before === 0 || change.after === 0 + ? "" + : paint(formatPercentDelta(change.ratio)), + status, + ]; + }), + }); + + const js = comparison.totals["client-js"]; + if (js !== undefined && js.before !== js.after) { + ui.note( + `Client JS total ${formatBytes(js.after)} (${formatByteDelta(js.after - js.before)})`, + ); + } +}; + +export const renderAnalysis = ( + ui: Ui, + analyses: null | readonly ChunkAnalysis[], +) => { + ui.section("Bundle analysis"); + + if (analyses === null || analyses.length === 0) { + ui.note( + "Unavailable: the bundler reported no module information for this build.", + ); + + return; + } + + for (const analysis of analyses) { + ui.line(); + ui.line( + `${ui.mode === "plain" ? "" : " "}${ui.colors.bold(analysis.file.fileName)} ${formatBytes(analysis.file.size)}`, + ); + + const entries = [ + ...analysis.contributions, + ...(analysis.other === null ? [] : [analysis.other]), + ]; + const width = Math.max(...entries.map(entry => entry.label.length)); + + entries.forEach(entry => { + ui.line( + `${ui.mode === "plain" ? " " : " "}${padEnd(entry.label, width)} ${padStart(`≈ ${formatBytes(entry.size)}`, 12)} ${ui.colors.muted(padStart(formatPercent(entry.share), 6))}`, + ); + }); + } + + ui.line(); + ui.note( + "Shares come from the bundler's per-module sizes before minification; sizes are that share of the file on disk.", + ); +}; + +export const renderWarnings = (ui: Ui, warnings: readonly BuildWarning[]) => { + if (warnings.length === 0) return; + + ui.section("Warnings"); + + for (const warning of warnings) { + const style = severityStyle(ui, warning.severity); + + if (ui.mode === "plain") { + ui.line(`[WARN] ${warning.title}`); + warning.files.forEach(file => { + ui.line(` ${file.name} ${formatBytes(file.size)}`); + }); + warning.suggestions.forEach(suggestion => { + ui.line(` - ${suggestion}`); + }); + continue; + } + + ui.line(`${style.symbol} ${warning.title}`); + warning.files.forEach(file => { + ui.line(` ${file.name} ${style.paint(formatBytes(file.size))}`); + }); + ui.line(` ${ui.colors.muted("Consider:")}`); + warning.suggestions.forEach(suggestion => { + ui.line(` ${ui.colors.muted(ui.symbols.bullet)} ${suggestion}`); + }); + ui.line(); + } + + ui.note("Size warnings are recommendations - they never fail a build."); +}; diff --git a/packages/vitnode/scripts/cli/builder/size-severity.test.ts b/packages/vitnode/scripts/cli/builder/size-severity.test.ts new file mode 100644 index 000000000..6b8bd2622 --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/size-severity.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { classifySize, needsWarning } from "./size-severity"; + +describe("classifySize", () => { + it.each([ + [82_400, "good"], + [114_700, "normal"], + [428_600, "warning"], + [612_400, "large"], + ] as const)("a %d byte client chunk is %s", (bytes, severity) => { + expect(classifySize("client-js", bytes)).toBe(severity); + }); + + it("holds stylesheets to a tighter budget than scripts", () => { + expect(classifySize("css", 120_000)).toBe("warning"); + expect(classifySize("client-js", 120_000)).toBe("normal"); + }); + + it("does not judge a server bundle by client thresholds", () => { + expect(classifySize("server", 1_400_000)).toBe("normal"); + expect(classifySize("client-js", 1_400_000)).toBe("large"); + }); + + it("treats an empty file as good", () => { + expect(classifySize("client-js", 0)).toBe("good"); + }); +}); + +describe("needsWarning", () => { + it("warns about client chunks from the warning severity up", () => { + expect(needsWarning("client-js", "warning")).toBe(true); + expect(needsWarning("client-js", "normal")).toBe(false); + }); + + it("only warns about other client files when they are large", () => { + expect(needsWarning("css", "warning")).toBe(false); + expect(needsWarning("css", "large")).toBe(true); + }); + + it("never warns about server output", () => { + expect(needsWarning("server", "large")).toBe(false); + }); +}); diff --git a/packages/vitnode/scripts/cli/builder/size-severity.ts b/packages/vitnode/scripts/cli/builder/size-severity.ts new file mode 100644 index 000000000..9af14da9d --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/size-severity.ts @@ -0,0 +1,53 @@ +import type { FileCategory } from "./output-files"; + +export type SizeSeverity = "good" | "large" | "normal" | "warning"; + +/** + * Upper bounds (inclusive, raw bytes on disk) for `good`, `normal` and + * `warning`; anything above the last is `large`. + * + * Different per category on purpose. Client JavaScript is downloaded, parsed + * and executed on every device that opens the page, so it is held to the + * tightest budget - and its `large` line is Vite's own 500 kB chunk warning. + * A stylesheet blocks rendering, so it is next. A server bundle is read once + * from a local disk at boot; it gets thresholds an order of magnitude looser, + * and no warnings at all. + * + * These are recommendations. A file over any of them never fails a build. + */ +export const SIZE_THRESHOLDS: Record< + FileCategory, + { good: number; normal: number; warning: number } +> = { + asset: { good: 100_000, normal: 500_000, warning: 1_000_000 }, + "client-js": { good: 100_000, normal: 250_000, warning: 500_000 }, + css: { good: 50_000, normal: 100_000, warning: 250_000 }, + other: { good: 100_000, normal: 500_000, warning: 1_000_000 }, + server: { good: 1_000_000, normal: 5_000_000, warning: 20_000_000 }, +}; + +export const classifySize = ( + category: FileCategory, + bytes: number, +): SizeSeverity => { + const limits = SIZE_THRESHOLDS[category]; + + if (bytes <= limits.good) return "good"; + if (bytes <= limits.normal) return "normal"; + if (bytes <= limits.warning) return "warning"; + + return "large"; +}; + +/** Whether a file of this category and severity deserves a build warning. */ +export const needsWarning = ( + category: FileCategory, + severity: SizeSeverity, +): boolean => { + if (category === "server") return false; + if (category === "client-js") { + return severity === "warning" || severity === "large"; + } + + return severity === "large"; +}; diff --git a/packages/vitnode/scripts/cli/builder/snapshot.test.ts b/packages/vitnode/scripts/cli/builder/snapshot.test.ts new file mode 100644 index 000000000..c666faaf3 --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/snapshot.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment node +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { FileCategory, MeasuredFile } from "./output-files"; + +import { + compareSnapshots, + createSnapshot, + isNotableGrowth, + readSnapshot, + snapshotPathFor, + writeSnapshot, +} from "./snapshot"; + +const file = ( + key: string, + fileName: string, + size: number, + category: FileCategory = "client-js", +): MeasuredFile => ({ + absolutePath: `/app/.output/public/${fileName}`, + brotli: null, + category, + consumer: category === "server" ? "server" : "client", + displayPath: `.output/public/${fileName}`, + environment: "client", + fileName, + gzip: Math.round(size / 3), + isEntry: false, + key, + modules: [], + size, + type: "chunk", +}); + +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "vitnode-snapshot-")); +}); + +afterEach(() => { + rmSync(root, { force: true, recursive: true }); +}); + +describe("reading a snapshot", () => { + it("reports a first build as missing", () => { + expect(readSnapshot(snapshotPathFor(root))).toEqual({ status: "missing" }); + }); + + it("round-trips through the cache folder", () => { + const path = snapshotPathFor(root); + const snapshot = createSnapshot([ + file("client:src/main.tsx", "assets/main-AAAAAAA1.js", 1000), + ]); + writeSnapshot(path, snapshot); + + expect(path).toContain(join("node_modules", ".cache", "vitnode")); + expect(readSnapshot(path)).toEqual({ snapshot, status: "ok" }); + }); + + it.each([ + ["is not JSON", "{ not json"], + ["is from another version", JSON.stringify({ files: {}, version: 0 })], + [ + "has malformed entries", + JSON.stringify({ files: { a: { size: "big" } }, version: 1 }), + ], + ])("treats a snapshot that %s as corrupt", (_, content) => { + const path = snapshotPathFor(root); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + + expect(readSnapshot(path)).toEqual({ status: "corrupt" }); + }); + + it("does not record server output", () => { + const snapshot = createSnapshot([ + file("client:a", "a.js", 1), + file("nitro:index", "index.mjs", 1, "server"), + ]); + + expect(Object.keys(snapshot.files)).toEqual(["client:a"]); + }); +}); + +describe("compareSnapshots", () => { + it("matches files by logical key even though their hashes changed", () => { + const before = createSnapshot([ + file("client:src/editor.tsx", "assets/editor-F9aK2bQ3.js", 342_500), + ]); + const after = createSnapshot([ + file("client:src/editor.tsx", "assets/editor-H2kq9ZZ1.js", 428_600), + ]); + + const { added, changed, removed } = compareSnapshots(before, after); + + expect(added).toEqual([]); + expect(removed).toEqual([]); + expect(changed).toEqual([ + expect.objectContaining({ + after: 428_600, + before: 342_500, + delta: 86_100, + file: "assets/editor-H2kq9ZZ1.js", + }), + ]); + expect(changed[0].ratio).toBeCloseTo(0.2514, 3); + expect(isNotableGrowth(changed[0])).toBe(true); + }); + + it("ignores changes under 1 kB", () => { + const before = createSnapshot([file("k", "a.js", 10_000)]); + const after = createSnapshot([file("k", "a.js", 10_400)]); + + expect(compareSnapshots(before, after).changed).toEqual([]); + }); + + it("reports added and removed files and totals per category", () => { + const before = createSnapshot([ + file("old", "old.js", 5000), + file("kept", "kept.js", 1000), + ]); + const after = createSnapshot([ + file("new", "new.js", 7000), + file("kept", "kept.js", 1000), + ]); + + const comparison = compareSnapshots(before, after); + + expect(comparison.added.map(change => change.key)).toEqual(["new"]); + expect(comparison.removed.map(change => change.key)).toEqual(["old"]); + expect(comparison.totals["client-js"]).toEqual({ + after: 8000, + before: 6000, + }); + }); + + it("orders changes by impact", () => { + const before = createSnapshot([ + file("a", "a.js", 10_000), + file("b", "b.js", 10_000), + ]); + const after = createSnapshot([ + file("a", "a.js", 12_000), + file("b", "b.js", 50_000), + ]); + + expect( + compareSnapshots(before, after).changed.map(change => change.key), + ).toEqual(["b", "a"]); + }); + + it("does not flag a shrinking file", () => { + const before = createSnapshot([file("a", "a.js", 625_200)]); + const after = createSnapshot([file("a", "a.js", 612_400)]); + + expect(isNotableGrowth(compareSnapshots(before, after).changed[0])).toBe( + false, + ); + }); +}); diff --git a/packages/vitnode/scripts/cli/builder/snapshot.ts b/packages/vitnode/scripts/cli/builder/snapshot.ts new file mode 100644 index 000000000..1c124e1a0 --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/snapshot.ts @@ -0,0 +1,196 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import type { FileCategory, MeasuredFile } from "./output-files"; + +/** + * Bumped whenever the shape - or the meaning of a key - changes, so an old + * snapshot is discarded instead of compared against something it cannot + * describe. + */ +export const SNAPSHOT_VERSION = 1; + +export interface SnapshotEntry { + category: FileCategory; + file: string; + gzip: null | number; + size: number; +} + +export interface BuildSnapshot { + files: Record; + version: typeof SNAPSHOT_VERSION; +} + +export type SnapshotRead = + | { snapshot: BuildSnapshot; status: "ok" } + | { status: "corrupt" } + | { status: "missing" }; + +/** + * Where the previous build's sizes are kept: next to the other tool caches in + * `node_modules/.cache`, which every VitNode project already ignores. Nothing + * here is ever committed, and deleting it only loses one comparison. + */ +export const snapshotPathFor = (projectRoot: string): string => + join(projectRoot, "node_modules", ".cache", "vitnode", "build-snapshot.json"); + +/** Only what a browser downloads is compared; server bundles are not. */ +const COMPARED: ReadonlySet = new Set([ + "asset", + "client-js", + "css", +]); + +export const createSnapshot = ( + files: readonly MeasuredFile[], +): BuildSnapshot => ({ + files: Object.fromEntries( + files + .filter(file => COMPARED.has(file.category)) + .map(file => [ + file.key, + { + category: file.category, + file: file.fileName, + gzip: file.gzip, + size: file.size, + }, + ]), + ), + version: SNAPSHOT_VERSION, +}); + +const isSnapshot = (value: unknown): value is BuildSnapshot => { + if (typeof value !== "object" || value === null) return false; + const { files, version } = value as Partial; + if (version !== SNAPSHOT_VERSION) return false; + if (typeof files !== "object" || files === null) return false; + + return Object.values(files).every( + entry => + typeof entry === "object" && + typeof entry.size === "number" && + typeof entry.file === "string" && + typeof entry.category === "string", + ); +}; + +export const readSnapshot = (path: string): SnapshotRead => { + if (!existsSync(path)) return { status: "missing" }; + + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + + return isSnapshot(parsed) + ? { snapshot: parsed, status: "ok" } + : { status: "corrupt" }; + } catch { + return { status: "corrupt" }; + } +}; + +export const writeSnapshot = (path: string, snapshot: BuildSnapshot): void => { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(snapshot)}\n`, "utf8"); +}; + +export interface SizeChange { + after: number; + before: number; + category: FileCategory; + delta: number; + file: string; + key: string; + ratio: number; +} + +export interface SnapshotComparison { + added: SizeChange[]; + changed: SizeChange[]; + removed: SizeChange[]; + totals: Partial>; +} + +/** Below this, a size change is noise from a hash or a timestamp. */ +const MIN_REPORTED_DELTA = 1000; + +/** + * Matches the current build against the previous one by logical key, so + * `editor-F9aK2.js` and `editor-H2kq9.js` are the same file that changed. + */ +export const compareSnapshots = ( + previous: BuildSnapshot, + current: BuildSnapshot, +): SnapshotComparison => { + const added: SizeChange[] = []; + const changed: SizeChange[] = []; + const removed: SizeChange[] = []; + const totals: SnapshotComparison["totals"] = {}; + + const addTotal = (category: FileCategory, before: number, after: number) => { + const total = totals[category] ?? { after: 0, before: 0 }; + total.before += before; + total.after += after; + totals[category] = total; + }; + + for (const [key, entry] of Object.entries(current.files)) { + const old = previous.files[key]; + addTotal(entry.category, old?.size ?? 0, entry.size); + + if (old === undefined) { + added.push({ + after: entry.size, + before: 0, + category: entry.category, + delta: entry.size, + file: entry.file, + key, + ratio: 1, + }); + continue; + } + + const delta = entry.size - old.size; + if (Math.abs(delta) < MIN_REPORTED_DELTA) continue; + + changed.push({ + after: entry.size, + before: old.size, + category: entry.category, + delta, + file: entry.file, + key, + ratio: old.size === 0 ? 1 : delta / old.size, + }); + } + + for (const [key, entry] of Object.entries(previous.files)) { + if (key in current.files) continue; + addTotal(entry.category, entry.size, 0); + removed.push({ + after: 0, + before: entry.size, + category: entry.category, + delta: -entry.size, + file: entry.file, + key, + ratio: -1, + }); + } + + const byImpact = (a: SizeChange, b: SizeChange) => + Math.abs(b.delta) - Math.abs(a.delta); + + return { + added: added.sort(byImpact), + changed: changed.sort(byImpact), + removed: removed.sort(byImpact), + totals, + }; +}; + +/** A change worth flagging: over 10% *and* over 10 kB bigger. */ +export const isNotableGrowth = (change: SizeChange): boolean => + change.delta > 10_000 && change.ratio > 0.1; diff --git a/packages/vitnode/scripts/cli/builder/warnings.ts b/packages/vitnode/scripts/cli/builder/warnings.ts new file mode 100644 index 000000000..332d6097a --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/warnings.ts @@ -0,0 +1,114 @@ +import type { ChunkAnalysis } from "./analysis"; +import type { FileCategory, MeasuredFile } from "./output-files"; +import type { SizeSeverity } from "./size-severity"; + +import { formatBytes, formatPercent } from "../ui/format"; +import { dominantPackage } from "./analysis"; +import { classifySize, needsWarning, SIZE_THRESHOLDS } from "./size-severity"; + +export interface BuildWarning { + files: { name: string; size: number }[]; + severity: SizeSeverity; + suggestions: string[]; + title: string; +} + +const SUGGESTIONS: Partial> = { + asset: [ + "compress it, or convert images to WebP or AVIF", + "serve large media from storage instead of bundling it", + ], + "client-js": [ + "load heavy libraries with dynamic import() where they are used", + "give pages their own chunk: component: lazy(() => import(...)) in routes.ts", + "lazy-load heavy UI such as editors and dialogs with React.lazy + Suspense", + ], + css: [ + "check that Tailwind only scans the sources it needs", + "move page-specific styles next to the page so they split with it", + ], +}; + +const NOUN: Record = { + asset: "asset", + "client-js": "client chunk", + css: "stylesheet", + other: "file", + server: "server file", +}; + +const MAX_LISTED = 5; + +/** + * Concise, actionable warnings about oversized output. + * + * Grouped by category and severity, so twelve oversized chunks are one warning + * listing the largest five rather than twelve copies of the same advice. When + * `--analyze` found a single package dominating a flagged chunk, that becomes + * the first suggestion: it is the most specific thing VitNode can say. + */ +export const collectWarnings = ( + files: readonly MeasuredFile[], + analyses: readonly ChunkAnalysis[] = [], +): BuildWarning[] => { + const groups = new Map(); + + for (const file of files) { + const severity = classifySize(file.category, file.size); + if (!needsWarning(file.category, severity)) continue; + + const key = `${file.category}:${severity}`; + groups.set(key, [...(groups.get(key) ?? []), file]); + } + + const order: SizeSeverity[] = ["large", "warning"]; + + return [...groups.entries()] + .map(([key, grouped]) => { + const [category, severity] = key.split(":") as [ + FileCategory, + SizeSeverity, + ]; + const sorted = [...grouped].sort((a, b) => b.size - a.size); + const limit = + severity === "large" + ? SIZE_THRESHOLDS[category].warning + : SIZE_THRESHOLDS[category].normal; + const noun = NOUN[category]; + const subject = + sorted.length === 1 + ? `${baseName(sorted[0].fileName)} is` + : `${String(sorted.length)} ${noun}s are`; + + const dominant = analyses + .filter(analysis => sorted.some(file => file === analysis.file)) + .map(analysis => ({ analysis, top: dominantPackage(analysis) })) + .find(({ top }) => top !== null); + + const suggestions = [ + ...(dominant?.top + ? [ + `${dominant.top.label} makes up ${formatPercent(dominant.top.share)} of ${baseName(dominant.analysis.file.fileName)} - import it with import() where it is needed`, + ] + : []), + ...(SUGGESTIONS[category] ?? []), + ]; + + return { + files: sorted + .slice(0, MAX_LISTED) + .map(file => ({ name: file.fileName, size: file.size })), + severity, + suggestions, + title: `${subject} larger than the recommended ${formatBytes(limit)} for a ${noun}.`, + } satisfies BuildWarning; + }) + .sort( + (a, b) => + order.indexOf(a.severity) - order.indexOf(b.severity) || + b.files[0].size - a.files[0].size, + ); +}; + +const baseName = (fileName: string) => + fileName.slice(fileName.lastIndexOf("/") + 1); diff --git a/packages/vitnode/scripts/cli/commands/build.test.ts b/packages/vitnode/scripts/cli/commands/build.test.ts new file mode 100644 index 000000000..1478af6fd --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/build.test.ts @@ -0,0 +1,449 @@ +// @vitest-environment node +import type { InlineConfig, Plugin } from "vite"; + +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { ViteBuildApi } from "../builder/app-build"; + +import { snapshotPathFor } from "../builder/snapshot"; +import { RuntimeError } from "../errors"; +import { createTestContext } from "../testing"; +import { captureProcessOutput } from "../ui/capture-output"; +import { runBuildCommand } from "./build"; + +interface FakeChunk { + code: string; + facadeModuleId?: null | string; + fileName: string; + modules?: Record; + name: string; +} + +interface FakeEnvironment { + chunks: FakeChunk[]; + consumer: "client" | "server"; + name: string; + outDir: string; +} + +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "vitnode-build-")); + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "web" })); + writeFileSync(join(root, "vite.config.ts"), "export default {};\n"); +}); + +afterEach(() => { + rmSync(root, { force: true, recursive: true }); +}); + +type Hook = (this: unknown, ...args: unknown[]) => unknown; + +const call = ( + plugin: Plugin, + hook: keyof Plugin, + context: unknown, + ...args: unknown[] +) => { + const handler = plugin[hook] as Hook | undefined; + + return handler?.call(context, ...args); +}; + +/** + * A stand-in for Vite: runs the plugin hooks the real builder would, in the + * same order, and writes the files it claims to have written - so the build + * report reads real files off disk. + */ +const fakeVite = ( + environments: FakeEnvironment[], + { fail }: { fail?: Error } = {}, +): ViteBuildApi & { configs: InlineConfig[] } => { + const configs: InlineConfig[] = []; + + return { + configs, + createBuilder: async config => { + await Promise.resolve(); + configs.push(config); + const plugins = (config.plugins ?? []) as Plugin[]; + + return { + buildApp: async () => { + for (const environment of environments) { + const context = { + environment: { + config: { consumer: environment.consumer }, + name: environment.name, + }, + }; + for (const plugin of plugins) + await call(plugin, "buildStart", context); + + if (fail !== undefined && environment.consumer === "server") { + process.stdout.write("plugin noise before the failure\n"); + for (const plugin of plugins) + await call(plugin, "buildEnd", context, fail); + throw fail; + } + + const dir = join(root, environment.outDir); + const bundle = Object.fromEntries( + environment.chunks.map(chunk => { + const path = join(dir, chunk.fileName); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, chunk.code); + + return [ + chunk.fileName, + { + facadeModuleId: chunk.facadeModuleId ?? null, + fileName: chunk.fileName, + isEntry: chunk.facadeModuleId != null, + modules: chunk.modules ?? {}, + name: chunk.name, + type: "chunk", + }, + ]; + }), + ); + for (const plugin of plugins) + await call(plugin, "writeBundle", context, { dir }, bundle); + for (const plugin of plugins) + await call(plugin, "closeBundle", context); + } + }, + config: { plugins: [{ name: "vitnode:plugin-routes" }] }, + }; + }, + }; +}; + +const noise = (bytes: number, seed: string) => { + let text = ""; + let state = seed.length; + while (text.length < bytes) { + state = (state * 1_103_515_245 + 12_345) % 2_147_483_648; + text += state.toString(36); + } + + return text.slice(0, bytes); +}; + +const appBuild = ( + sizes: { admin: number; index: number }, + hash = "AAAAAAA1", +): FakeEnvironment[] => [ + { + chunks: [ + { + code: noise(sizes.index, "index"), + facadeModuleId: join(root, "src", "main.tsx"), + fileName: `assets/index-${hash}.js`, + modules: { + [join(root, "src", "main.tsx")]: { renderedLength: 400 }, + [join(root, "node_modules", "react-dom", "index.js")]: { + renderedLength: 600, + }, + }, + name: "index", + }, + { + code: noise(sizes.admin, "admin"), + facadeModuleId: join(root, "src", "admin.tsx"), + fileName: `assets/admin-${hash}.js`, + modules: { + [join(root, "node_modules", "@tiptap", "core", "index.js")]: { + renderedLength: 900, + }, + [join(root, "src", "admin.tsx")]: { renderedLength: 100 }, + }, + name: "admin", + }, + ], + consumer: "client", + name: "client", + outDir: ".output/public", + }, + { + chunks: [{ code: "intermediate", fileName: "index.js", name: "index" }], + consumer: "server", + name: "ssr", + outDir: "node_modules/.nitro/ssr", + }, + { + chunks: [ + { + code: noise(2000, "server"), + facadeModuleId: "/x/server.mjs", + fileName: "index.mjs", + name: "index", + }, + ], + consumer: "server", + name: "nitro", + outDir: ".output/server", + }, +]; + +const passThrough = () => ({ restore: () => "" }); + +const build = async ( + vite: ViteBuildApi, + options: { + analyze?: boolean; + captureOutput?: () => { restore: () => string }; + env?: Record; + interactive?: boolean; + plain?: boolean; + verbose?: boolean; + } = {}, +) => { + const { context, runtime } = createTestContext({ cwd: root, ...options }); + const code = await runBuildCommand(context, options, { + appBuild: { + captureOutput: options.captureOutput ?? passThrough, + loadVite: async () => await Promise.resolve(vite), + }, + now: () => 0, + }); + + return { code, runtime }; +}; + +describe("vitnode build for an app", () => { + it("builds through Vite and reports each real environment", async () => { + const vite = fakeVite(appBuild({ admin: 612_400, index: 82_400 })); + const { code, runtime } = await build(vite); + const output = runtime.output(); + + expect(code).toBe(0); + expect(output).toContain("✓ Configuration loaded, plugin routes generated"); + expect(output).toContain("✓ Building client"); + expect(output).toContain("✓ Building server"); + expect(output).toContain("✓ Packaging server (Nitro)"); + expect(output).toContain("✓ Built in 0ms"); + expect(vite.configs[0]).toMatchObject({ mode: "production", root }); + }); + + it("lists the output with sizes, gzip and severity, grouped by kind", async () => { + const { runtime } = await build( + fakeVite(appBuild({ admin: 612_400, index: 82_400 })), + ); + const output = runtime.output(); + + expect(output).toMatch(/Client JS {2}\.output\/public/); + expect(output).toMatch( + /▲ assets\/admin-AAAAAAA1\.js\s+612\.4 kB\s+\d+\.\d kB/, + ); + expect(output).toMatch(/✓ assets\/index-AAAAAAA1\.js\s+82\.4 kB/); + expect(output).toMatch(/Server {2}\.output\/server/); + expect(output).toContain("index.mjs"); + }); + + it("leaves out intermediate output another environment consumes", async () => { + const { runtime } = await build( + fakeVite(appBuild({ admin: 1000, index: 1000 })), + ); + + expect(runtime.output()).not.toContain("node_modules/.nitro"); + }); + + it("warns about an oversized client chunk with actionable advice, and still succeeds", async () => { + const { code, runtime } = await build( + fakeVite(appBuild({ admin: 612_400, index: 1000 })), + ); + const output = runtime.output(); + + expect(code).toBe(0); + expect(output).toContain( + "▲ admin-AAAAAAA1.js is larger than the recommended 500.0 kB for a client chunk.", + ); + expect(output).toContain("dynamic import()"); + expect(output).toContain("never fail a build"); + }); + + it("does not warn about a large server bundle", async () => { + const environments = appBuild({ admin: 1000, index: 1000 }); + environments[2].chunks[0].code = noise(5_000_000, "big-server"); + const { runtime } = await build(fakeVite(environments)); + + expect(runtime.output()).not.toContain("Warnings"); + }); + + it("prints stable [OK] lines without color in plain mode", async () => { + const { runtime } = await build( + fakeVite(appBuild({ admin: 612_400, index: 1000 })), + { + interactive: true, + plain: true, + }, + ); + + expect(runtime.output()).toContain("[OK] Building client"); + expect(runtime.output()).toContain("[WARN] admin-AAAAAAA1.js is larger"); + expect(runtime.raw()).not.toContain("\x1b["); + expect(runtime.raw()).not.toContain("\r"); + }); + + it("never animates in CI, even on a TTY", async () => { + const { runtime } = await build( + fakeVite(appBuild({ admin: 1000, index: 1000 })), + { + env: { CI: "true" }, + interactive: true, + }, + ); + + expect(runtime.raw()).not.toContain("\r"); + }); + + it("passes the bundler's own log level through in verbose mode", async () => { + const vite = fakeVite(appBuild({ admin: 1000, index: 1000 })); + await build(vite, { verbose: true }); + + expect(vite.configs[0]).toMatchObject({ logLevel: "info" }); + expect(vite.configs[0].customLogger).toBeUndefined(); + }); +}); + +describe("comparison with the previous build", () => { + it("shows nothing to compare on the first build, then the change on the next", async () => { + const first = await build( + fakeVite(appBuild({ admin: 342_500, index: 82_400 }, "AAAAAAA1")), + ); + expect(first.runtime.output()).not.toContain("Bundle changes"); + expect(existsSync(snapshotPathFor(root))).toBe(true); + + const second = await build( + fakeVite(appBuild({ admin: 428_600, index: 82_400 }, "BBBBBBB2")), + ); + const output = second.runtime.output(); + + expect(output).toContain("Bundle changes"); + expect(output).toMatch( + /assets\/admin-BBBBBBB2\.js\s+428\.6 kB\s+\+86\.1 kB\s+\+25\.1%\s+▲/, + ); + expect(output).not.toMatch(/assets\/index-BBBBBBB2\.js\s+82\.4 kB\s+\+/); + }); + + it("survives a corrupted snapshot and replaces it", async () => { + const path = snapshotPathFor(root); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "{ corrupted"); + + const { code, runtime } = await build( + fakeVite(appBuild({ admin: 1000, index: 1000 })), + ); + + expect(code).toBe(0); + expect(runtime.output()).toContain("size snapshot was unreadable"); + expect( + ( + await build(fakeVite(appBuild({ admin: 1000, index: 1000 }))) + ).runtime.output(), + ).toContain("No size changes since the previous build."); + }); +}); + +describe("vitnode build --analyze", () => { + it("breaks the largest chunks down by package", async () => { + const { runtime } = await build( + fakeVite(appBuild({ admin: 612_400, index: 1000 })), + { + analyze: true, + }, + ); + const output = runtime.output(); + + expect(output).toContain("Bundle analysis"); + expect(output).toMatch(/@tiptap\/core\s+≈ 551\.2 kB\s+90\.0%/); + expect(output).toMatch(/application code\s+≈ 61\.2 kB\s+10\.0%/); + expect(output).toContain( + "@tiptap/core makes up 90.0% of admin-AAAAAAA1.js", + ); + expect(output).toContain("brotli"); + }); + + it("says when analysis is unavailable instead of failing the build", async () => { + const environments = appBuild({ admin: 1000, index: 1000 }); + environments[0].chunks.forEach(chunk => { + chunk.modules = {}; + }); + const { code, runtime } = await build(fakeVite(environments), { + analyze: true, + }); + + expect(code).toBe(0); + expect(runtime.output()).toContain( + "Unavailable: the bundler reported no module information", + ); + }); +}); + +describe("a failed build", () => { + it("fails with where it happened - including the plugin that owns the file", async () => { + const plugin = join(root, "plugins", "blog"); + mkdirSync(join(plugin, "src", "routes"), { recursive: true }); + writeFileSync( + join(plugin, "package.json"), + JSON.stringify({ + dependencies: { "@vitnode/core": "*" }, + name: "@acme/blog", + }), + ); + writeFileSync(join(plugin, "src", "config.tsx"), ""); + + const error = Object.assign(new Error("Missing export: title"), { + frame: "41 | const a = 1;\n42 | export { title } from './x';", + id: join(plugin, "src", "routes", "post.tsx"), + loc: { + column: 9, + file: join(plugin, "src", "routes", "post.tsx"), + line: 42, + }, + plugin: "vite:esbuild", + }); + + const failure = await build( + fakeVite(appBuild({ admin: 1000, index: 1000 }), { fail: error }), + { + captureOutput: captureProcessOutput, + }, + ).catch((thrown: unknown) => thrown); + + expect(failure).toBeInstanceOf(RuntimeError); + const { cause, details, message } = failure as RuntimeError; + expect(message).toBe("Build failed"); + expect(details).toContain("Plugin @acme/blog"); + expect(details).toContain("File plugins/blog/src/routes/post.tsx:42:9"); + expect(details).toContain("Step vite:esbuild"); + expect(details).toContain("Missing export: title"); + expect(cause).toBe(error); + // What plugins printed is held back during the build, then shown with + // the error it explains. + expect((failure as RuntimeError).output).toContain( + "plugin noise before the failure", + ); + }); + + it("does not invent context for an error that carries none", async () => { + const failure = (await build( + fakeVite(appBuild({ admin: 1000, index: 1000 }), { + fail: new Error("Something odd"), + }), + ).catch((thrown: unknown) => thrown)) as RuntimeError; + + expect(failure.details).toEqual(["Something odd"]); + }); +}); diff --git a/packages/vitnode/scripts/cli/commands/build.ts b/packages/vitnode/scripts/cli/commands/build.ts new file mode 100644 index 000000000..56f0706b0 --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/build.ts @@ -0,0 +1,168 @@ +import type { AppBuildOptions } from "../builder/app-build"; +import type { StepRunner } from "../builder/compiler-build"; +import type { MeasuredFile } from "../builder/output-files"; +import type { CommandContext, OutputOptions } from "../context"; +import type { Project } from "../project/project"; +import type { Ui } from "../ui/ui"; + +import { analyzeChunks } from "../builder/analysis"; +import { runAppBuild } from "../builder/app-build"; +import { + apiBuildSteps, + listOutput, + packageBuildSteps, + prepareRegistryStep, + runCompilerSteps, +} from "../builder/compiler-build"; +import { createPackageOwner } from "../builder/package-owner"; +import { + renderAnalysis, + renderComparison, + renderOutput, + renderWarnings, +} from "../builder/report"; +import { + compareSnapshots, + createSnapshot, + readSnapshot, + snapshotPathFor, + writeSnapshot, +} from "../builder/snapshot"; +import { collectWarnings } from "../builder/warnings"; +import { errorMessage, EXIT_CODE } from "../errors"; +import { isPluginPackage } from "../plugins/discover"; +import { detectProject } from "../project/project"; +import { formatDuration, plural } from "../ui/format"; + +export interface BuildOptions extends OutputOptions { + analyze?: boolean; +} + +export interface BuildDeps { + appBuild?: Partial>; + now?: () => number; + runStep?: StepRunner; +} + +/** + * Runs a reporting step that must never fail the build it reports on: a + * corrupt snapshot or an analyzer surprise costs one section, not the build. + */ +const safely = (ui: Ui, what: string, run: () => T): T | undefined => { + try { + return run(); + } catch (error) { + ui.line(); + ui.note(`${what} skipped: ${errorMessage(error)}`); + + return undefined; + } +}; + +const compareWithPrevious = ( + ui: Ui, + project: Project, + files: readonly MeasuredFile[], +) => { + const path = snapshotPathFor(project.root); + const previous = readSnapshot(path); + const current = createSnapshot(files); + + if (previous.status === "ok") + renderComparison(ui, compareSnapshots(previous.snapshot, current)); + if (previous.status === "corrupt") { + ui.line(); + ui.note( + "The previous build's size snapshot was unreadable - comparing from the next build on.", + ); + } + + writeSnapshot(path, current); +}; + +const reportAppBuild = ( + ui: Ui, + project: Project, + files: readonly MeasuredFile[], + { + analyze, + bundlerWarnings, + }: { analyze: boolean; bundlerWarnings: readonly string[] }, +) => { + const analyses = analyze + ? safely(ui, "Bundle analysis", () => + analyzeChunks(files, { owner: createPackageOwner(project.root) }), + ) + : undefined; + + ui.section("Output"); + renderOutput(ui, files, { brotli: analyze }); + + safely(ui, "Bundle comparison", () => { + compareWithPrevious(ui, project, files); + }); + + if (analyze && analyses !== undefined) renderAnalysis(ui, analyses); + + renderWarnings(ui, collectWarnings(files, analyses ?? [])); + + if (bundlerWarnings.length > 0 && !ui.verbose) { + ui.line(); + ui.note( + `${plural(bundlerWarnings.length, "bundler warning")} hidden - run with --verbose to see them.`, + ); + } +}; + +export const runBuildCommand = async ( + { cwd, ui }: CommandContext, + options: BuildOptions, + deps: BuildDeps = {}, +): Promise => { + const now = deps.now ?? Date.now; + const startedAt = now(); + const project = detectProject(cwd); + const analyze = options.analyze === true; + + if (project.kind === "package") { + ui.header(`Building ${project.name}`); + if (isPluginPackage(project.root)) prepareRegistryStep(project, ui); + await runCompilerSteps({ + project, + runStep: deps.runStep, + steps: packageBuildSteps(), + ui, + }); + } else if (project.kind === "api") { + ui.header("Production build"); + await runCompilerSteps({ + project, + runStep: deps.runStep, + steps: apiBuildSteps(), + ui, + }); + ui.section("Output"); + renderOutput(ui, listOutput(project, "dist"), { brotli: false }); + } else { + ui.header("Production build"); + const result = await runAppBuild({ + analyze, + project, + ui, + ...deps.appBuild, + }); + reportAppBuild(ui, project, result.files, { + analyze, + bundlerWarnings: result.bundlerWarnings, + }); + } + + ui.line(); + ui.success(`Built in ${formatDuration(now() - startedAt)}`); + if (project.kind !== "package") { + ui.note(`Run ${ui.colors.command("vitnode start")} to serve it.`); + } + ui.line(); + + return EXIT_CODE.ok; +}; diff --git a/packages/vitnode/scripts/cli/commands/db.test.ts b/packages/vitnode/scripts/cli/commands/db.test.ts new file mode 100644 index 000000000..83af40812 --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/db.test.ts @@ -0,0 +1,515 @@ +// @vitest-environment node +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { DatabaseHandle, DatabaseServices } from "../db/database"; +import type { LocalMigration } from "../db/migration-state"; + +import { RuntimeError, UserError } from "../errors"; +import { createScriptedPrompter, createTestContext } from "../testing"; +import { + runDbGenerateCommand, + runDbMigrateCommand, + runDbPushCommand, + runDbStatusCommand, +} from "./db"; + +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "vitnode-db-")); + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "api" })); + writeFileSync(join(root, "drizzle.config.ts"), "export default {};\n"); +}); + +afterEach(() => { + rmSync(root, { force: true, recursive: true }); +}); + +const migration = (name: string): LocalMigration => ({ + folderMillis: 0, + hash: name, + name, +}); + +interface FakeDatabase { + applied: string[]; + closed: number; + kitCalls: string[][]; + services: DatabaseServices; +} + +/** + * Everything outside the process, faked at the seam the commands use: + * drizzle-kit's JSON answers, the migrations on disk, the journal table. + */ +const fakeDatabase = ({ + applyError, + journal = [], + kit = {}, + local = [], + reachable = true, +}: { + applyError?: Error; + journal?: string[]; + kit?: Record; + local?: LocalMigration[]; + reachable?: boolean; +} = {}): FakeDatabase => { + const state: FakeDatabase = { + applied: [...journal], + closed: 0, + kitCalls: [], + services: undefined as unknown as DatabaseServices, + }; + const folders = local.map(m => m.name); + + const handle: DatabaseHandle = { + apply: async () => { + await Promise.resolve(); + if (applyError) throw applyError; + state.applied = local.map(m => m.name); + }, + close: async () => { + await Promise.resolve(); + state.closed += 1; + }, + location: "vitnode @ localhost:5432", + ping: async () => { + await Promise.resolve(); + if (!reachable) throw new Error("connect ECONNREFUSED 127.0.0.1:5432"); + }, + query: async query => + await Promise.resolve( + query.includes("information_schema") + ? [{ column_name: "name" }] + : state.applied.map(name => ({ created_at: 0, hash: name, name })), + ), + }; + + state.services = { + drizzleConfig: async () => + await Promise.resolve({ + dialect: "postgresql", + migrationsFolder: join(root, "migrations"), + migrationsSchema: "drizzle", + migrationsTable: "__drizzle_migrations", + }), + drizzleKit: async args => { + await Promise.resolve(); + state.kitCalls.push([...args]); + const key = args[0] ?? ""; + const explainKey = args.includes("--explain") ? `${key} --explain` : key; + if (key === "generate" && !args.includes("--explain")) + folders.push("0003_notifications"); + const answer = kit[explainKey] ?? { status: "no_changes" }; + + return { code: 0, output: `noise\n${JSON.stringify(answer)}\n` }; + }, + listMigrationFolders: () => [...folders], + open: async () => await Promise.resolve(handle), + readLocalMigrations: async () => await Promise.resolve(local), + }; + + return state; +}; + +const context = (options: Parameters[0] = {}) => + createTestContext({ cwd: root, ...options }); + +describe("vitnode db generate", () => { + it("shows the schema changes, then generates the migration", async () => { + const db = fakeDatabase({ + kit: { + "generate --explain": { + statements: [ + { table: { name: "notifications" }, type: "create_table" }, + { + column: { name: "notification_count", table: "users" }, + type: "add_column", + }, + ], + status: "ok", + }, + generate: { + migration_path: "migrations/0003_notifications", + status: "ok", + }, + up: { status: "ok" }, + }, + }); + const { context: ctx, runtime } = context(); + + const code = await runDbGenerateCommand( + ctx, + { name: "notifications" }, + { services: () => db.services }, + ); + + expect(code).toBe(0); + expect(runtime.output()).toContain("Schema changes detected"); + expect(runtime.output()).toContain("+ table notifications"); + expect(runtime.output()).toContain("+ column users.notification_count"); + expect(runtime.output()).toContain("✓ 0003_notifications"); + expect(db.kitCalls).toContainEqual([ + "generate", + "--output", + "json", + "--name", + "notifications", + ]); + }); + + it("generates nothing when the schema matches the last migration", async () => { + const db = fakeDatabase({ + kit: { "generate --explain": { status: "no_changes" } }, + }); + const { context: ctx, runtime } = context(); + + await runDbGenerateCommand(ctx, {}, { services: () => db.services }); + + expect(runtime.output()).toContain( + "No schema changes - nothing to generate.", + ); + expect( + db.kitCalls.some( + args => args[0] === "generate" && !args.includes("--explain"), + ), + ).toBe(false); + }); + + it("fails with drizzle-kit's own message when it reports an error", async () => { + const db = fakeDatabase({ + kit: { + "generate --explain": { + error: { message: "Snapshot conflict" }, + status: "error", + }, + }, + }); + + await expect( + runDbGenerateCommand( + context().context, + {}, + { services: () => db.services }, + ), + ).rejects.toThrow("Snapshot conflict"); + }); + + it("does not wait for a rename decision nobody can make", async () => { + const db = fakeDatabase({ + kit: { + "generate --explain": { + status: "missing_hints", + unresolved: [ + { + entity: ["public", "users", "name"], + kind: "column", + type: "rename_or_create", + }, + ], + }, + }, + }); + + await expect( + runDbGenerateCommand( + context().context, + {}, + { services: () => db.services }, + ), + ).rejects.toThrow(UserError); + }); +}); + +describe("vitnode db migrate", () => { + it("says the database is up to date when nothing is pending", async () => { + const db = fakeDatabase({ + journal: ["0001_init"], + local: [migration("0001_init")], + }); + const { context: ctx, runtime } = context(); + + expect( + await runDbMigrateCommand(ctx, {}, { services: () => db.services }), + ).toBe(0); + expect(runtime.output()).toContain("✓ Database is up to date."); + expect(db.closed).toBe(1); + }); + + it("lists pending migrations and applies them with --yes", async () => { + const db = fakeDatabase({ + journal: ["0001_init"], + local: [ + migration("0001_init"), + migration("0002_notifications"), + migration("0003_preferences"), + ], + }); + const { context: ctx, runtime } = context(); + + await runDbMigrateCommand( + ctx, + { yes: true }, + { services: () => db.services }, + ); + const output = runtime.output(); + + expect(output).toContain("Pending migrations"); + expect(output).toMatch(/✓ 0002_notifications\n {2}✓ 0003_preferences/); + expect(db.applied).toEqual([ + "0001_init", + "0002_notifications", + "0003_preferences", + ]); + }); + + it("refuses to apply without --yes when nobody can confirm", async () => { + const db = fakeDatabase({ local: [migration("0001_init")] }); + + await expect( + runDbMigrateCommand( + context().context, + {}, + { services: () => db.services }, + ), + ).rejects.toMatchObject({ + hint: expect.stringContaining("Pass --yes") as unknown, + }); + expect(db.applied).toEqual([]); + expect(db.closed).toBe(1); + }); + + it("asks in an interactive terminal, and applies nothing on no", async () => { + const db = fakeDatabase({ local: [migration("0001_init")] }); + const prompter = createScriptedPrompter({ confirm: [false] }); + const { context: ctx, runtime } = context({ interactive: true, prompter }); + + expect( + await runDbMigrateCommand(ctx, {}, { services: () => db.services }), + ).toBe(0); + expect(prompter.asked).toEqual(["Apply 1 migration?"]); + expect(runtime.output()).toContain("Cancelled - nothing was applied."); + expect(db.applied).toEqual([]); + }); + + it("fails - and still closes the connection - when a migration fails", async () => { + const db = fakeDatabase({ + applyError: new RuntimeError("Database migration failed."), + local: [migration("0001_init")], + }); + + await expect( + runDbMigrateCommand( + context().context, + { yes: true }, + { services: () => db.services }, + ), + ).rejects.toThrow("Database migration failed."); + expect(db.closed).toBe(1); + }); +}); + +describe("vitnode db status", () => { + it("reports a connected database and its migrations", async () => { + const db = fakeDatabase({ + journal: ["0001_init"], + local: [migration("0001_init"), migration("0002_notifications")], + }); + const { context: ctx, runtime } = context(); + + expect( + await runDbStatusCommand(ctx, {}, { services: () => db.services }), + ).toBe(0); + const output = runtime.output(); + + expect(output).toMatch(/Provider\s+PostgreSQL/); + expect(output).toMatch(/Database\s+vitnode @ localhost:5432/); + expect(output).toMatch(/Status\s+● connected/); + expect(output).toMatch(/Applied\s+1/); + expect(output).toMatch(/Pending\s+1/); + expect(output).toContain("○ 0002_notifications"); + }); + + it("fails when the database cannot be reached - it never assumes", async () => { + const db = fakeDatabase({ + local: [migration("0001_init")], + reachable: false, + }); + const { context: ctx, runtime } = context(); + + await expect( + runDbStatusCommand(ctx, {}, { services: () => db.services }), + ).rejects.toThrow("Could not connect to the database."); + expect(runtime.output()).toMatch(/Status\s+○ unreachable/); + }); + + it("refuses to run in a project without a database", async () => { + rmSync(join(root, "drizzle.config.ts")); + + await expect(runDbStatusCommand(context().context, {})).rejects.toThrow( + "does not own a database", + ); + }); +}); + +describe("vitnode db push", () => { + const changes = { + statements: [ + { table: { name: "notifications" }, type: "create_table" }, + { + index: { name: "notifications_user_id_idx", table: "notifications" }, + type: "create_index", + }, + ], + status: "ok", + }; + + it("shows the changes and pushes them in development", async () => { + const db = fakeDatabase({ + kit: { push: { status: "ok" }, "push --explain": changes }, + }); + const { context: ctx, runtime } = context(); + + expect( + await runDbPushCommand( + ctx, + { yes: true }, + { services: () => db.services }, + ), + ).toBe(0); + const output = runtime.output(); + + expect(output).toContain( + "! Direct schema synchronization is intended for development.", + ); + expect(output).toContain("+ table notifications"); + expect(output).toContain("+ index notifications.notifications_user_id_idx"); + expect(output).toContain("✓ Schema pushed."); + expect(db.kitCalls).toContainEqual(["push", "--output", "json"]); + }); + + it("refuses to push when NODE_ENV is production, before touching the database", async () => { + const db = fakeDatabase(); + const { context: ctx } = context({ env: { NODE_ENV: "production" } }); + + await expect( + runDbPushCommand(ctx, { yes: true }, { services: () => db.services }), + ).rejects.toThrow("NODE_ENV is production"); + expect(db.kitCalls).toEqual([]); + }); + + it("pushes in production only with --force", async () => { + const db = fakeDatabase({ + kit: { push: { status: "ok" }, "push --explain": changes }, + }); + const { context: ctx } = context({ env: { NODE_ENV: "production" } }); + + expect( + await runDbPushCommand( + ctx, + { force: true, yes: true }, + { services: () => db.services }, + ), + ).toBe(0); + }); + + it("names data loss and refuses it from a script without --accept-data-loss", async () => { + const db = fakeDatabase({ + kit: { + "push --explain": { + status: "missing_hints", + unresolved: [ + { + entity: ["public", "zz"], + kind: "table", + reason: "non_empty", + type: "confirm_data_loss", + }, + ], + }, + }, + }); + const original = db.services.drizzleKit; + db.services.drizzleKit = async (args, options) => + args.includes("--hints") + ? { + code: 0, + output: JSON.stringify({ + statements: [{ table: { name: "zz" }, type: "drop_table" }], + status: "ok", + }), + } + : original(args, options); + const { context: ctx, runtime } = context(); + + await expect( + runDbPushCommand(ctx, { yes: true }, { services: () => db.services }), + ).rejects.toThrow("These changes delete data."); + expect( + db.kitCalls.some( + args => args[0] === "push" && !args.includes("--explain"), + ), + ).toBe(false); + expect(runtime.output()).toContain("Data loss"); + expect(runtime.output()).toContain("table zz (non empty)"); + }); + + it("confirms data loss explicitly, by name, instead of with a blanket --force", async () => { + const hint = { + entity: ["public", "zz"], + kind: "table", + reason: "non_empty", + type: "confirm_data_loss", + }; + const db = fakeDatabase({ + kit: { + push: { status: "ok" }, + "push --explain": { status: "missing_hints", unresolved: [hint] }, + }, + }); + // The second explain - with the hint - returns the statements. + const original = db.services.drizzleKit; + db.services.drizzleKit = async (args, options) => + args.includes("--hints") && args.includes("--explain") + ? { + code: 0, + output: JSON.stringify({ + statements: [{ table: { name: "zz" }, type: "drop_table" }], + status: "ok", + }), + } + : original(args, options); + + const { context: ctx, runtime } = context(); + await runDbPushCommand( + ctx, + { acceptDataLoss: true, yes: true }, + { services: () => db.services }, + ); + + const pushed = db.kitCalls.find( + args => args[0] === "push" && !args.includes("--explain"), + ); + expect(pushed).toContain("--hints"); + expect(pushed).not.toContain("--force"); + expect(JSON.parse(pushed?.[pushed.indexOf("--hints") + 1] ?? "[]")).toEqual( + [{ entity: ["public", "zz"], kind: "table", type: "confirm_data_loss" }], + ); + expect(runtime.output()).toContain("- table zz"); + }); + + it("does nothing when the schema is already in sync", async () => { + const db = fakeDatabase({ + kit: { "push --explain": { statements: [], status: "ok" } }, + }); + const { context: ctx, runtime } = context(); + + await runDbPushCommand(ctx, {}, { services: () => db.services }); + + expect(runtime.output()).toContain("✓ Schema is already in sync."); + }); +}); diff --git a/packages/vitnode/scripts/cli/commands/db.ts b/packages/vitnode/scripts/cli/commands/db.ts new file mode 100644 index 000000000..b7cf901d9 --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/db.ts @@ -0,0 +1,469 @@ +import type { CommandContext, OutputOptions } from "../context"; +import type { DatabaseHandle, DatabaseServices } from "../db/database"; +import type { DrizzleStatement, MissingHint } from "../db/statements"; +import type { Ui } from "../ui/ui"; + +import { + createDatabaseServices, + explain, + PROVIDER_NAMES, +} from "../db/database"; +import { computeMigrationState, readJournal } from "../db/migration-state"; +import { + formatHintEntity, + isDataLossHint, + summarizeStatement, +} from "../db/statements"; +import { errorMessage, EXIT_CODE, RuntimeError, UserError } from "../errors"; +import { requireDatabaseProject } from "../project/project"; +import { withQuietOutput } from "../ui/capture-output"; +import { plural } from "../ui/format"; +import { requireConfirmation } from "../ui/prompts"; + +export interface DbOptions extends OutputOptions { + acceptDataLoss?: boolean; + force?: boolean; + name?: string; + yes?: boolean; +} + +type Services = (root: string) => DatabaseServices; + +const servicesFor = (context: CommandContext, create?: Services) => { + const project = requireDatabaseProject(context.cwd); + + return { + project, + services: (create ?? createDatabaseServices)(project.root), + }; +}; + +const renderStatements = (ui: Ui, statements: readonly DrizzleStatement[]) => { + const { colors } = ui; + const lines = statements.map(summarizeStatement); + const shown = ui.verbose ? lines : lines.slice(0, 25); + + shown.forEach(({ kind, name, sign }) => { + const paint = + sign === "+" + ? colors.success + : sign === "-" + ? colors.error + : colors.warning; + ui.line(` ${paint(sign)} ${kind} ${colors.bold(name)}`); + }); + if (shown.length < lines.length) { + ui.note( + `… and ${String(lines.length - shown.length)} more (--verbose lists all)`, + ); + } +}; + +const renderHints = (ui: Ui, hints: readonly MissingHint[]) => { + hints.forEach(hint => { + const entity = formatHintEntity(hint.entity); + ui.line( + isDataLossHint(hint) + ? ` ${ui.colors.error(ui.symbols.warning)} ${hint.kind ?? "entity"} ${ui.colors.bold(entity)} ${ui.colors.muted(`(${hint.reason?.replaceAll("_", " ") ?? "data loss"})`)}` + : ` ${ui.colors.warning("?")} ${hint.kind ?? "entity"} ${ui.colors.bold(entity)} ${ui.colors.muted("- renamed, or created new?")}`, + ); + }); +}; + +export const runDbGenerateCommand = async ( + context: CommandContext, + options: DbOptions, + { services: create }: { services?: Services } = {}, +): Promise => { + const { ui } = context; + ui.header("Database"); + + const { services } = servicesFor(context, create); + const config = await services.drizzleConfig(); + const nameArgs = options.name === undefined ? [] : ["--name", options.name]; + + await ui.runTask("Checking migration history", async () => + explain(services, ["up", "--output", "json"]), + ); + const planned = await ui.runTask( + "Comparing schema with the last migration", + async () => + explain(services, [ + "generate", + "--explain", + "--output", + "json", + ...nameArgs, + ]), + ); + + const statements = planned.status === "ok" ? (planned.statements ?? []) : []; + if ( + planned.status === "no_changes" || + (planned.status === "ok" && statements.length === 0) + ) { + ui.success("No schema changes - nothing to generate."); + ui.note( + "Changed a plugin's tables? Migrations are generated from built schemas - run vitnode build in the plugin first.", + ); + ui.line(); + + return EXIT_CODE.ok; + } + + const before = new Set( + services.listMigrationFolders(config.migrationsFolder), + ); + + if (planned.status === "missing_hints") { + ui.section("Schema changes need a decision"); + renderHints(ui, planned.unresolved); + + if (!ui.interactive) { + throw new UserError("drizzle-kit has to ask whether these are renames.", { + hint: "Run vitnode db generate in an interactive terminal and answer its questions.", + }); + } + ui.line(); + const { code } = await services.drizzleKit(["generate", ...nameArgs], { + capture: false, + }); + if (code !== 0) { + throw new RuntimeError( + `drizzle-kit generate exited with code ${String(code)}.`, + ); + } + } else { + ui.section("Schema changes detected"); + renderStatements(ui, statements); + ui.line(); + await ui.runTask("Generating migration", async () => + explain(services, ["generate", "--output", "json", ...nameArgs]), + ); + } + + const created = services + .listMigrationFolders(config.migrationsFolder) + .filter(folder => !before.has(folder)); + + ui.line(); + created.forEach(folder => { + ui.success(folder); + }); + ui.note(`Apply it with ${ui.colors.command("vitnode db migrate")}.`); + ui.line(); + + return EXIT_CODE.ok; +}; + +const connect = async ( + ui: Ui, + services: DatabaseServices, + config: Awaited>, +): Promise => + ui.runTask("Connecting to the database", async () => { + const handle = await services.open(config); + try { + await handle.ping(); + } catch (error) { + await handle.close().catch(() => undefined); + throw new RuntimeError("Could not connect to the database.", { + cause: error, + details: [errorMessage(error)], + hint: "Is it running? In development, pnpm docker:dev starts one.", + }); + } + + return handle; + }); + +export const runDbMigrateCommand = async ( + context: CommandContext, + options: DbOptions, + { services: create }: { services?: Services } = {}, +): Promise => { + const { prompter, ui } = context; + ui.header("Database migration"); + + const { services } = servicesFor(context, create); + const config = await services.drizzleConfig(); + const local = await services.readLocalMigrations(config.migrationsFolder); + const handle = await connect(ui, services, config); + + try { + const journal = await readJournal(handle.query, { + schema: config.migrationsSchema, + table: config.migrationsTable, + }); + const { pending } = computeMigrationState(local, journal); + + if (pending.length === 0) { + ui.success("Database is up to date."); + ui.line(); + + return EXIT_CODE.ok; + } + + ui.section("Pending migrations"); + pending.forEach(migration => { + ui.line(` ${ui.colors.muted(ui.symbols.pending)} ${migration.name}`); + }); + ui.line(); + + const confirmed = await requireConfirmation({ + message: `Apply ${plural(pending.length, "migration")}?`, + prompter, + ui, + yes: options.yes === true, + }); + if (!confirmed) { + ui.note("Cancelled - nothing was applied."); + ui.line(); + + return EXIT_CODE.ok; + } + + await ui.runTask("Applying migrations", async () => + withQuietOutput(ui.verbose, async () => + handle.apply(message => { + ui.note(message); + }), + ), + ); + pending.forEach(migration => { + ui.success(migration.name); + }); + ui.line(); + ui.success("Database is up to date."); + ui.line(); + + return EXIT_CODE.ok; + } finally { + await handle.close(); + } +}; + +export const runDbPushCommand = async ( + context: CommandContext, + options: DbOptions, + { services: create }: { services?: Services } = {}, +): Promise => { + const { env, prompter, ui } = context; + ui.header("Push database schema"); + + ui.warning("Direct schema synchronization is intended for development."); + ui.note( + `In production, use migrations: ${ui.colors.command("vitnode db generate")} and ${ui.colors.command("vitnode db migrate")}.`, + ); + ui.line(); + + if (env.NODE_ENV === "production" && options.force !== true) { + throw new UserError( + "Refusing to push the schema: NODE_ENV is production.", + { + hint: "Generate and apply a migration instead. If you really mean to push to this database, pass --force.", + }, + ); + } + + const { services } = servicesFor(context, create); + let planned = await ui.runTask( + "Comparing schema with the database", + async () => explain(services, ["push", "--explain", "--output", "json"]), + ); + + const unresolved = + planned.status === "missing_hints" ? planned.unresolved : []; + const renames = unresolved.filter(hint => !isDataLossHint(hint)); + const dataLoss = unresolved.filter(isDataLossHint); + + if (renames.length > 0) { + ui.section("Changes need a decision"); + renderHints(ui, unresolved); + + if (!ui.interactive) { + throw new UserError("drizzle-kit has to ask whether these are renames.", { + hint: "Run vitnode db push in an interactive terminal and answer its questions.", + }); + } + ui.line(); + const { code } = await services.drizzleKit(["push"], { capture: false }); + + return code === 0 ? EXIT_CODE.ok : EXIT_CODE.failure; + } + + // Data loss is confirmed here, once, by name - then handed to drizzle-kit as + // explicit hints, never as a blanket --force. + const hints = dataLoss.map(hint => ({ + entity: hint.entity, + kind: hint.kind, + type: "confirm_data_loss", + })); + const hintArgs = hints.length === 0 ? [] : ["--hints", JSON.stringify(hints)]; + + if (hints.length > 0) { + planned = await explain(services, [ + "push", + "--explain", + "--output", + "json", + ...hintArgs, + ]); + + if (planned.status === "missing_hints") { + throw new RuntimeError( + "drizzle-kit still needs decisions VitNode cannot make for you.", + { + details: planned.unresolved.map( + hint => + `${hint.type}: ${hint.kind ?? "entity"} ${formatHintEntity(hint.entity)}`, + ), + hint: "Run vitnode db push in an interactive terminal.", + }, + ); + } + } + + const statements = planned.status === "ok" ? (planned.statements ?? []) : []; + if (statements.length === 0) { + ui.success("Schema is already in sync."); + ui.line(); + + return EXIT_CODE.ok; + } + + ui.section("Changes"); + renderStatements(ui, statements); + + if (dataLoss.length > 0) { + ui.section(ui.colors.error("Data loss")); + renderHints(ui, dataLoss); + + if (!ui.interactive && options.acceptDataLoss !== true) { + ui.line(); + throw new UserError("These changes delete data.", { + hint: "Pass --accept-data-loss (with --yes) to apply them from a script.", + }); + } + } + ui.line(); + + const confirmed = + dataLoss.length > 0 && options.acceptDataLoss !== true && ui.interactive + ? await prompter.confirm( + `Apply ${plural(statements.length, "change")}, including ${plural(dataLoss.length, "that deletes", "that delete")} data?`, + { default: false }, + ) + : await requireConfirmation({ + message: `Apply ${plural(statements.length, "change")}?`, + prompter, + ui, + yes: options.yes === true, + }); + + if (!confirmed) { + ui.note("Cancelled - nothing was changed."); + ui.line(); + + return EXIT_CODE.ok; + } + + await ui.runTask("Pushing schema", async () => + explain(services, ["push", "--output", "json", ...hintArgs]), + ); + ui.success("Schema pushed."); + ui.line(); + + return EXIT_CODE.ok; +}; + +export const runDbStatusCommand = async ( + context: CommandContext, + _options: DbOptions, + { services: create }: { services?: Services } = {}, +): Promise => { + const { ui } = context; + ui.header("Database"); + + const { services } = servicesFor(context, create); + const config = await services.drizzleConfig(); + const local = await services.readLocalMigrations(config.migrationsFolder); + const provider = + config.dialect === null + ? "unknown" + : (PROVIDER_NAMES[config.dialect] ?? config.dialect); + + let handle: DatabaseHandle; + const connecting = ui.task("Connecting to the database"); + try { + handle = await services.open(config); + await handle.ping(); + connecting.skip(); + } catch (error) { + connecting.skip(); + ui.keyValue([ + ["Provider", provider], + ["Status", ui.colors.error(`${ui.symbols.pending} unreachable`)], + ["Migrations", `${String(local.length)} on disk`], + ]); + throw new RuntimeError("Could not connect to the database.", { + cause: error, + details: [errorMessage(error)], + hint: "Is it running? In development, pnpm docker:dev starts one.", + }); + } + + try { + const journal = await readJournal(handle.query, { + schema: config.migrationsSchema, + table: config.migrationsTable, + }); + const state = computeMigrationState(local, journal); + + ui.keyValue([ + ["Provider", provider], + ...(handle.location === null + ? [] + : [["Database", handle.location] as const]), + ["Status", ui.colors.success(`${ui.symbols.dot} connected`)], + ]); + + ui.section("Migrations"); + ui.keyValue([ + ["Applied", String(state.applied.length)], + [ + "Pending", + state.pending.length === 0 + ? "0" + : ui.colors.warning(String(state.pending.length)), + ], + ]); + + if (state.pending.length > 0) { + ui.section("Pending"); + state.pending.forEach(migration => { + ui.line(` ${ui.colors.muted(ui.symbols.pending)} ${migration.name}`); + }); + ui.line(); + ui.note(`Apply them with ${ui.colors.command("vitnode db migrate")}.`); + } + + if (state.modified.length > 0) { + ui.line(); + ui.warning( + `${plural(state.modified.length, "applied migration")} changed on disk since it was applied: ${state.modified.join(", ")}`, + ); + } + if (state.missingLocally.length > 0) { + ui.line(); + ui.warning( + `${plural(state.missingLocally.length, "applied migration")} missing from ${config.migrationsFolder}: ${state.missingLocally.join(", ")}`, + ); + } + ui.line(); + + return EXIT_CODE.ok; + } finally { + await handle.close(); + } +}; diff --git a/packages/vitnode/scripts/cli/commands/dev.test.ts b/packages/vitnode/scripts/cli/commands/dev.test.ts new file mode 100644 index 000000000..c3886f66b --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/dev.test.ts @@ -0,0 +1,391 @@ +// @vitest-environment node +import type { ViteDevServer } from "vite"; + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { DatabaseServices } from "../db/database"; +import type { RunProcessOptions } from "../project/processes"; + +import { formatRequest, shouldLogRequest } from "../dev/request-log"; +import { createDevReporter } from "../dev/vite-dev"; +import { RuntimeError } from "../errors"; +import { createTestContext } from "../testing"; +import { createUi } from "../ui/ui"; +import { runDevCommand } from "./dev"; + +const packageRoot = join(import.meta.dirname, "..", "..", ".."); +let root: string; + +beforeEach(() => { + // Inside the package, so compiler binaries resolve from its node_modules. + const cache = join(packageRoot, "node_modules", ".cache"); + mkdirSync(cache, { recursive: true }); + root = mkdtempSync(join(cache, "vitnode-dev-")); +}); + +afterEach(() => { + rmSync(root, { force: true, recursive: true }); +}); + +const write = (path: string, content = "") => { + mkdirSync(join(root, path, ".."), { recursive: true }); + writeFileSync(join(root, path), content); +}; + +/** A process group that records what it was asked to run. */ +const fakeGroup = (exit?: Promise) => { + const spawned: RunProcessOptions[] = []; + + return { + firstExit: vi.fn(async () => exit ?? new Promise(() => undefined)), + spawn: vi.fn((options: RunProcessOptions) => { + spawned.push(options); + + return {} as never; + }), + spawned, + stop: vi.fn(async () => await Promise.resolve(undefined)), + }; +}; + +const fakeServer = ({ listenError }: { listenError?: Error } = {}) => { + const server = { + bindCLIShortcuts: vi.fn(), + close: vi.fn(async () => await Promise.resolve(undefined)), + config: { server: { port: 3000 } }, + listen: vi.fn(async () => { + await Promise.resolve(); + if (listenError) throw listenError; + + return server; + }), + resolvedUrls: { + local: ["http://localhost:3000/"], + network: [] as string[], + }, + }; + + return server; +}; + +const tick = async () => new Promise(resolve => setTimeout(resolve, 10)); + +/** Resolves once `check` passes, failing the test if it never does. */ +const until = async (check: () => boolean) => { + for (let i = 0; i < 200; i += 1) { + if (check()) return; + await tick(); + } + throw new Error("condition never became true"); +}; + +describe("vitnode dev in a plugin package", () => { + beforeEach(() => { + write("package.json", JSON.stringify({ name: "@acme/blog" })); + write("tsconfig.build.json", "{}"); + write(".swcrc", "{}"); + }); + + it("runs the three compilers in watch mode and stops them on Ctrl+C", async () => { + const group = fakeGroup(); + const { context, runtime } = createTestContext({ cwd: root }); + + const running = runDevCommand(context, {}, { group }); + await until(() => group.spawn.mock.calls.length === 3); + runtime.signals.emit("SIGINT"); + + expect(await running).toBe(0); + expect(group.stop).toHaveBeenCalledOnce(); + expect( + group.spawned.map(options => options.args.slice(1).join(" ")), + ).toEqual([ + "-w -p tsconfig.build.json --preserveWatchOutput", + "src -d dist --config-file .swcrc --copy-files -w", + "-w -p tsconfig.build.json", + ]); + // Run by Node itself, never through a shell. + expect( + group.spawned.every(options => options.command === process.execPath), + ).toBe(true); + }); + + it("stops every watcher and fails when one of them crashes", async () => { + const group = fakeGroup(Promise.resolve(2)); + const { context } = createTestContext({ cwd: root }); + + await expect(runDevCommand(context, {}, { group })).rejects.toThrow( + "A watcher exited with code 2.", + ); + expect(group.stop).toHaveBeenCalledOnce(); + }); + + it("also stops on SIGTERM, as a container or process manager sends it", async () => { + const group = fakeGroup(); + const { context, runtime } = createTestContext({ cwd: root }); + + const running = runDevCommand(context, {}, { group }); + await until(() => group.spawn.mock.calls.length === 3); + runtime.signals.emit("SIGTERM"); + + expect(await running).toBe(0); + }); +}); + +describe("vitnode dev in an app", () => { + beforeEach(() => { + write("package.json", JSON.stringify({ name: "web" })); + write("vite.config.ts", "export default {};"); + }); + + const start = async ({ + database, + interactive = false, + server = fakeServer(), + }: { + database?: DatabaseServices; + interactive?: boolean; + server?: ReturnType; + } = {}) => { + await Promise.resolve(); + const { context, runtime } = createTestContext({ cwd: root, interactive }); + const createServer = vi.fn( + async () => await Promise.resolve(server as unknown as ViteDevServer), + ); + const running = runDevCommand( + context, + {}, + { + database: database === undefined ? undefined : () => database, + loadIds: async () => + await Promise.resolve(["@acme/blog", "@acme/forum"]), + loadVite: async () => await Promise.resolve({ createServer }), + }, + ); + + return { createServer, running, runtime, server }; + }; + + it("starts Vite, prints the real URLs, and closes the server on Ctrl+C", async () => { + const { createServer, running, runtime, server } = await start(); + await until(() => runtime.output().includes("AdminCP")); + + const output = runtime.output(); + expect(output).toContain("✓ Loading configuration"); + expect(output).toContain("✓ 2 plugins loaded"); + expect(output).toMatch(/Web\s+http:\/\/localhost:3000\n/); + expect(output).toMatch(/AdminCP\s+http:\/\/localhost:3000\/admin/); + expect(output).not.toContain("API"); + expect(output).not.toContain("Database"); + expect(createServer).toHaveBeenCalledWith( + expect.objectContaining({ mode: "development", root }), + ); + + runtime.signals.emit("SIGINT"); + expect(await running).toBe(0); + expect(server.close).toHaveBeenCalledOnce(); + }); + + it("only binds keyboard shortcuts in an interactive terminal", async () => { + const piped = await start(); + await until(() => piped.runtime.output().includes("AdminCP")); + piped.runtime.signals.emit("SIGINT"); + await piped.running; + + const tty = await start({ interactive: true }); + await until(() => tty.runtime.output().includes("AdminCP")); + tty.runtime.signals.emit("SIGINT"); + await tty.running; + + expect(piped.server.bindCLIShortcuts).not.toHaveBeenCalled(); + expect(tty.server.bindCLIShortcuts).toHaveBeenCalledWith( + expect.objectContaining({ + customShortcuts: [ + expect.objectContaining({ description: "open AdminCP", key: "a" }), + ], + }), + ); + }); + + it("fails clearly - and closes what it opened - when the server cannot start", async () => { + const server = fakeServer({ + listenError: new Error("Port 3000 is already in use"), + }); + const { running } = await start({ server }); + + const error = (await running.catch( + (thrown: unknown) => thrown, + )) as RuntimeError; + expect(error).toBeInstanceOf(RuntimeError); + expect(error.message).toBe("Could not start the dev server."); + expect(error.details).toEqual(["Port 3000 is already in use"]); + expect(server.close).toHaveBeenCalledOnce(); + }); + + it("prepares the database it owns before the server starts", async () => { + write("drizzle.config.ts", "export default {};"); + write("src/vitnode.api.config.ts", "export const vitNodeApiConfig = {};"); + const order: string[] = []; + const database: DatabaseServices = { + drizzleConfig: async () => + await Promise.resolve({ + dialect: "postgresql", + migrationsFolder: join(root, "migrations"), + migrationsSchema: "drizzle", + migrationsTable: "__drizzle_migrations", + }), + drizzleKit: async () => + await Promise.resolve({ code: 0, output: '{"status":"no_changes"}' }), + listMigrationFolders: () => [], + open: async () => + await Promise.resolve({ + apply: async () => { + await Promise.resolve(); + order.push("migrate"); + }, + close: async () => await Promise.resolve(undefined), + location: null, + ping: async () => await Promise.resolve(undefined), + query: async () => await Promise.resolve([]), + }), + readLocalMigrations: async () => await Promise.resolve([]), + }; + const server = fakeServer(); + server.listen.mockImplementation(async () => { + await Promise.resolve(); + order.push("listen"); + + return server; + }); + + const { running, runtime } = await start({ database, server }); + await until(() => runtime.output().includes("AdminCP")); + runtime.signals.emit("SIGINT"); + await running; + + expect(order).toEqual(["migrate", "listen"]); + expect(runtime.output()).toContain("✓ Database connected up to date"); + expect(runtime.output()).toMatch(/API\s+http:\/\/localhost:3000\/api/); + }); +}); + +describe("the dev request log", () => { + it.each([ + [{ accept: "text/html", method: "GET", url: "/" }, true], + [{ accept: "application/json", method: "GET", url: "/api/session" }, true], + [{ method: "POST", url: "/_serverFn/abc" }, true], + [{ method: "POST", url: "/login" }, true], + [{ method: "GET", url: "/@vite/client" }, false], + [{ method: "GET", url: "/src/main.tsx?t=123" }, false], + [{ method: "GET", url: "/node_modules/.vite/deps/react.js?v=1" }, false], + [{ accept: "*/*", method: "GET", url: "/assets/logo.svg" }, false], + [{ method: "GET", url: "/__tsr/routes" }, false], + ])("logs %j: %s", (request, expected) => { + expect(shouldLogRequest(request)).toBe(expected); + }); + + const plainUi = () => { + const lines: string[] = []; + const ui = createUi({ + capabilities: { + ci: false, + color: false, + columns: 80, + interactive: false, + unicode: true, + }, + stderr: { write: () => true }, + stdout: { + write: chunk => { + lines.push(chunk); + + return true; + }, + }, + }); + + return { lines, ui }; + }; + + it("aligns method, path, status and time", () => { + const { ui } = plainUi(); + + expect( + formatRequest(ui, { + durationMs: 18, + method: "GET", + status: 200, + url: "/", + }), + ).toBe(`GET /${" ".repeat(47)} 200 18ms`); + }); + + it("observes requests without answering them", async () => { + await Promise.resolve(); + const { lines, ui } = plainUi(); + const plugin = createDevReporter(ui, { defaultPort: 3000, root: "/app" }); + let middleware: + ((req: unknown, res: unknown, next: () => void) => void) | undefined; + (plugin.configureServer as (server: unknown) => void)({ + middlewares: { use: (fn: typeof middleware) => (middleware = fn) }, + }); + + const listeners: Record void> = {}; + const next = vi.fn(); + middleware?.( + { headers: { accept: "text/html" }, method: "GET", url: "/blog" }, + { + once: (event: string, fn: () => void) => (listeners[event] = fn), + statusCode: 200, + }, + next, + ); + + expect(next).toHaveBeenCalledOnce(); + listeners.finish(); + expect(lines.join("")).toMatch(/^GET {3}\/blog\s+200/); + }); + + it("defaults the port to VitNode's 3000 unless the app's config sets one", () => { + const { ui } = plainUi(); + const config = createDevReporter(ui, { defaultPort: 3000, root: "/app" }) + .config as (user: object) => unknown; + + expect(config({})).toEqual({ server: { port: 3000 } }); + expect(config({ server: { port: 4000 } })).toBeUndefined(); + }); + + it("reports a hot update of the app's own file, not of generated ones", () => { + const { lines, ui } = plainUi(); + const hotUpdate = createDevReporter(ui, { defaultPort: null, root: "/app" }) + .hotUpdate as ( + this: unknown, + options: { file: string; modules: unknown[] }, + ) => void; + const client = { environment: { name: "client" } }; + + hotUpdate.call(client, { + file: "/app/src/components/post.tsx", + modules: [{}], + }); + hotUpdate.call(client, { + file: "/app/src/routeTree.gen.ts", + modules: [{}], + }); + hotUpdate.call( + { environment: { name: "ssr" } }, + { file: "/app/src/x.tsx", modules: [{}] }, + ); + + expect(lines.join("")).toBe("HMR src/components/post.tsx\n"); + }); + + it("is only active while serving", () => { + const { ui } = plainUi(); + + expect(createDevReporter(ui, { defaultPort: null, root: "/" }).apply).toBe( + "serve", + ); + }); +}); diff --git a/packages/vitnode/scripts/cli/commands/dev.ts b/packages/vitnode/scripts/cli/commands/dev.ts new file mode 100644 index 000000000..b507550f8 --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/dev.ts @@ -0,0 +1,124 @@ +import type { CommandContext, OutputOptions } from "../context"; +import type { DatabaseServices } from "../db/database"; +import type { ViteDevApi } from "../dev/vite-dev"; +import type { LoadConfiguredPluginIds } from "../plugins/discover"; +import type { ProcessGroup } from "../project/processes"; +import type { Project } from "../project/project"; + +import { writePluginApiRegistry } from "../../write-plugin-api-registry"; +import { createDatabaseServices } from "../db/database"; +import { prepareDevelopmentDatabase } from "../db/prepare"; +import { openUrl } from "../dev/open-url"; +import { runViteDev } from "../dev/vite-dev"; +import { + apiWatchers, + packageWatchers, + runWatchers, + toProcess, +} from "../dev/watchers"; +import { loadConfiguredPluginIds } from "../plugins/discover"; +import { detectProject } from "../project/project"; +import { plural } from "../ui/format"; +import { parsePort } from "./start"; + +export interface DevOptions extends OutputOptions { + host?: boolean | string; + port?: string; +} + +export interface DevDeps { + database?: (root: string) => DatabaseServices; + group?: Pick; + loadIds?: LoadConfiguredPluginIds; + loadVite?: () => Promise; + openUrl?: (url: string) => void; +} + +/** Whether `vitnode dev` should prepare this project's database first. */ +const ownsDatabase = (project: Project) => + project.drizzleConfig !== null && project.hasApi; + +/** + * `vitnode dev` - the development environment for whatever this folder is. + * + * - An app: database bootstrap (when it owns the schema), then Vite. + * - An API: database bootstrap, then `tsx watch`. + * - A plugin package: its compilers in watch mode, so the apps importing its + * `dist` reload as it changes - what `vitnode dev` has always done there. + */ +export const runDevCommand = async ( + context: CommandContext, + options: DevOptions, + deps: DevDeps = {}, +): Promise => { + const { env, platform, signals, ui } = context; + const project = detectProject(context.cwd); + + if (project.kind === "package") { + ui.header(`Plugin development - ${project.name}`); + if (writePluginApiRegistry(project.root)) + ui.success("API registry generated"); + ui.info( + "Watching sources - apps using this plugin reload as dist/ changes", + ); + ui.line(); + + return runWatchers({ + group: deps.group, + processes: packageWatchers().map(watcher => toProcess(project, watcher)), + signals, + ui, + }); + } + + ui.header("Development"); + + const database = (deps.database ?? createDatabaseServices)(project.root); + // The same loader the app's Vite plugin uses, so a broken plugin list fails + // here with a message instead of halfway through the server start. + const plugins = + project.kind === "app" + ? await ui.runTask("Loading configuration", async () => + (deps.loadIds ?? loadConfiguredPluginIds)(project.root), + ) + : null; + + if (ownsDatabase(project)) await prepareDevelopmentDatabase(ui, database); + if (plugins !== null) + ui.success(`${plural(plugins.length, "plugin")} loaded`); + + if (project.kind === "api") { + const port = parsePort(env.PORT, 8000); + ui.line(); + ui.keyValue([ + ["API", ui.colors.command(`http://localhost:${String(port)}/api`)], + ]); + ui.rule(); + + return runWatchers({ + group: deps.group, + processes: apiWatchers({ bun: process.versions.bun !== undefined }).map( + watcher => toProcess(project, watcher), + ), + signals, + ui, + }); + } + + return runViteDev({ + host: options.host, + loadVite: deps.loadVite, + openUrl: + deps.openUrl ?? + (url => { + openUrl(url, platform); + }), + port: + options.port === undefined && env.PORT === undefined + ? undefined + : parsePort(options.port ?? env.PORT, 3000), + project, + signals, + ui, + }); +}; diff --git a/packages/vitnode/scripts/cli/commands/legacy.ts b/packages/vitnode/scripts/cli/commands/legacy.ts new file mode 100644 index 000000000..c0764b684 --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/legacy.ts @@ -0,0 +1,96 @@ +import type { CommandContext, OutputOptions } from "../context"; + +import { + databaseBootstrap, + generateDatabaseMigrations, +} from "../../prepare-database"; +import { EXIT_CODE } from "../errors"; + +/** + * `vitnode db:prepare` - generate, apply, seed: the bootstrap a `dev` script + * gates on. Unchanged to the step; only its output goes through the CLI's UI + * and its failure through the CLI's error boundary. + */ +export const runDbPrepareCommand = async ( + { ui }: CommandContext, + _options: OutputOptions, +): Promise => { + ui.header("Database"); + await databaseBootstrap({ + log: message => { + ui.note(message); + }, + }); + ui.success("Database ready."); + + return EXIT_CODE.ok; +}; + +/** + * `vitnode migrate` - the same bootstrap under its older name, kept because + * deployment guides and generated projects spell it. `--generate` only writes + * the migration. + */ +export const runMigrateCommand = async ( + { ui }: CommandContext, + { generate }: OutputOptions & { generate?: boolean }, +): Promise => { + ui.header("Database"); + + if (generate === true) { + await generateDatabaseMigrations(); + ui.success("Database migrations generated."); + + return EXIT_CODE.ok; + } + + await databaseBootstrap({ + log: message => { + ui.note(message); + }, + }); + ui.success("Database migrated."); + + return EXIT_CODE.ok; +}; + +// The i18n commands read their own arguments and exit on their own; the +// parser in front of them only refuses what they would not understand. + +export const runI18nCheckCommand = async ( + _context: CommandContext, + { ci }: OutputOptions & { ci?: boolean }, +): Promise => { + const { i18nCheck } = await import("../../i18n-check"); + await i18nCheck(ci === true ? "--ci" : undefined); + + return EXIT_CODE.ok; +}; + +export const runI18nCreateCommand = async (): Promise => { + const { i18nCreate } = await import("../../i18n-create"); + await i18nCreate(); + + return EXIT_CODE.ok; +}; + +export const runI18nDeleteCommand = async (): Promise => { + const { i18nDelete } = await import("../../i18n-delete"); + await i18nDelete(); + + return EXIT_CODE.ok; +}; + +export const runI18nUpdateCommand = async (): Promise => { + const { i18nUpdate } = await import("../../i18n-update"); + await i18nUpdate(); + + return EXIT_CODE.ok; +}; + +export const runI18nUpdateAiCommand = async (): Promise => { + const { i18nUpdateAi } = await import("../../i18n-update-ai"); + await i18nUpdateAi(); + + return EXIT_CODE.ok; +}; diff --git a/packages/vitnode/scripts/cli/commands/plugin-create.ts b/packages/vitnode/scripts/cli/commands/plugin-create.ts new file mode 100644 index 000000000..9234e819f --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/plugin-create.ts @@ -0,0 +1,147 @@ +import { relative } from "node:path"; + +import type { CommandContext, OutputOptions } from "../context"; +import type { TemplateGroup } from "../plugins/template"; + +import { EXIT_CODE, UserError } from "../errors"; +import { + planPlugin, + pluginDependencyVersions, + resolvePluginWorkspace, + writeTemplateFiles, +} from "../plugins/create"; +import { isPluginPackage } from "../plugins/discover"; +import { + defaultPackageName, + pluginApiVariableName, + pluginVariableName, + validatePackageName, + validatePluginName, +} from "../plugins/naming"; +import { pluginTemplate } from "../plugins/template"; +import { toDisplayPath } from "../ui/format"; +import { readCoreManifest } from "../version"; + +export interface PluginCreateOptions extends OutputOptions { + description?: string; + name?: string; + packageName?: string; + yes?: boolean; +} + +const STEPS: { group: TemplateGroup; label: string }[] = [ + { group: "package", label: "Package created" }, + { group: "definition", label: "Plugin definition" }, + { group: "structure", label: "Required structure" }, + { group: "translations", label: "Translations" }, + { group: "tests", label: "Tests" }, + { group: "documentation", label: "Documentation" }, +]; + +const asPrompt = + (validate: (value: string) => null | string) => (value: string) => + validate(value.trim()) ?? true; + +export const runPluginCreateCommand = async ( + { cwd, prompter, ui }: CommandContext, + options: PluginCreateOptions, +): Promise => { + ui.header("Create plugin"); + + const workspace = resolvePluginWorkspace(cwd); + const ask = ui.interactive && options.yes !== true; + + const name = + options.name ?? + (ui.interactive + ? ( + await prompter.text("Plugin name", { + validate: asPrompt(validatePluginName), + }) + ).trim() + : undefined); + + if (name === undefined) { + throw new UserError("A plugin name is required.", { + hint: "Pass it as an argument: vitnode plugin create ", + }); + } + + // Fail on a bad name before asking anything else about it. + const nameProblem = validatePluginName(name); + if (nameProblem !== null) throw new UserError(nameProblem); + if (options.name !== undefined && ui.interactive) + ui.success(`Plugin name ${name}`); + + const existingIds = [...workspace.packages.entries()] + .filter(([, dir]) => isPluginPackage(dir)) + .map(([id]) => id); + const suggestedPackage = defaultPackageName(name, existingIds); + + const packageName = + options.packageName ?? + (ask + ? ( + await prompter.text("Package name", { + default: suggestedPackage, + validate: asPrompt(validatePackageName), + }) + ).trim() + : suggestedPackage); + + const description = + options.description ?? + (ask + ? ( + await prompter.text("Description", { + default: `A VitNode plugin.`, + }) + ).trim() + : "A VitNode plugin."); + + const plan = planPlugin({ description, name, packageName, workspace }); + const files = pluginTemplate({ + description, + name, + packageName, + versions: pluginDependencyVersions(readCoreManifest(), workspace), + }); + + ui.line(); + ui.line( + ui.mode === "plain" + ? "Creating plugin..." + : ` ${ui.colors.muted("Creating plugin...")}`, + ); + for (const step of STEPS) { + writeTemplateFiles( + plan.targetDir, + files.filter(file => file.group === step.group), + ); + ui.success(step.label); + } + + const shown = toDisplayPath(relative(cwd, plan.targetDir)) || "."; + ui.section("Created"); + ui.line(` ${ui.colors.command(shown)}`); + + ui.section("Next steps"); + const steps = [ + ...(workspace.pluginsDirIsLinked + ? [] + : [ + `Add "${toDisplayPath(relative(workspace.root, workspace.pluginsDir))}/*" to your workspace packages`, + ]), + `Add "${packageName}": "workspace:*" to your app's dependencies, then install`, + `Register ${pluginVariableName(name)}() from "${packageName}/config" in vitnode.config.ts`, + `Register ${pluginApiVariableName(name)}() from "${packageName}/config.api" in vitnode.api.config.ts`, + `Build it: ${ui.colors.command(`cd ${shown} && vitnode build`)}`, + `Check it: ${ui.colors.command(`vitnode plugin validate ${name}`)}`, + ]; + steps.forEach((step, index) => { + ui.line(` ${ui.colors.muted(`${String(index + 1)}.`)} ${step}`); + }); + ui.line(); + + return EXIT_CODE.ok; +}; diff --git a/packages/vitnode/scripts/cli/commands/plugin-list.ts b/packages/vitnode/scripts/cli/commands/plugin-list.ts new file mode 100644 index 000000000..f370120be --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/plugin-list.ts @@ -0,0 +1,69 @@ +import { relative } from "node:path"; + +import type { CommandContext, OutputOptions } from "../context"; +import type { LoadConfiguredPluginIds } from "../plugins/discover"; + +import { EXIT_CODE } from "../errors"; +import { discoverPlugins } from "../plugins/discover"; +import { plural, toDisplayPath } from "../ui/format"; + +export const runPluginListCommand = async ( + { cwd, ui }: CommandContext, + _options: OutputOptions, + { loadIds }: { loadIds?: LoadConfiguredPluginIds } = {}, +): Promise => { + ui.header("Plugins"); + + const { appRoot, plugins } = await discoverPlugins(cwd, { loadIds }); + + if (plugins.length === 0) { + ui.note( + appRoot === null + ? "No VitNode app or workspace plugins found here." + : "No plugins configured yet.", + ); + ui.note( + `Create one with ${ui.colors.command("vitnode plugin create ")}.`, + ); + ui.line(); + + return EXIT_CODE.ok; + } + + ui.table({ + columns: [ + { header: "Plugin" }, + { header: "Version" }, + { header: "Source" }, + ...(ui.verbose ? [{ header: "Path" }, { header: "Description" }] : []), + ], + rows: plugins.map(plugin => [ + plugin.configured + ? plugin.id + : `${plugin.id} ${ui.colors.muted("(not configured)")}`, + plugin.version ?? ui.colors.warning("not installed"), + ui.colors.muted(plugin.source), + ...(ui.verbose + ? [ + ui.colors.muted( + plugin.root === null + ? "-" + : toDisplayPath(relative(cwd, plugin.root)) || ".", + ), + plugin.description ?? "", + ] + : []), + ]), + }); + + const configured = plugins.filter(plugin => plugin.configured).length; + ui.line(); + ui.note( + appRoot === null + ? `${plural(plugins.length, "plugin")} in this workspace` + : `${plural(configured, "plugin")} configured in ${toDisplayPath(relative(cwd, appRoot)) || "this app"}`, + ); + ui.line(); + + return EXIT_CODE.ok; +}; diff --git a/packages/vitnode/scripts/cli/commands/plugin-validate.ts b/packages/vitnode/scripts/cli/commands/plugin-validate.ts new file mode 100644 index 000000000..ac2b1784d --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/plugin-validate.ts @@ -0,0 +1,149 @@ +import { basename, relative } from "node:path"; + +import type { CommandContext, OutputOptions } from "../context"; +import type { + DiscoveredPlugin, + LoadConfiguredPluginIds, +} from "../plugins/discover"; +import type { ModuleImporter, PluginCheck } from "../plugins/validate"; +import type { Ui } from "../ui/ui"; + +import { EXIT_CODE, UserError, ValidationError } from "../errors"; +import { discoverPlugins, isPluginPackage } from "../plugins/discover"; +import { shortNameOf } from "../plugins/naming"; +import { validatePlugin } from "../plugins/validate"; +import { findPackageRoot, readPackageJson } from "../project/packages"; +import { plural, toDisplayPath } from "../ui/format"; + +export interface PluginValidateOptions extends OutputOptions { + name?: string; +} + +/** `blog`, `@vitnode/blog` and `plugins/blog` all name the same plugin. */ +export const matchesPlugin = (plugin: DiscoveredPlugin, query: string) => + plugin.id === query || + shortNameOf(plugin.id) === query || + (plugin.root !== null && basename(plugin.root) === query); + +const renderCheck = (ui: Ui, check: PluginCheck) => { + const summary = check.summary ?? ""; + + switch (check.status) { + case "error": + if (ui.mode === "plain") ui.line(`[FAIL] ${check.name}`); + else ui.line(` ${ui.colors.error(ui.symbols.error)} ${check.name}`); + break; + case "ok": + ui.success(check.name, summary === "" ? undefined : summary); + break; + case "skipped": + ui.note(`${check.name} (skipped)`); + break; + case "warning": + ui.warning(check.name); + break; + } + + check.details.forEach(detail => { + ui.line(ui.mode === "plain" ? ` ${detail}` : ` ${detail}`); + }); +}; + +export const runPluginValidateCommand = async ( + { cwd, ui }: CommandContext, + { name }: PluginValidateOptions, + deps: { + importModule?: ModuleImporter; + loadIds?: LoadConfiguredPluginIds; + } = {}, +): Promise => { + ui.header("Validate plugins"); + + const packageRoot = findPackageRoot(cwd); + const insidePlugin = + name === undefined && packageRoot !== null && isPluginPackage(packageRoot); + + let targets: { id: string; root: null | string }[]; + + if (insidePlugin) { + targets = [ + { + id: readPackageJson(packageRoot)?.name ?? packageRoot, + root: packageRoot, + }, + ]; + } else { + const { plugins } = await discoverPlugins(cwd, { loadIds: deps.loadIds }); + const selected = + name === undefined + ? plugins + : plugins.filter(plugin => matchesPlugin(plugin, name)); + + if (name !== undefined && selected.length === 0) { + throw new UserError(`No plugin named "${name}" was found.`, { + hint: + plugins.length === 0 + ? "Create one with vitnode plugin create ." + : `Known plugins: ${plugins.map(plugin => plugin.id).join(", ")}`, + }); + } + + targets = selected.map(plugin => ({ id: plugin.id, root: plugin.root })); + } + + if (targets.length === 0) { + ui.note("No plugins to validate."); + ui.line(); + + return EXIT_CODE.ok; + } + + const invalid: string[] = []; + + for (const [index, target] of targets.entries()) { + // The header already ends with a blank line. + if (index > 0) ui.line(); + const where = + target.root === null + ? "" + : toDisplayPath(relative(cwd, target.root)) || "."; + ui.line( + ui.mode === "plain" + ? `${target.id}${where ? ` (${where})` : ""}` + : ` ${ui.colors.bold(target.id)} ${ui.colors.muted(where)}`, + ); + + if (target.root === null) { + renderCheck(ui, { + details: [ + `${target.id} is configured but not installed - run your package manager's install.`, + ], + name: "Package", + status: "error", + }); + invalid.push(target.id); + continue; + } + + const result = await validatePlugin(target.root, { + importModule: deps.importModule, + }); + result.checks.forEach(check => { + renderCheck(ui, check); + }); + if (!result.valid) invalid.push(target.id); + } + + ui.line(); + + if (invalid.length > 0) { + throw new ValidationError( + `${plural(invalid.length, "plugin")} failed validation: ${invalid.join(", ")}`, + ); + } + + ui.success(`${plural(targets.length, "plugin")} valid`); + ui.line(); + + return EXIT_CODE.ok; +}; diff --git a/packages/vitnode/scripts/cli/commands/plugin.test.ts b/packages/vitnode/scripts/cli/commands/plugin.test.ts new file mode 100644 index 000000000..0978726e3 --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/plugin.test.ts @@ -0,0 +1,574 @@ +// @vitest-environment node +import { transformFile } from "@swc/core"; +import { createJiti } from "jiti"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { UserError, ValidationError } from "../errors"; +import { validatePlugin } from "../plugins/validate"; +import { createScriptedPrompter, createTestContext } from "../testing"; +import { runPluginCreateCommand } from "./plugin-create"; +import { runPluginListCommand } from "./plugin-list"; +import { runPluginValidateCommand } from "./plugin-validate"; + +const packageRoot = join(import.meta.dirname, "..", "..", ".."); +const coreSrc = join(packageRoot, "src"); + +let root: string; + +const write = (path: string, content: unknown) => { + const file = join(root, path); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync( + file, + typeof content === "string" ? content : JSON.stringify(content, null, 2), + ); +}; + +/** What `pnpm install` does for a workspace dependency: a link in the app. */ +const link = (name: string) => { + const target = join(root, "apps", "web", "node_modules", "@acme", name); + mkdirSync(dirname(target), { recursive: true }); + if (!existsSync(target)) + symlinkSync(join(root, "plugins", name), target, "junction"); +}; + +/** A workspace with an app and the given plugin packages. */ +const workspace = (plugins: string[] = []) => { + write("pnpm-workspace.yaml", "packages:\n - apps/*\n - plugins/*\n"); + write("package.json", { name: "acme", private: true }); + write("apps/web/package.json", { name: "web" }); + write( + "apps/web/src/vitnode.config.ts", + "export const vitNodeConfig = { plugins: [] };\n", + ); + for (const name of plugins) { + write(`plugins/${name}/package.json`, { + dependencies: { "@vitnode/core": "workspace:*" }, + exports: { "./*": "./dist/src/*.js" }, + name: `@acme/${name}`, + type: "module", + version: "1.4.0", + }); + write(`plugins/${name}/src/config.tsx`, ""); + link(name); + } +}; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "vitnode-plugin-")); +}); + +afterEach(() => { + rmSync(root, { force: true, recursive: true }); +}); + +const create = async ( + options: Parameters[1], + runtime: Parameters[0] = {}, +) => { + const { context, runtime: fake } = createTestContext({ + cwd: root, + ...runtime, + }); + + return { + code: await runPluginCreateCommand(context, options), + output: fake.output(), + }; +}; + +describe("vitnode plugin create", () => { + it("creates the canonical plugin in the workspace's plugins folder", async () => { + workspace(["forum"]); + + const { code, output } = await create({ name: "blog" }); + + expect(code).toBe(0); + for (const step of [ + "Package created", + "Plugin definition", + "Required structure", + "Translations", + "Tests", + "Documentation", + ]) { + expect(output).toContain(`✓ ${step}`); + } + expect(output).toContain("plugins/blog"); + + const manifest = JSON.parse( + readFileSync(join(root, "plugins/blog/package.json"), "utf8"), + ) as { + description: string; + name: string; + scripts: Record; + }; + // Joins the scope the workspace's plugins already share. + expect(manifest.name).toBe("@acme/blog"); + expect(manifest.description).toBe("A VitNode plugin."); + expect(manifest.scripts["build:plugins"]).toBe("vitnode build"); + expect( + readFileSync(join(root, "plugins/blog/src/routes.ts"), "utf8"), + ).toContain('page("/blog"'); + expect( + readFileSync(join(root, "plugins/blog/src/config.tsx"), "utf8"), + ).toContain("export const blogPlugin"); + expect( + readFileSync(join(root, "plugins/blog/src/config.api.ts"), "utf8"), + ).toContain("export const blogApiPlugin"); + }); + + it("generates exactly the canonical structure, with no feature options", async () => { + workspace(); + await create({ name: "blog", packageName: "@acme/blog" }); + + const files: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) walk(path); + else + files.push( + relative(join(root, "plugins/blog"), path).replaceAll("\\", "/"), + ); + } + }; + walk(join(root, "plugins/blog")); + + expect(files.sort()).toMatchInlineSnapshot(` + [ + ".npmignore", + ".swcrc", + "README.md", + "eslint.config.mjs", + "global.d.ts", + "package.json", + "src/api/modules/hello/hello.module.test.ts", + "src/api/modules/hello/hello.module.ts", + "src/api/modules/hello/hello.route.ts", + "src/config.api.ts", + "src/config.tsx", + "src/const.ts", + "src/locales/en.json", + "src/locales/index.ts", + "src/pages/home-page.tsx", + "src/routes.ts", + "tsconfig.build.json", + "tsconfig.json", + "vitest.config.ts", + ] + `); + }); + + it("uses the given package name and description", async () => { + workspace(); + await create({ + description: "Blogging for VitNode", + name: "blog", + packageName: "@acme/blog", + }); + + expect( + readFileSync(join(root, "plugins/blog/package.json"), "utf8"), + ).toContain('"description": "Blogging for VitNode"'); + expect( + readFileSync(join(root, "plugins/blog/src/const.ts"), "utf8"), + ).toContain('pluginId: "@acme/blog"'); + }); + + it("asks for the name - and only metadata it cannot derive - when interactive", async () => { + workspace(); + const prompter = createScriptedPrompter({ + text: ["blog", "", "Blogging for VitNode"], + }); + + await create({}, { interactive: true, prompter }); + + expect(prompter.asked).toEqual([ + "Plugin name", + "Package name", + "Description", + ]); + expect( + readFileSync(join(root, "plugins/blog/package.json"), "utf8"), + ).toContain('"name": "vitnode-plugin-blog"'); + }); + + it("requires the name as an argument when nobody can be asked", async () => { + workspace(); + + await expect(create({})).rejects.toThrow(UserError); + expect(existsSync(join(root, "plugins"))).toBe(false); + }); + + it.each([ + ["an invalid name", { name: "My Blog" }, "not a valid plugin name"], + ["a reserved name", { name: "admin" }, "reserved"], + [ + "an invalid package name", + { name: "blog", packageName: "Blog" }, + "lowercase", + ], + [ + "core's package name", + { name: "blog", packageName: "@vitnode/core" }, + "core package", + ], + ])("refuses %s before writing anything", async (_, options, message) => { + workspace(); + + await expect(create(options)).rejects.toThrow(message); + expect(existsSync(join(root, "plugins", "blog"))).toBe(false); + }); + + it("never overwrites an existing plugin folder", async () => { + workspace(); + write("plugins/blog/README.md", "mine"); + + await expect(create({ name: "blog" })).rejects.toThrow( + "already exists and is not empty", + ); + expect(readFileSync(join(root, "plugins/blog/README.md"), "utf8")).toBe( + "mine", + ); + }); + + it("refuses a package name - and so a plugin id - the workspace already has", async () => { + workspace(["forum"]); + + await expect( + create({ name: "community", packageName: "@acme/forum" }), + ).rejects.toThrow( + 'A package named "@acme/forum" already exists at plugins/forum', + ); + }); + + it("refuses to run outside a workspace", async () => { + write("package.json", { name: "solo" }); + + await expect(create({ name: "blog" })).rejects.toThrow( + "inside a workspace", + ); + }); +}); + +/** + * The strongest check there is: generate a plugin, compile it with the + * `.swcrc` it was generated with - the same compiler step `vitnode build` + * runs - and hand the output to the same validator `vitnode plugin validate` + * uses, which loads it through VitNode's real plugin, route and API loaders. + */ +describe("a generated plugin", () => { + let loadRoot: string; + + beforeEach(() => { + // Loaded from inside the package, so the compiled plugin resolves `hono` + // and friends from core's own node_modules. Compiled elsewhere, because + // swc leaves path aliases alone in files under node_modules. + const cache = join(packageRoot, "node_modules", ".cache"); + mkdirSync(cache, { recursive: true }); + loadRoot = mkdtempSync(join(cache, "vitnode-generated-plugin-")); + }); + + afterEach(() => { + rmSync(loadRoot, { force: true, recursive: true }); + }); + + it("compiles and passes validation through the real plugin loaders", async () => { + workspace(); + await create({ name: "blog", packageName: "@acme/blog" }); + const source = join(root, "plugins", "blog"); + const plugin = join(loadRoot, "blog"); + const swcrc = JSON.parse( + readFileSync(join(source, ".swcrc"), "utf8"), + ) as Record & { + jsc: Record; + }; + + const compile = async (dir: string) => { + for (const entry of readdirSync(join(source, dir))) { + const file = join(source, dir, entry); + const target = join(source, "dist", dir, entry); + if (statSync(file).isDirectory()) { + await compile(join(dir, entry)); + continue; + } + mkdirSync(dirname(target), { recursive: true }); + if (entry.endsWith(".json")) { + cpSync(file, target); + continue; + } + if (entry.includes(".test.")) continue; + const { code } = await transformFile(file, { + ...swcrc, + $schema: undefined, + exclude: undefined, + filename: file, + jsc: { ...swcrc.jsc, baseUrl: source }, + swcrc: false, + } as Parameters[1]); + writeFileSync(target.replace(/\.tsx?$/, ".js"), code); + } + }; + await compile("src"); + cpSync(source, plugin, { recursive: true }); + + const jiti = createJiti(import.meta.url, { + alias: { "@/": `${coreSrc}/`, "@vitnode/core/": `${coreSrc}/` }, + interopDefault: false, + moduleCache: false, + }); + + const result = await validatePlugin(plugin, { + importModule: async file => jiti.import(file), + }); + + expect(result.checks.filter(check => check.status !== "ok")).toEqual([]); + expect(result.checks.map(check => check.name)).toEqual([ + "Package", + "Plugin definition", + "API definition", + "Routes", + "Translations", + ]); + expect(result.valid).toBe(true); + }, 60_000); +}); + +/** A plugin's build output, written by hand: plain ESM, no imports. */ +const builtPlugin = ( + name: string, + { + apiPermissions = {}, + locale = { [`@acme/${name}`]: { home: { title: "Hi" } } }, + navPermission, + pluginId = `@acme/${name}`, + }: { + apiPermissions?: Record; + locale?: unknown; + navPermission?: { module: string; permission: string }; + pluginId?: string; + } = {}, +) => { + const base = `plugins/${name}`; + write(`${base}/package.json`, { + dependencies: { "@vitnode/core": "workspace:*" }, + exports: { + "./*": { default: "./dist/src/*.js", import: "./dist/src/*.js" }, + "./locales/*.json": "./src/locales/*.json", + }, + name: `@acme/${name}`, + type: "module", + version: "1.0.0", + }); + write(`${base}/src/config.tsx`, ""); + link(name); + write(`${base}/src/locales/en.json`, locale); + write( + `${base}/dist/src/config.js`, + `export const plugin = () => ({ pluginId: ${JSON.stringify(pluginId)}, localeFiles: { en: "@acme/${name}/locales/en.json" } });\n`, + ); + write( + `${base}/dist/src/config.api.js`, + `export const apiPlugin = () => ({ pluginId: ${JSON.stringify(pluginId)}, permissionStaff: { admin: ${JSON.stringify(apiPermissions)} } });\n`, + ); + if (navPermission) { + write( + `${base}/dist/src/admin/nav.js`, + `export const adminNav = { pluginId: "@acme/${name}", admin: { nav: [{ id: "posts", href: "/admin/${name}", permission: ${JSON.stringify(navPermission)} }] } };\n`, + ); + } +}; + +const validate = async (name?: string, cwd = join(root, "apps", "web")) => { + const { context, runtime } = createTestContext({ cwd }); + + try { + const code = await runPluginValidateCommand( + context, + { name }, + { + loadIds: async () => await Promise.resolve(["@acme/blog"]), + }, + ); + + return { code, error: null, output: runtime.output() }; + } catch (error) { + return { code: null, error, output: runtime.output() }; + } +}; + +describe("vitnode plugin validate", () => { + it("passes a valid plugin", async () => { + workspace(); + builtPlugin("blog", { + apiPermissions: { posts: ["can_manage"] }, + navPermission: { module: "posts", permission: "can_manage" }, + }); + + const { code, output } = await validate("blog"); + + expect(code).toBe(0); + expect(output).toContain("✓ Plugin definition"); + expect(output).toContain("✓ AdminCP navigation"); + expect(output).toContain("✓ 1 plugin valid"); + }); + + it("validates the plugin it is run inside", async () => { + workspace(); + builtPlugin("blog"); + + const { code, output } = await validate( + undefined, + join(root, "plugins", "blog"), + ); + + expect(code).toBe(0); + expect(output).toContain("@acme/blog"); + }); + + it("fails on a navigation item that requires an unregistered permission", async () => { + workspace(); + builtPlugin("blog", { + navPermission: { module: "posts", permission: "can_manage" }, + }); + + const { error, output } = await validate("blog"); + + expect(error).toBeInstanceOf(ValidationError); + expect(output).toContain("✖ AdminCP navigation"); + expect(output).toContain("requires the permission posts.can_manage"); + }); + + it("fails on a plugin id that is not its package name", async () => { + workspace(); + builtPlugin("blog", { pluginId: "@acme/other" }); + + const { error, output } = await validate("blog"); + + expect(error).toBeInstanceOf(ValidationError); + expect(output).toContain( + 'pluginId is "@acme/other" but the package is "@acme/blog"', + ); + }); + + it("fails on translations without the plugin's own namespace", async () => { + workspace(); + builtPlugin("blog", { locale: { title: "Hi" } }); + + const { error, output } = await validate("blog"); + + expect(error).toBeInstanceOf(ValidationError); + expect(output).toContain('has no top-level "@acme/blog" key'); + }); + + it("fails - and says how to fix it - when the plugin has not been built", async () => { + workspace(["blog"]); + + const { error, output } = await validate("blog"); + + expect(error).toBeInstanceOf(ValidationError); + expect(output).toContain("dist/src/config.js does not exist"); + }); + + it("fails on a package.json that cannot be imported by an app", async () => { + workspace(); + builtPlugin("blog"); + write("plugins/blog/package.json", { + dependencies: { "@vitnode/core": "*" }, + name: "@acme/blog", + }); + + const { output } = await validate("blog"); + + expect(output).toContain('must set "type": "module"'); + expect(output).toContain('"exports" must map'); + }); + + it("refuses a plugin name it cannot find", async () => { + workspace(); + + const { error } = await validate("nope"); + + expect(error).toBeInstanceOf(UserError); + }); +}); + +describe("vitnode plugin list", () => { + const list = async (ids: Error | string[]) => { + const { context, runtime } = createTestContext({ + cwd: join(root, "apps", "web"), + }); + const code = await runPluginListCommand( + context, + {}, + { + loadIds: async () => { + await Promise.resolve(); + if (ids instanceof Error) throw ids; + + return ids; + }, + }, + ); + + return { code, output: runtime.output() }; + }; + + it("lists configured plugins with their version and source", async () => { + workspace(["blog", "forum"]); + write("apps/web/node_modules/@acme/search/package.json", { + name: "@acme/search", + version: "1.1.2", + }); + + const { code, output } = await list(["@acme/blog", "@acme/search"]); + + expect(code).toBe(0); + expect(output).toMatch(/@acme\/blog\s+1\.4\.0\s+workspace/); + expect(output).toMatch(/@acme\/search\s+1\.1\.2\s+package/); + expect(output).toMatch( + /@acme\/forum \(not configured\)\s+1\.4\.0\s+workspace/, + ); + expect(output).toContain("2 plugins configured in this app"); + }); + + it("says so when there are no plugins", async () => { + workspace(); + + const { code, output } = await list([]); + + expect(code).toBe(0); + expect(output).toContain("No plugins configured yet."); + }); + + it("reports a plugin that is configured but not installed", async () => { + workspace(); + + expect((await list(["@acme/missing"])).output).toMatch( + /@acme\/missing\s+not installed/, + ); + }); + + it("explains a config that cannot be loaded", async () => { + workspace(); + + await expect(list(new Error("pluginId must be a string"))).rejects.toThrow( + "Could not load the plugins configured in", + ); + }); +}); diff --git a/packages/vitnode/scripts/cli/commands/start.test.ts b/packages/vitnode/scripts/cli/commands/start.test.ts new file mode 100644 index 000000000..455f8e3fd --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/start.test.ts @@ -0,0 +1,197 @@ +// @vitest-environment node +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { Project } from "../project/project"; + +import { RuntimeError, UserError } from "../errors"; +import { runProcess } from "../project/processes"; +import { resolveServerEntry } from "../start/server-entry"; +import { createTestContext } from "../testing"; +import { displayHost, parsePort, runStartCommand } from "./start"; + +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "vitnode-start-")); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "web", type: "module" }), + ); + writeFileSync(join(root, "vite.config.ts"), "export default {};"); +}); + +afterEach(() => { + rmSync(root, { force: true, recursive: true }); +}); + +const write = (path: string, content: string) => { + mkdirSync(join(root, path, ".."), { recursive: true }); + writeFileSync(join(root, path), content); +}; + +const project = (kind: Project["kind"]): Project => ({ + drizzleConfig: null, + hasApi: false, + kind, + name: "web", + packageJson: {}, + root, + viteConfig: null, +}); + +const freePort = async () => + new Promise(resolve => { + const server = createServer(); + server.listen(0, () => { + const address = server.address(); + server.close(() => { + resolve( + typeof address === "object" && address !== null ? address.port : 0, + ); + }); + }); + }); + +describe("resolveServerEntry", () => { + it("runs the entry Nitro recorded for a node build", () => { + write( + ".output/nitro.json", + JSON.stringify({ + preset: "node-server", + serverEntry: "server/index.mjs", + }), + ); + write(".output/server/index.mjs", ""); + + expect(resolveServerEntry(project("app"))).toEqual({ + defaultPort: 3000, + entry: join(root, ".output", "server", "index.mjs"), + }); + }); + + it("refuses a build for a platform that runs it itself", () => { + write(".output/nitro.json", JSON.stringify({ preset: "vercel" })); + + expect(() => resolveServerEntry(project("app"))).toThrow( + 'targets the "vercel" preset', + ); + }); + + it("asks for a build when there is none", () => { + expect(() => resolveServerEntry(project("app"))).toThrow( + "No production build found.", + ); + expect(() => resolveServerEntry(project("api"))).toThrow( + "No production build found.", + ); + }); + + it("runs an API's compiled entry", () => { + write("dist/index.js", ""); + + expect(resolveServerEntry(project("api")).entry).toBe( + join(root, "dist", "index.js"), + ); + }); +}); + +describe("start options", () => { + it("validates the port", () => { + expect(parsePort("4000", 3000)).toBe(4000); + expect(parsePort(undefined, 3000)).toBe(3000); + expect(() => parsePort("http", 3000)).toThrow(UserError); + expect(() => parsePort("70000", 3000)).toThrow(UserError); + }); + + it("shows a wildcard bind as localhost", () => { + expect(displayHost("0.0.0.0")).toBe("localhost"); + expect(displayHost(undefined)).toBe("localhost"); + expect(displayHost("127.0.0.1")).toBe("127.0.0.1"); + }); +}); + +/** A production server stand-in: listens on PORT, the way Nitro's does. */ +const serverScript = ` +import { createServer } from "node:http"; +const server = createServer((_, res) => res.end("ok")); +server.listen(Number(process.env.PORT), () => console.log("listening " + process.env.NODE_ENV)); +`; + +describe("vitnode start", () => { + it("announces the server only once it accepts connections, and stops it on Ctrl+C", async () => { + write( + ".output/nitro.json", + JSON.stringify({ + preset: "node-server", + serverEntry: "server/index.mjs", + }), + ); + write(".output/server/index.mjs", serverScript); + const port = await freePort(); + const { context, runtime } = createTestContext({ cwd: root }); + + const running = runStartCommand(context, { port: String(port) }); + for (let i = 0; i < 300 && !runtime.output().includes("Running"); i += 1) { + await new Promise(resolve => setTimeout(resolve, 20)); + } + + expect(runtime.output()).toContain( + `● Running http://localhost:${String(port)}`, + ); + expect(await (await fetch(`http://localhost:${String(port)}`)).text()).toBe( + "ok", + ); + + runtime.signals.emit("SIGINT"); + expect(await running).toBe(0); + await expect(fetch(`http://localhost:${String(port)}`)).rejects.toThrow(); + }, 20_000); + + it("fails - without claiming it is running - when the server dies during boot", async () => { + write(".output/server/index.mjs", "process.exit(3);"); + const { context, runtime } = createTestContext({ cwd: root }); + + const error = (await runStartCommand(context, { + port: String(await freePort()), + }).catch((thrown: unknown) => thrown)) as RuntimeError; + + expect(error).toBeInstanceOf(RuntimeError); + expect(error.message).toBe( + "The server exited with code 3 before it was ready.", + ); + expect(runtime.output()).not.toContain("Running"); + }, 20_000); +}); + +describe("runProcess", () => { + it("captures output and reports the exit code instead of throwing", async () => { + const result = await runProcess({ + args: [ + "-e", + "console.log('hello'); console.error('oops'); process.exit(4)", + ], + capture: true, + command: process.execPath, + cwd: root, + }); + + expect(result.code).toBe(4); + expect(result.output).toContain("hello"); + expect(result.output).toContain("oops"); + }); + + it("rejects only when the command cannot start at all", async () => { + await expect( + runProcess({ + args: [], + capture: true, + command: join(root, "missing-binary"), + cwd: root, + }), + ).rejects.toThrow("Could not start"); + }); +}); diff --git a/packages/vitnode/scripts/cli/commands/start.ts b/packages/vitnode/scripts/cli/commands/start.ts new file mode 100644 index 000000000..f9342ac3b --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/start.ts @@ -0,0 +1,116 @@ +import type { CommandContext, OutputOptions } from "../context"; + +import { EXIT_CODE, RuntimeError, UserError } from "../errors"; +import { ProcessGroup, waitForShutdownSignal } from "../project/processes"; +import { detectProject } from "../project/project"; +import { resolveServerEntry } from "../start/server-entry"; +import { waitForPort } from "../start/wait-for-port"; + +export interface StartOptions extends OutputOptions { + host?: string; + port?: string; +} + +export const parsePort = ( + value: string | undefined, + fallback: number, +): number => { + if (value === undefined || value === "") return fallback; + const port = Number(value); + + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new UserError(`"${value}" is not a valid port.`, { + hint: "Use a whole number between 1 and 65535.", + }); + } + + return port; +}; + +/** The address to show and to probe: a wildcard bind is reachable locally. */ +export const displayHost = (host: string | undefined): string => + host === undefined || host === "" || host === "0.0.0.0" || host === "::" + ? "localhost" + : host; + +/** + * `vitnode start` - the production server the build produced, run with Node. + * + * Not a server of VitNode's own: it is the build's entry (`.output/server` for + * an app, `dist/index.js` for an API), started exactly as `node ` + * would, with `PORT` and `HOST` passed the way Nitro and the API template + * already read them. The CLI only adds a readiness check and a clean + * shutdown: SIGINT or SIGTERM is forwarded, and the server is given time to + * close before it is killed. + */ +export const runStartCommand = async ( + { cwd, env, signals, ui }: CommandContext, + options: StartOptions, +): Promise => { + const project = detectProject(cwd); + const { defaultPort, entry } = resolveServerEntry(project); + const port = parsePort(options.port ?? env.PORT, defaultPort); + const host = options.host ?? env.HOST; + const shownHost = displayHost(host); + + ui.header("Production"); + + const group = new ProcessGroup(); + const child = group.spawn({ + args: [entry], + command: process.execPath, + cwd: project.root, + env: { + ...env, + NODE_ENV: env.NODE_ENV ?? "production", + PORT: String(port), + ...(host === undefined ? {} : { HOST: host }), + }, + }); + + let exitCode: null | number = null; + child.once("exit", code => { + exitCode = code ?? 1; + }); + + const ready = await waitForPort({ + host: shownHost, + isAlive: () => exitCode === null, + port, + }); + + if (!ready) { + await group.stop(); + throw new RuntimeError( + exitCode === null + ? `The server did not start listening on port ${String(port)} within 60s.` + : `The server exited with code ${String(exitCode)} before it was ready.`, + ); + } + + const url = `http://${shownHost}:${String(port)}`; + ui.line( + ui.mode === "plain" + ? `[OK] Running at ${url}` + : ` ${ui.colors.success(ui.symbols.dot)} Running ${ui.colors.command(url)}`, + ); + ui.line(); + + const outcome = await Promise.race([ + group.firstExit().then(code => ({ code, kind: "exit" as const })), + waitForShutdownSignal(signals).then(signal => ({ + kind: "signal" as const, + signal, + })), + ]); + + if (outcome.kind === "signal") { + ui.line(); + ui.note("Stopping the server..."); + await group.stop(); + + return EXIT_CODE.ok; + } + + return outcome.code === 0 ? EXIT_CODE.ok : outcome.code; +}; diff --git a/packages/vitnode/scripts/cli/context.ts b/packages/vitnode/scripts/cli/context.ts new file mode 100644 index 000000000..b622f05d5 --- /dev/null +++ b/packages/vitnode/scripts/cli/context.ts @@ -0,0 +1,76 @@ +import type { SignalSource } from "./project/processes"; +import type { Prompter } from "./ui/prompts"; +import type { TerminalInput, TerminalStream } from "./ui/terminal"; +import type { Ui } from "./ui/ui"; + +import { createPrompter } from "./ui/prompts"; +import { detectTerminal } from "./ui/terminal"; +import { createUi } from "./ui/ui"; + +export type Env = Record; + +/** + * Everything the CLI reads from the process it runs in. + * + * Commands never touch `process` directly - they get this, so a test can run + * `vitnode build` against a fake terminal, a fake environment and a fake + * Ctrl+C without spawning anything. + */ +export interface CliRuntime { + createPrompter?: (ui: Ui) => Prompter; + cwd: string; + env: Env; + platform: NodeJS.Platform; + signals: SignalSource; + stderr: TerminalStream; + stdin: TerminalInput; + stdout: TerminalStream; + version: string; +} + +export interface OutputOptions { + plain?: boolean; + verbose?: boolean; +} + +/** What a command handler receives. */ +export interface CommandContext { + cwd: string; + env: Env; + platform: NodeJS.Platform; + prompter: Prompter; + signals: SignalSource; + ui: Ui; + version: string; +} + +export const createCommandUi = ( + runtime: CliRuntime, + { plain = false, verbose = false }: OutputOptions, +): Ui => + createUi({ + capabilities: detectTerminal({ + env: runtime.env, + platform: runtime.platform, + plain, + stdin: runtime.stdin, + stdout: runtime.stdout, + }), + mode: plain ? "plain" : "pretty", + stderr: runtime.stderr, + stdout: runtime.stdout, + verbose, + }); + +export const createCommandContext = ( + runtime: CliRuntime, + ui: Ui, +): CommandContext => ({ + cwd: runtime.cwd, + env: runtime.env, + platform: runtime.platform, + prompter: (runtime.createPrompter ?? createPrompter)(ui), + signals: runtime.signals, + ui, + version: runtime.version, +}); diff --git a/packages/vitnode/scripts/cli/db/database.ts b/packages/vitnode/scripts/cli/db/database.ts new file mode 100644 index 000000000..19d6630a5 --- /dev/null +++ b/packages/vitnode/scripts/cli/db/database.ts @@ -0,0 +1,186 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import type { DrizzleProjectConfig } from "../../prepare-database"; +import type { ProcessResult } from "../project/processes"; +import type { LocalMigration, QueryRows } from "./migration-state"; +import type { ExplainResult } from "./statements"; + +import { RuntimeError } from "../errors"; +import { parseDrizzleJson } from "./statements"; + +/** A live connection to the project's database, through its own config. */ +export interface DatabaseHandle { + /** Applies pending migrations and ensures initial data, under the lock. */ + apply: (log: (message: string) => void) => Promise; + close: () => Promise; + /** `vitnode @ localhost:5432` - never the user or the password. */ + location: null | string; + ping: () => Promise; + query: QueryRows; +} + +/** Everything the `db` commands touch outside the process - injectable. */ +export interface DatabaseServices { + drizzleConfig: () => Promise; + drizzleKit: ( + args: readonly string[], + options: { capture: boolean }, + ) => Promise; + /** Folder names under the migrations folder, sorted. */ + listMigrationFolders: (folder: string) => string[]; + open: (config: DrizzleProjectConfig) => Promise; + readLocalMigrations: (folder: string) => Promise; +} + +const PING_TIMEOUT_MS = 10_000; + +export const PROVIDER_NAMES: Record = { + gel: "Gel", + mssql: "SQL Server", + mysql: "MySQL", + postgresql: "PostgreSQL", + singlestore: "SingleStore", + sqlite: "SQLite", + turso: "Turso", +}; + +const locationOf = (client: unknown): null | string => { + const options = (client as null | { options?: Record }) + ?.options; + if (options === undefined) return null; + + const first = (value: unknown) => + Array.isArray(value) ? (value[0] as unknown) : value; + const host = first(options.host); + const port = first(options.port); + const database = options.database; + + if (typeof database !== "string" || typeof host !== "string") return null; + + const shownPort = + typeof port === "number" || typeof port === "string" + ? `:${String(port)}` + : ""; + + return `${database} @ ${host}${shownPort}`; +}; + +const withTimeout = async (promise: Promise, ms: number): Promise => { + let timer: NodeJS.Timeout | undefined; + + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new Error( + `No answer from the database within ${String(ms / 1000)}s.`, + ), + ); + }, ms); + }), + ]); + } finally { + clearTimeout(timer); + } +}; + +/** + * The real implementations: the app's own `vitnode.api.config.ts` connection, + * its own `drizzle-kit`, and drizzle-orm's migration reader. Everything heavy + * is imported on first use. + */ +export const createDatabaseServices = (root: string): DatabaseServices => ({ + drizzleConfig: async () => { + const { readDrizzleConfig } = await import("../../prepare-database"); + + return readDrizzleConfig(root); + }, + drizzleKit: async (args, { capture }) => { + const { runDrizzleKit } = await import("../../prepare-database"); + + return runDrizzleKit(args, { capture, root }); + }, + listMigrationFolders: folder => + existsSync(folder) + ? readdirSync(folder) + .filter(entry => statSync(join(folder, entry)).isDirectory()) + .sort() + : [], + open: async config => { + const [{ getConfig }, bootstrap, { sql }] = await Promise.all([ + import("../../get-config"), + import("../../prepare-database"), + import("drizzle-orm"), + ]); + const apiConfig = await getConfig({ baseDir: root, type: "api.config" }); + const db = apiConfig.dbProvider; + const query: QueryRows = async text => await db.execute(sql.raw(text)); + + return { + apply: async log => { + await bootstrap.withMigrationLock( + db, + "[VitNode]", + async () => { + await bootstrap.runMigrations({ + config: apiConfig, + migrationsFolder: config.migrationsFolder, + }); + await bootstrap.initialDataForDatabase(apiConfig); + }, + log, + ); + }, + close: async () => { + const client = db.$client as { + end?: (options: { timeout: number }) => Promise; + }; + await client.end?.({ timeout: 5 }); + }, + location: locationOf(db.$client), + ping: async () => { + await withTimeout(query("SELECT 1"), PING_TIMEOUT_MS); + }, + query, + }; + }, + readLocalMigrations: async folder => { + if (!existsSync(folder)) return []; + const { readMigrationFiles } = await import("drizzle-orm/migrator"); + + return readMigrationFiles({ migrationsFolder: folder }).map(migration => ({ + folderMillis: migration.folderMillis, + hash: migration.hash, + name: migration.name, + })); + }, +}); + +/** + * Runs drizzle-kit in JSON mode and insists on an answer it can read - its + * structured result, never its terminal text. + */ +export const explain = async ( + services: Pick, + args: readonly string[], +): Promise => { + const { code, output } = await services.drizzleKit(args, { capture: true }); + const result = parseDrizzleJson(output); + const command = `drizzle-kit ${args[0] ?? ""}`; + + if (result === null) { + throw new RuntimeError(`${command} failed (exit code ${String(code)}).`, { + output, + }); + } + if (result.status === "error") { + throw new RuntimeError(result.error?.message ?? `${command} failed.`, { + output, + }); + } + + return result; +}; diff --git a/packages/vitnode/scripts/cli/db/db.test.ts b/packages/vitnode/scripts/cli/db/db.test.ts new file mode 100644 index 000000000..27af97079 --- /dev/null +++ b/packages/vitnode/scripts/cli/db/db.test.ts @@ -0,0 +1,192 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import type { AppliedMigration, LocalMigration } from "./migration-state"; + +import { computeMigrationState, readJournal } from "./migration-state"; +import { + formatHintEntity, + parseDrizzleJson, + summarizeStatement, +} from "./statements"; + +const local = (name: string, hash = `hash-${name}`): LocalMigration => ({ + folderMillis: Number(name.slice(0, 4)), + hash, + name, +}); + +describe("computeMigrationState", () => { + it("is pending exactly when the journal has no row with the folder's name", () => { + const state = computeMigrationState( + [local("0001_init"), local("0002_users"), local("0003_posts")], + [{ createdAt: 1, hash: "hash-0001_init", name: "0001_init" }], + ); + + expect(state.applied.map(m => m.name)).toEqual(["0001_init"]); + expect(state.pending.map(m => m.name)).toEqual([ + "0002_users", + "0003_posts", + ]); + }); + + it("reports an applied migration whose file changed afterwards", () => { + const state = computeMigrationState( + [local("0001_init", "new-hash")], + [{ createdAt: 1, hash: "old-hash", name: "0001_init" }], + ); + + expect(state.modified).toEqual(["0001_init"]); + expect(state.pending).toEqual([]); + }); + + it("reports a journal row without a migration on disk", () => { + expect( + computeMigrationState( + [], + [{ createdAt: 9, hash: "h", name: "0009_gone" }], + ).missingLocally, + ).toEqual(["0009_gone"]); + }); + + it("matches an old journal without names by timestamp or hash", () => { + const journal: AppliedMigration[] = [ + { createdAt: 1, hash: "unrelated", name: null }, + { createdAt: null, hash: "hash-0002_users", name: null }, + ]; + + expect( + computeMigrationState([local("0001_init"), local("0002_users")], journal) + .pending, + ).toEqual([]); + }); +}); + +describe("readJournal", () => { + it("reads nothing - and creates nothing - when the journal table does not exist", async () => { + const queries: string[] = []; + const journal = await readJournal( + async query => { + await Promise.resolve(); + queries.push(query); + + return []; + }, + { schema: "drizzle", table: "__drizzle_migrations" }, + ); + + expect(journal).toEqual([]); + expect(queries).toHaveLength(1); + expect(queries[0]).toMatch( + /^SELECT column_name FROM information_schema\.columns/, + ); + }); + + it("reads names when the table has them, and copes when it does not", async () => { + const run = async (columns: string[]) => + readJournal( + async query => + await Promise.resolve( + query.includes("information_schema") + ? columns.map(column_name => ({ column_name })) + : [{ created_at: "1700000000000", hash: "h", name: "0001_init" }], + ), + { schema: "drizzle", table: "__drizzle_migrations" }, + ); + + expect(await run(["id", "hash", "created_at", "name"])).toEqual([ + { createdAt: 1_700_000_000_000, hash: "h", name: "0001_init" }, + ]); + expect((await run(["id", "hash", "created_at"]))[0].name).toBeNull(); + }); + + it("refuses a table name it would have to quote", async () => { + await expect( + readJournal(async () => await Promise.resolve([]), { + schema: "drizzle", + table: 'x"; DROP TABLE users; --', + }), + ).rejects.toThrow("Invalid migrations table"); + }); +}); + +describe("drizzle-kit JSON output", () => { + it("reads the result line among its other output", () => { + expect( + parseDrizzleJson( + 'Reading config file\n[✓] Pulling schema\n{"status":"no_changes","dialect":"postgresql"}\n', + ), + ).toEqual({ dialect: "postgresql", status: "no_changes" }); + }); + + it("returns null when there is no result to read", () => { + expect(parseDrizzleJson("Error: something broke\n")).toBeNull(); + }); + + it.each([ + [ + { + table: { name: "notifications", schema: "public" }, + type: "create_table", + }, + "+", + "table", + "notifications", + ], + [ + { table: { name: "zz", schema: "public" }, type: "drop_table" }, + "-", + "table", + "zz", + ], + [ + { + column: { + name: "notification_count", + schema: "public", + table: "users", + }, + type: "add_column", + }, + "+", + "column", + "users.notification_count", + ], + [ + { + index: { name: "notifications_user_id_idx", table: "notifications" }, + type: "create_index", + }, + "+", + "index", + "notifications.notifications_user_id_idx", + ], + [ + { fk: { name: "posts_user_fk", table: "posts" }, type: "create_fk" }, + "+", + "foreign key", + "posts.posts_user_fk", + ], + [ + { table: { name: "audit", schema: "logs" }, type: "alter_table" }, + "~", + "table", + "logs.audit", + ], + [ + { from: { name: "a" }, to: { name: "b" }, type: "rename_table" }, + "~", + "table", + "a → b", + ], + ])("summarizes %j", (statement, sign, kind, name) => { + expect(summarizeStatement(statement)).toEqual({ kind, name, sign }); + }); + + it("formats a hint entity without the default schema", () => { + expect(formatHintEntity(["public", "core_roles", "zz_tmp"])).toBe( + "core_roles.zz_tmp", + ); + expect(formatHintEntity(["auth", "users"])).toBe("auth.users"); + }); +}); diff --git a/packages/vitnode/scripts/cli/db/migration-state.ts b/packages/vitnode/scripts/cli/db/migration-state.ts new file mode 100644 index 000000000..4c65cab42 --- /dev/null +++ b/packages/vitnode/scripts/cli/db/migration-state.ts @@ -0,0 +1,108 @@ +export interface LocalMigration { + folderMillis: number; + hash: string; + name: string; +} + +export interface AppliedMigration { + createdAt: null | number; + hash: string; + /** `null` in a journal written before drizzle recorded names. */ + name: null | string; +} + +export interface MigrationState { + applied: LocalMigration[]; + /** Recorded as applied, but no longer on disk. */ + missingLocally: string[]; + /** Applied, but the file on disk has changed since. */ + modified: string[]; + pending: LocalMigration[]; +} + +/** + * Which local migrations the database has, and which it does not. + * + * The same rule `drizzle-orm`'s migrator applies - a migration is pending when + * no journal row carries its folder name - so `db status` and `db migrate` + * can never disagree about what is pending. Two things the migrator does not + * check are reported on top: an applied migration whose file has since been + * edited (its hash no longer matches) and a journal row with no file at all. + * + * A journal from before drizzle stored names is matched the way drizzle's own + * upgrade does it: by timestamp, then by hash. + */ +export const computeMigrationState = ( + local: readonly LocalMigration[], + journal: readonly AppliedMigration[], +): MigrationState => { + const byName = new Map(); + + for (const row of journal) { + const name = + row.name ?? + local.find( + migration => + migration.folderMillis === row.createdAt || + migration.hash === row.hash, + )?.name ?? + null; + if (name !== null) byName.set(name, row); + } + + const localNames = new Set(local.map(migration => migration.name)); + + return { + applied: local.filter(migration => byName.has(migration.name)), + missingLocally: [...byName.keys()].filter(name => !localNames.has(name)), + modified: local + .filter(migration => { + const row = byName.get(migration.name); + + return row !== undefined && row.hash !== migration.hash; + }) + .map(migration => migration.name), + pending: local.filter(migration => !byName.has(migration.name)), + }; +}; + +/** A query runner - the app's Drizzle database, or a fake in tests. */ +export type QueryRows = (query: string) => Promise[]>; + +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** + * The migrations journal, read without writing anything. + * + * `migrate()` is not usable for this: it creates the journal's schema and + * table, and may upgrade an old table in place. Status must not change the + * database it reports on, so this only reads - and an absent journal simply + * means nothing has been applied yet. + */ +export const readJournal = async ( + query: QueryRows, + { schema, table }: { schema: string; table: string }, +): Promise => { + if (!IDENTIFIER.test(schema) || !IDENTIFIER.test(table)) { + throw new Error(`Invalid migrations table "${schema}"."${table}".`); + } + + const columns = await query( + `SELECT column_name FROM information_schema.columns WHERE table_schema = '${schema}' AND table_name = '${table}'`, + ); + if (columns.length === 0) return []; + + const hasName = columns.some(column => column.column_name === "name"); + const rows = await query( + `SELECT hash, created_at${hasName ? ", name" : ""} FROM "${schema}"."${table}" ORDER BY id`, + ); + + return rows.map(row => ({ + createdAt: + row.created_at === null || row.created_at === undefined + ? null + : Number(row.created_at), + hash: String(row.hash), + name: hasName && typeof row.name === "string" ? row.name : null, + })); +}; diff --git a/packages/vitnode/scripts/cli/db/prepare.ts b/packages/vitnode/scripts/cli/db/prepare.ts new file mode 100644 index 000000000..0d0272727 --- /dev/null +++ b/packages/vitnode/scripts/cli/db/prepare.ts @@ -0,0 +1,114 @@ +import type { Ui } from "../ui/ui"; +import type { DatabaseServices } from "./database"; + +import { errorMessage, RuntimeError } from "../errors"; +import { withQuietOutput } from "../ui/capture-output"; +import { plural } from "../ui/format"; +import { explain } from "./database"; +import { computeMigrationState, readJournal } from "./migration-state"; + +export interface PreparedDatabase { + /** Migrations applied by this run. */ + applied: number; + /** Migration folders generated by this run. */ + generated: string[]; +} + +/** + * The development bootstrap `vitnode dev` runs before anything serves a + * request - the same three steps as `vitnode db:prepare`: generate a migration + * for schema changes, apply what is pending, ensure initial data. All under + * the migration lock, so two apps starting at once cannot race. + * + * Quieter than `db:prepare`: drizzle-kit runs in JSON mode, and only hands + * the terminal over when it genuinely has to ask whether a change is a + * rename. In a terminal nobody can answer, that generation is skipped with a + * warning instead of hanging the dev server. + */ +export const prepareDevelopmentDatabase = async ( + ui: Ui, + services: DatabaseServices, +): Promise => { + const config = await services.drizzleConfig(); + const before = new Set( + services.listMigrationFolders(config.migrationsFolder), + ); + + const planned = await ui.runTask("Checking database schema", async () => { + await explain(services, ["up", "--output", "json"]); + + return explain(services, ["generate", "--explain", "--output", "json"]); + }); + + if (planned.status === "missing_hints") { + if (ui.interactive) { + ui.warning( + "Schema changes need a decision - handing over to drizzle-kit:", + ); + const { code } = await services.drizzleKit(["generate"], { + capture: false, + }); + if (code !== 0) { + throw new RuntimeError( + `drizzle-kit generate exited with code ${String(code)}.`, + ); + } + } else { + ui.warning( + "Schema changes need a rename decision - run vitnode db generate in a terminal. Continuing without a new migration.", + ); + } + } else if (planned.status === "ok" && (planned.statements?.length ?? 0) > 0) { + await ui.runTask("Generating migration", async () => + explain(services, ["generate", "--output", "json"]), + ); + } + + const generated = services + .listMigrationFolders(config.migrationsFolder) + .filter(folder => !before.has(folder)); + + const task = ui.task("Connecting to the database"); + const handle = await services.open(config).catch((error: unknown) => { + task.fail(); + throw error; + }); + + try { + try { + await handle.ping(); + } catch (error) { + task.fail("Database unreachable"); + throw new RuntimeError("Could not connect to the database.", { + cause: error, + details: [errorMessage(error)], + hint: "Is it running? In development, pnpm docker:dev starts one.", + }); + } + + const local = await services.readLocalMigrations(config.migrationsFolder); + const { pending } = computeMigrationState( + local, + await readJournal(handle.query, { + schema: config.migrationsSchema, + table: config.migrationsTable, + }), + ); + + await withQuietOutput(ui.verbose, async () => + handle.apply(message => { + ui.note(message); + }), + ); + task.succeed( + "Database connected", + pending.length === 0 + ? "up to date" + : `${plural(pending.length, "migration")} applied`, + ); + + return { applied: pending.length, generated }; + } finally { + await handle.close(); + } +}; diff --git a/packages/vitnode/scripts/cli/db/statements.ts b/packages/vitnode/scripts/cli/db/statements.ts new file mode 100644 index 000000000..7e5951aa6 --- /dev/null +++ b/packages/vitnode/scripts/cli/db/statements.ts @@ -0,0 +1,128 @@ +/** + * drizzle-kit's `--explain --output json` result, as far as VitNode reads it. + * + * This is drizzle-kit's structured output, not its terminal text - nothing + * here parses a human-readable log. + */ +export type ExplainResult = + | { error?: { message?: string }; status: "error" } + | { statements?: DrizzleStatement[]; status: "ok" } + | { status: "missing_hints"; unresolved: MissingHint[] } + | { status: "no_changes" }; + +export interface DrizzleStatement { + [field: string]: unknown; + type: string; +} + +export interface MissingHint { + entity?: unknown; + kind?: string; + reason?: string; + type: string; +} + +/** The last JSON object a drizzle-kit `--output json` run printed. */ +export const parseDrizzleJson = (output: string): ExplainResult | null => { + const lines = output.trim().split(/\r?\n/).reverse(); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) continue; + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed === "object" && parsed !== null && "status" in parsed) { + return parsed as ExplainResult; + } + } catch { + // Not the result line - keep looking. + } + } + + return null; +}; + +export interface StatementSummary { + /** What kind of object: table, column, index, foreign key... */ + kind: string; + /** The object's name, with its table when it belongs to one. */ + name: string; + sign: "+" | "-" | "~"; +} + +const KIND_NAMES: Record = { + check: "check constraint", + fk: "foreign key", + pk: "primary key", + unique: "unique constraint", +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const qualified = (entity: Record): string => { + const name = typeof entity.name === "string" ? entity.name : ""; + const table = typeof entity.table === "string" ? entity.table : null; + const schema = + typeof entity.schema === "string" && entity.schema !== "public" + ? `${entity.schema}.` + : ""; + + return table === null || table === name + ? `${schema}${name}` + : `${schema}${table}.${name}`; +}; + +/** + * One line per statement: `+ table notifications`, `~ column users.email`. + * + * Read from the statement's `type` (`create_table`, `drop_column`, ...) and + * the first object in it that has a `name` - drizzle-kit's own vocabulary, so + * a statement type VitNode has never seen still gets a sensible line instead + * of being dropped. + */ +export const summarizeStatement = ( + statement: DrizzleStatement, +): StatementSummary => { + const [verb = "", ...rest] = statement.type.split("_"); + const sign = + verb === "create" || verb === "add" + ? "+" + : verb === "drop" || verb === "delete" + ? "-" + : "~"; + const kindKey = rest.join("_"); + const kind = KIND_NAMES[kindKey] ?? rest.join(" "); + + const { from, to } = statement; + if (isRecord(from) && isRecord(to)) { + return { kind, name: `${qualified(from)} → ${qualified(to)}`, sign }; + } + + const entity = Object.entries(statement).find( + ([key, value]) => + key !== "type" && isRecord(value) && typeof value.name === "string", + )?.[1] as Record | undefined; + + return { + kind, + name: + entity === undefined + ? typeof statement.key === "string" + ? statement.key.replaceAll('"', "") + : "" + : qualified(entity), + sign, + }; +}; + +/** `["public", "users", "email"]` → `users.email`. */ +export const formatHintEntity = (entity: unknown): string => + Array.isArray(entity) + ? entity + .filter((part, index) => !(index === 0 && part === "public")) + .join(".") + : String(entity); + +export const isDataLossHint = (hint: MissingHint) => + hint.type === "confirm_data_loss"; diff --git a/packages/vitnode/scripts/cli/dev/open-url.ts b/packages/vitnode/scripts/cli/dev/open-url.ts new file mode 100644 index 000000000..e5aac2f31 --- /dev/null +++ b/packages/vitnode/scripts/cli/dev/open-url.ts @@ -0,0 +1,24 @@ +import { spawn } from "node:child_process"; + +/** + * Opens a URL in the default browser, without a shell. + * + * `rundll32 url.dll,FileProtocolHandler` is how Windows opens a URL without + * `cmd /c start`, whose quoting rules are a trap for anything with `&` in it. + */ +export const openUrl = (url: string, platform: NodeJS.Platform): void => { + const [command, args] = + platform === "darwin" + ? ["open", [url]] + : platform === "win32" + ? ["rundll32", ["url.dll,FileProtocolHandler", url]] + : ["xdg-open", [url]]; + + const child = spawn(command, args, { + detached: true, + shell: false, + stdio: "ignore", + }); + child.on("error", () => undefined); + child.unref(); +}; diff --git a/packages/vitnode/scripts/cli/dev/request-log.ts b/packages/vitnode/scripts/cli/dev/request-log.ts new file mode 100644 index 000000000..9591b1f53 --- /dev/null +++ b/packages/vitnode/scripts/cli/dev/request-log.ts @@ -0,0 +1,66 @@ +import type { Ui } from "../ui/ui"; + +import { padEnd, padStart } from "../ui/colors"; +import { formatDuration } from "../ui/format"; + +export interface LoggedRequest { + accept?: string; + method: string; + url: string; +} + +/** Vite's own module traffic: thousands of requests nobody needs to read. */ +const INTERNAL_PREFIXES = ["/@", "/node_modules/", "/__", "/.well-known/"]; + +const STATIC_EXTENSION = + /\.(?:css|gif|ico|jpe?g|js|json|jsx|map|mjs|mts|png|svg|ts|tsx|txt|webmanifest|webp|woff2?)$/i; + +/** + * Whether a dev-server request is one a developer would recognise as theirs: + * a page, an API call, a server function, a form post. Module requests, + * HMR pings and static files are left out - they are the dev server talking to + * itself. + */ +export const shouldLogRequest = ({ accept, method, url }: LoggedRequest) => { + const path = url.split("?")[0] ?? url; + + if (INTERNAL_PREFIXES.some(prefix => path.startsWith(prefix))) return false; + if (/[?&](?:import|direct|raw|url|worker|t|v)(?:=|&|$)/.test(url)) + return false; + if (path.startsWith("/api/") || path === "/api") return true; + if (path.startsWith("/_serverFn")) return true; + if (method !== "GET" && method !== "HEAD") return true; + if (STATIC_EXTENSION.test(path)) return false; + + return ( + accept === undefined || + accept.includes("text/html") || + accept.includes("*/*") + ); +}; + +/** `GET /api/session 200 6ms` - aligned, status colored by class. */ +export const formatRequest = ( + ui: Ui, + { + durationMs, + method, + status, + url, + }: { durationMs: number; method: string; status: number; url: string }, +): string => { + const paint = + status >= 500 + ? ui.colors.error + : status >= 400 + ? ui.colors.warning + : status >= 300 + ? ui.colors.muted + : ui.colors.success; + const path = url.length > 48 ? `${url.slice(0, 47)}…` : url; + + return `${ui.colors.muted(padEnd(method, 6))}${padEnd(path, 48)} ${paint(padStart(String(status), 3))} ${ui.colors.muted(padStart(formatDuration(durationMs), 6))}`; +}; + +export const formatHotUpdate = (ui: Ui, file: string): string => + `${ui.colors.primary(padEnd("HMR", 6))}${file}`; diff --git a/packages/vitnode/scripts/cli/dev/vite-dev.ts b/packages/vitnode/scripts/cli/dev/vite-dev.ts new file mode 100644 index 000000000..996d13e8d --- /dev/null +++ b/packages/vitnode/scripts/cli/dev/vite-dev.ts @@ -0,0 +1,189 @@ +import type { InlineConfig, Plugin, ViteDevServer } from "vite"; + +import { relative } from "node:path"; + +import type { SignalSource } from "../project/processes"; +import type { Project } from "../project/project"; +import type { Ui } from "../ui/ui"; + +import { errorMessage, EXIT_CODE, RuntimeError } from "../errors"; +import { importFromProject } from "../project/packages"; +import { waitForShutdownSignal } from "../project/processes"; +import { toDisplayPath } from "../ui/format"; +import { + formatHotUpdate, + formatRequest, + shouldLogRequest, +} from "./request-log"; + +export interface ViteDevApi { + createServer: (config: InlineConfig) => Promise; +} + +/** The port a VitNode app uses when nothing else says otherwise. */ +export const DEFAULT_DEV_PORT = 3000; + +/** + * The CLI's view into the dev server: request lines and HMR updates. + * + * It only observes. The middleware calls `next()` straight away and logs once + * the response has finished; `hotUpdate` returns nothing, so Vite's own + * update handling is untouched. + */ +export const createDevReporter = ( + ui: Ui, + { defaultPort, root }: { defaultPort: null | number; root: string }, +): Plugin => ({ + apply: "serve", + // First in line, so its middleware sees every request before a framework + // middleware answers it and never calls `next()`. + enforce: "pre", + name: "vitnode:dev-reporter", + + config: userConfig => + defaultPort !== null && userConfig.server?.port === undefined + ? { server: { port: defaultPort } } + : undefined, + + configureServer: server => { + server.middlewares.use((req, res, next) => { + const startedAt = performance.now(); + const request = { + accept: req.headers.accept, + method: req.method ?? "GET", + url: req.url ?? "/", + }; + + if (shouldLogRequest(request)) { + res.once("finish", () => { + ui.line( + formatRequest(ui, { + durationMs: performance.now() - startedAt, + method: request.method, + status: res.statusCode, + url: request.url, + }), + ); + }); + } + next(); + }); + }, + + hotUpdate(this: { environment?: { name: string } }, { file, modules }) { + if (this.environment?.name !== "client" || modules.length === 0) return; + if (/\.gen\.[cm]?[jt]sx?$/.test(file)) return; + + ui.line(formatHotUpdate(ui, toDisplayPath(relative(root, file)))); + }, +}); + +export interface ViteDevOptions { + host?: boolean | string; + loadVite?: () => Promise; + openUrl: (url: string) => void; + port?: number; + project: Project; + signals: SignalSource; + ui: Ui; +} + +/** + * `vite dev` for a VitNode app, through Vite's JavaScript API - the app's own + * config, the app's own Vite, one server. Owning the server object is what + * gives VitNode a clean lifecycle: the URLs it prints are the ones Vite + * actually bound, and Ctrl+C closes the server (and every environment and + * watcher it started) before the process exits. + */ +export const runViteDev = async ({ + host, + loadVite, + openUrl, + port, + project, + signals, + ui, +}: ViteDevOptions): Promise => { + const vite = await ( + loadVite ?? + (async () => importFromProject(project.root, "vite")) + )(); + + let server: ViteDevServer; + try { + server = await ui.runTask("Starting dev server", async () => { + const created = await vite.createServer({ + clearScreen: false, + configFile: project.viteConfig ?? undefined, + logLevel: ui.verbose ? "info" : "warn", + mode: "development", + plugins: [ + createDevReporter(ui, { + defaultPort: port === undefined ? DEFAULT_DEV_PORT : null, + root: project.root, + }), + ], + root: project.root, + server: { + ...(port === undefined ? {} : { port }), + ...(host === undefined ? {} : { host }), + }, + }); + + try { + await created.listen(); + } catch (error) { + await created.close().catch(() => undefined); + throw error; + } + + return created; + }); + } catch (error) { + throw new RuntimeError("Could not start the dev server.", { + cause: error, + details: [errorMessage(error)], + }); + } + + const base = ( + server.resolvedUrls?.local[0] ?? + `http://localhost:${String(server.config.server.port)}/` + ).replace(/\/$/, ""); + const network = server.resolvedUrls?.network[0]?.replace(/\/$/, ""); + + ui.line(); + ui.keyValue([ + ["Web", ui.colors.command(base)], + ["AdminCP", ui.colors.command(`${base}/admin`)], + ...(project.hasApi + ? [["API", ui.colors.command(`${base}/api`)] as const] + : []), + ...(network === undefined + ? [] + : [["Network", ui.colors.command(network)] as const]), + ]); + ui.rule(); + + if (ui.interactive) { + server.bindCLIShortcuts({ + customShortcuts: [ + { + action: () => { + openUrl(`${base}/admin`); + }, + description: "open AdminCP", + key: "a", + }, + ], + print: true, + }); + } + + await waitForShutdownSignal(signals); + ui.line(); + ui.note("Stopping the dev server..."); + await server.close(); + + return EXIT_CODE.ok; +}; diff --git a/packages/vitnode/scripts/cli/dev/watchers.ts b/packages/vitnode/scripts/cli/dev/watchers.ts new file mode 100644 index 000000000..ea650682f --- /dev/null +++ b/packages/vitnode/scripts/cli/dev/watchers.ts @@ -0,0 +1,111 @@ +import { join } from "node:path"; + +import type { RunProcessOptions, SignalSource } from "../project/processes"; +import type { Project } from "../project/project"; +import type { Ui } from "../ui/ui"; + +import { EXIT_CODE, RuntimeError } from "../errors"; +import { resolveBin } from "../project/packages"; +import { ProcessGroup, waitForShutdownSignal } from "../project/processes"; + +export interface Watcher { + args: string[]; + bin: { name: string; package: string }; +} + +/** The plugin package's three compilers, each in watch mode. */ +export const packageWatchers = (): Watcher[] => [ + { + args: ["-w", "-p", "tsconfig.build.json", "--preserveWatchOutput"], + bin: { name: "tsc", package: "typescript" }, + }, + { + args: [ + "src", + "-d", + "dist", + "--config-file", + ".swcrc", + "--copy-files", + "-w", + ], + bin: { name: "swc", package: "@swc/cli" }, + }, + { + args: ["-w", "-p", "tsconfig.build.json"], + bin: { name: "tsc-alias", package: "tsc-alias" }, + }, +]; + +/** A standalone API restarted on every change. */ +export const apiWatchers = (runtime: { bun: boolean }): Watcher[] => + runtime.bun + ? [ + { + args: ["--hot", join("src", "index.ts")], + bin: { name: "bun", package: "" }, + }, + ] + : [ + { + args: ["watch", join("src", "index.ts")], + bin: { name: "tsx", package: "tsx" }, + }, + ]; + +export const toProcess = ( + project: Project, + watcher: Watcher, +): RunProcessOptions => + // Bun runs the entry itself; everything else is a package's bin run by Node. + watcher.bin.package === "" + ? { args: watcher.args, command: process.execPath, cwd: project.root } + : { + args: [ + resolveBin(project.root, watcher.bin.package, watcher.bin.name), + ...watcher.args, + ], + command: process.execPath, + cwd: project.root, + }; + +/** + * Runs long-lived watchers until one of them dies or the developer stops them. + * + * Every watcher is a direct child spawned without a shell, so stopping the + * group reaches the real process - no `cmd.exe` or `sh` in between to orphan a + * compiler when the parent goes away. + */ +export const runWatchers = async ({ + group = new ProcessGroup(), + processes, + signals, + ui, +}: { + group?: Pick; + processes: readonly RunProcessOptions[]; + signals: SignalSource; + ui: Ui; +}): Promise => { + processes.forEach(options => group.spawn(options)); + + const outcome = await Promise.race([ + group.firstExit().then(code => ({ code, kind: "exit" as const })), + waitForShutdownSignal(signals).then(() => ({ + code: 0, + kind: "signal" as const, + })), + ]); + + ui.line(); + ui.note("Stopping..."); + await group.stop(); + + if (outcome.kind === "exit" && outcome.code !== 0) { + throw new RuntimeError( + `A watcher exited with code ${String(outcome.code)}.`, + ); + } + + return EXIT_CODE.ok; +}; diff --git a/packages/vitnode/scripts/cli/errors.ts b/packages/vitnode/scripts/cli/errors.ts new file mode 100644 index 000000000..936512cc4 --- /dev/null +++ b/packages/vitnode/scripts/cli/errors.ts @@ -0,0 +1,95 @@ +/** + * Process exit codes the CLI chooses between. + * + * `usage` is the conventional `2` for "the command line itself is wrong", so a + * CI log can tell a typo from a failed build without reading the message. + */ +export const EXIT_CODE = { + failure: 1, + interrupted: 130, + ok: 0, + usage: 2, +} as const; + +export type CliErrorKind = "config" | "runtime" | "usage" | "validation"; + +export interface CliErrorOptions { + cause?: unknown; + /** Extra lines printed under the message, indented. */ + details?: readonly string[]; + exitCode?: number; + /** One actionable sentence, printed after the details. */ + hint?: string; + /** Output a failed child process printed, shown when it explains the error. */ + output?: string; +} + +/** + * An error the CLI knows how to explain. + * + * Anything thrown that is *not* one of these is an internal error, and the + * boundary prints its stack - a message VitNode did not write is not one it can + * vouch for. + */ +export class CliError extends Error { + constructor( + kind: CliErrorKind, + message: string, + options: CliErrorOptions = {}, + ) { + super(message, { cause: options.cause }); + this.name = "CliError"; + this.kind = kind; + this.details = options.details ?? []; + this.hint = options.hint; + this.output = options.output; + this.exitCode = + options.exitCode ?? + (kind === "usage" ? EXIT_CODE.usage : EXIT_CODE.failure); + } + readonly details: readonly string[]; + readonly exitCode: number; + readonly hint?: string; + readonly kind: CliErrorKind; + + readonly output?: string; +} + +/** The command line is wrong: an unknown flag, a missing confirmation. */ +export class UserError extends CliError { + constructor(message: string, options?: CliErrorOptions) { + super("usage", message, options); + this.name = "UserError"; + } +} + +/** The project is not set up the way the command needs. */ +export class ConfigError extends CliError { + constructor(message: string, options?: CliErrorOptions) { + super("config", message, options); + this.name = "ConfigError"; + } +} + +/** Something was checked and found invalid - a plugin, a schema. */ +export class ValidationError extends CliError { + constructor(message: string, options?: CliErrorOptions) { + super("validation", message, options); + this.name = "ValidationError"; + } +} + +/** A real operation failed: a build, a migration, a child process. */ +export class RuntimeError extends CliError { + constructor(message: string, options?: CliErrorOptions) { + super("runtime", message, options); + this.name = "RuntimeError"; + } +} + +export const isCliError = (error: unknown): error is CliError => + error instanceof CliError; + +/** The message of anything thrown, for places that only need one line. */ +export const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); diff --git a/packages/vitnode/scripts/cli/help.ts b/packages/vitnode/scripts/cli/help.ts new file mode 100644 index 000000000..8dda27f64 --- /dev/null +++ b/packages/vitnode/scripts/cli/help.ts @@ -0,0 +1,63 @@ +import type { Ui } from "./ui/ui"; + +import { padEnd } from "./ui/colors"; + +/** + * The public commands, in the order root help lists them. + * + * One list for both the parser's descriptions and the help screen, so the two + * cannot describe different CLIs. + */ +export const PUBLIC_COMMANDS = [ + { description: "Start the development environment", name: "dev" }, + { description: "Build for production", name: "build" }, + { description: "Start the production server", name: "start" }, + { description: "Create a plugin", name: "plugin create" }, + { description: "List plugins", name: "plugin list" }, + { description: "Validate plugins", name: "plugin validate" }, + { description: "Generate a migration", name: "db generate" }, + { description: "Run pending migrations", name: "db migrate" }, + { description: "Push schema changes (development)", name: "db push" }, + { description: "Show migration status", name: "db status" }, +] as const; + +export type PublicCommandName = (typeof PUBLIC_COMMANDS)[number]["name"]; + +export const describeCommand = (name: PublicCommandName): string => + PUBLIC_COMMANDS.find(command => command.name === name)?.description ?? ""; + +const EXAMPLES = [ + "vitnode dev", + "vitnode plugin create blog", + "vitnode build --analyze", +]; + +/** `vitnode` and `vitnode --help`: short on purpose - the docs hold the rest. */ +export const renderRootHelp = (ui: Ui): string => { + const { colors, symbols } = ui; + const width = Math.max(...PUBLIC_COMMANDS.map(({ name }) => name.length)) + 4; + const title = (text: string) => colors.bold(text); + + return [ + "", + ` ${colors.primary(`${symbols.brand} VitNode`)}`, + ` ${colors.muted("Build extensible applications and communities.")}`, + "", + title("Usage"), + ` ${colors.command("vitnode [options]")}`, + "", + title("Commands"), + ...PUBLIC_COMMANDS.map( + ({ description, name }) => + ` ${padEnd(colors.command(name), width)}${description}`, + ), + "", + title("Examples"), + ...EXAMPLES.map(example => ` ${colors.command(example)}`), + "", + colors.muted( + `Run ${colors.command("vitnode --help")} for a command's options.`, + ), + "", + ].join("\n"); +}; diff --git a/packages/vitnode/scripts/cli/index.test.ts b/packages/vitnode/scripts/cli/index.test.ts new file mode 100644 index 000000000..5fb08b14e --- /dev/null +++ b/packages/vitnode/scripts/cli/index.test.ts @@ -0,0 +1,171 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { PUBLIC_COMMANDS } from "./help"; +import { runCli } from "./index"; +import { createFakeRuntime } from "./testing"; + +const run = async ( + argv: string[], + options: Parameters[0] = {}, +) => { + const runtime = createFakeRuntime(options); + const code = await runCli(argv, runtime); + + return { + code, + errors: runtime.errors(), + output: runtime.output(), + raw: runtime.raw(), + }; +}; + +describe("vitnode", () => { + it("prints the root help and succeeds", async () => { + const { code, output } = await run([]); + + expect(code).toBe(0); + expect(output).toMatchInlineSnapshot(` + " + ◆ VitNode + Build extensible applications and communities. + + Usage + vitnode [options] + + Commands + dev Start the development environment + build Build for production + start Start the production server + plugin create Create a plugin + plugin list List plugins + plugin validate Validate plugins + db generate Generate a migration + db migrate Run pending migrations + db push Push schema changes (development) + db status Show migration status + + Examples + vitnode dev + vitnode plugin create blog + vitnode build --analyze + + Run vitnode --help for a command's options. + " + `); + }); + + it("prints the same help for --help", async () => { + expect((await run(["--help"])).output).toBe((await run([])).output); + expect((await run(["--help"])).code).toBe(0); + }); + + it("prints the version", async () => { + const { code, output } = await run(["--version"]); + + expect(code).toBe(0); + expect(output.trim()).toBe("1.2.3-test"); + }); + + it("lists only the public commands - legacy names keep working but stay out of help", async () => { + const { output } = await run([]); + + expect(PUBLIC_COMMANDS.every(({ name }) => output.includes(name))).toBe( + true, + ); + expect(output).not.toContain("db:prepare"); + expect(output).not.toContain("i18n:"); + }); + + it("never mentions out-of-scope commands", async () => { + const { output } = await run([]); + + for (const command of [ + "doctor", + "info", + "seed", + "cache", + "update", + "enable", + "disable", + ]) { + expect(output).not.toMatch(new RegExp(`\\b${command}\\b`)); + } + }); +}); + +describe("usage errors", () => { + it("refuses an unknown command with exit code 2 and a hint", async () => { + const { code, errors } = await run(["deploy"]); + + expect(code).toBe(2); + expect(errors).toContain("✖ unknown command 'deploy'"); + expect(errors).toContain("vitnode --help"); + }); + + it("suggests the closest command for a typo", async () => { + expect((await run(["biuld"])).errors).toContain("Did you mean build?"); + }); + + it("refuses an unknown flag on a known command", async () => { + const { code, errors } = await run(["build", "--minify"]); + + expect(code).toBe(2); + expect(errors).toContain("unknown option '--minify'"); + }); + + it("refuses an unknown subcommand", async () => { + const { code, errors } = await run(["db", "seed"]); + + expect(code).toBe(2); + expect(errors).toContain("unknown command 'seed'"); + }); + + it("reports errors in plain mode when asked to, without colors", async () => { + const { errors, raw } = await run(["build", "--plain", "--minify"], { + interactive: true, + }); + + expect(errors).toContain("[ERROR] unknown option '--minify'"); + expect(raw).not.toContain("\x1b["); + }); + + it("keeps the old flag validation of the legacy commands", async () => { + expect((await run(["i18n:check", "--cii"])).code).toBe(2); + expect((await run(["migrate", "--generat"])).code).toBe(2); + }); +}); + +describe("command help", () => { + it.each([ + ["build", ["--analyze", "--plain", "--verbose"]], + ["dev", ["--port", "--host"]], + ["start", ["--port", "--host"]], + ["db", ["generate", "migrate", "push", "status"]], + ["plugin", ["create", "list", "validate"]], + ])("vitnode %s --help documents its options", async (command, expected) => { + const { code, output } = await run([command, "--help"]); + + expect(code).toBe(0); + expected.forEach(option => { + expect(output).toContain(option); + }); + }); + + it("documents the production guard of db push", async () => { + const { output } = await run(["db", "push", "--help"]); + + expect(output).toContain("--force"); + expect(output).toContain("--accept-data-loss"); + }); +}); + +describe("errors from commands", () => { + it("explains a missing project instead of printing a stack", async () => { + const { code, errors } = await run(["build"], { cwd: "/" }); + + expect(code).toBe(1); + expect(errors).toContain("✖ No package.json found"); + expect(errors).not.toContain(" at "); + }); +}); diff --git a/packages/vitnode/scripts/cli/index.ts b/packages/vitnode/scripts/cli/index.ts new file mode 100644 index 000000000..306f90aa1 --- /dev/null +++ b/packages/vitnode/scripts/cli/index.ts @@ -0,0 +1,80 @@ +import { CommanderError } from "commander"; + +import type { CliRuntime, OutputOptions } from "./context"; +import type { Ui } from "./ui/ui"; + +import { createCommandContext, createCommandUi } from "./context"; +import { EXIT_CODE, UserError } from "./errors"; +import { renderRootHelp } from "./help"; +import { createProgram } from "./program"; +import { reportError } from "./report-error"; + +export type { CliRuntime } from "./context"; + +/** Commander's own exits that are not failures: help and version. */ +const QUIET_EXITS = new Set([ + "commander.help", + "commander.helpDisplayed", + "commander.version", +]); + +const fromCommanderError = (error: CommanderError): UserError => + new UserError(error.message.replace(/^error:\s*/i, ""), { + hint: "Run vitnode --help to see every command, or vitnode --help for its options.", + }); + +/** + * Runs one invocation and resolves with the exit code. + * + * Never calls `process.exit` and never throws - the entry point decides what + * to do with the number, which is what lets the whole CLI run inside a test. + */ +export const runCli = async ( + argv: readonly string[], + runtime: CliRuntime, +): Promise => { + // Read before parsing, so even a usage error is reported the way the + // developer asked: plain in CI, with a stack trace under --verbose. + const requested: OutputOptions = { + plain: argv.includes("--plain"), + verbose: argv.includes("--verbose") || runtime.env.VITNODE_DEBUG === "1", + }; + let ui: Ui = createCommandUi(runtime, requested); + let exitCode: number = EXIT_CODE.ok; + + if (argv.length === 0) { + ui.line(renderRootHelp(ui).trimEnd()); + + return EXIT_CODE.ok; + } + + const program = createProgram({ + context: options => { + ui = createCommandUi(runtime, { + plain: options.plain ?? requested.plain, + verbose: options.verbose ?? requested.verbose, + }); + + return createCommandContext(runtime, ui); + }, + setExitCode: code => { + exitCode = code; + }, + ui, + version: runtime.version, + }); + + try { + await program.parseAsync([...argv], { from: "user" }); + + return exitCode; + } catch (error) { + if (error instanceof CommanderError) { + if (QUIET_EXITS.has(error.code)) return error.exitCode; + + return reportError(ui, fromCommanderError(error)); + } + + return reportError(ui, error); + } +}; diff --git a/packages/vitnode/scripts/cli/plugins/create.ts b/packages/vitnode/scripts/cli/plugins/create.ts new file mode 100644 index 000000000..04168936d --- /dev/null +++ b/packages/vitnode/scripts/cli/plugins/create.ts @@ -0,0 +1,159 @@ +import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; + +import type { PackageJson } from "../project/packages"; +import type { TemplateFile } from "./template"; + +import { ConfigError, UserError } from "../errors"; +import { readPackageJson } from "../project/packages"; +import { toDisplayPath } from "../ui/format"; +import { isPluginPackage } from "./discover"; +import { validatePackageName, validatePluginName } from "./naming"; +import { + findWorkspaceRoot, + workspaceGlobs, + workspacePackageDirs, +} from "./workspace"; + +export interface PluginWorkspace { + /** Package name → directory, for every package the workspace declares. */ + packages: Map; + /** Where new plugins go. */ + pluginsDir: string; + /** Whether the workspace globs cover `pluginsDir`, i.e. pnpm will link it. */ + pluginsDirIsLinked: boolean; + root: string; +} + +/** + * The workspace a new plugin is created in, and where in it. + * + * Plugins live in `plugins/` - the folder VitNode's own repository and every + * generated monorepo use. A workspace that keeps its plugins elsewhere is + * followed instead: the folder its existing plugins are in wins. + */ +export const resolvePluginWorkspace = (cwd: string): PluginWorkspace => { + const root = findWorkspaceRoot(cwd); + + if (root === null) { + throw new ConfigError( + "Plugins are created inside a workspace, and none was found.", + { + hint: "Run this from a VitNode monorepo (one with pnpm-workspace.yaml or package.json workspaces).", + }, + ); + } + + const packages = new Map(); + const dirs = workspacePackageDirs(root); + for (const dir of dirs) { + const name = readPackageJson(dir)?.name; + if (name !== undefined) packages.set(name, dir); + } + + const existingPluginDir = dirs.find(dir => isPluginPackage(dir)); + const pluginsDir = + existingPluginDir === undefined + ? join(root, "plugins") + : dirname(existingPluginDir); + const relativeDir = toDisplayPath(relative(root, pluginsDir)); + + return { + packages, + pluginsDir, + pluginsDirIsLinked: workspaceGlobs(root).some( + glob => glob === `${relativeDir}/*` || glob === `${relativeDir}/**`, + ), + root, + }; +}; + +export interface PluginPlan { + description: string; + name: string; + packageName: string; + targetDir: string; + workspace: PluginWorkspace; +} + +/** + * Checks everything that could make creating the plugin fail or clobber + * something - before a single file is written. + */ +export const planPlugin = ({ + description, + name, + packageName, + workspace, +}: { + description: string; + name: string; + packageName: string; + workspace: PluginWorkspace; +}): PluginPlan => { + const nameProblem = validatePluginName(name); + if (nameProblem !== null) throw new UserError(nameProblem); + + const packageProblem = validatePackageName(packageName); + if (packageProblem !== null) throw new UserError(packageProblem); + + const existing = workspace.packages.get(packageName); + if (existing !== undefined) { + throw new UserError( + `A package named "${packageName}" already exists at ${toDisplayPath(relative(workspace.root, existing))}.`, + { + hint: "The package name is the plugin id, so it has to be unique - pick another name or --package-name.", + }, + ); + } + + const targetDir = join(workspace.pluginsDir, name); + if (existsSync(targetDir) && readdirSync(targetDir).length > 0) { + throw new UserError( + `${toDisplayPath(relative(workspace.root, targetDir))} already exists and is not empty.`, + { + hint: "VitNode never overwrites an existing plugin - choose another name.", + }, + ); + } + + return { description, name, packageName, targetDir, workspace }; +}; + +/** + * Dependency versions for a new plugin, taken from `@vitnode/core` itself, so + * a plugin is generated against the versions the core it depends on was built + * and tested with. Inside a workspace that contains core (VitNode's own + * repository) core and its config are linked rather than installed. + */ +export const pluginDependencyVersions = ( + core: null | PackageJson, + workspace: PluginWorkspace, +): Record => { + const version = core?.version ?? "latest"; + const linked = (name: string) => + workspace.packages.has(name) ? "workspace:*" : `^${version}`; + + return { + ...core?.peerDependencies, + ...core?.dependencies, + ...core?.devDependencies, + "@vitnode/config": linked("@vitnode/config"), + "@vitnode/core": linked("@vitnode/core"), + }; +}; + +/** + * Writes the files of one template group. `wx` refuses to replace anything, + * so even a file that appeared since {@link planPlugin} checked is kept. + */ +export const writeTemplateFiles = ( + targetDir: string, + files: readonly TemplateFile[], +): void => { + for (const file of files) { + const path = join(targetDir, file.path); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, file.content, { encoding: "utf8", flag: "wx" }); + } +}; diff --git a/packages/vitnode/scripts/cli/plugins/discover.ts b/packages/vitnode/scripts/cli/plugins/discover.ts new file mode 100644 index 000000000..6cffe09cb --- /dev/null +++ b/packages/vitnode/scripts/cli/plugins/discover.ts @@ -0,0 +1,187 @@ +import { existsSync } from "node:fs"; +import { dirname, join, relative, sep } from "node:path"; + +import type { PackageJson } from "../project/packages"; + +import { CORE_PLUGIN_ID } from "../../../src/framework/plugin-routes/core"; +import { findConfigFile } from "../../get-config"; +import { ConfigError, errorMessage } from "../errors"; +import { + findPackageDir, + findPackageRoot, + readPackageJson, +} from "../project/packages"; +import { findWorkspaceRoot, workspacePackageDirs } from "./workspace"; + +/** + * Where a plugin comes from: + * + * - `workspace` - a package in this repository, linked into the app. + * - `package` - installed from a registry into `node_modules`. + */ +export type PluginSource = "package" | "workspace"; + +export interface DiscoveredPlugin { + /** Listed in the app's `vitnode.config.ts`. */ + configured: boolean; + description: null | string; + /** The plugin id, which VitNode requires to equal the package name. */ + id: string; + /** The package directory, or `null` when it is configured but not installed. */ + root: null | string; + source: PluginSource; + version: null | string; +} + +export interface PluginDiscovery { + /** The app whose `vitnode.config.ts` was read, if one was found. */ + appRoot: null | string; + plugins: DiscoveredPlugin[]; + workspaceRoot: null | string; +} + +const PLUGIN_SOURCES = [ + "src/config.tsx", + "src/config.ts", + "dist/src/config.js", +]; + +const dependsOnCore = (manifest: PackageJson) => + [ + manifest.dependencies, + manifest.devDependencies, + manifest.peerDependencies, + ].some(deps => deps !== undefined && CORE_PLUGIN_ID in deps); + +/** + * Whether a package is a VitNode plugin: it depends on core and has the plugin + * definition module (`src/config.tsx`, or its build output). Adapters such as + * `@vitnode/s3` depend on core too, but define no plugin. + */ +export const isPluginPackage = (dir: string): boolean => { + const manifest = readPackageJson(dir); + if (manifest === null || manifest.name === CORE_PLUGIN_ID) return false; + + return ( + dependsOnCore(manifest) && + PLUGIN_SOURCES.some(file => existsSync(join(dir, file))) + ); +}; + +/** + * The app whose plugins a command should talk about: the one at or below + * `cwd`, otherwise the first one in the workspace `cwd` belongs to - which is + * what lets `vitnode plugin list` work from a plugin's own folder. + */ +export const findAppRoot = ( + cwd: string, + workspaceRoot: null | string, +): null | string => { + const packageRoot = findPackageRoot(cwd) ?? cwd; + const config = + findConfigFile(packageRoot, "vitnode.config.ts") ?? + (workspaceRoot === null + ? null + : findConfigFile(workspaceRoot, "vitnode.config.ts")); + + return config === null ? null : dirname(dirname(config)); +}; + +const sourceOf = (dir: string, workspaceRoot: null | string): PluginSource => { + const inNodeModules = dir.split(sep).includes("node_modules"); + + return workspaceRoot !== null && + !inNodeModules && + !relative(workspaceRoot, dir).startsWith("..") + ? "workspace" + : "package"; +}; + +const describe = ( + id: string, + dir: null | string, + configured: boolean, + workspaceRoot: null | string, +): DiscoveredPlugin => { + const manifest = dir === null ? null : readPackageJson(dir); + + return { + configured, + description: manifest?.description ?? null, + id, + root: dir, + source: dir === null ? "package" : sourceOf(dir, workspaceRoot), + version: manifest?.version ?? null, + }; +}; + +export type LoadConfiguredPluginIds = (appRoot: string) => Promise; + +/** + * The app's configured plugins, read by the same loader its Vite build uses. + * + * Imported lazily: it brings jiti and the route compiler with it, which + * nothing but plugin discovery needs. + */ +export const loadConfiguredPluginIds: LoadConfiguredPluginIds = + async appRoot => { + const { configuredPluginIds } = + await import("../../../src/framework/vite/plugin-routes"); + + return configuredPluginIds(appRoot); + }; + +/** + * Every plugin a developer would expect to see: the ones the app configures, + * then workspace plugins it does not (yet) configure. + * + * Nothing is kept in a registry of the CLI's own. Configured plugins come from + * the app's `vitnode.config.ts` - evaluated, not pattern-matched, because a + * plugin list can be built any way TypeScript allows - and workspace plugins + * from the workspace's own package globs. + */ +export const discoverPlugins = async ( + cwd: string, + { + loadIds = loadConfiguredPluginIds, + }: { loadIds?: LoadConfiguredPluginIds } = {}, +): Promise => { + const workspaceRoot = findWorkspaceRoot(cwd); + const appRoot = findAppRoot(cwd, workspaceRoot); + const plugins: DiscoveredPlugin[] = []; + + if (appRoot !== null) { + let ids: string[]; + try { + ids = await loadIds(appRoot); + } catch (error) { + throw new ConfigError( + `Could not load the plugins configured in ${relative(cwd, join(appRoot, "src", "vitnode.config.ts")) || "vitnode.config.ts"}.`, + { + cause: error, + details: [errorMessage(error).split("\n")[0] ?? ""], + hint: "Plugins are loaded from their build output - build them first (pnpm build:plugins), then run this again.", + }, + ); + } + + for (const id of ids) { + plugins.push( + describe(id, findPackageDir(appRoot, id), true, workspaceRoot), + ); + } + } + + if (workspaceRoot !== null) { + const known = new Set(plugins.map(plugin => plugin.id)); + + for (const dir of workspacePackageDirs(workspaceRoot)) { + if (!isPluginPackage(dir)) continue; + const id = readPackageJson(dir)?.name; + if (id === undefined || known.has(id)) continue; + plugins.push(describe(id, dir, false, workspaceRoot)); + } + } + + return { appRoot, plugins, workspaceRoot }; +}; diff --git a/packages/vitnode/scripts/cli/plugins/naming.test.ts b/packages/vitnode/scripts/cli/plugins/naming.test.ts new file mode 100644 index 000000000..5d46abe1f --- /dev/null +++ b/packages/vitnode/scripts/cli/plugins/naming.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + defaultPackageName, + pluginApiVariableName, + pluginVariableName, + shortNameOf, + validatePackageName, + validatePluginName, +} from "./naming"; + +describe("validatePluginName", () => { + it.each(["blog", "event-calendar", "forum2"])("accepts %s", name => { + expect(validatePluginName(name)).toBeNull(); + }); + + it.each([ + ["", "required"], + ["Blog", "not a valid plugin name"], + ["my_blog", "not a valid plugin name"], + ["2fa", "not a valid plugin name"], + ["blog-", "not a valid plugin name"], + ["my--blog", "not a valid plugin name"], + ["@acme/blog", "not a valid plugin name"], + ["admin", "reserved"], + ["core", "reserved"], + ["login", "reserved"], + ["x".repeat(51), "under 50"], + ])("refuses %j (%s)", (name, reason) => { + expect(validatePluginName(name)).toContain(reason); + }); +}); + +describe("validatePackageName", () => { + it.each(["@acme/blog", "vitnode-plugin-blog", "@vitnode/blog"])( + "accepts %s", + name => { + expect(validatePackageName(name)).toBeNull(); + }, + ); + + it.each([ + ["@vitnode/core", "core package"], + ["Acme-Blog", "lowercase"], + ["acme blog", "not a valid npm package name"], + [".hidden", "not a valid npm package name"], + ["fs", "built-in"], + ["@acme/", "not a valid npm package name"], + ])("refuses %j (%s)", (name, reason) => { + expect(validatePackageName(name)).toContain(reason); + }); +}); + +describe("package naming", () => { + it("joins the workspace's plugin scope when its plugins share one", () => { + expect( + defaultPackageName("blog", ["@vitnode/example", "@vitnode/forum"]), + ).toBe("@vitnode/blog"); + }); + + it("falls back to an unscoped vitnode-plugin- name", () => { + expect(defaultPackageName("blog", [])).toBe("vitnode-plugin-blog"); + expect(defaultPackageName("blog", ["@a/x", "@b/y"])).toBe( + "vitnode-plugin-blog", + ); + }); + + it("derives the short name from a package name", () => { + expect(shortNameOf("@acme/blog")).toBe("blog"); + expect(shortNameOf("blog")).toBe("blog"); + }); + + it("derives identifiers for the generated factories", () => { + expect(pluginVariableName("event-calendar")).toBe("eventCalendarPlugin"); + expect(pluginVariableName("blog-plugin")).toBe("blogPlugin"); + expect(pluginApiVariableName("blog")).toBe("blogApiPlugin"); + }); +}); diff --git a/packages/vitnode/scripts/cli/plugins/naming.ts b/packages/vitnode/scripts/cli/plugins/naming.ts new file mode 100644 index 000000000..e8a8964ad --- /dev/null +++ b/packages/vitnode/scripts/cli/plugins/naming.ts @@ -0,0 +1,125 @@ +import { builtinModules } from "node:module"; + +import { CORE_PLUGIN_ID } from "../../../src/framework/plugin-routes/core"; + +/** + * Folder names a plugin cannot take, because its example page is served at + * `/` and these are core's own top-level routes (or areas). A plugin + * named `admin` would fail its first build with a route collision; refusing it + * here says so before any file is written. + */ +export const RESERVED_PLUGIN_NAMES: ReadonlySet = new Set([ + "admin", + "api", + "core", + "discover", + "files", + "login", + "notifications", + "register", + "search", + "users", + "vitnode", +]); + +const NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; + +/** + * Why `name` cannot be a plugin's short name, or `null`. + * + * The short name is the folder under `plugins/`, the example route and the + * base of the package name, so it is held to the strictest of the three: + * lowercase kebab-case starting with a letter. + */ +export const validatePluginName = (name: string): null | string => { + if (name === "") return "A plugin name is required."; + if (name.length > 50) return "Keep the plugin name under 50 characters."; + if (!NAME_PATTERN.test(name)) { + return `"${name}" is not a valid plugin name. Use lowercase letters, digits and single dashes, starting with a letter - e.g. "blog" or "event-calendar".`; + } + if (RESERVED_PLUGIN_NAMES.has(name)) { + return `"${name}" is reserved by VitNode - core already serves /${name}.`; + } + + return null; +}; + +const PACKAGE_PATTERN = + /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; + +/** + * Why `name` cannot be the plugin's npm package name, or `null`. + * + * npm's own rules for new packages, plus VitNode's: the package name *is* the + * plugin id, and `@vitnode/core` belongs to core. + */ +export const validatePackageName = (name: string): null | string => { + if (name === "") return "A package name is required."; + if (name.length > 214) + return "npm package names are limited to 214 characters."; + if (name !== name.toLowerCase()) + return "npm package names must be lowercase."; + if (!PACKAGE_PATTERN.test(name)) { + return `"${name}" is not a valid npm package name.`; + } + if (name === CORE_PLUGIN_ID) + return `"${CORE_PLUGIN_ID}" is VitNode's core package.`; + if (!name.startsWith("@") && builtinModules.includes(name)) { + return `"${name}" is a Node.js built-in module.`; + } + + return null; +}; + +/** `@acme/my-blog` → `my-blog`; `blog` → `blog`. */ +export const shortNameOf = (packageName: string): string => + packageName.includes("/") + ? packageName.slice(packageName.indexOf("/") + 1) + : packageName; + +/** + * The package name a new plugin gets unless the developer types another. + * + * Follows the workspace: if its plugins share a scope (`@vitnode/blog`, + * `@vitnode/example`), the new one joins it. Otherwise it is unscoped and + * prefixed, the npm convention for an ecosystem's plugins. + */ +export const defaultPackageName = ( + name: string, + existingPluginIds: readonly string[], +): string => { + const scopes = new Set( + existingPluginIds + .filter(id => id.startsWith("@") && id.includes("/")) + .map(id => id.slice(0, id.indexOf("/"))), + ); + + return scopes.size === 1 + ? `${[...scopes][0]}/${name}` + : `vitnode-plugin-${name}`; +}; + +/** `my-blog` → `myBlogPlugin`; a leading digit gets a `vitnode` prefix. */ +export const pluginVariableName = (name: string): string => { + const camel = name + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((part, index) => + index === 0 ? part : `${part[0].toUpperCase()}${part.slice(1)}`, + ) + .join(""); + const safe = /^[A-Za-z]/.test(camel) ? camel : `vitnode${camel}`; + const stem = safe.replace(/plugin$/i, ""); + + return `${stem === "" ? safe : stem}Plugin`; +}; + +export const pluginApiVariableName = (name: string): string => + pluginVariableName(name).replace(/Plugin$/, "ApiPlugin"); + +/** `event-calendar` → `Event calendar`. */ +export const titleOf = (name: string): string => { + const words = name.replaceAll("-", " "); + + return `${words[0]?.toUpperCase() ?? ""}${words.slice(1)}`; +}; diff --git a/packages/vitnode/scripts/cli/plugins/template.ts b/packages/vitnode/scripts/cli/plugins/template.ts new file mode 100644 index 000000000..ecbc5487d --- /dev/null +++ b/packages/vitnode/scripts/cli/plugins/template.ts @@ -0,0 +1,471 @@ +import type { PackageJson } from "../project/packages"; + +import { pluginApiVariableName, pluginVariableName, titleOf } from "./naming"; + +export interface PluginTemplateInput { + description: string; + /** Short name: folder, route and the base of the variable names. */ + name: string; + packageName: string; + /** Versions for every dependency the template names. */ + versions: Record; +} + +const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; + +const pick = (versions: Record, names: readonly string[]) => + Object.fromEntries(names.map(name => [name, versions[name] ?? "*"] as const)); + +/** Exactly what the repository's own plugins depend on - and nothing more. */ +const DEPENDENCIES = [ + "@hono/zod-openapi", + "@tanstack/react-form", + "@vitnode/core", + "drizzle-kit", + "drizzle-orm", + "hono", + "react", + "react-dom", + "use-intl", + "zod", +] as const; + +const DEV_DEPENDENCIES = [ + "@swc/cli", + "@swc/core", + "@types/react", + "@types/react-dom", + "@vitnode/config", + "eslint", + "tsc-alias", + "typescript", + "vitest", +] as const; + +export const pluginPackageJson = ({ + description, + packageName, + versions, +}: PluginTemplateInput): PackageJson & Record => ({ + name: packageName, + version: "0.1.0", + description, + private: true, + type: "module", + exports: { + // Strings are copied, not compiled, so they are exported from source. + "./locales/*.json": "./src/locales/*.json", + "./*": { + import: "./dist/src/*.js", + types: "./dist/src/*.d.ts", + default: "./dist/src/*.js", + }, + }, + scripts: { + "build:plugins": "vitnode build", + dev: "vitnode dev", + lint: "eslint .", + "lint:fix": "eslint . --fix", + test: "vitest run", + "test:watch": "vitest", + }, + dependencies: pick(versions, DEPENDENCIES), + devDependencies: pick(versions, DEV_DEPENDENCIES), +}); + +const constTemplate = ({ packageName }: PluginTemplateInput) => + `export const CONFIG_PLUGIN = { + pluginId: "${packageName}" as const, +}; +`; + +const routesTemplate = ({ name }: PluginTemplateInput) => + `import { definePluginRoutes, lazy, page } from "@vitnode/core/routing"; + +import { CONFIG_PLUGIN } from "./const"; + +export const routes = definePluginRoutes([ + page("/${name}", { + component: lazy(() => import("./pages/home-page")), + messages: [\`\${CONFIG_PLUGIN.pluginId}.home\`], + }), +]); +`; + +const pageTemplate = ({ packageName }: PluginTemplateInput) => + `import type { PluginRoutePageProps } from "@vitnode/core/routing"; + +import { definePluginRoute } from "@vitnode/core/routing"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { useTranslations } from "use-intl"; + +import { CONFIG_PLUGIN } from "@/const"; + +interface HelloMessage { + message: string; +} + +export const route = definePluginRoute({ + load: async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "hello", + path: "/", + }); + + return await response.json(); + }, +}); + +const HomePage = ({ loaderData }: PluginRoutePageProps) => { + const t = useTranslations("${packageName}"); + + return ( +
+

+ {t("home.title")} +

+ +

+ {t("home.desc")} +

+ +
+ {t("home.api")} + {loaderData.message} +
+
+ ); +}; + +export default HomePage; +`; + +const messagesTemplate = ({ name, packageName }: PluginTemplateInput) => + json({ + [packageName]: { + home: { + api: "Your plugin's API answered:", + desc: "This page ships inside the plugin and is served by every app that installs it.", + title: `Hello from ${titleOf(name)}`, + }, + }, + }); + +const messagesBarrelTemplate = () => + `import type { LocaleMessagesMap } from "@vitnode/core/lib/i18n/types"; + +const messages: LocaleMessagesMap = { + en: async () => await import("./en.json", { with: { type: "json" } }), +}; + +export default messages; +`; + +const configTemplate = ({ name, packageName }: PluginTemplateInput) => + `import { buildPlugin } from "@vitnode/core/lib/plugin"; + +import { CONFIG_PLUGIN } from "@/const"; + +import messages from "./locales"; +import { routes } from "./routes"; + +export const ${pluginVariableName(name)} = () => + buildPlugin({ + ...CONFIG_PLUGIN, + localeFiles: { + en: "${packageName}/locales/en.json", + }, + messages, + routes, + }); +`; + +const apiRouteTemplate = () => + `import { z } from "@hono/zod-openapi"; +import { buildRoute } from "@vitnode/core/api/lib/route"; + +import { CONFIG_PLUGIN } from "@/const"; + +export const helloRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + path: "/", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ message: z.string() }), + }, + }, + description: "A greeting from the plugin.", + }, + }, + }, + handler: c => c.json({ message: \`Hello from \${CONFIG_PLUGIN.pluginId}!\` }), +}); +`; + +const apiModuleTemplate = () => + `import { buildModule } from "@vitnode/core/api/lib/module"; + +import { CONFIG_PLUGIN } from "@/const"; + +import { helloRoute } from "./hello.route"; + +export const helloModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "hello", + routes: [helloRoute], +}); +`; + +const apiModuleTestTemplate = ({ packageName }: PluginTemplateInput) => + `import { describe, expect, it } from "vitest"; + +import { helloModule } from "./hello.module"; + +describe("hello module", () => { + it("answers GET / with a greeting from the plugin", async () => { + const response = await helloModule.hono.request("/"); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + message: "Hello from ${packageName}!", + }); + }); +}); +`; + +const apiConfigTemplate = ({ name }: PluginTemplateInput) => + `import type { ApiPluginContract } from "@vitnode/core/api/lib/plugin"; + +import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; + +import { helloModule } from "@/api/modules/hello/hello.module"; +import { CONFIG_PLUGIN } from "@/const"; + +export const ${pluginApiVariableName(name)} = () => + buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + modules: [helloModule], + }); + +/** What an app's generated api-registry.gen.ts imports to type this plugin's routes. */ +export type VitNodeApiPlugin = ApiPluginContract< + ReturnType +>; +`; + +const globalTypesTemplate = () => + `/// + +import core from "@vitnode/core/locales/en.json" with { type: "json" }; +import plugin from "./src/locales/en.json" with { type: "json" }; + +declare module "use-intl" { + interface AppConfig { + Messages: typeof plugin & typeof core; + } +} +`; + +const readmeTemplate = ({ + description, + name, + packageName, +}: PluginTemplateInput) => + `# ${titleOf(name)} + +${description} + +A VitNode plugin. It adds a page at \`/${name}\`, served from \`src/pages/home-page.tsx\`, which loads its text from the plugin's own API (\`GET /api/${packageName}/hello\`). + +## Develop + +\`\`\`bash +vitnode dev # rebuild dist/ on every change +vitnode build # one-off build +vitnode plugin validate ${name} +\`\`\` + +Docs: https://vitnode.com/docs/dev/plugins +`; + +const TSCONFIG = { + $schema: "https://json.schemastore.org/tsconfig", + extends: "@vitnode/config/tsconfig", + compilerOptions: { + target: "ESNext", + module: "esnext", + moduleResolution: "bundler", + rootDir: "./", + outDir: "./dist", + incremental: false, + jsx: "react-jsx", + emitDeclarationOnly: true, + declaration: true, + declarationMap: true, + paths: { "@/*": ["./src/*"] }, + }, + exclude: ["node_modules"], + include: ["types", "src", "global.d.ts", "vitest.config.ts"], +}; + +const TSCONFIG_BUILD = { + $schema: "https://json.schemastore.org/tsconfig", + extends: "./tsconfig.json", + exclude: [ + "node_modules", + "vitest.config.ts", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.test-d.ts", + ], +}; + +const SWCRC = { + $schema: "https://swc.rs/schema.json", + exclude: ["\\.test\\.tsx?$", "\\.test-d\\.ts$", "^src/tests/"], + minify: true, + jsc: { + baseUrl: "./", + target: "esnext", + paths: { "@/*": ["./src/*"] }, + parser: { syntax: "typescript", tsx: true }, + transform: { react: { runtime: "automatic" } }, + }, + module: { type: "nodenext", strict: true, resolveFully: true }, +}; + +const NPMIGNORE = `/src/* +!/src/locales +!/src/locales/** + +/node_modules +/.turbo +/.swcrc +/global.d.ts +/types +/tsconfig.json +/tsconfig.build.json +/vitest.config.ts +`; + +const VITEST_CONFIG = `import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + exclude: ["**/node_modules/**", "**/dist/**"], + passWithNoTests: true, + }, + resolve: { + alias: { + "@": resolve(import.meta.dirname, "./src"), + }, + }, +}); +`; + +const ESLINT_CONFIG = `import eslintVitNode from "@vitnode/config/eslint"; +import eslintVitNodeReact from "@vitnode/config/eslint.react"; + +export default [ + ...eslintVitNode, + ...eslintVitNodeReact, + { + languageOptions: { + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; +`; + +export type TemplateGroup = + | "definition" + | "documentation" + | "package" + | "structure" + | "tests" + | "translations"; + +export interface TemplateFile { + content: string; + group: TemplateGroup; + path: string; +} + +/** + * The canonical VitNode plugin: one page, one API endpoint feeding it, its + * strings, and a test of the endpoint - the smallest plugin that exercises + * every layer, with the same structure and build setup as the plugins in + * VitNode's own repository. + */ +export const pluginTemplate = (input: PluginTemplateInput): TemplateFile[] => [ + { + content: json(pluginPackageJson(input)), + group: "package", + path: "package.json", + }, + { content: json(TSCONFIG), group: "package", path: "tsconfig.json" }, + { + content: json(TSCONFIG_BUILD), + group: "package", + path: "tsconfig.build.json", + }, + { content: json(SWCRC), group: "package", path: ".swcrc" }, + { content: NPMIGNORE, group: "package", path: ".npmignore" }, + { content: ESLINT_CONFIG, group: "package", path: "eslint.config.mjs" }, + { content: globalTypesTemplate(), group: "package", path: "global.d.ts" }, + { content: constTemplate(input), group: "definition", path: "src/const.ts" }, + { + content: configTemplate(input), + group: "definition", + path: "src/config.tsx", + }, + { + content: apiConfigTemplate(input), + group: "definition", + path: "src/config.api.ts", + }, + { content: routesTemplate(input), group: "structure", path: "src/routes.ts" }, + { + content: pageTemplate(input), + group: "structure", + path: "src/pages/home-page.tsx", + }, + { + content: apiModuleTemplate(), + group: "structure", + path: "src/api/modules/hello/hello.module.ts", + }, + { + content: apiRouteTemplate(), + group: "structure", + path: "src/api/modules/hello/hello.route.ts", + }, + { + content: messagesTemplate(input), + group: "translations", + path: "src/locales/en.json", + }, + { + content: messagesBarrelTemplate(), + group: "translations", + path: "src/locales/index.ts", + }, + { content: VITEST_CONFIG, group: "tests", path: "vitest.config.ts" }, + { + content: apiModuleTestTemplate(input), + group: "tests", + path: "src/api/modules/hello/hello.module.test.ts", + }, + { content: readmeTemplate(input), group: "documentation", path: "README.md" }, +]; diff --git a/packages/vitnode/scripts/cli/plugins/validate.ts b/packages/vitnode/scripts/cli/plugins/validate.ts new file mode 100644 index 000000000..6a96a1064 --- /dev/null +++ b/packages/vitnode/scripts/cli/plugins/validate.ts @@ -0,0 +1,420 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { compilePluginRoutes } from "../../../src/framework/plugin-routes/compile"; +import { + assertPluginId, + pluginsFromLoadedConfig, + routeDeclarationsFromRoutesModule, +} from "../../../src/framework/plugin-routes/resolve"; +import { errorMessage } from "../errors"; +import { readPackageJson } from "../project/packages"; +import { toDisplayPath } from "../ui/format"; + +export type CheckStatus = "error" | "ok" | "skipped" | "warning"; + +export interface PluginCheck { + /** Extra lines: what is wrong, where. */ + details: string[]; + name: string; + status: CheckStatus; + /** One line shown next to the check name. */ + summary?: string; +} + +export interface PluginValidation { + checks: PluginCheck[]; + id: string; + root: string; + valid: boolean; +} + +export type ModuleImporter = (file: string) => Promise; + +const nativeImport: ModuleImporter = async file => + import(pathToFileURL(file).href) as Promise; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * The plugin definition a module exports: the first exported factory that, + * called with no arguments, returns an object with a `pluginId`. + * + * A plugin exports `blogPlugin = () => buildPlugin(...)` under a name of its + * own choosing, so the name cannot be relied on - the shape can. + */ +export const findDefinition = ( + loaded: unknown, +): null | Record => { + if (!isRecord(loaded)) return null; + + for (const value of Object.values(loaded)) { + if (typeof value !== "function") continue; + let result: unknown; + try { + result = (value as () => unknown)(); + } catch { + continue; + } + if (isRecord(result) && typeof result.pluginId === "string") return result; + } + + return null; +}; + +/** `"@vitnode/blog.home.title"` → is that path an object or string in `messages`? */ +export const hasMessagePath = ( + messages: unknown, + namespace: string, + pluginId: string, +): boolean => { + // A namespace starts with the plugin id, which itself contains dots only in + // its scope - so it is matched as one key, not split. + const rest = namespace.startsWith(`${pluginId}.`) + ? namespace.slice(pluginId.length + 1).split(".") + : namespace === pluginId + ? [] + : null; + const head = rest === null ? namespace.split(".") : [pluginId, ...rest]; + + let current: unknown = messages; + for (const key of head) { + if (!isRecord(current) || !(key in current)) return false; + current = current[key]; + } + + return true; +}; + +const flattenKeys = (value: unknown, prefix = ""): string[] => + isRecord(value) + ? Object.entries(value).flatMap(([key, child]) => + flattenKeys(child, prefix === "" ? key : `${prefix}.${key}`), + ) + : [prefix]; + +interface NavPermission { + module?: unknown; + permission?: unknown; + plugin?: unknown; +} + +const navPermissions = ( + nav: unknown, +): { href: string; permission: NavPermission }[] => { + if (!isRecord(nav) || !isRecord(nav.admin) || !Array.isArray(nav.admin.nav)) { + return []; + } + + return (nav.admin.nav as unknown[]).flatMap(item => { + if (!isRecord(item)) return []; + const children = Array.isArray(item.items) ? (item.items as unknown[]) : []; + + return [item, ...children].flatMap(entry => + isRecord(entry) && isRecord(entry.permission) + ? [{ href: String(entry.href), permission: entry.permission }] + : [], + ); + }); +}; + +const DRIZZLE_NAME = Symbol.for("drizzle:Name"); + +const tableNames = (loaded: unknown): string[] => + isRecord(loaded) + ? Object.values(loaded).flatMap(value => + typeof value === "object" && value !== null && DRIZZLE_NAME in value + ? [String((value as Record)[DRIZZLE_NAME])] + : [], + ) + : []; + +/** + * Validates one plugin package the way an application will load it. + * + * Every check reads the plugin's *build output* - the same `dist` files an + * app's Vite plugin and API import - and reuses VitNode's own validators: + * `pluginsFromLoadedConfig` for the definition, `buildApiPlugin` (which + * validates as it builds) for the API, and `compilePluginRoutes` for routes. + * A check only exists where the plugin has the system it checks: a plugin + * without `admin/nav` is not told its navigation is fine. + */ +export const validatePlugin = async ( + root: string, + { importModule = nativeImport }: { importModule?: ModuleImporter } = {}, +): Promise => { + const checks: PluginCheck[] = []; + const manifest = readPackageJson(root); + const id = manifest?.name ?? toDisplayPath(root); + const dist = (file: string) => join(root, "dist", "src", file); + const show = (file: string) => toDisplayPath(relative(root, file)); + + const fail = (name: string, details: string[], summary?: string) => { + checks.push({ details, name, status: "error", summary }); + }; + const pass = (name: string, summary?: string) => { + checks.push({ details: [], name, status: "ok", summary }); + }; + const done = (): PluginValidation => ({ + checks, + id, + root, + valid: checks.every(check => check.status !== "error"), + }); + + // Package + const packageProblems: string[] = []; + if (manifest === null) { + packageProblems.push("package.json is missing or is not valid JSON."); + } else { + if (manifest.name === undefined) { + packageProblems.push('package.json has no "name" - it is the plugin id.'); + } else { + try { + assertPluginId(manifest.name, "package.json"); + } catch (error) { + packageProblems.push(errorMessage(error)); + } + } + if (manifest.type !== "module") { + packageProblems.push('package.json must set "type": "module".'); + } + const exportsMap = isRecord(manifest.exports) ? manifest.exports : {}; + if (!("./*" in exportsMap) && !("./config" in exportsMap)) { + packageProblems.push( + 'package.json "exports" must map "./*" (or at least "./config") to the build output, so apps can import the plugin.', + ); + } + } + if (packageProblems.length > 0) { + fail("Package", packageProblems); + + return done(); + } + pass("Package", manifest?.version); + + // Build output + const configFile = dist("config.js"); + if (!existsSync(configFile)) { + fail("Build output", [ + `${show(configFile)} does not exist - apps load plugins from their build output.`, + "Run vitnode build in the plugin's folder (or pnpm build:plugins), then validate again.", + ]); + + return done(); + } + + // Plugin definition + let definition: null | Record = null; + try { + definition = findDefinition(await importModule(configFile)); + if (definition === null) { + fail("Plugin definition", [ + `${show(configFile)} exports no factory returning buildPlugin({ pluginId, ... }).`, + ]); + } else { + pluginsFromLoadedConfig( + { vitNodeConfig: { plugins: [definition] } }, + show(configFile), + ); + if (definition.pluginId !== id) { + fail("Plugin definition", [ + `pluginId is "${String(definition.pluginId)}" but the package is "${id}". VitNode requires them to be equal - apps import the plugin by its id.`, + ]); + } else { + pass("Plugin definition"); + } + } + } catch (error) { + fail("Plugin definition", [errorMessage(error)]); + } + + // API definition + const apiFile = dist("config.api.js"); + let apiDefinition: null | Record = null; + if (existsSync(apiFile)) { + try { + apiDefinition = findDefinition(await importModule(apiFile)); + if (apiDefinition === null) { + fail("API definition", [ + `${show(apiFile)} exports no factory returning buildApiPlugin({ pluginId, ... }).`, + ]); + } else if (apiDefinition.pluginId !== id) { + fail("API definition", [ + `pluginId is "${String(apiDefinition.pluginId)}" but the package is "${id}".`, + ]); + } else { + pass("API definition"); + } + } catch (error) { + fail("API definition", [errorMessage(error)]); + } + } + + // Routes + const routesFile = dist("routes.js"); + let routeNamespaces: { namespace: string; path: string }[] = []; + if (existsSync(routesFile)) { + try { + const routes = routeDeclarationsFromRoutesModule( + await importModule(routesFile), + `${id}/routes`, + ); + const compiled = compilePluginRoutes({ + sources: [{ pluginId: id, routes, routesSpecifier: `${id}/routes` }], + }); + const { assertComponentsImportable } = + await import("../../../src/framework/vite/plugin-routes"); + assertComponentsImportable(compiled, new Map([[id, routesFile]])); + const own = compiled.manifest.filter(route => route.pluginId === id); + routeNamespaces = own.flatMap(route => + route.messages.map(namespace => ({ namespace, path: route.path })), + ); + pass( + "Routes", + own.length === 1 ? "1 route" : `${String(own.length)} routes`, + ); + } catch (error) { + fail("Routes", [errorMessage(error)]); + } + } + + // Translations + const localeFiles = isRecord(definition?.localeFiles) + ? definition.localeFiles + : null; + if (localeFiles !== null) { + const problems: string[] = []; + const warnings: string[] = []; + const resolveFromPlugin = createRequire(join(root, "package.json")); + const loaded = new Map(); + + for (const [locale, specifier] of Object.entries(localeFiles)) { + try { + const file = resolveFromPlugin.resolve(String(specifier)); + const json: unknown = JSON.parse(readFileSync(file, "utf8")); + if (!isRecord(json) || !(id in json)) { + problems.push( + `${show(file)} has no top-level "${id}" key - a plugin's strings live under its id.`, + ); + } + loaded.set(locale, json); + } catch (error) { + problems.push( + `${locale}: ${String(specifier)} could not be read (${errorMessage(error).split("\n")[0]}).`, + ); + } + } + + const [defaultLocale, defaultMessages] = [...loaded.entries()][0] ?? []; + if (defaultLocale !== undefined) { + for (const { namespace, path } of routeNamespaces) { + if (!hasMessagePath(defaultMessages, namespace, id)) { + problems.push( + `Route ${path} loads the namespace "${namespace}", which ${defaultLocale} does not define.`, + ); + } + } + + const expected = new Set(flattenKeys(defaultMessages)); + for (const [locale, messages] of loaded) { + if (locale === defaultLocale) continue; + const have = new Set(flattenKeys(messages)); + const missing = [...expected].filter(key => !have.has(key)); + if (missing.length > 0) { + warnings.push( + `${locale} is missing ${String(missing.length)} key(s) ${defaultLocale} has, e.g. ${missing[0]}.`, + ); + } + } + } + + if (problems.length > 0) fail("Translations", [...problems, ...warnings]); + else if (warnings.length > 0) { + checks.push({ + details: warnings, + name: "Translations", + status: "warning", + }); + } else { + pass("Translations", [...loaded.keys()].join(", ")); + } + } + + // Permissions referenced by the AdminCP navigation + const navFile = dist("admin/nav.js"); + if (existsSync(navFile)) { + try { + const navModule = await importModule(navFile); + const nav = isRecord(navModule) ? navModule.adminNav : undefined; + const declared = isRecord(apiDefinition?.permissionStaff) + ? apiDefinition.permissionStaff.admin + : undefined; + const problems = navPermissions(nav).flatMap(({ href, permission }) => { + if (permission.plugin !== undefined && permission.plugin !== id) + return []; + const module = String(permission.module); + const wanted = String(permission.permission); + const entries = + isRecord(declared) && Array.isArray(declared[module]) + ? (declared[module] as unknown[]) + : []; + const registered = entries.some(entry => + typeof entry === "string" + ? entry === wanted + : isRecord(entry) && entry.permission === wanted, + ); + + return registered + ? [] + : [ + `AdminCP navigation item ${href} requires the permission ${module}.${wanted},`, + `but ${id} does not register it in buildApiPlugin({ permissionStaff: { admin } }).`, + ]; + }); + + if (problems.length > 0) { + fail("AdminCP navigation", [...problems, show(navFile)]); + } else pass("AdminCP navigation"); + } catch (error) { + fail("AdminCP navigation", [errorMessage(error)]); + } + } + + // Database schema + const databaseDir = dist("database"); + if (existsSync(databaseDir)) { + const files = readdirSync(databaseDir).filter(file => file.endsWith(".js")); + const problems: string[] = []; + const seen = new Map(); + + for (const file of files) { + const path = join(databaseDir, file); + try { + for (const table of tableNames(await importModule(path))) { + const previous = seen.get(table); + if (previous !== undefined && previous !== file) { + problems.push( + `Table "${table}" is declared in both ${previous} and ${file}.`, + ); + } + seen.set(table, file); + } + } catch (error) { + problems.push(`${show(path)}: ${errorMessage(error).split("\n")[0]}`); + } + } + + if (problems.length > 0) fail("Database schema", problems); + else + pass( + "Database schema", + seen.size === 1 ? "1 table" : `${String(seen.size)} tables`, + ); + } + + return done(); +}; diff --git a/packages/vitnode/scripts/cli/plugins/workspace.test.ts b/packages/vitnode/scripts/cli/plugins/workspace.test.ts new file mode 100644 index 000000000..b6f2ce5a8 --- /dev/null +++ b/packages/vitnode/scripts/cli/plugins/workspace.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment node +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + findWorkspaceRoot, + parsePnpmWorkspaceGlobs, + workspacePackageDirs, +} from "./workspace"; + +describe("parsePnpmWorkspaceGlobs", () => { + it("reads the packages list and nothing else", () => { + expect( + parsePnpmWorkspaceGlobs(`packages: + - apps/* + - "packages/*" + - 'plugins/*' # plugins +allowBuilds: + esbuild: true +catalog: + - not-a-package +`), + ).toEqual(["apps/*", "packages/*", "plugins/*"]); + }); +}); + +describe("workspace discovery", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "vitnode-workspace-")); + }); + + afterEach(() => { + rmSync(root, { force: true, recursive: true }); + }); + + it("finds package folders from package.json workspaces", () => { + writeFileSync( + join(root, "package.json"), + JSON.stringify({ workspaces: ["apps/*", "tools"] }), + ); + for (const dir of ["apps/web", "apps/api", "tools"]) { + mkdirSync(join(root, dir), { recursive: true }); + writeFileSync(join(root, dir, "package.json"), "{}"); + } + mkdirSync(join(root, "apps", "not-a-package")); + mkdirSync(join(root, "apps", "web", "src"), { recursive: true }); + + expect(findWorkspaceRoot(join(root, "apps", "web", "src"))).toBe(root); + expect(workspacePackageDirs(root)).toEqual([ + join(root, "apps", "api"), + join(root, "apps", "web"), + join(root, "tools"), + ]); + }); + + it("returns null outside a workspace", () => { + expect(findWorkspaceRoot(root)).toBeNull(); + }); +}); diff --git a/packages/vitnode/scripts/cli/plugins/workspace.ts b/packages/vitnode/scripts/cli/plugins/workspace.ts new file mode 100644 index 000000000..8314cff64 --- /dev/null +++ b/packages/vitnode/scripts/cli/plugins/workspace.ts @@ -0,0 +1,79 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +import { readPackageJson } from "../project/packages"; + +/** The closest directory at or above `from` that declares a workspace. */ +export const findWorkspaceRoot = (from: string): null | string => { + let current = resolve(from); + + for (;;) { + if (existsSync(join(current, "pnpm-workspace.yaml"))) return current; + if (readPackageJson(current)?.workspaces !== undefined) return current; + + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +}; + +/** + * The `packages:` globs of a `pnpm-workspace.yaml`, read line by line. + * + * Only the list itself is needed, and it is always a flat sequence of strings - + * not worth a YAML parser in the CLI's dependencies. + */ +export const parsePnpmWorkspaceGlobs = (source: string): string[] => { + const globs: string[] = []; + let inPackages = false; + + for (const raw of source.split(/\r?\n/)) { + const line = raw.replace(/#.*$/, "").trimEnd(); + if (line.trim() === "") continue; + + if (/^\S/.test(line)) { + inPackages = /^packages\s*:/.test(line); + continue; + } + + const item = /^\s*-\s*["']?([^"']+)["']?\s*$/.exec(line); + if (inPackages && item) globs.push(item[1]); + } + + return globs; +}; + +export const workspaceGlobs = (workspaceRoot: string): string[] => { + const pnpm = join(workspaceRoot, "pnpm-workspace.yaml"); + if (existsSync(pnpm)) { + return parsePnpmWorkspaceGlobs(readFileSync(pnpm, "utf8")); + } + + const { workspaces } = readPackageJson(workspaceRoot) ?? {}; + + return Array.isArray(workspaces) ? workspaces : (workspaces?.packages ?? []); +}; + +/** + * Every package directory a workspace declares. + * + * Supports the two shapes VitNode workspaces use - `dir/*` and an exact `dir` - + * and skips negations. A deeper glob (`dir/**`) is treated as `dir/*`. + */ +export const workspacePackageDirs = (workspaceRoot: string): string[] => + workspaceGlobs(workspaceRoot) + .filter(glob => !glob.startsWith("!")) + .flatMap(glob => { + const wildcard = glob.indexOf("*"); + if (wildcard === -1) return [join(workspaceRoot, glob)]; + + const base = join(workspaceRoot, glob.slice(0, wildcard)); + if (!existsSync(base)) return []; + + return readdirSync(base) + .filter(entry => !entry.startsWith(".")) + .map(entry => join(base, entry)) + .filter(dir => statSync(dir).isDirectory()); + }) + .filter(dir => existsSync(join(dir, "package.json"))) + .sort(); diff --git a/packages/vitnode/scripts/cli/program.ts b/packages/vitnode/scripts/cli/program.ts new file mode 100644 index 000000000..423279595 --- /dev/null +++ b/packages/vitnode/scripts/cli/program.ts @@ -0,0 +1,230 @@ +import { Command, Option } from "commander"; + +import type { CommandContext, OutputOptions } from "./context"; +import type { PublicCommandName } from "./help"; +import type { Ui } from "./ui/ui"; + +import { describeCommand, renderRootHelp } from "./help"; + +/** + * A command's handler, resolved lazily: `vitnode --help` imports none of them, + * and `vitnode db status` never loads Vite. + */ +type Handler = (context: CommandContext, options: O) => Promise; + +export interface ProgramHooks { + /** Builds the context a handler runs with, from that command's own flags. */ + context: (options: OutputOptions) => CommandContext; + /** Records what the CLI should exit with once the handler resolves. */ + setExitCode: (code: number) => void; + /** Where commander's own output (help, usage errors) is written. */ + ui: Ui; + version: string; +} + +const outputOptions = (command: Command): Command => + command + .option("--plain", "plain, line-based output without colors or animation") + .option("--verbose", "show underlying tool output and full stack traces"); + +export const createProgram = (hooks: ProgramHooks): Command => { + const run = + ( + load: () => Promise>, + fromArguments?: (values: unknown[]) => Partial, + ) => + async (...actionArgs: unknown[]) => { + // Commander passes the positionals first and the command itself last. + const command = actionArgs[actionArgs.length - 1] as Command; + const options = { + ...command.opts(), + ...fromArguments?.(command.processedArgs as unknown[]), + }; + const handler = await load(); + + hooks.setExitCode(await handler(hooks.context(options), options)); + }; + + const program = new Command("vitnode") + .description("Build extensible applications and communities.") + .version(hooks.version, "-v, --version", "print the VitNode version") + .helpOption("-h, --help", "show help") + .helpCommand(false) + .showSuggestionAfterError(true) + .exitOverride() + .configureOutput({ + // Usage errors are thrown and reported by the CLI's own boundary, in the + // same style as every other failure - not printed here as well. + outputError: () => undefined, + writeErr: text => { + hooks.ui.writeError(text.trimEnd()); + }, + writeOut: text => { + hooks.ui.line(text.trimEnd()); + }, + }) + .configureHelp({ + styleCommandText: text => hooks.ui.colors.command(text), + styleTitle: text => hooks.ui.colors.bold(text), + }); + + program.helpInformation = () => renderRootHelp(hooks.ui); + + const describe = (name: PublicCommandName) => describeCommand(name); + + outputOptions(program.command("dev")) + .description(describe("dev")) + .option("-p, --port ", "port for the dev server") + .option("--host [host]", "listen on all addresses, or on the given host") + .action(run(async () => (await import("./commands/dev")).runDevCommand)); + + outputOptions(program.command("build")) + .description(describe("build")) + .option("--analyze", "show what contributes to the largest client bundles") + .action( + run(async () => (await import("./commands/build")).runBuildCommand), + ); + + outputOptions(program.command("start")) + .description(describe("start")) + .option("-p, --port ", "port for the production server") + .option("--host ", "host for the production server") + .action( + run(async () => (await import("./commands/start")).runStartCommand), + ); + + const plugin = program + .command("plugin") + .description("Create, list and validate plugins"); + + outputOptions(plugin.command("create")) + .description(describe("plugin create")) + .argument("[name]", "plugin name, e.g. blog") + .option("--package-name ", "npm package name (default: derived)") + .option("--description ", "one-line description") + .option("-y, --yes", "accept the defaults instead of asking") + .action( + run( + async () => + (await import("./commands/plugin-create")).runPluginCreateCommand, + ([name]) => ({ name: name as string | undefined }), + ), + ); + + outputOptions(plugin.command("list")) + .description(describe("plugin list")) + .action( + run( + async () => + (await import("./commands/plugin-list")).runPluginListCommand, + ), + ); + + outputOptions(plugin.command("validate")) + .description(describe("plugin validate")) + .argument("[name]", "plugin id, package name or folder (default: all)") + .action( + run( + async () => + (await import("./commands/plugin-validate")).runPluginValidateCommand, + ([name]) => ({ name: name as string | undefined }), + ), + ); + + const db = program + .command("db") + .description("Generate, apply and inspect database migrations"); + + outputOptions(db.command("generate")) + .description(describe("db generate")) + .option("--name ", "migration name") + .action( + run(async () => (await import("./commands/db")).runDbGenerateCommand), + ); + + outputOptions(db.command("migrate")) + .description(describe("db migrate")) + .option("-y, --yes", "apply without asking") + .action( + run(async () => (await import("./commands/db")).runDbMigrateCommand), + ); + + outputOptions(db.command("push")) + .description(describe("db push")) + .option("-y, --yes", "apply without asking") + .option("--accept-data-loss", "allow statements that may delete data") + .addOption( + new Option( + "--force", + "allow pushing when NODE_ENV is production", + ).default(false), + ) + .action(run(async () => (await import("./commands/db")).runDbPushCommand)); + + outputOptions(db.command("status")) + .description(describe("db status")) + .action( + run(async () => (await import("./commands/db")).runDbStatusCommand), + ); + + registerLegacyCommands(program, run); + + return program; +}; + +/** + * The commands that existed before the CLI had a public surface. + * + * Kept working - they are in generated projects' `package.json` files and in + * deployment guides - but left out of the root help, which lists the commands a + * developer should reach for today. + */ +const registerLegacyCommands = ( + program: Command, + run: ( + load: () => Promise>, + ) => (...actionArgs: unknown[]) => Promise, +) => { + const legacy = async () => import("./commands/legacy"); + + outputOptions(program.command("db:prepare", { hidden: true })) + .description("Generate and apply migrations, then seed initial data") + .action(run(async () => (await legacy()).runDbPrepareCommand)); + + outputOptions(program.command("migrate", { hidden: true })) + .description("Same as db:prepare; --generate only generates") + .option("--generate", "only generate migrations") + .action(run(async () => (await legacy()).runMigrateCommand)); + + program + .command("i18n:check", { hidden: true }) + .description("Find missing and unused translation keys") + .option("--ci", "exit with an error when keys are missing") + .action(run(async () => (await legacy()).runI18nCheckCommand)); + + program + .command("i18n:create", { hidden: true }) + .description("Add a language") + .argument("[code]") + .argument("[name...]") + .action(run(async () => (await legacy()).runI18nCreateCommand)); + + program + .command("i18n:delete", { hidden: true }) + .description("Remove a language") + .argument("[code]") + .action(run(async () => (await legacy()).runI18nDeleteCommand)); + + program + .command("i18n:update", { hidden: true }) + .description("Sync translation files with the default language") + .action(run(async () => (await legacy()).runI18nUpdateCommand)); + + program + .command("i18n:update:ai", { hidden: true }) + .description("Translate missing keys with AI") + .argument("[codes...]") + .option("--model ", "model id") + .option("--concurrency ", "parallel requests") + .action(run(async () => (await legacy()).runI18nUpdateAiCommand)); +}; diff --git a/packages/vitnode/scripts/cli/project/packages.ts b/packages/vitnode/scripts/cli/project/packages.ts new file mode 100644 index 000000000..cfc17f37f --- /dev/null +++ b/packages/vitnode/scripts/cli/project/packages.ts @@ -0,0 +1,125 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { ConfigError } from "../errors"; + +export interface PackageJson { + bin?: Record | string; + dependencies?: Record; + description?: string; + devDependencies?: Record; + exports?: unknown; + name?: string; + peerDependencies?: Record; + private?: boolean; + scripts?: Record; + type?: string; + version?: string; + workspaces?: string[] | { packages?: string[] }; +} + +export const readPackageJson = (dir: string): null | PackageJson => { + const file = join(dir, "package.json"); + if (!existsSync(file)) return null; + + try { + return JSON.parse(readFileSync(file, "utf8")) as PackageJson; + } catch { + return null; + } +}; + +/** The closest directory at or above `from` with a `package.json`. */ +export const findPackageRoot = (from: string): null | string => { + let current = resolve(from); + + for (;;) { + if (existsSync(join(current, "package.json"))) return current; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +}; + +/** + * Where an installed package lives, as Node would find it from `from`. + * + * Walks `node_modules` folders upward instead of `require.resolve`-ing + * `/package.json`, because a package with an `exports` map may not export + * its manifest - drizzle-kit does not - and that is no reason to call it + * missing. Symlinks are followed, so a pnpm workspace link reports the real + * package directory. + */ +export const findPackageDir = (from: string, name: string): null | string => { + let current = resolve(from); + + for (;;) { + const candidate = join(current, "node_modules", name); + if (existsSync(join(candidate, "package.json"))) { + return realpathSync(candidate); + } + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +}; + +/** + * The absolute path of a package's executable, run later with `node` itself. + * + * Executing the script through `process.execPath` rather than the `.bin` shim + * is what keeps spawning shell-free on every platform: on Windows the shim is a + * `.cmd` file, which only a shell can run. + */ +export const resolveBin = ( + from: string, + packageName: string, + binName: string = packageName.split("/").pop() ?? packageName, +): string => { + const dir = findPackageDir(from, packageName); + const manifest = dir === null ? null : readPackageJson(dir); + + if (dir === null || manifest === null) { + throw new ConfigError(`"${packageName}" is not installed.`, { + hint: `Add it to the project, e.g. pnpm add -D ${packageName}`, + }); + } + + const bin = + typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.[binName]; + + if (bin === undefined) { + throw new ConfigError( + `"${packageName}" does not provide a "${binName}" executable.`, + ); + } + + return join(dir, bin); +}; + +/** + * Imports a package the way the project itself would, not the way the CLI + * would. + * + * Vite in particular has to be the app's own copy: its config, its plugins and + * the Vite version they were written against all live there. + */ +export const importFromProject = async ( + root: string, + specifier: string, +): Promise => { + let file: string; + + try { + file = createRequire(join(root, "package.json")).resolve(specifier); + } catch (error) { + throw new ConfigError(`Could not find "${specifier}" from ${root}.`, { + cause: error, + hint: `Install it in the project, e.g. pnpm add -D ${specifier}`, + }); + } + + return (await import(pathToFileURL(file).href)) as T; +}; diff --git a/packages/vitnode/scripts/cli/project/processes.ts b/packages/vitnode/scripts/cli/project/processes.ts new file mode 100644 index 000000000..379394e55 --- /dev/null +++ b/packages/vitnode/scripts/cli/project/processes.ts @@ -0,0 +1,170 @@ +import type { ChildProcess } from "node:child_process"; + +import { spawn } from "node:child_process"; + +import { RuntimeError } from "../errors"; + +export type Signal = "SIGINT" | "SIGTERM"; + +/** `process`, as far as shutdown is concerned - injectable for tests. */ +export interface SignalSource { + off: (signal: Signal, listener: () => void) => unknown; + once: (signal: Signal, listener: () => void) => unknown; +} + +/** Resolves with the first of SIGINT or SIGTERM, then stops listening. */ +export const waitForShutdownSignal = async ( + signals: SignalSource, +): Promise => + new Promise(resolve => { + const onInt = () => { + cleanup(); + resolve("SIGINT"); + }; + const onTerm = () => { + cleanup(); + resolve("SIGTERM"); + }; + const cleanup = () => { + signals.off("SIGINT", onInt); + signals.off("SIGTERM", onTerm); + }; + + signals.once("SIGINT", onInt); + signals.once("SIGTERM", onTerm); + }); + +export interface RunProcessOptions { + args: readonly string[]; + /** + * `true` collects stdout and stderr into `output` instead of printing them, + * for steps whose output only matters when they fail. + */ + capture?: boolean; + command: string; + cwd: string; + env?: NodeJS.ProcessEnv; +} + +export interface ProcessResult { + code: number; + output: string; +} + +/** Keeps the end of a long log, which is where the error usually is. */ +const MAX_CAPTURE = 256 * 1024; + +const spawnChild = ( + options: RunProcessOptions, + stdio: "inherit" | "pipe", +): ChildProcess => + // Never `shell: true`: every command here is an absolute executable - Node + // itself, running a package's resolved bin script - so there is nothing for + // a shell to resolve, quote or get wrong on Windows. + spawn(options.command, [...options.args], { + cwd: options.cwd, + env: options.env ?? process.env, + shell: false, + stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + +/** + * Runs a command to completion. + * + * Resolves with the exit code rather than rejecting on a non-zero one: whether + * that is an error, and how to explain it, is the caller's decision. Rejects + * only when the process could not be started at all. + */ +export const runProcess = async ( + options: RunProcessOptions, +): Promise => + new Promise((resolve, reject) => { + const child = spawnChild(options, options.capture ? "pipe" : "inherit"); + let output = ""; + + const collect = (chunk: Buffer) => { + output += chunk.toString("utf8"); + if (output.length > MAX_CAPTURE) output = output.slice(-MAX_CAPTURE); + }; + + child.stdout?.on("data", collect); + child.stderr?.on("data", collect); + + child.once("error", error => { + reject( + new RuntimeError(`Could not start ${options.command}.`, { + cause: error, + }), + ); + }); + child.once("close", (code, signal) => { + resolve({ code: code ?? (signal === null ? 0 : 1), output }); + }); + }); + +/** How long a child gets to exit cleanly before it is killed outright. */ +const KILL_TIMEOUT_MS = 5000; + +/** + * Long-running children that live and die together. + * + * `vitnode dev` starts several watchers; when one of them exits, or the + * developer presses Ctrl+C, every one of them has to go - an orphaned `tsc -w` + * holding a terminal open is exactly what a dev command must never leave + * behind. + */ +export class ProcessGroup { + private readonly children = new Set(); + + /** Resolves with the exit code of whichever child exits first. */ + async firstExit(): Promise { + return new Promise(resolve => { + for (const child of this.children) { + child.once("exit", code => { + resolve(code ?? 0); + }); + } + }); + } + + spawn(options: RunProcessOptions): ChildProcess { + const child = spawnChild(options, "inherit"); + this.children.add(child); + child.once("exit", () => this.children.delete(child)); + child.once("error", () => this.children.delete(child)); + + return child; + } + + /** SIGTERM to every child, then SIGKILL to whatever is still running. */ + async stop(): Promise { + const running = [...this.children].filter( + child => child.exitCode === null && child.signalCode === null, + ); + + await Promise.all( + running.map( + async child => + new Promise(resolve => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve(); + }, KILL_TIMEOUT_MS); + timer.unref(); + + child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + child.kill("SIGTERM"); + }), + ), + ); + this.children.clear(); + } + + get size(): number { + return this.children.size; + } +} diff --git a/packages/vitnode/scripts/cli/project/project.ts b/packages/vitnode/scripts/cli/project/project.ts new file mode 100644 index 000000000..e7fa7d7e9 --- /dev/null +++ b/packages/vitnode/scripts/cli/project/project.ts @@ -0,0 +1,138 @@ +import { existsSync } from "node:fs"; +import { join, relative } from "node:path"; + +import type { PackageJson } from "./packages"; + +import { ConfigError } from "../errors"; +import { findPackageRoot, readPackageJson } from "./packages"; + +/** + * What the current directory is, as far as `dev`, `build` and `start` care. + * + * - `app` - a TanStack Start application, built and served by Vite. + * - `api` - a standalone Hono API, compiled by `tsc` and run by Node. + * - `package` - a plugin or adapter package, compiled into `dist` for apps to + * import. This is what `vitnode build` and `vitnode dev` have always meant + * inside one, and they still do. + */ +export type ProjectKind = "api" | "app" | "package"; + +export interface Project { + /** The Drizzle config, when this project owns a database schema. */ + drizzleConfig: null | string; + /** `src/vitnode.api.config.ts`, when this project serves the API. */ + hasApi: boolean; + kind: ProjectKind; + name: string; + packageJson: PackageJson; + root: string; + viteConfig: null | string; +} + +const VITE_CONFIGS = [ + "vite.config.ts", + "vite.config.mts", + "vite.config.js", + "vite.config.mjs", + "vite.config.cts", + "vite.config.cjs", +]; + +const DRIZZLE_CONFIGS = [ + "drizzle.config.ts", + "drizzle.config.mts", + "drizzle.config.js", + "drizzle.config.mjs", +]; + +const firstExisting = (root: string, files: readonly string[]) => { + const found = files.find(file => existsSync(join(root, file))); + + return found === undefined ? null : join(root, found); +}; + +export const kindOf = (root: string): null | ProjectKind => { + if (firstExisting(root, VITE_CONFIGS) !== null) return "app"; + if (existsSync(join(root, "src", "vitnode.api.config.ts"))) return "api"; + if ( + existsSync(join(root, "tsconfig.build.json")) && + existsSync(join(root, ".swcrc")) + ) { + return "package"; + } + + return null; +}; + +const isWorkspaceRoot = (root: string, manifest: PackageJson) => + manifest.workspaces !== undefined || + existsSync(join(root, "pnpm-workspace.yaml")) || + existsSync(join(root, "turbo.json")); + +export const detectProject = (cwd: string): Project => { + const root = findPackageRoot(cwd); + const packageJson = root === null ? null : readPackageJson(root); + + if (root === null || packageJson === null) { + throw new ConfigError(`No package.json found in ${cwd} or above it.`, { + hint: "Run VitNode commands from inside a VitNode app or plugin.", + }); + } + + const kind = kindOf(root); + + if (kind === null) { + throw new ConfigError( + `${relative(cwd, root) || "This directory"} is not a VitNode app, API or plugin package.`, + { + details: [ + "An app has a vite.config.ts, an API has src/vitnode.api.config.ts,", + "and a plugin package has tsconfig.build.json and .swcrc.", + ], + hint: isWorkspaceRoot(root, packageJson) + ? "This is a workspace root - run the command inside one of its apps, e.g. cd apps/web." + : undefined, + }, + ); + } + + return { + drizzleConfig: firstExisting(root, DRIZZLE_CONFIGS), + hasApi: existsSync(join(root, "src", "vitnode.api.config.ts")), + kind, + name: packageJson.name ?? relative(cwd, root), + packageJson, + root, + viteConfig: firstExisting(root, VITE_CONFIGS), + }; +}; + +/** The project, insisting that it owns a database. */ +export const requireDatabaseProject = ( + cwd: string, +): Project & { + drizzleConfig: string; +} => { + const root = findPackageRoot(cwd) ?? cwd; + const drizzleConfig = firstExisting(root, DRIZZLE_CONFIGS); + const packageJson = readPackageJson(root); + + if (drizzleConfig === null || packageJson === null) { + throw new ConfigError( + "This project does not own a database: no drizzle.config.ts was found.", + { + hint: "Run database commands in the app that owns the schema - the API app, or a single app that mounts the API.", + }, + ); + } + + return { + drizzleConfig, + hasApi: existsSync(join(root, "src", "vitnode.api.config.ts")), + kind: kindOf(root) ?? "api", + name: packageJson.name ?? root, + packageJson, + root, + viteConfig: firstExisting(root, VITE_CONFIGS), + }; +}; diff --git a/packages/vitnode/scripts/cli/report-error.ts b/packages/vitnode/scripts/cli/report-error.ts new file mode 100644 index 000000000..6fd380b15 --- /dev/null +++ b/packages/vitnode/scripts/cli/report-error.ts @@ -0,0 +1,83 @@ +import type { Ui } from "./ui/ui"; + +import { errorMessage, EXIT_CODE, isCliError } from "./errors"; + +/** Lines of a failed child's output shown without `--verbose`. */ +const OUTPUT_TAIL_LINES = 60; + +const stackOf = (error: unknown): string | undefined => + error instanceof Error ? error.stack : undefined; + +const printOutput = (ui: Ui, output: string) => { + const lines = output.trimEnd().split(/\r?\n/); + const shown = ui.verbose ? lines : lines.slice(-OUTPUT_TAIL_LINES); + + ui.writeError(); + if (shown.length < lines.length) { + ui.writeError( + ui.colors.muted( + ` … ${String(lines.length - shown.length)} earlier lines hidden (--verbose shows everything)`, + ), + ); + } + shown.forEach(line => { + ui.writeError(` ${line}`); + }); +}; + +/** + * The CLI's single error boundary: every failure leaves through here, and this + * is the only place that turns one into text and an exit code. + * + * A {@link CliError} was written by VitNode and is printed as a message with + * its details. Anything else is a bug or a dependency's surprise - its stack is + * the only honest description, so `--verbose` prints it, and normal mode says + * how to get it rather than paraphrasing a message VitNode did not write. + */ +export const reportError = (ui: Ui, error: unknown): number => { + const plain = ui.mode === "plain"; + const hintLine = (hint: string) => + plain ? `Hint: ${hint}` : ` ${ui.colors.muted(hint)}`; + + if (isCliError(error)) { + ui.writeError(); + ui.error(error.message); + error.details.forEach(detail => { + ui.writeError(` ${detail}`); + }); + + if (error.output !== undefined && error.output.trim() !== "") { + printOutput(ui, error.output); + } + + if (error.hint !== undefined) { + ui.writeError(); + ui.writeError(hintLine(error.hint)); + } + + if (ui.verbose && error.cause !== undefined) { + ui.writeError(); + ui.writeError( + ui.colors.muted(stackOf(error.cause) ?? errorMessage(error.cause)), + ); + } + + return error.exitCode; + } + + ui.writeError(); + ui.error(`Unexpected error: ${errorMessage(error)}`); + + if (ui.verbose) { + const stack = stackOf(error); + if (stack !== undefined) ui.writeError(ui.colors.muted(stack)); + } else { + ui.writeError( + hintLine( + "Run the command again with --verbose for the full stack trace.", + ), + ); + } + + return EXIT_CODE.failure; +}; diff --git a/packages/vitnode/scripts/cli/start/server-entry.ts b/packages/vitnode/scripts/cli/start/server-entry.ts new file mode 100644 index 000000000..12e62a7fc --- /dev/null +++ b/packages/vitnode/scripts/cli/start/server-entry.ts @@ -0,0 +1,67 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { Project } from "../project/project"; + +import { ConfigError } from "../errors"; + +export interface ServerEntry { + defaultPort: number; + entry: string; +} + +/** Nitro presets whose output is a Node server `vitnode start` can run. */ +const NODE_PRESETS = new Set(["node", "node-cluster", "node-server"]); + +/** + * The file `vitnode start` runs, as the build itself recorded it. + * + * For an app that is Nitro's `.output/nitro.json`, which names the server + * entry and the preset it was built for - so a build for Vercel is refused + * here with a reason, instead of being started as if it were a Node server. + * For a standalone API it is the `dist/index.js` its own `tsc` build writes. + */ +export const resolveServerEntry = (project: Project): ServerEntry => { + const notBuilt = () => + new ConfigError("No production build found.", { + hint: "Run vitnode build first.", + }); + + if (project.kind === "api") { + const entry = join(project.root, "dist", "index.js"); + if (!existsSync(entry)) throw notBuilt(); + + return { defaultPort: 8000, entry }; + } + + if (project.kind === "package") { + throw new ConfigError("A plugin package has no server to start.", { + hint: "Run vitnode start in the app that installs it.", + }); + } + + const output = join(project.root, ".output"); + const manifestPath = join(output, "nitro.json"); + + if (existsSync(manifestPath)) { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + preset?: string; + serverEntry?: string; + }; + + if (manifest.preset !== undefined && !NODE_PRESETS.has(manifest.preset)) { + throw new ConfigError( + `This build targets the "${manifest.preset}" preset, which that platform runs - not vitnode start.`, + { hint: "Build with the node-server preset to run it yourself." }, + ); + } + + const entry = join(output, manifest.serverEntry ?? "server/index.mjs"); + if (existsSync(entry)) return { defaultPort: 3000, entry }; + } + + const fallback = join(output, "server", "index.mjs"); + if (existsSync(fallback)) return { defaultPort: 3000, entry: fallback }; + + throw notBuilt(); +}; diff --git a/packages/vitnode/scripts/cli/start/wait-for-port.ts b/packages/vitnode/scripts/cli/start/wait-for-port.ts new file mode 100644 index 000000000..55aac78f5 --- /dev/null +++ b/packages/vitnode/scripts/cli/start/wait-for-port.ts @@ -0,0 +1,48 @@ +import { connect } from "node:net"; + +const canConnect = async (port: number, host: string): Promise => + new Promise(resolve => { + const socket = connect({ host, port }); + const done = (result: boolean) => { + socket.destroy(); + resolve(result); + }; + + socket.once("connect", () => { + done(true); + }); + socket.once("error", () => { + done(false); + }); + socket.setTimeout(1000, () => { + done(false); + }); + }); + +/** + * Resolves `true` once something accepts connections on the port, `false` when + * `isAlive` says the server is gone or the timeout passes first. + * + * "Running" is printed only after this - a server that crashed during boot + * must not have been announced as up. + */ +export const waitForPort = async ({ + host, + isAlive, + port, + timeoutMs = 60_000, +}: { + host: string; + isAlive: () => boolean; + port: number; + timeoutMs?: number; +}): Promise => { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline && isAlive()) { + if (await canConnect(port, host)) return true; + await new Promise(resolve => setTimeout(resolve, 200)); + } + + return false; +}; diff --git a/packages/vitnode/scripts/cli/testing.ts b/packages/vitnode/scripts/cli/testing.ts new file mode 100644 index 000000000..baa405e8a --- /dev/null +++ b/packages/vitnode/scripts/cli/testing.ts @@ -0,0 +1,132 @@ +import { EventEmitter } from "node:events"; + +import type { CliRuntime, CommandContext, Env } from "./context"; +import type { Signal, SignalSource } from "./project/processes"; +import type { Prompter } from "./ui/prompts"; + +import { createCommandContext, createCommandUi } from "./context"; +import { stripAnsi } from "./ui/colors"; + +export interface FakeTerminal { + /** Everything written to stderr, ANSI stripped. */ + errors: () => string; + /** Everything written to stdout, ANSI stripped. */ + output: () => string; + /** Everything, raw - for asserting on escape codes themselves. */ + raw: () => string; +} + +export interface FakeSignals extends SignalSource { + emit: (signal: Signal) => void; +} + +export const createFakeSignals = (): FakeSignals => { + const emitter = new EventEmitter(); + + return { + emit: signal => emitter.emit(signal), + off: (signal, listener) => emitter.off(signal, listener), + once: (signal, listener) => emitter.once(signal, listener), + }; +}; + +export interface FakeRuntimeOptions { + cwd?: string; + env?: Env; + interactive?: boolean; + prompter?: Prompter; +} + +/** + * A runtime with an in-memory terminal: what a command printed can be read + * back, and whether the terminal "is" interactive is a switch. + */ +export const createFakeRuntime = ({ + cwd = process.cwd(), + env = {}, + interactive = false, + prompter, +}: FakeRuntimeOptions = {}): CliRuntime & + FakeTerminal & { signals: FakeSignals } => { + const out: string[] = []; + const err: string[] = []; + const all: string[] = []; + + return { + createPrompter: prompter === undefined ? undefined : () => prompter, + cwd, + env: { TERM: "xterm-256color", ...env }, + errors: () => stripAnsi(err.join("")), + output: () => stripAnsi(out.join("")), + platform: "linux", + raw: () => all.join(""), + signals: createFakeSignals(), + stderr: { + isTTY: interactive, + write: chunk => { + err.push(chunk); + all.push(chunk); + + return true; + }, + }, + stdin: { isTTY: interactive }, + stdout: { + columns: 120, + isTTY: interactive, + write: chunk => { + out.push(chunk); + all.push(chunk); + + return true; + }, + }, + version: "1.2.3-test", + }; +}; + +/** A command context over a fake runtime, the way `runCli` builds one. */ +export const createTestContext = ( + options: FakeRuntimeOptions & { plain?: boolean; verbose?: boolean } = {}, +): { + context: CommandContext; + runtime: ReturnType; +} => { + const runtime = createFakeRuntime(options); + const ui = createCommandUi(runtime, { + plain: options.plain, + verbose: options.verbose, + }); + + return { context: createCommandContext(runtime, ui), runtime }; +}; + +/** A prompter that answers from a script and records the questions. */ +export const createScriptedPrompter = (answers: { + confirm?: boolean[]; + text?: string[]; +}): Prompter & { asked: string[] } => { + const asked: string[] = []; + const confirms = [...(answers.confirm ?? [])]; + const texts = [...(answers.text ?? [])]; + + return { + asked, + confirm: async message => { + asked.push(message); + + return Promise.resolve(confirms.shift() ?? true); + }, + text: async (message, options) => { + asked.push(message); + // An empty answer is Enter on the suggested default, as in a terminal. + const answer = texts.shift(); + + return Promise.resolve( + answer === undefined || answer === "" + ? (options?.default ?? "") + : answer, + ); + }, + }; +}; diff --git a/packages/vitnode/scripts/cli/ui/capture-output.ts b/packages/vitnode/scripts/cli/ui/capture-output.ts new file mode 100644 index 000000000..594a96160 --- /dev/null +++ b/packages/vitnode/scripts/cli/ui/capture-output.ts @@ -0,0 +1,69 @@ +export interface OutputCapture { + /** Stops capturing and returns everything that was written meanwhile. */ + restore: () => string; +} + +type Write = typeof process.stdout.write; + +/** + * Holds back what third-party code prints straight to the console - build + * plugins, or a database driver logging every Postgres NOTICE. + * + * Vite's own logging goes through a logger VitNode controls, but plugins such + * as Nitro and the TanStack devtools write to `process.stdout` directly - and + * one stray line through a spinner leaves the terminal garbled. The CLI's own + * UI keeps a reference to the original `write`, so it is unaffected. + * + * Nothing is thrown away: `--verbose` skips the capture entirely, and a failed + * build prints what was captured with its error. + */ +export const captureProcessOutput = (): OutputCapture => { + const chunks: string[] = []; + const originalOut = process.stdout.write; + const originalErr = process.stderr.write; + + const capture = ((chunk: string | Uint8Array, ...rest: unknown[]) => { + chunks.push( + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"), + ); + const callback = rest.find(arg => typeof arg === "function") as + (() => void) | undefined; + callback?.(); + + return true; + }) as Write; + + process.stdout.write = capture; + process.stderr.write = capture; + + return { + restore: () => { + process.stdout.write = originalOut; + process.stderr.write = originalErr; + + return chunks.join(""); + }, + }; +}; + +/** + * Runs `action` with console output held back unless `--verbose`. If it fails, + * what was held back is printed after all - it is the context of the error. + */ +export const withQuietOutput = async ( + verbose: boolean, + action: () => Promise, + capture: () => OutputCapture = captureProcessOutput, +): Promise => { + if (verbose) return action(); + + const session = capture(); + try { + return await action(); + } catch (error) { + process.stderr.write(session.restore()); + throw error; + } finally { + session.restore(); + } +}; diff --git a/packages/vitnode/scripts/cli/ui/colors.ts b/packages/vitnode/scripts/cli/ui/colors.ts new file mode 100644 index 000000000..460f9a574 --- /dev/null +++ b/packages/vitnode/scripts/cli/ui/colors.ts @@ -0,0 +1,56 @@ +import pc from "picocolors"; + +type Paint = (text: string) => string; + +/** + * The CLI's whole palette, by meaning rather than by hue. + * + * Commands never reach for a color directly: a "warning" is yellow because this + * file says so, which is what keeps the output from drifting into a rainbow. + */ +export interface Palette { + bold: Paint; + /** Commands, URLs and paths a developer may copy. */ + command: Paint; + error: Paint; + /** Secondary text: timings, hints, labels. */ + muted: Paint; + /** VitNode's own name and section markers. */ + primary: Paint; + success: Paint; + warning: Paint; +} + +export const createPalette = (enabled: boolean): Palette => { + const colors = pc.createColors(enabled); + + return { + bold: colors.bold, + command: colors.cyan, + error: colors.red, + muted: colors.gray, + primary: text => colors.bold(colors.blue(text)), + success: colors.green, + warning: colors.yellow, + }; +}; + +// eslint-disable-next-line no-control-regex +const ANSI_PATTERN = /\x1b\[[0-9;?]*[A-Za-z]/g; + +export const stripAnsi = (text: string): string => + text.replace(ANSI_PATTERN, ""); + +/** + * The printed width of a line. + * + * Everything the CLI draws is ASCII or a single-width symbol, so the length + * without escape codes is the width - no East Asian width table needed. + */ +export const visibleWidth = (text: string): number => stripAnsi(text).length; + +export const padEnd = (text: string, width: number): string => + text + " ".repeat(Math.max(0, width - visibleWidth(text))); + +export const padStart = (text: string, width: number): string => + " ".repeat(Math.max(0, width - visibleWidth(text))) + text; diff --git a/packages/vitnode/scripts/cli/ui/format.test.ts b/packages/vitnode/scripts/cli/ui/format.test.ts new file mode 100644 index 000000000..4fc841890 --- /dev/null +++ b/packages/vitnode/scripts/cli/ui/format.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { + formatByteDelta, + formatBytes, + formatDuration, + formatPercentDelta, + plural, + toDisplayPath, +} from "./format"; + +describe("formatBytes", () => { + it.each([ + [0, "0 B"], + [999, "999 B"], + [1000, "1.0 kB"], + [1024, "1.0 kB"], + [100_000, "100.0 kB"], + [184_234, "184.2 kB"], + [999_949, "999.9 kB"], + [999_999, "1.0 MB"], + [1_000_000, "1.0 MB"], + [1_400_000, "1.4 MB"], + [55_200_000, "55.2 MB"], + [2_500_000_000, "2.5 GB"], + ])("formats %d bytes as %s", (bytes, expected) => { + expect(formatBytes(bytes)).toBe(expected); + }); + + it("keeps the sign of a negative size", () => { + expect(formatBytes(-12_800)).toBe("-12.8 kB"); + }); +}); + +describe("size changes", () => { + it("always signs a byte delta", () => { + expect(formatByteDelta(4200)).toBe("+4.2 kB"); + expect(formatByteDelta(-12_800)).toBe("-12.8 kB"); + expect(formatByteDelta(0)).toBe("0 B"); + }); + + it("always signs a percentage delta", () => { + expect(formatPercentDelta(0.023)).toBe("+2.3%"); + expect(formatPercentDelta(-0.02)).toBe("-2.0%"); + }); +}); + +describe("formatDuration", () => { + it.each([ + [18, "18ms"], + [6800, "6.8s"], + [72_000, "1m 12s"], + ])("formats %dms as %s", (ms, expected) => { + expect(formatDuration(ms)).toBe(expected); + }); +}); + +describe("small helpers", () => { + it("pluralizes", () => { + expect(plural(1, "plugin")).toBe("1 plugin"); + expect(plural(3, "plugin")).toBe("3 plugins"); + }); + + it("prints Windows paths with forward slashes", () => { + expect(toDisplayPath(String.raw`dist\client\assets\index.js`)).toBe( + "dist/client/assets/index.js", + ); + }); +}); diff --git a/packages/vitnode/scripts/cli/ui/format.ts b/packages/vitnode/scripts/cli/ui/format.ts new file mode 100644 index 000000000..dd18dd6b4 --- /dev/null +++ b/packages/vitnode/scripts/cli/ui/format.ts @@ -0,0 +1,59 @@ +const KB = 1000; +const MB = KB * 1000; +const GB = MB * 1000; + +/** + * Bytes as a developer reads them in a build report. + * + * Decimal units, like Vite, Rollup and every browser's network panel, so the + * numbers here match the numbers anywhere else the same file shows up. A value + * that would round up to the next unit is printed in it - `999_999` is + * `1.0 MB`, never `1000.0 kB`. + */ +export const formatBytes = (bytes: number): string => { + const abs = Math.abs(bytes); + const sign = bytes < 0 ? "-" : ""; + + if (abs < KB) return `${sign}${String(Math.round(abs))} B`; + if (Math.round((abs / KB) * 10) / 10 < 1000) { + return `${sign}${(abs / KB).toFixed(1)} kB`; + } + if (Math.round((abs / MB) * 10) / 10 < 1000) { + return `${sign}${(abs / MB).toFixed(1)} MB`; + } + + return `${sign}${(abs / GB).toFixed(1)} GB`; +}; + +/** A size change, always signed: `+4.2 kB`, `-12.8 kB`, `0 B`. */ +export const formatByteDelta = (bytes: number): string => + bytes > 0 ? `+${formatBytes(bytes)}` : formatBytes(bytes); + +/** A ratio change, always signed: `+2.3%`. */ +export const formatPercentDelta = (ratio: number): string => { + const percent = (ratio * 100).toFixed(1); + + return ratio > 0 ? `+${percent}%` : `${percent}%`; +}; + +export const formatPercent = (ratio: number): string => + `${(ratio * 100).toFixed(1)}%`; + +/** `840ms`, `6.8s`, `1m 12s`. */ +export const formatDuration = (ms: number): string => { + if (ms < 1000) return `${String(Math.round(ms))}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + + const minutes = Math.floor(ms / 60_000); + const seconds = Math.round((ms % 60_000) / 1000); + + return `${String(minutes)}m ${String(seconds)}s`; +}; + +/** `1 plugin`, `3 plugins`. */ +export const plural = (count: number, singular: string, many?: string) => + `${String(count)} ${count === 1 ? singular : (many ?? `${singular}s`)}`; + +/** A path for display: forward slashes on every platform. */ +export const toDisplayPath = (path: string): string => + path.replaceAll("\\", "/"); diff --git a/packages/vitnode/scripts/cli/ui/prompts.ts b/packages/vitnode/scripts/cli/ui/prompts.ts new file mode 100644 index 000000000..672c33cc8 --- /dev/null +++ b/packages/vitnode/scripts/cli/ui/prompts.ts @@ -0,0 +1,106 @@ +import type { Ui } from "./ui"; + +import { CliError, EXIT_CODE, UserError } from "../errors"; + +export interface Prompter { + confirm: ( + message: string, + options?: { default?: boolean }, + ) => Promise; + text: ( + message: string, + options?: { + default?: string; + validate?: (value: string) => string | true; + }, + ) => Promise; +} + +const cancelled = () => + new CliError("usage", "Cancelled.", { exitCode: EXIT_CODE.interrupted }); + +const isPromptExit = (error: unknown): boolean => + error instanceof Error && error.name === "ExitPromptError"; + +/** + * Prompts in the CLI's own visual language: `◆` while asking, `◇` once + * answered, so a finished prompt reads like the rest of the output. + * + * `@inquirer/prompts` is imported on first use - `vitnode --help` and every + * command that never asks anything should not pay for it. + */ +export const createPrompter = (ui: Ui): Prompter => { + const theme = { + prefix: { + done: ui.colors.success(ui.symbols.active), + idle: ui.colors.primary(ui.symbols.brand), + }, + }; + + const guard = async (ask: () => Promise): Promise => { + if (!ui.interactive) { + throw new UserError( + "This command needs an answer, but the terminal is not interactive.", + ); + } + + try { + return await ask(); + } catch (error) { + if (isPromptExit(error)) throw cancelled(); + throw error; + } + }; + + return { + confirm: async (message, options = {}) => + guard(async () => { + const { confirm } = await import("@inquirer/prompts"); + + return confirm({ default: options.default, message, theme }); + }), + text: async (message, options = {}) => + guard(async () => { + const { input } = await import("@inquirer/prompts"); + + return input({ + default: options.default, + message, + theme, + validate: options.validate, + }); + }), + }; +}; + +/** + * Asks before something irreversible - or, where nobody can be asked, refuses + * unless the command line already said yes. + * + * Never waits on a non-interactive terminal: a CI job either passed the flag + * or gets a usage error naming it. + */ +export const requireConfirmation = async ({ + flag = "--yes", + message, + prompter, + ui, + yes, +}: { + flag?: string; + message: string; + prompter: Prompter; + ui: Ui; + yes: boolean; +}): Promise => { + if (yes) return true; + + if (!ui.interactive) { + throw new UserError( + `${message} Confirmation is required, but the terminal is not interactive.`, + { hint: `Pass ${flag} to confirm from a script or CI job.` }, + ); + } + + return prompter.confirm(message, { default: true }); +}; diff --git a/packages/vitnode/scripts/cli/ui/symbols.ts b/packages/vitnode/scripts/cli/ui/symbols.ts new file mode 100644 index 000000000..dc6b0014a --- /dev/null +++ b/packages/vitnode/scripts/cli/ui/symbols.ts @@ -0,0 +1,56 @@ +export interface Symbols { + active: string; + arrow: string; + bar: string; + brand: string; + bullet: string; + corner: string; + dot: string; + error: string; + large: string; + line: string; + pending: string; + spinner: readonly string[]; + success: string; + tee: string; + warning: string; +} + +const UNICODE: Symbols = { + active: "◇", + arrow: "→", + bar: "│", + brand: "◆", + bullet: "•", + corner: "└", + dot: "●", + error: "✖", + large: "▲", + line: "─", + pending: "○", + spinner: ["◒", "◐", "◓", "◑"], + success: "✓", + tee: "├", + warning: "!", +}; + +const ASCII: Symbols = { + active: "o", + arrow: "->", + bar: "|", + brand: "*", + bullet: "-", + corner: "`-", + dot: "*", + error: "x", + large: "^", + line: "-", + pending: "o", + spinner: ["-", "\\", "|", "/"], + success: "+", + tee: "+", + warning: "!", +}; + +export const createSymbols = (unicode: boolean): Symbols => + unicode ? UNICODE : ASCII; diff --git a/packages/vitnode/scripts/cli/ui/table.ts b/packages/vitnode/scripts/cli/ui/table.ts new file mode 100644 index 000000000..ba4e2fc8b --- /dev/null +++ b/packages/vitnode/scripts/cli/ui/table.ts @@ -0,0 +1,61 @@ +import { padEnd, padStart, visibleWidth } from "./colors"; + +export interface TableColumn { + align?: "left" | "right"; + header: string; +} + +export interface RenderTableOptions { + columns: readonly TableColumn[]; + /** Paints the header row and the rule under it. */ + muted?: (text: string) => string; + /** Cells may already be colored; widths ignore escape codes. */ + rows: readonly (readonly string[])[]; + /** The character the rule under the header is drawn with. */ + rule?: string; +} + +const GAP = " "; + +/** + * A plain aligned table: header, rule, rows. No borders - in a terminal the + * alignment is the structure, and a box only adds characters to copy around. + */ +export const renderTable = ({ + columns, + muted = text => text, + rows, + rule = "─", +}: RenderTableOptions): string[] => { + const widths = columns.map((column, index) => + Math.max( + visibleWidth(column.header), + ...rows.map(row => visibleWidth(row[index] ?? "")), + ), + ); + + const line = (cells: readonly string[]) => + columns + .map((column, index) => { + const cell = cells[index] ?? ""; + const width = widths[index]; + + if (column.align === "right") return padStart(cell, width); + + // The last left-aligned column is not padded: trailing spaces are + // invisible and only get in the way of a copy-paste. + return index === columns.length - 1 ? cell : padEnd(cell, width); + }) + .join(GAP) + .trimEnd(); + + const totalWidth = + widths.reduce((sum, width) => sum + width, 0) + + GAP.length * (columns.length - 1); + + return [ + muted(line(columns.map(column => column.header))), + muted(rule.repeat(totalWidth)), + ...rows.map(line), + ]; +}; diff --git a/packages/vitnode/scripts/cli/ui/terminal.test.ts b/packages/vitnode/scripts/cli/ui/terminal.test.ts new file mode 100644 index 000000000..8b7108a0f --- /dev/null +++ b/packages/vitnode/scripts/cli/ui/terminal.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { detectTerminal, isCi, isUnicodeSupported } from "./terminal"; + +const tty = { isTTY: true, write: () => true }; +const pipe = { isTTY: false, write: () => true }; + +describe("detectTerminal", () => { + it("draws colors, spinners and prompts in an interactive terminal", () => { + expect( + detectTerminal({ env: {}, platform: "linux", stdin: tty, stdout: tty }), + ).toMatchObject({ + ci: false, + color: true, + interactive: true, + unicode: true, + }); + }); + + it("respects NO_COLOR even when FORCE_COLOR is also set", () => { + const { color } = detectTerminal({ + env: { FORCE_COLOR: "1", NO_COLOR: "1" }, + platform: "linux", + stdin: tty, + stdout: tty, + }); + + expect(color).toBe(false); + }); + + it("allows FORCE_COLOR to color redirected output", () => { + const { color, interactive } = detectTerminal({ + env: { FORCE_COLOR: "1" }, + platform: "linux", + stdin: pipe, + stdout: pipe, + }); + + expect(color).toBe(true); + expect(interactive).toBe(false); + }); + + it("never prompts or animates in CI, even on a TTY", () => { + expect( + detectTerminal({ + env: { GITHUB_ACTIONS: "true" }, + platform: "linux", + stdin: tty, + stdout: tty, + }), + ).toMatchObject({ ci: true, interactive: false }); + }); + + it("does not prompt when stdin is not a terminal (piped input)", () => { + expect( + detectTerminal({ env: {}, platform: "linux", stdin: pipe, stdout: tty }) + .interactive, + ).toBe(false); + }); + + it("turns everything off in plain mode", () => { + expect( + detectTerminal({ + env: {}, + plain: true, + platform: "linux", + stdin: tty, + stdout: tty, + }), + ).toMatchObject({ color: false, interactive: false, unicode: false }); + }); + + it("treats TERM=dumb as a non-terminal", () => { + expect( + detectTerminal({ + env: { TERM: "dumb" }, + platform: "linux", + stdin: tty, + stdout: tty, + }), + ).toMatchObject({ color: false, interactive: false }); + }); +}); + +describe("isCi", () => { + it("ignores CI=false", () => { + expect(isCi({ CI: "false" })).toBe(false); + expect(isCi({ CI: "1" })).toBe(true); + }); +}); + +describe("isUnicodeSupported", () => { + it("falls back to ASCII on the legacy Windows console", () => { + expect(isUnicodeSupported({}, "win32")).toBe(false); + expect(isUnicodeSupported({ WT_SESSION: "1" }, "win32")).toBe(true); + }); + + it("falls back to ASCII on the Linux virtual console", () => { + expect(isUnicodeSupported({ TERM: "linux" }, "linux")).toBe(false); + }); +}); diff --git a/packages/vitnode/scripts/cli/ui/terminal.ts b/packages/vitnode/scripts/cli/ui/terminal.ts new file mode 100644 index 000000000..0d19a1a98 --- /dev/null +++ b/packages/vitnode/scripts/cli/ui/terminal.ts @@ -0,0 +1,99 @@ +export interface TerminalStream { + columns?: number; + isTTY?: boolean; + write: (chunk: string) => boolean; +} + +export interface TerminalInput { + isTTY?: boolean; +} + +/** What the terminal the CLI is writing to can do. */ +export interface TerminalCapabilities { + /** Running under a CI provider. */ + ci: boolean; + /** ANSI colors are allowed. */ + color: boolean; + columns: number; + /** A person can answer prompts and watch a spinner. */ + interactive: boolean; + /** Box-drawing and symbol glyphs render. */ + unicode: boolean; +} + +type Env = Record; + +const isTruthy = (value: string | undefined): boolean => + value !== undefined && value !== "" && value !== "0" && value !== "false"; + +/** The same providers `ci-info` checks first, without the dependency. */ +export const isCi = (env: Env): boolean => + isTruthy(env.CI) || + isTruthy(env.CONTINUOUS_INTEGRATION) || + isTruthy(env.BUILD_NUMBER) || + isTruthy(env.RUN_ID) || + isTruthy(env.GITHUB_ACTIONS) || + isTruthy(env.GITLAB_CI) || + isTruthy(env.BUILDKITE) || + isTruthy(env.TF_BUILD); + +/** + * `is-unicode-supported`, inlined: everything but the legacy Windows console + * and the Linux virtual console draws the glyphs. + */ +export const isUnicodeSupported = (env: Env, platform: string): boolean => { + if (platform !== "win32") return env.TERM !== "linux"; + + return ( + isTruthy(env.WT_SESSION) || + isTruthy(env.TERMINUS_SUBLIME) || + env.ConEmuTask === "{cmd::Cmder}" || + env.TERM_PROGRAM === "Terminus-Sublime" || + env.TERM_PROGRAM === "vscode" || + env.TERM === "xterm-256color" || + env.TERM === "alacritty" || + env.TERMINAL_EMULATOR === "JetBrains-JediTerm" + ); +}; + +export interface DetectTerminalOptions { + env: Env; + /** `--plain`: no color, no animation, stable lines. */ + plain?: boolean; + platform: string; + stdin: TerminalInput; + stdout: TerminalStream; +} + +/** + * Decides what the CLI may draw. + * + * `NO_COLOR` always wins over `FORCE_COLOR`, because it is the user's setting + * and `FORCE_COLOR` is usually a tool's. Interactivity needs *both* ends to be + * a terminal: piping the output into a file must not leave a prompt waiting on + * a keyboard nobody is at. + */ +export const detectTerminal = ({ + env, + platform, + plain = false, + stdin, + stdout, +}: DetectTerminalOptions): TerminalCapabilities => { + const ci = isCi(env); + const dumb = env.TERM === "dumb"; + const tty = stdout.isTTY === true && !dumb; + + const color = + !plain && + !("NO_COLOR" in env && env.NO_COLOR !== "") && + (isTruthy(env.FORCE_COLOR) || tty); + + return { + ci, + color, + columns: stdout.columns ?? 80, + interactive: !plain && !ci && tty && stdin.isTTY === true, + unicode: !plain && isUnicodeSupported(env, platform), + }; +}; diff --git a/packages/vitnode/scripts/cli/ui/ui.test.ts b/packages/vitnode/scripts/cli/ui/ui.test.ts new file mode 100644 index 000000000..6c3f1164a --- /dev/null +++ b/packages/vitnode/scripts/cli/ui/ui.test.ts @@ -0,0 +1,153 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; + +import { createFakeRuntime } from "../testing"; +import { stripAnsi } from "./colors"; +import { detectTerminal } from "./terminal"; +import { createUi } from "./ui"; + +const uiFor = ({ + env = {}, + interactive = false, + mode = "pretty" as const, +}: { + env?: Record; + interactive?: boolean; + mode?: "plain" | "pretty"; +} = {}) => { + const runtime = createFakeRuntime({ env, interactive }); + const timers = { + clear: vi.fn(), + set: vi.fn(() => 1 as unknown as ReturnType), + }; + let now = 0; + const ui = createUi({ + capabilities: detectTerminal({ + env: runtime.env, + plain: mode === "plain", + platform: "linux", + stdin: runtime.stdin, + stdout: runtime.stdout, + }), + mode, + now: () => now, + stderr: runtime.stderr, + stdout: runtime.stdout, + timers, + }); + + return { + advance: (ms: number) => { + now += ms; + }, + runtime, + timers, + ui, + }; +}; + +describe("pretty output", () => { + it("prints the VitNode header and status lines with symbols", () => { + const { runtime, ui } = uiFor(); + + ui.header("Production build"); + ui.success("Plugins validated"); + ui.warning("Something to look at"); + + expect(runtime.output()).toBe( + "\n◆ VitNode\n Production build\n\n ✓ Plugins validated\n ! Something to look at\n", + ); + }); + + it("writes errors to stderr, not stdout", () => { + const { runtime, ui } = uiFor(); + + ui.error("Build failed"); + + expect(runtime.output()).toBe(""); + expect(runtime.errors()).toBe("✖ Build failed\n"); + }); + + it("emits no ANSI codes when colors are disabled", () => { + const { runtime, ui } = uiFor({ + env: { NO_COLOR: "1" }, + interactive: true, + }); + + ui.success("Done"); + ui.table({ columns: [{ header: "A" }], rows: [["b"]] }); + + expect(runtime.raw()).toBe(stripAnsi(runtime.raw())); + }); +}); + +describe("plain output", () => { + it("uses stable tags instead of symbols and colors", () => { + const { runtime, ui } = uiFor({ interactive: true, mode: "plain" }); + + ui.header("Production build"); + ui.success("Client built", "3.1s"); + ui.warning("admin.js 612.4 kB"); + ui.error("Nope"); + + expect(runtime.output()).toBe( + "VitNode - Production build\n[OK] Client built (3.1s)\n[WARN] admin.js 612.4 kB\n", + ); + expect(runtime.errors()).toBe("[ERROR] Nope\n"); + expect(runtime.raw()).not.toContain("\x1b["); + }); +}); + +describe("tasks", () => { + it("animates a spinner only in an interactive terminal", async () => { + const interactive = uiFor({ interactive: true }); + const piped = uiFor(); + + await interactive.ui.runTask( + "Building client", + async () => await Promise.resolve(undefined), + ); + await piped.ui.runTask( + "Building client", + async () => await Promise.resolve(undefined), + ); + + expect(interactive.timers.set).toHaveBeenCalledOnce(); + expect(interactive.timers.clear).toHaveBeenCalledOnce(); + expect(piped.timers.set).not.toHaveBeenCalled(); + expect(piped.runtime.raw()).not.toContain("\r"); + }); + + it("ends with the elapsed time", () => { + const { advance, runtime, ui } = uiFor(); + const task = ui.task("Building client"); + + advance(3100); + task.succeed(); + + expect(runtime.output()).toBe(" ✓ Building client 3.1s\n"); + }); + + it("reports a failed task and rethrows its error", async () => { + const { runtime, ui } = uiFor(); + + await expect( + ui.runTask("Building client", async () => { + await Promise.resolve(); + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + expect(runtime.output()).toBe(" ✖ Building client\n"); + }); + + it("prints lines above a running spinner instead of through it", () => { + const { runtime, ui } = uiFor({ interactive: true }); + const task = ui.task("Working"); + + ui.line("a request"); + task.succeed("Worked", "1s"); + + expect(runtime.output()).toContain("a request\n"); + expect(runtime.output()).toContain("✓ Worked 1s\n"); + }); +}); diff --git a/packages/vitnode/scripts/cli/ui/ui.ts b/packages/vitnode/scripts/cli/ui/ui.ts new file mode 100644 index 000000000..baa1ffda8 --- /dev/null +++ b/packages/vitnode/scripts/cli/ui/ui.ts @@ -0,0 +1,284 @@ +import type { Palette } from "./colors"; +import type { Symbols } from "./symbols"; +import type { RenderTableOptions } from "./table"; +import type { TerminalCapabilities, TerminalStream } from "./terminal"; + +import { createPalette, padEnd, visibleWidth } from "./colors"; +import { formatDuration } from "./format"; +import { createSymbols } from "./symbols"; +import { renderTable } from "./table"; + +/** + * `pretty` is for a person: symbols, colors and - when the terminal is + * interactive - spinners. `plain` is for a log: `[OK]`-style prefixes, one + * stable line per event, nothing that moves. + */ +export type OutputMode = "plain" | "pretty"; + +export interface TaskHandle { + fail: (label?: string) => void; + /** Ends the task without a result line, e.g. when the step was a no-op. */ + skip: (label?: string) => void; + succeed: (label?: string, detail?: string) => void; +} + +export interface Ui { + readonly capabilities: TerminalCapabilities; + readonly colors: Palette; + error: (text: string) => void; + /** The opening block of every command: `◆ VitNode` and what it is doing. */ + header: (title: string) => void; + info: (text: string) => void; + readonly interactive: boolean; + + keyValue: (rows: readonly (readonly [string, string])[]) => void; + /** One raw line, written as given. */ + line: (text?: string) => void; + readonly mode: OutputMode; + /** A secondary line: a hint, a path, a reason. */ + note: (text: string) => void; + rule: () => void; + runTask: ( + label: string, + action: () => Promise, + options?: { done?: (result: T) => string | undefined }, + ) => Promise; + section: (title: string) => void; + success: (text: string, detail?: string) => void; + readonly symbols: Symbols; + table: (options: Omit) => void; + task: (label: string) => TaskHandle; + readonly verbose: boolean; + warning: (text: string) => void; + /** Writes to stderr, for diagnostics that must not end up in piped output. */ + writeError: (text?: string) => void; +} + +export interface CreateUiOptions { + capabilities: TerminalCapabilities; + mode?: OutputMode; + now?: () => number; + stderr: TerminalStream; + stdout: TerminalStream; + timers?: { + clear: (handle: ReturnType) => void; + set: (callback: () => void, ms: number) => ReturnType; + }; + verbose?: boolean; +} + +const INDENT = " "; +const SPINNER_INTERVAL_MS = 80; +const CLEAR_LINE = "\r\x1b[2K"; + +export const createUi = ({ + capabilities, + mode = "pretty", + now = Date.now, + stderr, + stdout, + timers = { + clear: handle => { + clearInterval(handle); + }, + set: (callback, ms) => setInterval(callback, ms), + }, + verbose = false, +}: CreateUiOptions): Ui => { + const plain = mode === "plain"; + const colors = createPalette(capabilities.color && !plain); + const symbols = createSymbols(capabilities.unicode && !plain); + const interactive = capabilities.interactive && !plain; + + /** The one spinner that may be on screen, and what it is drawing. */ + let spinner: null | { + frame: number; + handle: ReturnType; + label: string; + } = null; + + const renderSpinner = () => { + if (spinner === null) return; + const glyph = symbols.spinner[spinner.frame % symbols.spinner.length]; + stdout.write( + `${CLEAR_LINE}${INDENT}${colors.primary(glyph)} ${spinner.label}`, + ); + }; + + /** Anything printed while a spinner runs goes above it, not through it. */ + const write = (stream: TerminalStream, text: string) => { + if (spinner !== null) stdout.write(CLEAR_LINE); + stream.write(`${text}\n`); + renderSpinner(); + }; + + const line = (text = "") => { + write(stdout, text); + }; + + const status = ( + prefix: string, + plainTag: string, + text: string, + detail?: string, + ) => { + if (plain) { + line(`[${plainTag}] ${text}${detail ? ` (${detail})` : ""}`); + + return; + } + line( + `${INDENT}${prefix} ${text}${detail ? ` ${colors.muted(detail)}` : ""}`, + ); + }; + + const success = (text: string, detail?: string) => { + status(colors.success(symbols.success), "OK", text, detail); + }; + + const warning = (text: string) => { + status(colors.warning(symbols.warning), "WARN", text); + }; + + const info = (text: string) => { + status(colors.primary(symbols.dot), "INFO", text); + }; + + const error = (text: string) => { + if (plain) { + write(stderr, `[ERROR] ${text}`); + + return; + } + write(stderr, `${colors.error(symbols.error)} ${colors.error(text)}`); + }; + + const stopSpinner = () => { + if (spinner === null) return; + timers.clear(spinner.handle); + stdout.write(CLEAR_LINE); + spinner = null; + }; + + const task = (label: string): TaskHandle => { + const startedAt = now(); + let ended = false; + + if (interactive) { + stopSpinner(); + spinner = { + frame: 0, + handle: timers.set(() => { + if (spinner === null) return; + spinner.frame += 1; + renderSpinner(); + }, SPINNER_INTERVAL_MS), + label, + }; + renderSpinner(); + } + + const end = (print: () => void) => { + if (ended) return; + ended = true; + if (interactive) stopSpinner(); + print(); + }; + + const elapsed = () => formatDuration(now() - startedAt); + + return { + fail: (failedLabel = label) => { + end(() => { + status(colors.error(symbols.error), "FAIL", failedLabel); + }); + }, + skip: skippedLabel => { + end(() => { + if (skippedLabel !== undefined) { + status(colors.muted(symbols.pending), "SKIP", skippedLabel); + } + }); + }, + succeed: (doneLabel = label, detail) => { + end(() => { + success(doneLabel, detail ?? elapsed()); + }); + }, + }; + }; + + return { + capabilities, + colors, + interactive, + mode, + symbols, + verbose, + + error, + header: title => { + if (plain) { + line(`VitNode - ${title}`); + + return; + } + line(); + line(colors.primary(`${symbols.brand} VitNode`)); + line(`${INDENT}${colors.muted(title)}`); + line(); + }, + info, + keyValue: rows => { + const width = Math.max(...rows.map(([key]) => visibleWidth(key))); + + rows.forEach(([key, value]) => { + line( + plain + ? `${key}: ${value}` + : `${INDENT}${colors.muted(padEnd(key, width))} ${value}`, + ); + }); + }, + line, + note: text => { + line(plain ? text : `${INDENT}${colors.muted(text)}`); + }, + rule: () => { + if (plain) return; + line(colors.muted(symbols.line.repeat(40))); + }, + runTask: async (label, action, options = {}) => { + const handle = task(label); + + try { + const result = await action(); + handle.succeed(label, options.done?.(result)); + + return result; + } catch (thrown) { + handle.fail(); + throw thrown; + } + }, + section: title => { + line(); + line(plain ? `${title}:` : colors.bold(title)); + }, + success, + table: options => { + renderTable({ + ...options, + muted: colors.muted, + rule: plain ? "-" : symbols.line, + }).forEach(row => { + line(plain ? row : `${INDENT}${row}`); + }); + }, + task, + warning, + writeError: (text = "") => { + write(stderr, text); + }, + }; +}; diff --git a/packages/vitnode/scripts/cli/version.ts b/packages/vitnode/scripts/cli/version.ts new file mode 100644 index 000000000..36868be91 --- /dev/null +++ b/packages/vitnode/scripts/cli/version.ts @@ -0,0 +1,33 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { PackageJson } from "./project/packages"; + +/** + * `@vitnode/core`'s own `package.json` - the CLI's version, and the versions + * a generated plugin depends on. + * + * Found by walking up from this module rather than by a fixed `../..`, because + * the same code runs from `scripts/` in tests and from a bundled chunk in + * `dist/scripts/` once published. + */ +export const readCoreManifest = ( + from: string = import.meta.url, +): null | PackageJson => { + let current = dirname(fileURLToPath(from)); + + for (;;) { + const manifest = join(current, "package.json"); + if (existsSync(manifest)) { + const parsed = JSON.parse(readFileSync(manifest, "utf8")) as PackageJson; + if (parsed.name === "@vitnode/core") return parsed; + } + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +}; + +export const readCliVersion = (from?: string): string => + readCoreManifest(from)?.version ?? "0.0.0"; diff --git a/packages/vitnode/scripts/database-bootstrap.test.ts b/packages/vitnode/scripts/database-bootstrap.test.ts index c34b83741..50e6cc8c0 100644 --- a/packages/vitnode/scripts/database-bootstrap.test.ts +++ b/packages/vitnode/scripts/database-bootstrap.test.ts @@ -1,13 +1,23 @@ // @vitest-environment node -import { existsSync, readFileSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { runCli } from "./cli/index.js"; +import { createFakeRuntime } from "./cli/testing.js"; import { databaseBootstrapSteps, generateDatabaseMigrations, initialDataForDatabase, languagesFromApiConfig, + readDrizzleConfig, runMigrations, runWithMigrationLock, } from "./prepare-database.js"; @@ -20,7 +30,8 @@ const codeOf = (file: string): string => .replace(/\/\*[\s\S]*?\*\//g, "") .replace(/\/\/.*$/gm, ""); -const cli = codeOf("scripts.ts"); +const cli = codeOf("cli/commands/legacy.ts"); +const program = codeOf("cli/program.ts"); const bootstrap = codeOf("prepare-database.ts"); describe("the steps of a database bootstrap", () => { @@ -73,24 +84,44 @@ describe("the steps of a database bootstrap", () => { describe("the `db:prepare` command", () => { it("exists", () => { - expect(cli).toContain('case "db:prepare":'); + expect(program).toContain('"db:prepare"'); }); - it("awaits the bootstrap and exits non-zero when it throws", () => { + /** + * A `dev` script chains on this with `&&`, so a failed bootstrap has to be a + * non-zero exit. The command awaits the bootstrap and does not catch: the + * CLI's error boundary turns the throw into exit code 1. + */ + it("awaits the bootstrap and lets a failure reach the error boundary", () => { const branch = cli.slice( - cli.indexOf('case "db:prepare":'), - cli.indexOf('case "migrate":'), + cli.indexOf("runDbPrepareCommand"), + cli.indexOf("runMigrateCommand"), ); expect(branch).toMatch(/await databaseBootstrap\(/); - expect(branch).toMatch(/try\s*\{/); - expect(branch).toMatch(/catch/); - expect(branch).toMatch(/process\.exit\(1\)/); + expect(branch).not.toMatch(/catch/); expect(branch).not.toMatch(/\bvoid\s+databaseBootstrap/); }); + it("exits non-zero when the bootstrap cannot run", async () => { + const cwd = mkdtempSync(join(tmpdir(), "vitnode-db-prepare-")); + const runtime = createFakeRuntime({ cwd }); + + try { + expect(await runCli(["db:prepare"], runtime)).toBe(1); + expect(runtime.errors()).toContain( + "Config file not found: src/vitnode.api.config.ts", + ); + } finally { + rmSync(cwd, { force: true, recursive: true }); + } + }); + it("shares one implementation with `migrate`", () => { - const branch = cli.slice(cli.indexOf('case "migrate":')); + const branch = cli.slice( + cli.indexOf("runMigrateCommand"), + cli.indexOf("runI18nCheckCommand"), + ); expect(branch).toMatch(/await databaseBootstrap\(/); // The one thing `migrate --generate` does that the bootstrap does not. @@ -100,7 +131,7 @@ describe("the `db:prepare` command", () => { }); it("no longer offers `init` or a `--web` no-op", () => { - expect(cli).not.toContain('case "init"'); + expect(program).not.toContain('"init"'); expect(cli).not.toContain("prepareDatabase"); expect(bootstrap).not.toContain('"--web"'); expect(bootstrap).not.toContain("prepareDatabase"); @@ -116,11 +147,33 @@ describe("what decides whether work is pending", () => { } }); - it("reads the migrations folder from drizzle.config.ts", () => { - expect(bootstrap).toContain("drizzle.config.ts"); - expect(bootstrap).toMatch(/loaded\.default\?\.out \?\? loaded\.out/); - // The fallback stays a fallback. - expect(bootstrap).toContain('return "./migrations"'); + it("reads the migrations folder - and journal table - from drizzle.config.ts", async () => { + const root = mkdtempSync(join(tmpdir(), "vitnode-drizzle-config-")); + + try { + // The fallback stays a fallback: drizzle's own defaults. + expect(await readDrizzleConfig(root)).toEqual({ + dialect: null, + migrationsFolder: join(root, "migrations"), + migrationsSchema: "drizzle", + migrationsTable: "__drizzle_migrations", + }); + + writeFileSync( + join(root, "drizzle.config.ts"), + 'export default { dialect: "postgresql", out: "./db/migrations/", migrations: { schema: "ops", table: "journal" } };\n', + ); + + expect(await readDrizzleConfig(root)).toEqual({ + dialect: "postgresql", + migrationsFolder: join(root, "db", "migrations"), + migrationsSchema: "ops", + migrationsTable: "journal", + }); + } finally { + rmSync(root, { force: true, recursive: true }); + } + expect(bootstrap).toMatch( /migrate\(config\.dbProvider, \{ migrationsFolder/, ); diff --git a/packages/vitnode/scripts/dev.ts b/packages/vitnode/scripts/dev.ts deleted file mode 100644 index bc28cdf86..000000000 --- a/packages/vitnode/scripts/dev.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable no-console */ -import { spawnCommand } from "./spawn-command.js"; -import { writePluginApiRegistry } from "./write-plugin-api-registry.js"; - -const spawnWatch = (command: string, args: string[]) => { - const child = spawnCommand(command, args, { - stdio: "inherit", - env: process.env, - }); - - child.on("error", error => { - console.error(`\x1b[31m${command} failed:\x1b[0m`, error); - }); - - child.on("exit", code => { - if (code !== null && code !== 0) { - console.error(`\x1b[31m${command} exited with code ${code}\x1b[0m`); - } - }); - - return child; -}; - -/** - * `vitnode dev` - a plugin's own build, in watch mode. - * - * Three compilers over the plugin's `src/`, and nothing else. This also used to - * start a chokidar watcher that copied the plugin's - * `src/routes/{main,admin,blank,breadcrumb}/` into every host app on every save, - * rewriting each import as it went - so a plugin page existed twice and the copy - * was the one that ran. - * - * An app now reads a plugin's routes out of its `dist` through the generated - * route registry, which is why watching `dist` is the whole job: `swc -w` - * writes the page, the app's Vite server sees the file it already imports - * change, and the page reloads. Nothing is copied and nothing has to be cleaned - * up when a route file is deleted. - */ -export const devPlugin = ({ initMessage }: { initMessage: string }) => { - writePluginApiRegistry(); - - const children = [ - spawnWatch("tsc", [ - "-w", - "-p", - "tsconfig.build.json", - "--preserveWatchOutput", - ]), - spawnWatch("swc", [ - "src", - "-d", - "dist", - "--config-file", - ".swcrc", - // Keeps locale JSON in `dist` alongside the compiled barrel. - "--copy-files", - "-w", - ]), - spawnWatch("tsc-alias", ["-w", "-p", "tsconfig.build.json"]), - ]; - - const shutdown = () => { - children.forEach(child => child.kill()); - process.exit(0); - }; - - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); - - console.log(`${initMessage} \x1b[34mWatching plugin sources...\x1b[0m`); -}; diff --git a/packages/vitnode/scripts/get-config.ts b/packages/vitnode/scripts/get-config.ts index abc9540dd..4352d9409 100644 --- a/packages/vitnode/scripts/get-config.ts +++ b/packages/vitnode/scripts/get-config.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import { createJiti } from "jiti"; import { existsSync, readdirSync, statSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -9,6 +8,8 @@ import type { VitNodeServerConfig, } from "../src/vitnode.config.js"; +import { ConfigError } from "./cli/errors.js"; + type ConfigName = "api.config" | "config" | "server.config"; type ConfigType = T extends "config" @@ -101,11 +102,11 @@ export async function getConfig({ if (!configPath) { if (optional) return null; - console.error(`Config file not found: ${filename}`); - console.error( - `Searched recursively in ${cwd} (excluding node_modules, .*, dist, build, out)`, - ); - process.exit(1); + throw new ConfigError(`Config file not found: src/${filename}`, { + details: [ + `Searched recursively in ${cwd} (excluding node_modules, .*, dist, build, out).`, + ], + }); } try { @@ -122,14 +123,18 @@ export async function getConfig({ if (!config) { if (optional) return null; - console.error(`Export "${configVarName}" not found in ${configPath}`); - process.exit(1); + throw new ConfigError( + `Export "${configVarName}" not found in ${configPath}`, + ); } return config as ConfigType; } catch (error) { if (optional) return null; - console.error("Failed to load config:", error); - process.exit(1); + if (error instanceof ConfigError) throw error; + throw new ConfigError(`Failed to load ${configPath}`, { + cause: error, + details: [error instanceof Error ? error.message : String(error)], + }); } } diff --git a/packages/vitnode/scripts/no-route-copier.test.ts b/packages/vitnode/scripts/no-route-copier.test.ts index d1f51c9da..7d5dd182b 100644 --- a/packages/vitnode/scripts/no-route-copier.test.ts +++ b/packages/vitnode/scripts/no-route-copier.test.ts @@ -59,7 +59,12 @@ describe("the plugin route copier", () => { * three compilers - and `vitnode init` is where a fresh project ran the copy. */ it("is not started by `vitnode dev` or `vitnode init`", () => { - expect(codeOf("scripts/dev.ts")).not.toContain("processPlugin"); + expect(codeOf("scripts/cli/commands/dev.ts")).not.toContain( + "processPlugin", + ); + expect(codeOf("scripts/cli/dev/watchers.ts")).not.toContain( + "processPlugin", + ); expect(codeOf("scripts/prepare-database.ts")).not.toContain( "preparePluginsFiles", ); diff --git a/packages/vitnode/scripts/prepare-database.ts b/packages/vitnode/scripts/prepare-database.ts index 025e70437..b953ae553 100644 --- a/packages/vitnode/scripts/prepare-database.ts +++ b/packages/vitnode/scripts/prepare-database.ts @@ -3,7 +3,7 @@ import { count, sql } from "drizzle-orm"; import { migrate } from "drizzle-orm/postgres-js/migrator"; import { createJiti } from "jiti"; import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import postgres from "postgres"; import { core_admin_permissions } from "@/database/admins.js"; @@ -15,41 +15,109 @@ import { SEARCH_TEXT_CONFIGS } from "@/database/search.js"; import type { VitNodeApiI18nConfig } from "../src/lib/i18n/types.js"; import type { VitNodeApiConfig } from "../src/vitnode.config.js"; +import { RuntimeError } from "./cli/errors.js"; +import { resolveBin } from "./cli/project/packages.js"; +import { runProcess } from "./cli/project/processes.js"; import { getConfig } from "./get-config.js"; -import { runInteractiveShellCommand } from "./run-interactive-shell-command.js"; -export const generateDatabaseMigrations = async () => { - try { - await runInteractiveShellCommand("npm", ["run", "drizzle-kit", "up"]); - await runInteractiveShellCommand("npm", ["run", "drizzle-kit", "generate"]); - } catch (err) { - console.error("\x1b[31m%s\x1b[0m", err); - process.exit(1); +/** + * Runs the project's own `drizzle-kit` with Node, without a shell or an npm + * script in between - the app needs `drizzle-kit` installed, not a + * `"drizzle-kit"` entry in its `package.json`. + */ +export const runDrizzleKit = async ( + args: readonly string[], + { + capture = false, + root = process.cwd(), + }: { capture?: boolean; root?: string } = {}, +) => + runProcess({ + args: [resolveBin(root, "drizzle-kit"), ...args], + capture, + command: process.execPath, + cwd: root, + }); + +/** + * `drizzle-kit up` then `drizzle-kit generate`, attached to the terminal so + * drizzle-kit can ask whether a changed column is a rename. + */ +export const generateDatabaseMigrations = async ({ + root = process.cwd(), +}: { root?: string } = {}) => { + for (const command of ["up", "generate"]) { + const { code } = await runDrizzleKit([command], { root }); + + if (code !== 0) { + throw new RuntimeError( + `drizzle-kit ${command} exited with code ${String(code)}.`, + ); + } } }; -// Reads the migrations output folder from the app's `drizzle.config.ts` (`out`), -// falling back to `./migrations` so the in-process migrator points at the same -// files `drizzle-kit generate` writes. -const getMigrationsFolder = async (): Promise => { - const configPath = join(process.cwd(), "drizzle.config.ts"); - if (existsSync(configPath)) { - try { - const jiti = createJiti(import.meta.url, { interopDefault: true }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const loaded = (await jiti.import(configPath)) as any; - const out: unknown = loaded.default?.out ?? loaded.out; - if (typeof out === "string" && out.length > 0) { - return out; - } - } catch { - // Fall back to the default below if the config can't be read. +export interface DrizzleProjectConfig { + dialect: null | string; + /** Where `drizzle-kit generate` writes migrations - `out`. */ + migrationsFolder: string; + migrationsSchema: string; + migrationsTable: string; +} + +/** + * The parts of the app's `drizzle.config.ts` the migrator and status need, + * with drizzle's own defaults: `./migrations`, and `drizzle.__drizzle_migrations` + * for the journal table. + */ +export const readDrizzleConfig = async ( + root: string = process.cwd(), +): Promise => { + const configPath = join(root, "drizzle.config.ts"); + const config: DrizzleProjectConfig = { + dialect: null, + migrationsFolder: join(root, "migrations"), + migrationsSchema: "drizzle", + migrationsTable: "__drizzle_migrations", + }; + + if (!existsSync(configPath)) return config; + + try { + const jiti = createJiti(import.meta.url, { interopDefault: true }); + const loaded = await jiti.import< + Record & { default?: Record } + >(configPath); + const declared = loaded.default ?? loaded; + const migrations = (declared.migrations ?? {}) as { + schema?: unknown; + table?: unknown; + }; + + if (typeof declared.out === "string" && declared.out.length > 0) { + config.migrationsFolder = resolve(root, declared.out); } + if (typeof declared.dialect === "string") config.dialect = declared.dialect; + if (typeof migrations.table === "string") { + config.migrationsTable = migrations.table; + } + if (typeof migrations.schema === "string") { + config.migrationsSchema = migrations.schema; + } + } catch { + // Fall back to drizzle's defaults if the config can't be read. } - return "./migrations"; + return config; }; +// Reads the migrations output folder from the app's `drizzle.config.ts` (`out`), +// falling back to `./migrations` so the in-process migrator points at the same +// files `drizzle-kit generate` writes. +export const getMigrationsFolder = async ( + root: string = process.cwd(), +): Promise => (await readDrizzleConfig(root)).migrationsFolder; + // Every `regconfig` literal referenced by the generated `search_vector` column // (see SEARCH_TEXT_CONFIGS) has to exist on the target database before the // column is created - Postgres resolves all branches of the `CASE`, even ones no @@ -116,14 +184,45 @@ const ensureSearchTextConfigs = async ( } }; -export const runMigrations = async () => { - const config = await getConfig({ type: "api.config" }); +/** Every field Postgres attaches to an error, as lines a person can read. */ +export const describePostgresError = (err: unknown): string[] => { + const e = err as { + code?: string; + detail?: string; + hint?: string; + message?: string; + position?: string; + query?: string; + severity?: string; + where?: string; + }; + + return [ + ...(e.severity ? [`Severity: ${e.severity}`] : []), + ...(e.code ? [`SQLSTATE: ${e.code}`] : []), + `Message: ${e.message ?? String(err)}`, + ...(e.detail ? [`Detail: ${e.detail}`] : []), + ...(e.hint ? [`Hint: ${e.hint}`] : []), + ...(e.where ? [`Where: ${e.where}`] : []), + ...(e.position ? [`Position: ${e.position}`] : []), + ...(e.query ? ["Failing SQL:", e.query] : []), + ]; +}; + +export const runMigrations = async ({ + config: given, + migrationsFolder: folder, +}: { + config?: VitNodeApiConfig; + migrationsFolder?: string; +} = {}) => { + const config = given ?? (await getConfig({ type: "api.config" })); // Provision any missing text-search configs before applying migrations, so the // generated `search_vector` column (0017/0018) can resolve every `regconfig`. await ensureSearchTextConfigs(config.dbProvider); - const migrationsFolder = await getMigrationsFolder(); + const migrationsFolder = folder ?? (await getMigrationsFolder()); try { // Run migrations in-process instead of shelling out to `drizzle-kit migrate`: @@ -134,31 +233,12 @@ export const runMigrations = async () => { // `drizzle-kit` left off. await migrate(config.dbProvider, { migrationsFolder }); } catch (err) { - const e = err as { - code?: string; - detail?: string; - hint?: string; - message?: string; - position?: string; - query?: string; - severity?: string; - where?: string; - }; - - console.error("\x1b[31m[VitNode] Database migration failed.\x1b[0m"); - if (e.severity) console.error(`\x1b[31mSeverity:\x1b[0m ${e.severity}`); - if (e.code) console.error(`\x1b[31mSQLSTATE:\x1b[0m ${e.code}`); - console.error(`\x1b[31mMessage:\x1b[0m ${e.message ?? String(err)}`); - if (e.detail) console.error(`\x1b[31mDetail:\x1b[0m ${e.detail}`); - if (e.hint) console.error(`\x1b[31mHint:\x1b[0m ${e.hint}`); - if (e.where) console.error(`\x1b[31mWhere:\x1b[0m ${e.where}`); - if (e.position) console.error(`\x1b[31mPosition:\x1b[0m ${e.position}`); - if (e.query) console.error(`\x1b[31mFailing SQL:\x1b[0m\n${e.query}`); - if (err instanceof Error && err.stack) { - console.error(`\n\x1b[90m${err.stack}\x1b[0m`); - } - - process.exit(1); + // The in-process migrator throws Postgres' own error, with every field + // that explains it - kept whole rather than squashed into one line. + throw new RuntimeError("Database migration failed.", { + cause: err, + details: describePostgresError(err), + }); } }; @@ -244,7 +324,7 @@ const MIGRATION_LOCK_APPLICATION_NAME = "vitnode-migration-lock"; * than the race it avoids - which, for the single-process case that every * non-monorepo app has, does not exist anyway. */ -const openMigrationLock = ( +export const openMigrationLock = ( dbClient: VitNodeApiConfig["dbProvider"], ): MigrationLock | null => { const options = ( @@ -318,15 +398,20 @@ const openMigrationLock = ( * nothing - without a Postgres. */ export const runWithMigrationLock = async ({ - initMessage, + initMessage = "[VitNode]", lock, + log = message => { + console.log(`${initMessage} ${message}`); + }, run, sleep = async ms => { await new Promise(resolve => setTimeout(resolve, ms)); }, }: { - initMessage: string; + initMessage?: string; lock: MigrationLock | null; + /** Where the one "waiting for another process" notice goes. */ + log?: (message: string) => void; run: () => Promise; sleep?: (ms: number) => Promise; }): Promise => { @@ -355,8 +440,8 @@ export const runWithMigrationLock = async ({ } if (!announced) { - console.log( - `${initMessage} Another process is preparing this database - waiting for it to finish...`, + log( + "Another process is preparing this database - waiting for it to finish...", ); announced = true; } @@ -401,14 +486,16 @@ export const runWithMigrationLock = async ({ * The lock is held on a connection of its own, so the application pool is left * entirely to the work being serialised - see {@link openMigrationLock}. */ -const withMigrationLock = async ( +export const withMigrationLock = async ( dbClient: VitNodeApiConfig["dbProvider"], initMessage: string, run: () => Promise, + log?: (message: string) => void, ): Promise => { await runWithMigrationLock({ initMessage, lock: openMigrationLock(dbClient), + log, run, }); }; @@ -505,8 +592,8 @@ export const languagesFromApiConfig = ( })); }; -export const initialDataForDatabase = async () => { - const config = await getConfig({ type: "api.config" }); +export const initialDataForDatabase = async (given?: VitNodeApiConfig) => { + const config = given ?? (await getConfig({ type: "api.config" })); const dbClient = config.dbProvider; const [roleCount] = await dbClient @@ -698,21 +785,29 @@ export const databaseBootstrapSteps = ({ */ export const databaseBootstrap = async ({ generate = true, - initMessage, + initMessage = "[VitNode]", + log = message => { + console.log(`${initMessage} ${message}`); + }, }: { generate?: boolean; - initMessage: string; + initMessage?: string; + /** Where progress goes - the CLI passes its own UI. */ + log?: (message: string) => void; }): Promise => { const config = await getConfig({ type: "api.config" }); const steps = databaseBootstrapSteps({ generate }); - await withMigrationLock(config.dbProvider, initMessage, async () => { - for (const [index, step] of steps.entries()) { - console.log( - `${initMessage} [${index + 1}/${steps.length}] ${step.label}`, - ); + await withMigrationLock( + config.dbProvider, + initMessage, + async () => { + for (const [index, step] of steps.entries()) { + log(`[${index + 1}/${steps.length}] ${step.label}`); - await step.action(); - } - }); + await step.action(); + } + }, + log, + ); }; diff --git a/packages/vitnode/scripts/run-interactive-shell-command.ts b/packages/vitnode/scripts/run-interactive-shell-command.ts deleted file mode 100644 index 1d92e56a6..000000000 --- a/packages/vitnode/scripts/run-interactive-shell-command.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { spawnCommand } from "./spawn-command.js"; - -export const runInteractiveShellCommand = async ( - cmd: string, - args: string[] = [], -) => { - return await new Promise((resolve, reject) => { - const child = spawnCommand(cmd, args, { - stdio: "inherit", - env: process.env, - }); - - child.on("error", error => { - reject(error); - }); - - child.on("close", code => { - if (code !== 0) { - reject(new Error(`Command failed with exit code ${code}`)); - } else { - resolve(true); - } - }); - }); -}; diff --git a/packages/vitnode/scripts/scripts.ts b/packages/vitnode/scripts/scripts.ts index 9706fa972..eae79640e 100644 --- a/packages/vitnode/scripts/scripts.ts +++ b/packages/vitnode/scripts/scripts.ts @@ -1,141 +1,37 @@ #!/usr/bin/env node -/* eslint-disable no-console */ - import { config } from "dotenv"; -import { buildPlugin } from "./build.js"; -import { parseCliArguments } from "./cli-arguments.js"; -import { devPlugin } from "./dev.js"; -import { i18nCheck } from "./i18n-check.js"; -import { i18nCreate } from "./i18n-create.js"; -import { i18nDelete } from "./i18n-delete.js"; -import { i18nUpdateAi } from "./i18n-update-ai.js"; -import { i18nUpdate } from "./i18n-update.js"; -import { - databaseBootstrap, - generateDatabaseMigrations, -} from "./prepare-database.js"; - -config({ - quiet: true, +import { runCli } from "./cli/index.js"; +import { readCliVersion } from "./cli/version.js"; + +config({ quiet: true }); + +const code = await runCli(process.argv.slice(2), { + cwd: process.cwd(), + env: process.env, + platform: process.platform, + signals: process, + // The CLI keeps its own handle on the real `write`: a build temporarily + // redirects `process.stdout` to hold back noisy plugin output, and the + // progress lines must keep reaching the terminal while it does. + stderr: { + isTTY: process.stderr.isTTY, + write: process.stderr.write.bind(process.stderr), + }, + stdin: process.stdin, + stdout: { + get columns() { + return process.stdout.columns; + }, + isTTY: process.stdout.isTTY, + write: process.stdout.write.bind(process.stdout), + }, + version: readCliVersion(), }); -const initMessage = "\x1b[34m[VitNode]\x1b[0m"; - -const parsed = parseCliArguments(process.argv.slice(2)); - -if (!parsed.ok) { - console.error(`${initMessage} \x1b[31m${parsed.message}\x1b[0m`); - process.exit(1); -} - -const { args, command } = parsed; - -switch (command) { - case "build": - try { - await buildPlugin(); - console.log( - `${initMessage} \x1b[32mBuild completed successfully.\x1b[0m`, - ); - process.exit(0); - } catch { - process.exit(1); - } - break; - - /** - * The development bootstrap, and the one command a `dev` script waits for. - * - * `await`ed, and the `catch` is the whole reason this branch is not a - * one-liner: it was `case "init": void prepareDatabase(...)`, and `void` on an - * async call means a step that throws becomes an unhandled rejection rather - * than an exit code this process chose. It happened to exit non-zero because - * Node's default for an unhandled rejection is to crash - which is to say the - * fail-fast a dev server depends on was a Node default rather than a decision. - * Now it is a decision. - * - * Nothing after this may start unless it resolved. `dev` scripts chain with - * `&&` for exactly that reason. - */ - case "db:prepare": - try { - await databaseBootstrap({ initMessage }); - console.log(`${initMessage} \x1b[32mDatabase ready.\x1b[0m`); - process.exit(0); - } catch (error) { - console.error( - `${initMessage} \x1b[31mDatabase bootstrap failed - not starting anything.\x1b[0m`, - ); - console.error( - error instanceof Error ? (error.stack ?? error.message) : String(error), - ); - process.exit(1); - } - break; - - case "dev": - devPlugin({ initMessage }); - break; - - /** - * `--ci` is read off the validated arguments, so the only thing that can turn - * it on is that exact spelling. `--cii` no longer reaches this line at all - - * it used to arrive as an unrecognised `flag` and quietly produce a soft, - * zero-exit report for a CI job that had asked for a hard failure. - */ - case "i18n:check": - await i18nCheck(args.includes("--ci") ? "--ci" : undefined); - break; - - case "i18n:create": - await i18nCreate(); - break; - - case "i18n:delete": - await i18nDelete(); - break; - - case "i18n:update": - await i18nUpdate(); - break; - - case "i18n:update:ai": - await i18nUpdateAi(); - break; - - /** - * The same bootstrap under its older name, kept because it is the one the - * documentation and every deployment guide spell. - * - * `db:migrate` in a generated project runs this, `docs/dev/database`, - * `docs/dev/content-engine/*` and the Vercel deployment guide all tell people - * to run it, and published projects have it in their `package.json`. Its - * behaviour is therefore unchanged to the step - generate, apply, seed - and it - * delegates rather than reimplementing, so the two names cannot drift into two - * behaviours. - */ - case "migrate": - try { - if (args.includes("--generate")) { - await generateDatabaseMigrations(); - console.log( - `${initMessage} \x1b[32mDatabase migrations generated successfully.\x1b[0m`, - ); - process.exit(0); - } - - await databaseBootstrap({ initMessage }); - console.log( - `${initMessage} \x1b[32mDatabase migrated successfully.\x1b[0m`, - ); - process.exit(0); - } catch (error) { - console.error(`${initMessage} \x1b[31mDatabase migration failed.\x1b[0m`); - console.error( - error instanceof Error ? (error.stack ?? error.message) : String(error), - ); - process.exit(1); - } - break; -} +// Exit explicitly - a database client or a file watcher left open by a +// command must not keep the process alive - but only once stdout has drained, +// so piped output is never cut short. +process.stdout.write("", () => { + process.exit(code); +}); diff --git a/packages/vitnode/scripts/spawn-command.ts b/packages/vitnode/scripts/spawn-command.ts deleted file mode 100644 index a7e1b97de..000000000 --- a/packages/vitnode/scripts/spawn-command.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ChildProcess, SpawnOptions } from "node:child_process"; - -import { spawn } from "node:child_process"; - -export const spawnCommand = ( - command: string, - args: string[] = [], - options: Omit = {}, -): ChildProcess => { - const spawnOptions: SpawnOptions = { ...options, shell: false }; - - return process.platform === "win32" - ? spawn("cmd.exe", ["/c", command, ...args], spawnOptions) - : spawn(command, args, spawnOptions); -}; diff --git a/packages/vitnode/src/framework/vite/plugin-routes.ts b/packages/vitnode/src/framework/vite/plugin-routes.ts index 07824767e..831d21f30 100644 --- a/packages/vitnode/src/framework/vite/plugin-routes.ts +++ b/packages/vitnode/src/framework/vite/plugin-routes.ts @@ -421,7 +421,7 @@ export const readOptionalPluginModules = < * package specifier - and this skips those rather than guessing. A check that * failed a build over a callback it misread would be worse than no check. */ -const assertComponentsImportable = ( +export const assertComponentsImportable = ( compiled: CompiledPluginRoutes, routesFiles: ReadonlyMap, ): void => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e63fd3082..b054137b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -638,6 +638,9 @@ importers: cn: specifier: ^0.4.0 version: 0.4.0 + commander: + specifier: ^15.0.0 + version: 15.0.0 cron-parser: specifier: ^5.10.1 version: 5.10.1 @@ -662,6 +665,9 @@ importers: motion: specifier: ^13.4.6 version: 13.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + picocolors: + specifier: ^1.1.1 + version: 1.1.1 postgres: specifier: ^3.4.9 version: 3.4.9 From d6f90e7b129ff06dcc1e8dc505d34321b3e7cc02 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 08:41:06 +0000 Subject: [PATCH 2/4] =?UTF-8?q?refactor(cli):=20=E2=99=BB=EF=B8=8F=20move?= =?UTF-8?q?=20plugin=20creation=20to=20create-vitnode-app,=20add=20i18n=20?= =?UTF-8?q?subcommands=20and=20Bun=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Plugin creation lives in `create-vitnode-app --plugin` only. Its template is aligned with the repo plugins (tsconfig without the stale `next` plugin, test-aware .swcrc/tsconfig.build, vitest config), and it now generates a real endpoint test and a README and refuses names core already routes. `vitnode plugin create` is removed; `plugin list|validate` stay. - `vitnode i18n check|create|delete|update|update-ai` replace the colon-separated commands, which remain as hidden aliases. The scripts take their arguments explicitly, report through the CLI UI and prompter, and throw typed errors instead of calling `process.exit`. `delete` needs `--yes` when non-interactive. - API apps on Bun (run by Bun, `packageManager: bun@`, or a Bun lockfile): `vitnode dev` runs `bun --hot`, `vitnode start` runs `bun src/index.ts`, `vitnode build` has nothing to compile. The Bun template uses the CLI too. - Review fixes: read and pass whichever drizzle.config extension the project uses; `vitnode start` refuses an occupied port and no longer misses a child that exits early; `vitnode dev --port` reaches an API app as PORT; the entry drains stderr as well as stdout before exiting. - Database connection errors show the driver's root cause. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XoLwCe46nQZD8LHRwayaax --- apps/api/package.json | 10 +- apps/web/content/docs/dev/cli/index.mdx | 47 +- apps/web/content/docs/dev/cli/plugins.mdx | 101 +--- apps/web/content/docs/dev/i18n/index.mdx | 28 +- apps/web/content/docs/dev/i18n/server.mdx | 2 +- apps/web/content/docs/dev/plugins/create.mdx | 43 ++ .../copy-of-vitnode-app/README.md | 2 +- .../copy-of-vitnode-plugin/root/.swcrc | 2 +- .../root/tsconfig.build.json | 8 +- .../copy-of-vitnode-plugin/root/tsconfig.json | 8 +- .../root/vitest.config.ts | 15 + .../src/create/create-package-json.test.ts | 28 ++ .../src/create/create-package-json.ts | 21 +- .../src/create/package-versions.ts | 1 + .../src/plugin/create/create-package-json.ts | 4 +- .../plugin/create/create-plugin-vitnode.ts | 10 +- .../src/plugin/create/route-templates.test.ts | 32 ++ .../src/plugin/create/route-templates.ts | 44 ++ .../create-vitnode-app/src/plugin/index.ts | 14 +- .../src/plugin/validation.test.ts | 16 + .../src/plugin/validation.ts | 31 ++ .../vitnode/scripts/cli/commands/build.ts | 11 +- .../vitnode/scripts/cli/commands/db.test.ts | 12 +- packages/vitnode/scripts/cli/commands/db.ts | 11 +- .../vitnode/scripts/cli/commands/dev.test.ts | 47 ++ packages/vitnode/scripts/cli/commands/dev.ts | 16 +- .../vitnode/scripts/cli/commands/i18n.test.ts | 187 +++++++ packages/vitnode/scripts/cli/commands/i18n.ts | 89 ++++ .../vitnode/scripts/cli/commands/legacy.ts | 41 -- .../scripts/cli/commands/plugin-create.ts | 147 ------ .../scripts/cli/commands/plugin-validate.ts | 7 +- .../scripts/cli/commands/plugin.test.ts | 231 ++------- .../scripts/cli/commands/start.test.ts | 47 +- .../vitnode/scripts/cli/commands/start.ts | 21 +- packages/vitnode/scripts/cli/db/prepare.ts | 4 +- packages/vitnode/scripts/cli/dev/watchers.ts | 38 +- packages/vitnode/scripts/cli/errors.ts | 16 + packages/vitnode/scripts/cli/help.ts | 4 +- packages/vitnode/scripts/cli/index.test.ts | 4 +- .../vitnode/scripts/cli/plugins/create.ts | 159 ------ .../scripts/cli/plugins/naming.test.ts | 79 --- .../vitnode/scripts/cli/plugins/naming.ts | 125 ----- .../vitnode/scripts/cli/plugins/template.ts | 471 ------------------ packages/vitnode/scripts/cli/program.ts | 140 ++++-- .../vitnode/scripts/cli/project/packages.ts | 1 + .../vitnode/scripts/cli/project/processes.ts | 32 +- .../vitnode/scripts/cli/project/project.ts | 4 + .../scripts/cli/project/runtime.test.ts | 74 +++ .../vitnode/scripts/cli/project/runtime.ts | 62 +++ .../vitnode/scripts/cli/start/server-entry.ts | 24 +- .../scripts/cli/start/wait-for-port.ts | 5 +- packages/vitnode/scripts/cli/testing.ts | 12 + packages/vitnode/scripts/cli/ui/prompts.ts | 39 ++ .../scripts/database-bootstrap.test.ts | 30 ++ packages/vitnode/scripts/i18n-check.test.ts | 51 +- packages/vitnode/scripts/i18n-check.ts | 139 ++++-- packages/vitnode/scripts/i18n-create.ts | 152 +++--- packages/vitnode/scripts/i18n-delete.ts | 116 ++--- packages/vitnode/scripts/i18n-shared.ts | 84 ++-- packages/vitnode/scripts/i18n-update-ai.ts | 291 ++++++----- packages/vitnode/scripts/i18n-update.ts | 86 ++-- packages/vitnode/scripts/prepare-database.ts | 28 +- packages/vitnode/scripts/scripts.ts | 18 +- 63 files changed, 1692 insertions(+), 1930 deletions(-) create mode 100644 packages/create-vitnode-app/copy-of-vitnode-plugin/root/vitest.config.ts create mode 100644 packages/create-vitnode-app/src/create/create-package-json.test.ts create mode 100644 packages/create-vitnode-app/src/plugin/validation.test.ts create mode 100644 packages/vitnode/scripts/cli/commands/i18n.test.ts create mode 100644 packages/vitnode/scripts/cli/commands/i18n.ts delete mode 100644 packages/vitnode/scripts/cli/commands/plugin-create.ts delete mode 100644 packages/vitnode/scripts/cli/plugins/create.ts delete mode 100644 packages/vitnode/scripts/cli/plugins/naming.test.ts delete mode 100644 packages/vitnode/scripts/cli/plugins/naming.ts delete mode 100644 packages/vitnode/scripts/cli/plugins/template.ts create mode 100644 packages/vitnode/scripts/cli/project/runtime.test.ts create mode 100644 packages/vitnode/scripts/cli/project/runtime.ts diff --git a/apps/api/package.json b/apps/api/package.json index 197f3b3b6..012b9b5d8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,11 +14,11 @@ "drizzle-kit": "drizzle-kit", "lint": "eslint .", "lint:fix": "eslint . --fix", - "i18n:create": "vitnode i18n:create", - "i18n:check": "vitnode i18n:check", - "i18n:delete": "vitnode i18n:delete", - "i18n:update": "vitnode i18n:update", - "i18n:update:ai": "vitnode i18n:update:ai" + "i18n:create": "vitnode i18n create", + "i18n:check": "vitnode i18n check", + "i18n:delete": "vitnode i18n delete", + "i18n:update": "vitnode i18n update", + "i18n:update:ai": "vitnode i18n update-ai" }, "dependencies": { "@ai-sdk/anthropic": "^4.0.70", diff --git a/apps/web/content/docs/dev/cli/index.mdx b/apps/web/content/docs/dev/cli/index.mdx index 05df9270b..0a3ade2c0 100644 --- a/apps/web/content/docs/dev/cli/index.mdx +++ b/apps/web/content/docs/dev/cli/index.mdx @@ -29,18 +29,24 @@ Generated projects call it from their `package.json` scripts, so `pnpm dev`, `pn ## Commands -| Command | What it does | -| ------------------------- | --------------------------------------------- | -| `vitnode dev` | Start the development environment | -| `vitnode build` | Build for production and report bundle sizes | -| `vitnode start` | Start the production server | -| `vitnode plugin create` | Create a plugin from the official template | -| `vitnode plugin list` | List the plugins your app uses | -| `vitnode plugin validate` | Check plugins the way your app will load them | -| `vitnode db generate` | Generate a migration from schema changes | -| `vitnode db migrate` | Apply pending migrations | -| `vitnode db push` | Push the schema directly (development only) | -| `vitnode db status` | Show the connection and migration status | +| Command | What it does | +| ------------------------- | ------------------------------------------------ | +| `vitnode dev` | Start the development environment | +| `vitnode build` | Build for production and report bundle sizes | +| `vitnode start` | Start the production server | +| `vitnode plugin list` | List the plugins your app uses | +| `vitnode plugin validate` | Check plugins the way your app will load them | +| `vitnode db generate` | Generate a migration from schema changes | +| `vitnode db migrate` | Apply pending migrations | +| `vitnode db push` | Push the schema directly (development only) | +| `vitnode db status` | Show the connection and migration status | +| `vitnode i18n check` | Find missing, unknown and unloaded translations | +| `vitnode i18n create` | Add a language | +| `vitnode i18n delete` | Remove a language | +| `vitnode i18n update` | Sync translation files with the default language | +| `vitnode i18n update-ai` | Translate missing strings with an AI model | + +Create plugins with `create-vitnode-app --plugin` - see [Create a plugin](/docs/dev/plugins/create). Run `vitnode` alone for a short overview, or `vitnode --help` for a command's options. @@ -51,9 +57,12 @@ Run `vitnode` alone for a short overview, or `vitnode --help` for a co | Folder | `vitnode dev` | `vitnode build` | `vitnode start` | | ------------------------------------------------- | ------------------------------------ | --------------------------------------- | -------------------------- | | App (`vite.config.ts`) | Database bootstrap, then Vite | Vite production build + size report | `.output/server/index.mjs` | -| API app (`src/vitnode.api.config.ts`) | Database bootstrap, then `tsx watch` | `tsc` + `tsc-alias` | `dist/index.js` | +| API app on Node (`src/vitnode.api.config.ts`) | Database bootstrap, then `tsx watch` | `tsc` + `tsc-alias` | `dist/index.js` | +| API app on Bun | Database bootstrap, then `bun --hot` | Nothing to compile | `bun src/index.ts` | | Plugin package (`tsconfig.build.json` + `.swcrc`) | Compilers in watch mode | Types, JavaScript and aliases to `dist` | - | +An API app runs on Bun when Bun runs the script (`bun run dev`), or when the project or its workspace uses Bun (`packageManager: "bun@..."` or a `bun.lock`). Otherwise it runs on Node. + The database bootstrap runs only in an app that owns the schema - one with a `drizzle.config.ts`. It generates a migration for schema changes, applies pending migrations and seeds initial data, just like `vitnode db:prepare`. ## Start development @@ -150,11 +159,11 @@ The CLI drives these tools - through Vite's JavaScript API, your project's own ` These still work for existing projects and deployment scripts: -| Command | Same as | -| -------------------- | ---------------------------------------------------- | -| `vitnode db:prepare` | Generate, migrate and seed - what `vitnode dev` runs | -| `vitnode migrate` | `vitnode db:prepare`; `--generate` only generates | -| `vitnode i18n:*` | See [Internationalization](/docs/dev/i18n) | +| Command | Same as | +| ------------------------------------------------- | --------------------------------------------------------------- | +| `vitnode db:prepare` | Generate, migrate and seed - what `vitnode dev` runs | +| `vitnode migrate` | `vitnode db:prepare`; `--generate` only generates | +| `vitnode i18n:check` and the other `i18n:*` names | `vitnode i18n check`, `create`, `delete`, `update`, `update-ai` | ## Learn more @@ -166,7 +175,7 @@ These still work for existing projects and deployment scripts: /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -The plugin is small but complete: a page at `/blog` whose loader calls the plugin's own `hello` API endpoint through the [fetcher](/docs/dev/fetcher), its strings in `en.json`, and a Vitest test for the endpoint. - -### Use it in your app - -1. Add `"@acme/blog": "workspace:*"` to your app's dependencies and install. -2. Register `blogPlugin()` from `@acme/blog/config` in `vitnode.config.ts`. -3. Register `blogApiPlugin()` from `@acme/blog/config.api` in `vitnode.api.config.ts`. -4. Build it: `cd plugins/blog && vitnode build` (or `vitnode dev` to rebuild on every change). - -### Name rules - -| Rule | Example | -| ----------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | -| Lowercase letters, digits and single dashes | `event-calendar` | -| Starts with a letter, under 50 characters | `forum2` | -| Not one of core's routes: `admin`, `api`, `core`, `discover`, `files`, `login`, `notifications`, `register`, `search`, `users`, `vitnode` | - | - -The package name must be a valid npm name and must not be `@vitnode/core`. Creation stops before writing anything if the folder already exists, or a workspace package already has that name - a plugin id must be unique. + + New plugins come from the generator, which also registers, installs and builds + them: `pnpm create vitnode-app@canary --plugin`. See [Create a + plugin](/docs/dev/plugins/create). + ## List plugins diff --git a/apps/web/content/docs/dev/i18n/index.mdx b/apps/web/content/docs/dev/i18n/index.mdx index 69f5598cc..ce21e454e 100644 --- a/apps/web/content/docs/dev/i18n/index.mdx +++ b/apps/web/content/docs/dev/i18n/index.mdx @@ -18,23 +18,33 @@ Add a new language in two CLI commands: ```bash tab="bun" -bun run vitnode i18n:create de Deutsch -bun run vitnode i18n:check +bun run vitnode i18n create de Deutsch +bun run vitnode i18n check ``` ```bash tab="pnpm" -pnpm vitnode i18n:create de Deutsch -pnpm vitnode i18n:check +pnpm vitnode i18n create de Deutsch +pnpm vitnode i18n check ``` ```bash tab="npm" -npx vitnode i18n:create de Deutsch -npx vitnode i18n:check +npx vitnode i18n create de Deutsch +npx vitnode i18n check ``` -`i18n:create` adds the language to `src/vitnode.config.ts`, seeds a translation file per installed package, and registers the loaders in `src/locales/app.ts`. `i18n:check` scans for missing or untranslated keys - including a file nobody imports, which is the usual reason a translation "does not apply". +`vitnode i18n create` adds the language to `src/vitnode.config.ts`, seeds a translation file per installed package, and registers the loaders in `src/locales/app.ts`. `vitnode i18n check` scans for missing or untranslated keys - including a file nobody imports, which is the usual reason a translation "does not apply". + +| Command | What it does | +| ------------------------ | --------------------------------------------------------------------------------------------- | +| `vitnode i18n create` | Add a language: seed its files and wire them into the config | +| `vitnode i18n check` | Report missing, unknown and unloaded translations (`--ci` fails on missing keys) | +| `vitnode i18n update` | Sync translation files with the default language's keys | +| `vitnode i18n update-ai` | Translate strings still in the default language with an AI model (`--model`, `--concurrency`) | +| `vitnode i18n delete` | Remove a language (`--yes` to skip the question) | + +Arguments skip their questions, so every command works in a script: without a terminal, a missing argument is an error (exit code `2`) rather than a prompt that waits forever. The older `i18n:create`-style names still work. --- @@ -82,7 +92,7 @@ export const vitNodeServerConfig = buildServerConfig({ Putting a loader in the shared config puts every plugin's AdminCP copy in your - browser bundle, and makes your Vite build execute it. `vitnode i18n:create` + browser bundle, and makes your Vite build execute it. `vitnode i18n create` writes to the right file for you. @@ -182,7 +192,7 @@ export const appMessages: AppMessagesMap = { } ``` -`i18n:check` treats it like a package: your default-locale file is the source of truth, and every other language is checked against it for missing and leftover keys. No default-locale file at all is an error - there would be nothing to translate from. +`vitnode i18n check` treats it like a package: your default-locale file is the source of truth, and every other language is checked against it for missing and leftover keys. No default-locale file at all is an error - there would be nothing to translate from. --- diff --git a/apps/web/content/docs/dev/i18n/server.mdx b/apps/web/content/docs/dev/i18n/server.mdx index 52f7cb710..3fc1ae22d 100644 --- a/apps/web/content/docs/dev/i18n/server.mdx +++ b/apps/web/content/docs/dev/i18n/server.mdx @@ -222,4 +222,4 @@ VitNode says so, once per package and locale, rather than quietly rendering raw [VitNode i18n] Could not load "pl" messages for "@vitnode/blog" - its strings will render as raw keys. ``` -Run [`vitnode i18n:check`](/docs/dev/i18n) to find gaps before your users do. +Run [`vitnode i18n check`](/docs/dev/i18n) to find gaps before your users do. diff --git a/apps/web/content/docs/dev/plugins/create.mdx b/apps/web/content/docs/dev/plugins/create.mdx index c49b9dfde..0fbcc466f 100644 --- a/apps/web/content/docs/dev/plugins/create.mdx +++ b/apps/web/content/docs/dev/plugins/create.mdx @@ -10,6 +10,7 @@ import { LayoutDashboardIcon, RouteIcon, } from 'lucide-react' +import { File, Files, Folder } from 'fumadocs-ui/components/files' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' A plugin is the starting point for a VitNode feature. It keeps routes, API @@ -85,6 +86,48 @@ import generatedPageImage from './generated-page-image.png' +## What gets generated + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +One page at `/site-notes` whose loader calls the plugin's own `hello` endpoint, its strings, and a Vitest test for the endpoint (`pnpm test` in the plugin). The generator also registers the plugin in your apps' `vitnode.config.ts` and `vitnode.api.config.ts`, installs it and builds it. + +The plugin name must be a valid npm name and must not be one of core's routes: `admin`, `api`, `core`, `discover`, `files`, `login`, `notifications`, `register`, `search`, `users` or `vitnode`. + +Check the result the way your app will load it: + +```bash +vitnode plugin validate site-notes +``` + +See [Plugin CLI](/docs/dev/cli/plugins) for what validation checks. + ## Add the next capability diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/README.md b/packages/create-vitnode-app/copy-of-vitnode-app/README.md index 26e2c5d46..6cdbd7447 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/README.md +++ b/packages/create-vitnode-app/copy-of-vitnode-app/README.md @@ -50,7 +50,7 @@ Two files, and the line between them is one question: may a browser hold this? Add a language to `i18n.locales` in the shared config; register the files that translate it in `src/locales/packages.ts` (a package's own translations) or -`src/locales/app.ts` (your rewordings). `pnpm vitnode i18n:create de Deutsch` +`src/locales/app.ts` (your rewordings). `pnpm vitnode i18n create de Deutsch` does all three. `src/start.ts` is one call to `createVitNodeStart`, which installs CSRF diff --git a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/.swcrc b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/.swcrc index 8f099dc7a..91fc9a4b0 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/.swcrc +++ b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/.swcrc @@ -1,6 +1,6 @@ { "$schema": "https://swc.rs/schema.json", - "exclude": ["\\.test\\.tsx?$"], + "exclude": ["\\.test\\.tsx?$", "\\.test-d\\.ts$", "^src/tests/"], "minify": true, "jsc": { "baseUrl": "./", diff --git a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/tsconfig.build.json b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/tsconfig.build.json index 997f1529d..37c87582a 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/tsconfig.build.json +++ b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/tsconfig.build.json @@ -1,5 +1,11 @@ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "./tsconfig.json", - "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"] + "exclude": [ + "node_modules", + "vitest.config.ts", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.test-d.ts" + ] } diff --git a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/tsconfig.json b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/tsconfig.json index 3bc4e1d49..ad8d8e759 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/tsconfig.json +++ b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/tsconfig.json @@ -7,19 +7,15 @@ "moduleResolution": "bundler", "rootDir": "./", "outDir": "./dist", + "incremental": false, "jsx": "react-jsx", "emitDeclarationOnly": true, "declaration": true, "declarationMap": true, - "plugins": [ - { - "name": "next" - } - ], "paths": { "@/*": ["./src/*"] } }, "exclude": ["node_modules"], - "include": ["src", "global.d.ts", "types"] + "include": ["types", "src", "global.d.ts", "vitest.config.ts"] } diff --git a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/vitest.config.ts b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/vitest.config.ts new file mode 100644 index 000000000..3bc9152d2 --- /dev/null +++ b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/vitest.config.ts @@ -0,0 +1,15 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + exclude: ["**/node_modules/**", "**/dist/**"], + passWithNoTests: true, + }, + resolve: { + alias: { + "@": resolve(import.meta.dirname, "./src"), + }, + }, +}); diff --git a/packages/create-vitnode-app/src/create/create-package-json.test.ts b/packages/create-vitnode-app/src/create/create-package-json.test.ts new file mode 100644 index 000000000..9bbdcb58f --- /dev/null +++ b/packages/create-vitnode-app/src/create/create-package-json.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { apiScripts, i18nCliCommand } from "./create-package-json.js"; + +describe("generated scripts", () => { + it("map each i18n script to its vitnode i18n subcommand", () => { + expect(i18nCliCommand("i18n:check")).toBe("vitnode i18n check"); + expect(i18nCliCommand("i18n:update:ai")).toBe("vitnode i18n update-ai"); + }); + + it("run a Bun API through vitnode too, with nothing to build", () => { + const scripts = apiScripts("bun", false, false, true, "app"); + + expect(scripts).toMatchObject({ + dev: "vitnode dev", + start: "vitnode start", + }); + expect(scripts).not.toHaveProperty("build"); + }); + + it("build and start a Node API through vitnode", () => { + expect(apiScripts("pnpm", false, false, true, "app")).toMatchObject({ + build: "vitnode build", + dev: "vitnode dev", + start: "vitnode start", + }); + }); +}); diff --git a/packages/create-vitnode-app/src/create/create-package-json.ts b/packages/create-vitnode-app/src/create/create-package-json.ts index a3ac9c54d..90944440b 100644 --- a/packages/create-vitnode-app/src/create/create-package-json.ts +++ b/packages/create-vitnode-app/src/create/create-package-json.ts @@ -34,8 +34,12 @@ const i18nCommands = [ "i18n:update", "i18n:update:ai", ] as const; +/** `i18n:update:ai` → `vitnode i18n update-ai`: the script keeps its name. */ +export const i18nCliCommand = (script: (typeof i18nCommands)[number]) => + `vitnode i18n ${script.slice("i18n:".length).replace(":", "-")}`; + const i18nScripts = Object.fromEntries( - i18nCommands.map(command => [command, `vitnode ${command}`]), + i18nCommands.map(command => [command, i18nCliCommand(command)]), ); const runScript = (pm: string, script: string) => @@ -113,16 +117,11 @@ export const apiScripts = ( return { "db:migrate": "vitnode migrate", "db:prepare": "vitnode db:prepare", - ...(pm === "bun" - ? { - dev: "vitnode db:prepare && bun run --hot src/index.ts", - start: "NODE_ENV=production bun run src/index.ts", - } - : { - dev: "vitnode dev", - build: "vitnode build", - start: "vitnode start", - }), + // `vitnode` follows the project's runtime: on Bun it runs `bun --hot` + // and starts `src/index.ts` directly, so there is nothing to build. + dev: "vitnode dev", + ...(pm === "bun" ? {} : { build: "vitnode build" }), + start: "vitnode start", "dev:email": "email dev --dir src/emails", ...i18nScripts, ...withIf(eslint, eslintScripts), diff --git a/packages/create-vitnode-app/src/create/package-versions.ts b/packages/create-vitnode-app/src/create/package-versions.ts index f65f44ccc..788f411f7 100644 --- a/packages/create-vitnode-app/src/create/package-versions.ts +++ b/packages/create-vitnode-app/src/create/package-versions.ts @@ -49,5 +49,6 @@ export const versionsPackageJson = { tslib: "^2.8.1", swcCli: "^0.8.1", swcCore: "^1.16", + vitest: "^5.0", shadcn: "^4.21", }; diff --git a/packages/create-vitnode-app/src/plugin/create/create-package-json.ts b/packages/create-vitnode-app/src/plugin/create/create-package-json.ts index 1cf91b88e..83d4a622e 100644 --- a/packages/create-vitnode-app/src/plugin/create/create-package-json.ts +++ b/packages/create-vitnode-app/src/plugin/create/create-package-json.ts @@ -13,7 +13,8 @@ const writeJson = async (path: string, data: unknown) => export const pluginScripts = (eslint: boolean) => ({ "build:plugins": "vitnode build", dev: "vitnode dev", - "dev:email": "email dev --dir src/emails", + test: "vitest run", + "test:watch": "vitest", ...withIf(eslint, { lint: "turbo lint", "lint:fix": "turbo lint:fix", @@ -66,6 +67,7 @@ export const createPluginPackageJSON = async ({ }), "tsc-alias": versionsPackageJson.tscAlias, typescript: versionsPackageJson.typescript, + vitest: versionsPackageJson.vitest, }, }; diff --git a/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts b/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts index 8bb121367..01c192b90 100644 --- a/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts +++ b/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts @@ -20,7 +20,10 @@ import { import { addPluginToWorkspace } from "./add-plugin-to-workspace.js"; import { createPluginPackageJSON } from "./create-package-json.js"; import { devCommandFor, restartDevServers } from "./restart-dev-servers.js"; -import { pluginRouteScaffold } from "./route-templates.js"; +import { + pluginReadmeTemplate, + pluginRouteScaffold, +} from "./route-templates.js"; const BUILD_SCRIPT = "build:plugins"; @@ -160,6 +163,11 @@ export const createPluginVitNode = async ({ spinner.text = "Writing the plugin's first route and API..."; await writePluginRouteScaffold({ pluginName, pluginPath }); + await writeFile( + join(pluginPath, "README.md"), + pluginReadmeTemplate(pluginName), + "utf-8", + ); spinner.text = "Creating package.json..."; await createPluginPackageJSON({ diff --git a/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts b/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts index a4401df41..51c2bd997 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { pluginApiConfigTemplate, pluginApiModuleTemplate, + pluginApiModuleTestTemplate, pluginApiRouteTemplate, pluginApiVariableName, pluginConfigTemplate, @@ -12,6 +13,7 @@ import { pluginGlobalTypesTemplate, pluginMessagesTemplate, pluginPackageExports, + pluginReadmeTemplate, pluginRouteModuleTemplate, pluginRouteScaffold, pluginRoutesTemplate, @@ -480,3 +482,33 @@ describe("the scaffold as a whole", () => { ); }); }); + +describe("the generated endpoint test", () => { + it("is part of the scaffold, next to the module it tests", () => { + expect(Object.keys(pluginRouteScaffold("@acme/blog"))).toContain( + "src/api/modules/hello/hello.module.test.ts", + ); + }); + + it("requests the module through Hono and expects the plugin's own greeting", () => { + const test = pluginApiModuleTestTemplate("@acme/blog"); + + expect(test).toContain('import { helloModule } from "./hello.module";'); + expect(test).toContain('helloModule.hono.request("/")'); + expect(test).toContain('message: "Hello from @acme/blog!"'); + // The same greeting the route answers with. + expect(pluginApiRouteTemplate()).toContain( + "Hello from ${CONFIG_PLUGIN.pluginId}!", + ); + }); +}); + +describe("the generated README", () => { + it("names the page the plugin serves and the commands that work on it", () => { + const readme = pluginReadmeTemplate("@acme/blog"); + + expect(readme).toContain("# @acme/blog"); + expect(readme).toContain("`/blog`"); + expect(readme).toContain("vitnode plugin validate blog"); + }); +}); diff --git a/packages/create-vitnode-app/src/plugin/create/route-templates.ts b/packages/create-vitnode-app/src/plugin/create/route-templates.ts index a67e271fd..4c986aa30 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.ts @@ -251,6 +251,48 @@ export type VitNodeApiPlugin = ApiPluginContract< >; `; +/** + * `src/api/modules/hello/hello.module.test.ts` - the endpoint, exercised + * through its own Hono app, with no server and no database. + * + * A real test of the one thing the scaffold does, so `pnpm test` means + * something from the first commit - and a pattern to copy for the next route. + */ +export const pluginApiModuleTestTemplate = (pluginName: string): string => + `import { describe, expect, it } from "vitest"; + +import { helloModule } from "./hello.module"; + +describe("hello module", () => { + it("answers GET / with a greeting from the plugin", async () => { + const response = await helloModule.hono.request("/"); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + message: "Hello from ${pluginName}!", + }); + }); +}); +`; + +/** `README.md` - what the plugin is and the commands that work on it. */ +export const pluginReadmeTemplate = (pluginName: string): string => + `# ${pluginName} + +A VitNode plugin. It adds a page at \`/${routeSlugFor(pluginName)}\`, rendered by \`src/pages/home-page.tsx\`, which loads its text from the plugin's own API module in \`src/api/modules/hello\`. + +## Develop + +\`\`\`bash +vitnode dev # rebuild dist/ on every change +vitnode build # one-off build +vitest # run the plugin's tests +vitnode plugin validate ${routeSlugFor(pluginName)} +\`\`\` + +Docs: https://vitnode.com/docs/dev/plugins +`; + export const pluginGlobalTypesTemplate = (): string => `/// @@ -297,6 +339,8 @@ export const pluginRouteScaffold = ( pluginName: string, ): Record => ({ "global.d.ts": pluginGlobalTypesTemplate(), + "src/api/modules/hello/hello.module.test.ts": + pluginApiModuleTestTemplate(pluginName), "src/api/modules/hello/hello.module.ts": pluginApiModuleTemplate(), "src/api/modules/hello/hello.route.ts": pluginApiRouteTemplate(), "src/config.api.ts": pluginApiConfigTemplate(pluginName), diff --git a/packages/create-vitnode-app/src/plugin/index.ts b/packages/create-vitnode-app/src/plugin/index.ts index d2e4ca81b..4373cca0c 100644 --- a/packages/create-vitnode-app/src/plugin/index.ts +++ b/packages/create-vitnode-app/src/plugin/index.ts @@ -6,7 +6,10 @@ import { basename, resolve } from "node:path"; import { validateNpmName } from "../helpers/validate-pkg.js"; import { createPluginVitNode } from "./create/create-plugin-vitnode.js"; import { createPluginQuestionsCli } from "./questions.js"; -import { validationProjectForPlugin } from "./validation.js"; +import { + reservedPluginNameProblem, + validationProjectForPlugin, +} from "./validation.js"; export const createPlugin = async ({ program, @@ -21,10 +24,13 @@ export const createPlugin = async ({ message: "What is your plugin named?", default: "my-vitnode-plugin", validate: (name: string) => { - const validation = validateNpmName({ name: basename(resolve(name)) }); - if (validation.valid) return true; + const base = basename(resolve(name)); + const validation = validateNpmName({ name: base }); + if (!validation.valid) { + return `Invalid plugin name: ${validation.problems[0]}`; + } - return `Invalid plugin name: ${validation.problems[0]}`; + return reservedPluginNameProblem(base) ?? true; }, }); } diff --git a/packages/create-vitnode-app/src/plugin/validation.test.ts b/packages/create-vitnode-app/src/plugin/validation.test.ts new file mode 100644 index 000000000..2241c68a0 --- /dev/null +++ b/packages/create-vitnode-app/src/plugin/validation.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { reservedPluginNameProblem } from "./validation.js"; + +describe("reservedPluginNameProblem", () => { + it.each(["admin", "api", "login", "users"])( + "refuses %s, a route core already serves", + name => { + expect(reservedPluginNameProblem(name)).toContain(`/${name}`); + }, + ); + + it.each(["blog", "forum", "admin-tools"])("accepts %s", name => { + expect(reservedPluginNameProblem(name)).toBeNull(); + }); +}); diff --git a/packages/create-vitnode-app/src/plugin/validation.ts b/packages/create-vitnode-app/src/plugin/validation.ts index 88ad7d9a1..c5ee653c7 100644 --- a/packages/create-vitnode-app/src/plugin/validation.ts +++ b/packages/create-vitnode-app/src/plugin/validation.ts @@ -10,6 +10,31 @@ import { isFolderEmpty } from "../helpers/is-folder-empty.js"; import { isWriteable } from "../helpers/is-writeable.js"; import { validateNpmName } from "../helpers/validate-pkg.js"; +/** + * Names a plugin cannot take: its first page is served at `/`, and + * these are core's own top-level routes (or route areas). A plugin named + * `admin` would fail its first build with a route collision; refusing it here + * says so before any file is written. + */ +export const RESERVED_PLUGIN_NAMES: ReadonlySet = new Set([ + "admin", + "api", + "core", + "discover", + "files", + "login", + "notifications", + "register", + "search", + "users", + "vitnode", +]); + +export const reservedPluginNameProblem = (name: string): null | string => + RESERVED_PLUGIN_NAMES.has(name) + ? `"${name}" is reserved - VitNode core already serves /${name}. Choose another name.` + : null; + export const validationProjectForPlugin = async (projectPath: string) => { if (!projectPath) { console.log( @@ -98,6 +123,12 @@ export const validationProjectForPlugin = async (projectPath: string) => { process.exit(1); } + const reserved = reservedPluginNameProblem(projectName); + if (reserved !== null) { + console.error(`${color.red("Error:")} ${reserved}`); + process.exit(1); + } + const pluginsDir = join(cwd, "plugins"); const pluginPath = join(pluginsDir, projectName); const pluginName = basename(pluginPath); diff --git a/packages/vitnode/scripts/cli/commands/build.ts b/packages/vitnode/scripts/cli/commands/build.ts index 56f0706b0..42564f604 100644 --- a/packages/vitnode/scripts/cli/commands/build.ts +++ b/packages/vitnode/scripts/cli/commands/build.ts @@ -32,6 +32,7 @@ import { collectWarnings } from "../builder/warnings"; import { errorMessage, EXIT_CODE } from "../errors"; import { isPluginPackage } from "../plugins/discover"; import { detectProject } from "../project/project"; +import { detectRuntime } from "../project/runtime"; import { formatDuration, plural } from "../ui/format"; export interface BuildOptions extends OutputOptions { @@ -115,7 +116,7 @@ const reportAppBuild = ( }; export const runBuildCommand = async ( - { cwd, ui }: CommandContext, + { cwd, env, ui }: CommandContext, options: BuildOptions, deps: BuildDeps = {}, ): Promise => { @@ -133,6 +134,14 @@ export const runBuildCommand = async ( steps: packageBuildSteps(), ui, }); + } else if ( + project.kind === "api" && + detectRuntime(project.root, env) === "bun" + ) { + // Bun runs the TypeScript entry as it is: there is no output to produce, + // and `vitnode start` runs `src/index.ts` directly. + ui.header("Production build"); + ui.success("Nothing to compile - Bun runs src/index.ts directly"); } else if (project.kind === "api") { ui.header("Production build"); await runCompilerSteps({ diff --git a/packages/vitnode/scripts/cli/commands/db.test.ts b/packages/vitnode/scripts/cli/commands/db.test.ts index 83af40812..5e53bb093 100644 --- a/packages/vitnode/scripts/cli/commands/db.test.ts +++ b/packages/vitnode/scripts/cli/commands/db.test.ts @@ -79,7 +79,12 @@ const fakeDatabase = ({ location: "vitnode @ localhost:5432", ping: async () => { await Promise.resolve(); - if (!reachable) throw new Error("connect ECONNREFUSED 127.0.0.1:5432"); + if (!reachable) { + // Drizzle wraps the driver's error; the inner one is what matters. + throw new Error("Failed query: SELECT 1", { + cause: new Error("connect ECONNREFUSED 127.0.0.1:5432"), + }); + } }, query: async query => await Promise.resolve( @@ -343,7 +348,10 @@ describe("vitnode db status", () => { await expect( runDbStatusCommand(ctx, {}, { services: () => db.services }), - ).rejects.toThrow("Could not connect to the database."); + ).rejects.toMatchObject({ + details: ["connect ECONNREFUSED 127.0.0.1:5432"], + message: "Could not connect to the database.", + }); expect(runtime.output()).toMatch(/Status\s+○ unreachable/); }); diff --git a/packages/vitnode/scripts/cli/commands/db.ts b/packages/vitnode/scripts/cli/commands/db.ts index b7cf901d9..d375101bb 100644 --- a/packages/vitnode/scripts/cli/commands/db.ts +++ b/packages/vitnode/scripts/cli/commands/db.ts @@ -14,7 +14,12 @@ import { isDataLossHint, summarizeStatement, } from "../db/statements"; -import { errorMessage, EXIT_CODE, RuntimeError, UserError } from "../errors"; +import { + EXIT_CODE, + rootCauseMessage, + RuntimeError, + UserError, +} from "../errors"; import { requireDatabaseProject } from "../project/project"; import { withQuietOutput } from "../ui/capture-output"; import { plural } from "../ui/format"; @@ -169,7 +174,7 @@ const connect = async ( await handle.close().catch(() => undefined); throw new RuntimeError("Could not connect to the database.", { cause: error, - details: [errorMessage(error)], + details: [rootCauseMessage(error)], hint: "Is it running? In development, pnpm docker:dev starts one.", }); } @@ -408,7 +413,7 @@ export const runDbStatusCommand = async ( ]); throw new RuntimeError("Could not connect to the database.", { cause: error, - details: [errorMessage(error)], + details: [rootCauseMessage(error)], hint: "Is it running? In development, pnpm docker:dev starts one.", }); } diff --git a/packages/vitnode/scripts/cli/commands/dev.test.ts b/packages/vitnode/scripts/cli/commands/dev.test.ts index c3886f66b..6b29f87e9 100644 --- a/packages/vitnode/scripts/cli/commands/dev.test.ts +++ b/packages/vitnode/scripts/cli/commands/dev.test.ts @@ -133,6 +133,53 @@ describe("vitnode dev in a plugin package", () => { }); }); +describe("vitnode dev in an API app", () => { + beforeEach(() => { + write("package.json", JSON.stringify({ name: "api" })); + write("src/vitnode.api.config.ts", "export const vitNodeApiConfig = {};"); + }); + + const start = async ( + options: { port?: string }, + env: Record = {}, + ) => { + const group = fakeGroup(); + const { context, runtime } = createTestContext({ cwd: root, env }); + const running = runDevCommand(context, options, { group }); + await until(() => group.spawn.mock.calls.length === 1); + runtime.signals.emit("SIGINT"); + await running; + + return { output: runtime.output(), spawned: group.spawned[0] }; + }; + + it("passes --port to the API as PORT, which it already reads", async () => { + const { output, spawned } = await start({ port: "9000" }, { PORT: "8000" }); + + expect(spawned.env?.PORT).toBe("9000"); + expect(output).toMatch(/API\s+http:\/\/localhost:9000\/api/); + }); + + it("runs a Node project through tsx watch", async () => { + const { spawned } = await start({}); + + expect(spawned.command).toBe(process.execPath); + expect(spawned.args.slice(1)).toEqual(["watch", join("src", "index.ts")]); + expect(spawned.env?.PORT).toBe("8000"); + }); + + it("runs a Bun project with bun --hot", async () => { + const { output, spawned } = await start( + {}, + { npm_config_user_agent: "bun/1.3.14 npm/? node/v24.3.0" }, + ); + + expect(spawned.command).toBe("bun"); + expect(spawned.args).toEqual(["--hot", join("src", "index.ts")]); + expect(output).toMatch(/Runtime\s+Bun \(--hot\)/); + }); +}); + describe("vitnode dev in an app", () => { beforeEach(() => { write("package.json", JSON.stringify({ name: "web" })); diff --git a/packages/vitnode/scripts/cli/commands/dev.ts b/packages/vitnode/scripts/cli/commands/dev.ts index b507550f8..632c40cfb 100644 --- a/packages/vitnode/scripts/cli/commands/dev.ts +++ b/packages/vitnode/scripts/cli/commands/dev.ts @@ -18,6 +18,7 @@ import { } from "../dev/watchers"; import { loadConfiguredPluginIds } from "../plugins/discover"; import { detectProject } from "../project/project"; +import { detectRuntime } from "../project/runtime"; import { plural } from "../ui/format"; import { parsePort } from "./start"; @@ -88,17 +89,26 @@ export const runDevCommand = async ( ui.success(`${plural(plugins.length, "plugin")} loaded`); if (project.kind === "api") { - const port = parsePort(env.PORT, 8000); + // The API reads PORT itself, so `--port` reaches it the same way. + const port = parsePort(options.port ?? env.PORT, 8000); + const runtime = detectRuntime(project.root, env); ui.line(); ui.keyValue([ ["API", ui.colors.command(`http://localhost:${String(port)}/api`)], + [ + "Runtime", + ui.colors.muted(runtime === "bun" ? "Bun (--hot)" : "Node (tsx watch)"), + ], ]); ui.rule(); return runWatchers({ group: deps.group, - processes: apiWatchers({ bun: process.versions.bun !== undefined }).map( - watcher => toProcess(project, watcher), + processes: apiWatchers(runtime).map(watcher => + toProcess(project, watcher, { + env: { ...env, PORT: String(port) }, + runtime, + }), ), signals, ui, diff --git a/packages/vitnode/scripts/cli/commands/i18n.test.ts b/packages/vitnode/scripts/cli/commands/i18n.test.ts new file mode 100644 index 000000000..26a5e52c9 --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/i18n.test.ts @@ -0,0 +1,187 @@ +// @vitest-environment node +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { runCli } from "../index"; +import { createFakeRuntime, createScriptedPrompter } from "../testing"; + +let root: string; + +const write = (path: string, content: string) => { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), content); +}; + +/** An app with an English and a Polish translation of its own namespace. */ +const app = () => { + write("package.json", "{}"); + write( + "src/i18n.ts", + `export const i18n = { + defaultLocale: "en", + locales: [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, + ], +}; +`, + ); + write( + "src/vitnode.config.ts", + `import { i18n } from "./i18n";\nexport const vitNodeConfig = { i18n, plugins: [] };\n`, + ); + write( + "src/locales/app.ts", + "export const appMessages = { en: { site: () => null }, pl: { site: () => null } };\n", + ); + write( + "src/vitnode.server.config.ts", + 'import { appMessages } from "@/locales/app";\nexport const vitNodeServerConfig = { messages: appMessages };\n', + ); + write( + "src/locales/site/en.json", + JSON.stringify({ site: { cta: "Explore", title: "Title" } }), + ); + write( + "src/locales/site/pl.json", + JSON.stringify({ site: { title: "Tytuł" } }), + ); +}; + +const run = async ( + argv: string[], + options: Parameters[0] = {}, +) => { + const runtime = createFakeRuntime({ cwd: root, ...options }); + const code = await runCli(argv, runtime); + + return { code, errors: runtime.errors(), output: runtime.output() }; +}; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "vitnode-i18n-")); + app(); +}); + +afterEach(() => { + rmSync(root, { force: true, recursive: true }); +}); + +describe("vitnode i18n check", () => { + it("fails under --ci with exit code 1 - never through process.exit", async () => { + const { code, errors, output } = await run(["i18n", "check", "--ci"]); + + expect(code).toBe(1); + expect(output).toContain("site · pl: 1 key(s) missing"); + expect(errors).toMatch(/✖ \d+ issue\(s\), 1 untranslated key\(s\)\./); + }); + + it("keeps working under its older name", async () => { + expect((await run(["i18n:check", "--ci"])).code).toBe(1); + expect((await run(["i18n:check", "--cii"])).code).toBe(2); + }); +}); + +describe("vitnode i18n update", () => { + it("adds the missing keys in the default language and keeps translations", async () => { + const { code, output } = await run(["i18n", "update"]); + + expect(code).toBe(0); + expect(output).toContain("updated src/locales/site/pl.json"); + expect( + JSON.parse(readFileSync(join(root, "src/locales/site/pl.json"), "utf8")), + ).toEqual({ site: { cta: "Explore", title: "Tytuł" } }); + }); +}); + +describe("vitnode i18n create", () => { + it("adds a language from the command line, without asking", async () => { + const { code, output } = await run(["i18n", "create", "de", "Deutsch"]); + + expect(code).toBe(0); + expect(output).toContain("✓ Deutsch (de) added."); + expect(readFileSync(join(root, "src/i18n.ts"), "utf8")).toContain( + '{ code: "de", name: "Deutsch" }', + ); + }); + + it("refuses to wait for a code nobody can type", async () => { + const { code, errors } = await run(["i18n", "create"]); + + expect(code).toBe(2); + expect(errors).toContain("Missing locale code."); + }); + + it("refuses an invalid code given on the command line", async () => { + const { code, errors } = await run(["i18n", "create", "Polish!", "Polski"]); + + expect(code).toBe(2); + expect(errors).toContain("Use an ISO code"); + }); + + it("asks for what is missing in an interactive terminal", async () => { + const prompter = createScriptedPrompter({ text: ["de", "Deutsch"] }); + const { code } = await run(["i18n", "create"], { + interactive: true, + prompter, + }); + + expect(code).toBe(0); + expect(prompter.asked).toEqual([ + "Locale code (e.g. pl, de, pt-BR)", + "Language name (e.g. Polski, Deutsch)", + ]); + }); +}); + +describe("vitnode i18n delete", () => { + it("needs --yes to delete from a script", async () => { + const { code, errors } = await run(["i18n", "delete", "pl"]); + + expect(code).toBe(2); + expect(errors).toContain("Pass --yes"); + expect(existsSync(join(root, "src/locales/site/pl.json"))).toBe(true); + }); + + it("removes the language's files and config entry with --yes", async () => { + const { code, output } = await run(["i18n", "delete", "pl", "--yes"]); + + expect(code).toBe(0); + expect(output).toContain("deleted src/locales/site/pl.json"); + expect(existsSync(join(root, "src/locales/site/pl.json"))).toBe(false); + expect(readFileSync(join(root, "src/i18n.ts"), "utf8")).not.toContain( + '"pl"', + ); + }); + + it("refuses to remove the default locale", async () => { + const { code, errors } = await run(["i18n", "delete", "en", "--yes"]); + + expect(code).toBe(2); + expect(errors).toContain("English is the built-in fallback"); + }); +}); + +describe("vitnode i18n update-ai", () => { + it("validates --concurrency before doing anything", async () => { + const { code, errors } = await run([ + "i18n", + "update-ai", + "pl", + "--concurrency", + "zero", + ]); + + expect(code).toBe(2); + expect(errors).toContain('"zero" is not a valid --concurrency.'); + }); +}); diff --git a/packages/vitnode/scripts/cli/commands/i18n.ts b/packages/vitnode/scripts/cli/commands/i18n.ts new file mode 100644 index 000000000..7e7b454c1 --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/i18n.ts @@ -0,0 +1,89 @@ +import type { CommandContext, OutputOptions } from "../context"; + +import { UserError } from "../errors"; + +export interface I18nOptions extends OutputOptions { + ci?: boolean; + code?: string; + codes?: string[]; + concurrency?: string; + model?: string; + name?: string[]; + yes?: boolean; +} + +/** + * The i18n commands. Each one runs the matching script with the CLI's own + * terminal, prompter and error boundary; the scripts never touch `process`. + */ +export const runI18nCheckCommand = async ( + context: CommandContext, + { ci }: I18nOptions, +): Promise => { + context.ui.header("Translations"); + const { i18nCheck } = await import("../../i18n-check"); + + return i18nCheck({ ci, cwd: context.cwd, ui: context.ui }); +}; + +export const runI18nCreateCommand = async ( + context: CommandContext, + { code, name }: I18nOptions, +): Promise => { + context.ui.header("Add a language"); + const { i18nCreate } = await import("../../i18n-create"); + + return i18nCreate({ + code, + context, + name: name === undefined || name.length === 0 ? undefined : name.join(" "), + }); +}; + +export const runI18nDeleteCommand = async ( + context: CommandContext, + { code, yes }: I18nOptions, +): Promise => { + context.ui.header("Remove a language"); + const { i18nDelete } = await import("../../i18n-delete"); + + return i18nDelete({ code, context, yes }); +}; + +export const runI18nUpdateCommand = async ( + context: CommandContext, + _options: I18nOptions, +): Promise => { + context.ui.header("Sync translations"); + const { i18nUpdate } = await import("../../i18n-update"); + + return i18nUpdate({ cwd: context.cwd, ui: context.ui }); +}; + +export const parseConcurrency = (value: string | undefined) => { + if (value === undefined) return undefined; + const parsed = Number(value); + + if (!Number.isInteger(parsed) || parsed < 1) { + throw new UserError(`"${value}" is not a valid --concurrency.`, { + hint: "Use a whole number above 0, e.g. --concurrency 4.", + }); + } + + return parsed; +}; + +export const runI18nUpdateAiCommand = async ( + context: CommandContext, + { codes, concurrency, model }: I18nOptions, +): Promise => { + context.ui.header("Translate with AI"); + const { i18nUpdateAi } = await import("../../i18n-update-ai"); + + return i18nUpdateAi({ + codes, + concurrency: parseConcurrency(concurrency), + context, + model, + }); +}; diff --git a/packages/vitnode/scripts/cli/commands/legacy.ts b/packages/vitnode/scripts/cli/commands/legacy.ts index c0764b684..36ff690ef 100644 --- a/packages/vitnode/scripts/cli/commands/legacy.ts +++ b/packages/vitnode/scripts/cli/commands/legacy.ts @@ -53,44 +53,3 @@ export const runMigrateCommand = async ( return EXIT_CODE.ok; }; - -// The i18n commands read their own arguments and exit on their own; the -// parser in front of them only refuses what they would not understand. - -export const runI18nCheckCommand = async ( - _context: CommandContext, - { ci }: OutputOptions & { ci?: boolean }, -): Promise => { - const { i18nCheck } = await import("../../i18n-check"); - await i18nCheck(ci === true ? "--ci" : undefined); - - return EXIT_CODE.ok; -}; - -export const runI18nCreateCommand = async (): Promise => { - const { i18nCreate } = await import("../../i18n-create"); - await i18nCreate(); - - return EXIT_CODE.ok; -}; - -export const runI18nDeleteCommand = async (): Promise => { - const { i18nDelete } = await import("../../i18n-delete"); - await i18nDelete(); - - return EXIT_CODE.ok; -}; - -export const runI18nUpdateCommand = async (): Promise => { - const { i18nUpdate } = await import("../../i18n-update"); - await i18nUpdate(); - - return EXIT_CODE.ok; -}; - -export const runI18nUpdateAiCommand = async (): Promise => { - const { i18nUpdateAi } = await import("../../i18n-update-ai"); - await i18nUpdateAi(); - - return EXIT_CODE.ok; -}; diff --git a/packages/vitnode/scripts/cli/commands/plugin-create.ts b/packages/vitnode/scripts/cli/commands/plugin-create.ts deleted file mode 100644 index 9234e819f..000000000 --- a/packages/vitnode/scripts/cli/commands/plugin-create.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { relative } from "node:path"; - -import type { CommandContext, OutputOptions } from "../context"; -import type { TemplateGroup } from "../plugins/template"; - -import { EXIT_CODE, UserError } from "../errors"; -import { - planPlugin, - pluginDependencyVersions, - resolvePluginWorkspace, - writeTemplateFiles, -} from "../plugins/create"; -import { isPluginPackage } from "../plugins/discover"; -import { - defaultPackageName, - pluginApiVariableName, - pluginVariableName, - validatePackageName, - validatePluginName, -} from "../plugins/naming"; -import { pluginTemplate } from "../plugins/template"; -import { toDisplayPath } from "../ui/format"; -import { readCoreManifest } from "../version"; - -export interface PluginCreateOptions extends OutputOptions { - description?: string; - name?: string; - packageName?: string; - yes?: boolean; -} - -const STEPS: { group: TemplateGroup; label: string }[] = [ - { group: "package", label: "Package created" }, - { group: "definition", label: "Plugin definition" }, - { group: "structure", label: "Required structure" }, - { group: "translations", label: "Translations" }, - { group: "tests", label: "Tests" }, - { group: "documentation", label: "Documentation" }, -]; - -const asPrompt = - (validate: (value: string) => null | string) => (value: string) => - validate(value.trim()) ?? true; - -export const runPluginCreateCommand = async ( - { cwd, prompter, ui }: CommandContext, - options: PluginCreateOptions, -): Promise => { - ui.header("Create plugin"); - - const workspace = resolvePluginWorkspace(cwd); - const ask = ui.interactive && options.yes !== true; - - const name = - options.name ?? - (ui.interactive - ? ( - await prompter.text("Plugin name", { - validate: asPrompt(validatePluginName), - }) - ).trim() - : undefined); - - if (name === undefined) { - throw new UserError("A plugin name is required.", { - hint: "Pass it as an argument: vitnode plugin create ", - }); - } - - // Fail on a bad name before asking anything else about it. - const nameProblem = validatePluginName(name); - if (nameProblem !== null) throw new UserError(nameProblem); - if (options.name !== undefined && ui.interactive) - ui.success(`Plugin name ${name}`); - - const existingIds = [...workspace.packages.entries()] - .filter(([, dir]) => isPluginPackage(dir)) - .map(([id]) => id); - const suggestedPackage = defaultPackageName(name, existingIds); - - const packageName = - options.packageName ?? - (ask - ? ( - await prompter.text("Package name", { - default: suggestedPackage, - validate: asPrompt(validatePackageName), - }) - ).trim() - : suggestedPackage); - - const description = - options.description ?? - (ask - ? ( - await prompter.text("Description", { - default: `A VitNode plugin.`, - }) - ).trim() - : "A VitNode plugin."); - - const plan = planPlugin({ description, name, packageName, workspace }); - const files = pluginTemplate({ - description, - name, - packageName, - versions: pluginDependencyVersions(readCoreManifest(), workspace), - }); - - ui.line(); - ui.line( - ui.mode === "plain" - ? "Creating plugin..." - : ` ${ui.colors.muted("Creating plugin...")}`, - ); - for (const step of STEPS) { - writeTemplateFiles( - plan.targetDir, - files.filter(file => file.group === step.group), - ); - ui.success(step.label); - } - - const shown = toDisplayPath(relative(cwd, plan.targetDir)) || "."; - ui.section("Created"); - ui.line(` ${ui.colors.command(shown)}`); - - ui.section("Next steps"); - const steps = [ - ...(workspace.pluginsDirIsLinked - ? [] - : [ - `Add "${toDisplayPath(relative(workspace.root, workspace.pluginsDir))}/*" to your workspace packages`, - ]), - `Add "${packageName}": "workspace:*" to your app's dependencies, then install`, - `Register ${pluginVariableName(name)}() from "${packageName}/config" in vitnode.config.ts`, - `Register ${pluginApiVariableName(name)}() from "${packageName}/config.api" in vitnode.api.config.ts`, - `Build it: ${ui.colors.command(`cd ${shown} && vitnode build`)}`, - `Check it: ${ui.colors.command(`vitnode plugin validate ${name}`)}`, - ]; - steps.forEach((step, index) => { - ui.line(` ${ui.colors.muted(`${String(index + 1)}.`)} ${step}`); - }); - ui.line(); - - return EXIT_CODE.ok; -}; diff --git a/packages/vitnode/scripts/cli/commands/plugin-validate.ts b/packages/vitnode/scripts/cli/commands/plugin-validate.ts index ac2b1784d..9467f7fa2 100644 --- a/packages/vitnode/scripts/cli/commands/plugin-validate.ts +++ b/packages/vitnode/scripts/cli/commands/plugin-validate.ts @@ -10,7 +10,6 @@ import type { Ui } from "../ui/ui"; import { EXIT_CODE, UserError, ValidationError } from "../errors"; import { discoverPlugins, isPluginPackage } from "../plugins/discover"; -import { shortNameOf } from "../plugins/naming"; import { validatePlugin } from "../plugins/validate"; import { findPackageRoot, readPackageJson } from "../project/packages"; import { plural, toDisplayPath } from "../ui/format"; @@ -19,6 +18,12 @@ export interface PluginValidateOptions extends OutputOptions { name?: string; } +/** `@acme/blog` → `blog`; `blog` → `blog`. */ +const shortNameOf = (packageName: string): string => + packageName.includes("/") + ? packageName.slice(packageName.indexOf("/") + 1) + : packageName; + /** `blog`, `@vitnode/blog` and `plugins/blog` all name the same plugin. */ export const matchesPlugin = (plugin: DiscoveredPlugin, query: string) => plugin.id === query || diff --git a/packages/vitnode/scripts/cli/commands/plugin.test.ts b/packages/vitnode/scripts/cli/commands/plugin.test.ts index 0978726e3..3165c153d 100644 --- a/packages/vitnode/scripts/cli/commands/plugin.test.ts +++ b/packages/vitnode/scripts/cli/commands/plugin.test.ts @@ -14,13 +14,13 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join, relative } from "node:path"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { UserError, ValidationError } from "../errors"; import { validatePlugin } from "../plugins/validate"; -import { createScriptedPrompter, createTestContext } from "../testing"; -import { runPluginCreateCommand } from "./plugin-create"; +import { createTestContext } from "../testing"; import { runPluginListCommand } from "./plugin-list"; import { runPluginValidateCommand } from "./plugin-validate"; @@ -76,201 +76,13 @@ afterEach(() => { rmSync(root, { force: true, recursive: true }); }); -const create = async ( - options: Parameters[1], - runtime: Parameters[0] = {}, -) => { - const { context, runtime: fake } = createTestContext({ - cwd: root, - ...runtime, - }); - - return { - code: await runPluginCreateCommand(context, options), - output: fake.output(), - }; -}; - -describe("vitnode plugin create", () => { - it("creates the canonical plugin in the workspace's plugins folder", async () => { - workspace(["forum"]); - - const { code, output } = await create({ name: "blog" }); - - expect(code).toBe(0); - for (const step of [ - "Package created", - "Plugin definition", - "Required structure", - "Translations", - "Tests", - "Documentation", - ]) { - expect(output).toContain(`✓ ${step}`); - } - expect(output).toContain("plugins/blog"); - - const manifest = JSON.parse( - readFileSync(join(root, "plugins/blog/package.json"), "utf8"), - ) as { - description: string; - name: string; - scripts: Record; - }; - // Joins the scope the workspace's plugins already share. - expect(manifest.name).toBe("@acme/blog"); - expect(manifest.description).toBe("A VitNode plugin."); - expect(manifest.scripts["build:plugins"]).toBe("vitnode build"); - expect( - readFileSync(join(root, "plugins/blog/src/routes.ts"), "utf8"), - ).toContain('page("/blog"'); - expect( - readFileSync(join(root, "plugins/blog/src/config.tsx"), "utf8"), - ).toContain("export const blogPlugin"); - expect( - readFileSync(join(root, "plugins/blog/src/config.api.ts"), "utf8"), - ).toContain("export const blogApiPlugin"); - }); - - it("generates exactly the canonical structure, with no feature options", async () => { - workspace(); - await create({ name: "blog", packageName: "@acme/blog" }); - - const files: string[] = []; - const walk = (dir: string) => { - for (const entry of readdirSync(dir)) { - const path = join(dir, entry); - if (statSync(path).isDirectory()) walk(path); - else - files.push( - relative(join(root, "plugins/blog"), path).replaceAll("\\", "/"), - ); - } - }; - walk(join(root, "plugins/blog")); - - expect(files.sort()).toMatchInlineSnapshot(` - [ - ".npmignore", - ".swcrc", - "README.md", - "eslint.config.mjs", - "global.d.ts", - "package.json", - "src/api/modules/hello/hello.module.test.ts", - "src/api/modules/hello/hello.module.ts", - "src/api/modules/hello/hello.route.ts", - "src/config.api.ts", - "src/config.tsx", - "src/const.ts", - "src/locales/en.json", - "src/locales/index.ts", - "src/pages/home-page.tsx", - "src/routes.ts", - "tsconfig.build.json", - "tsconfig.json", - "vitest.config.ts", - ] - `); - }); - - it("uses the given package name and description", async () => { - workspace(); - await create({ - description: "Blogging for VitNode", - name: "blog", - packageName: "@acme/blog", - }); - - expect( - readFileSync(join(root, "plugins/blog/package.json"), "utf8"), - ).toContain('"description": "Blogging for VitNode"'); - expect( - readFileSync(join(root, "plugins/blog/src/const.ts"), "utf8"), - ).toContain('pluginId: "@acme/blog"'); - }); - - it("asks for the name - and only metadata it cannot derive - when interactive", async () => { - workspace(); - const prompter = createScriptedPrompter({ - text: ["blog", "", "Blogging for VitNode"], - }); - - await create({}, { interactive: true, prompter }); - - expect(prompter.asked).toEqual([ - "Plugin name", - "Package name", - "Description", - ]); - expect( - readFileSync(join(root, "plugins/blog/package.json"), "utf8"), - ).toContain('"name": "vitnode-plugin-blog"'); - }); - - it("requires the name as an argument when nobody can be asked", async () => { - workspace(); - - await expect(create({})).rejects.toThrow(UserError); - expect(existsSync(join(root, "plugins"))).toBe(false); - }); - - it.each([ - ["an invalid name", { name: "My Blog" }, "not a valid plugin name"], - ["a reserved name", { name: "admin" }, "reserved"], - [ - "an invalid package name", - { name: "blog", packageName: "Blog" }, - "lowercase", - ], - [ - "core's package name", - { name: "blog", packageName: "@vitnode/core" }, - "core package", - ], - ])("refuses %s before writing anything", async (_, options, message) => { - workspace(); - - await expect(create(options)).rejects.toThrow(message); - expect(existsSync(join(root, "plugins", "blog"))).toBe(false); - }); - - it("never overwrites an existing plugin folder", async () => { - workspace(); - write("plugins/blog/README.md", "mine"); - - await expect(create({ name: "blog" })).rejects.toThrow( - "already exists and is not empty", - ); - expect(readFileSync(join(root, "plugins/blog/README.md"), "utf8")).toBe( - "mine", - ); - }); - - it("refuses a package name - and so a plugin id - the workspace already has", async () => { - workspace(["forum"]); - - await expect( - create({ name: "community", packageName: "@acme/forum" }), - ).rejects.toThrow( - 'A package named "@acme/forum" already exists at plugins/forum', - ); - }); - - it("refuses to run outside a workspace", async () => { - write("package.json", { name: "solo" }); - - await expect(create({ name: "blog" })).rejects.toThrow( - "inside a workspace", - ); - }); -}); - /** - * The strongest check there is: generate a plugin, compile it with the - * `.swcrc` it was generated with - the same compiler step `vitnode build` - * runs - and hand the output to the same validator `vitnode plugin validate` - * uses, which loads it through VitNode's real plugin, route and API loaders. + * The contract between `create-vitnode-app --plugin` and this CLI, checked + * end to end: scaffold a plugin exactly as `create-vitnode-app` writes it, + * compile it with the `.swcrc` it ships - the same compiler step + * `vitnode build` runs - and hand the output to the validator + * `vitnode plugin validate` uses, which loads it through VitNode's real + * plugin, route and API loaders. */ describe("a generated plugin", () => { let loadRoot: string; @@ -289,9 +101,30 @@ describe("a generated plugin", () => { }); it("compiles and passes validation through the real plugin loaders", async () => { - workspace(); - await create({ name: "blog", packageName: "@acme/blog" }); + const creator = join(packageRoot, "..", "create-vitnode-app"); + const { pluginPackageExports, pluginRouteScaffold } = (await import( + pathToFileURL( + join(creator, "src", "plugin", "create", "route-templates.ts"), + ).href + )) as { + pluginPackageExports: () => Record; + pluginRouteScaffold: (name: string) => Record; + }; const source = join(root, "plugins", "blog"); + cpSync(join(creator, "copy-of-vitnode-plugin", "root"), source, { + recursive: true, + }); + for (const [file, content] of Object.entries( + pluginRouteScaffold("@acme/blog"), + )) { + write(join("plugins", "blog", file), content); + } + write("plugins/blog/package.json", { + exports: pluginPackageExports(), + name: "@acme/blog", + type: "module", + version: "0.1.0", + }); const plugin = join(loadRoot, "blog"); const swcrc = JSON.parse( readFileSync(join(source, ".swcrc"), "utf8"), diff --git a/packages/vitnode/scripts/cli/commands/start.test.ts b/packages/vitnode/scripts/cli/commands/start.test.ts index 455f8e3fd..17df6a19e 100644 --- a/packages/vitnode/scripts/cli/commands/start.test.ts +++ b/packages/vitnode/scripts/cli/commands/start.test.ts @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { Project } from "../project/project"; import { RuntimeError, UserError } from "../errors"; -import { runProcess } from "../project/processes"; +import { ProcessGroup, runProcess } from "../project/processes"; import { resolveServerEntry } from "../start/server-entry"; import { createTestContext } from "../testing"; import { displayHost, parsePort, runStartCommand } from "./start"; @@ -70,6 +70,7 @@ describe("resolveServerEntry", () => { expect(resolveServerEntry(project("app"))).toEqual({ defaultPort: 3000, entry: join(root, ".output", "server", "index.mjs"), + runtime: "node", }); }); @@ -97,6 +98,15 @@ describe("resolveServerEntry", () => { join(root, "dist", "index.js"), ); }); + + it("runs a Bun API's TypeScript entry directly - there is no build", () => { + write("src/index.ts", ""); + + expect(resolveServerEntry(project("api"), "bun")).toMatchObject({ + entry: join(root, "src", "index.ts"), + runtime: "bun", + }); + }); }); describe("start options", () => { @@ -151,6 +161,27 @@ describe("vitnode start", () => { await expect(fetch(`http://localhost:${String(port)}`)).rejects.toThrow(); }, 20_000); + it("refuses a port something else already listens on, instead of announcing it", async () => { + write(".output/server/index.mjs", serverScript); + const blocker = createServer(); + await new Promise(resolve => { + blocker.listen(0, "localhost", resolve); + }); + const address = blocker.address(); + const port = + typeof address === "object" && address !== null ? address.port : 0; + const { context, runtime } = createTestContext({ cwd: root }); + + try { + await expect( + runStartCommand(context, { port: String(port) }), + ).rejects.toThrow(`Port ${String(port)} is already in use.`); + expect(runtime.output()).not.toContain("Running"); + } finally { + blocker.close(); + } + }); + it("fails - without claiming it is running - when the server dies during boot", async () => { write(".output/server/index.mjs", "process.exit(3);"); const { context, runtime } = createTestContext({ cwd: root }); @@ -167,6 +198,20 @@ describe("vitnode start", () => { }, 20_000); }); +describe("ProcessGroup", () => { + it("reports a child that exited before anyone asked", async () => { + const group = new ProcessGroup(); + const child = group.spawn({ + args: ["-e", "process.exit(7)"], + command: process.execPath, + cwd: root, + }); + await new Promise(resolve => child.once("exit", resolve)); + + expect(await group.firstExit()).toBe(7); + }); +}); + describe("runProcess", () => { it("captures output and reports the exit code instead of throwing", async () => { const result = await runProcess({ diff --git a/packages/vitnode/scripts/cli/commands/start.ts b/packages/vitnode/scripts/cli/commands/start.ts index f9342ac3b..61f0eb415 100644 --- a/packages/vitnode/scripts/cli/commands/start.ts +++ b/packages/vitnode/scripts/cli/commands/start.ts @@ -3,8 +3,9 @@ import type { CommandContext, OutputOptions } from "../context"; import { EXIT_CODE, RuntimeError, UserError } from "../errors"; import { ProcessGroup, waitForShutdownSignal } from "../project/processes"; import { detectProject } from "../project/project"; +import { detectRuntime, runtimeExecutable } from "../project/runtime"; import { resolveServerEntry } from "../start/server-entry"; -import { waitForPort } from "../start/wait-for-port"; +import { canConnect, waitForPort } from "../start/wait-for-port"; export interface StartOptions extends OutputOptions { host?: string; @@ -48,17 +49,29 @@ export const runStartCommand = async ( options: StartOptions, ): Promise => { const project = detectProject(cwd); - const { defaultPort, entry } = resolveServerEntry(project); + const { defaultPort, entry, runtime } = resolveServerEntry( + project, + project.kind === "api" ? detectRuntime(project.root, env) : "node", + ); const port = parsePort(options.port ?? env.PORT, defaultPort); const host = options.host ?? env.HOST; const shownHost = displayHost(host); ui.header("Production"); + // Checked before starting anything: something already answering on the port + // would otherwise pass the readiness probe below for a server that never + // got to listen. + if (await canConnect(port, shownHost)) { + throw new RuntimeError(`Port ${String(port)} is already in use.`, { + hint: "Stop whatever is listening on it, or pick another port with --port.", + }); + } + const group = new ProcessGroup(); const child = group.spawn({ args: [entry], - command: process.execPath, + command: runtimeExecutable(runtime), cwd: project.root, env: { ...env, @@ -79,7 +92,7 @@ export const runStartCommand = async ( port, }); - if (!ready) { + if (!ready || exitCode !== null) { await group.stop(); throw new RuntimeError( exitCode === null diff --git a/packages/vitnode/scripts/cli/db/prepare.ts b/packages/vitnode/scripts/cli/db/prepare.ts index 0d0272727..ac42d3459 100644 --- a/packages/vitnode/scripts/cli/db/prepare.ts +++ b/packages/vitnode/scripts/cli/db/prepare.ts @@ -1,7 +1,7 @@ import type { Ui } from "../ui/ui"; import type { DatabaseServices } from "./database"; -import { errorMessage, RuntimeError } from "../errors"; +import { rootCauseMessage, RuntimeError } from "../errors"; import { withQuietOutput } from "../ui/capture-output"; import { plural } from "../ui/format"; import { explain } from "./database"; @@ -81,7 +81,7 @@ export const prepareDevelopmentDatabase = async ( task.fail("Database unreachable"); throw new RuntimeError("Could not connect to the database.", { cause: error, - details: [errorMessage(error)], + details: [rootCauseMessage(error)], hint: "Is it running? In development, pnpm docker:dev starts one.", }); } diff --git a/packages/vitnode/scripts/cli/dev/watchers.ts b/packages/vitnode/scripts/cli/dev/watchers.ts index ea650682f..feb857211 100644 --- a/packages/vitnode/scripts/cli/dev/watchers.ts +++ b/packages/vitnode/scripts/cli/dev/watchers.ts @@ -2,15 +2,18 @@ import { join } from "node:path"; import type { RunProcessOptions, SignalSource } from "../project/processes"; import type { Project } from "../project/project"; +import type { Runtime } from "../project/runtime"; import type { Ui } from "../ui/ui"; import { EXIT_CODE, RuntimeError } from "../errors"; import { resolveBin } from "../project/packages"; import { ProcessGroup, waitForShutdownSignal } from "../project/processes"; +import { runtimeExecutable } from "../project/runtime"; export interface Watcher { args: string[]; - bin: { name: string; package: string }; + /** The package executable to run, or `null` to run the runtime itself. */ + bin: null | { name: string; package: string }; } /** The plugin package's three compilers, each in watch mode. */ @@ -37,15 +40,13 @@ export const packageWatchers = (): Watcher[] => [ }, ]; -/** A standalone API restarted on every change. */ -export const apiWatchers = (runtime: { bun: boolean }): Watcher[] => - runtime.bun - ? [ - { - args: ["--hot", join("src", "index.ts")], - bin: { name: "bun", package: "" }, - }, - ] +/** + * A standalone API, restarted on every change: Bun reloads its TypeScript + * entry itself (`bun --hot`), Node goes through `tsx watch`. + */ +export const apiWatchers = (runtime: Runtime): Watcher[] => + runtime === "bun" + ? [{ args: ["--hot", join("src", "index.ts")], bin: null }] : [ { args: ["watch", join("src", "index.ts")], @@ -56,10 +57,20 @@ export const apiWatchers = (runtime: { bun: boolean }): Watcher[] => export const toProcess = ( project: Project, watcher: Watcher, + { + env, + runtime = "node", + }: { env?: NodeJS.ProcessEnv; runtime?: Runtime } = {}, ): RunProcessOptions => - // Bun runs the entry itself; everything else is a package's bin run by Node. - watcher.bin.package === "" - ? { args: watcher.args, command: process.execPath, cwd: project.root } + // No bin: the runtime runs the entry itself. Otherwise a package's bin, run + // by Node. + watcher.bin === null + ? { + args: watcher.args, + command: runtimeExecutable(runtime), + cwd: project.root, + env, + } : { args: [ resolveBin(project.root, watcher.bin.package, watcher.bin.name), @@ -67,6 +78,7 @@ export const toProcess = ( ], command: process.execPath, cwd: project.root, + env, }; /** diff --git a/packages/vitnode/scripts/cli/errors.ts b/packages/vitnode/scripts/cli/errors.ts index 936512cc4..5c3f2f657 100644 --- a/packages/vitnode/scripts/cli/errors.ts +++ b/packages/vitnode/scripts/cli/errors.ts @@ -90,6 +90,22 @@ export class RuntimeError extends CliError { export const isCliError = (error: unknown): error is CliError => error instanceof CliError; +/** + * The message of the innermost `cause` - for errors a library wraps, such as + * Drizzle's "Failed query: SELECT 1" around the driver's `ECONNREFUSED`, where + * only the inner one says what actually went wrong. + */ +export const rootCauseMessage = (error: unknown): string => { + let current = error; + for (let depth = 0; depth < 10; depth += 1) { + const { cause } = (current ?? {}) as { cause?: unknown }; + if (!(cause instanceof Error) || cause.message === "") break; + current = cause; + } + + return errorMessage(current); +}; + /** The message of anything thrown, for places that only need one line. */ export const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); diff --git a/packages/vitnode/scripts/cli/help.ts b/packages/vitnode/scripts/cli/help.ts index 8dda27f64..18c9ecf19 100644 --- a/packages/vitnode/scripts/cli/help.ts +++ b/packages/vitnode/scripts/cli/help.ts @@ -12,13 +12,13 @@ export const PUBLIC_COMMANDS = [ { description: "Start the development environment", name: "dev" }, { description: "Build for production", name: "build" }, { description: "Start the production server", name: "start" }, - { description: "Create a plugin", name: "plugin create" }, { description: "List plugins", name: "plugin list" }, { description: "Validate plugins", name: "plugin validate" }, { description: "Generate a migration", name: "db generate" }, { description: "Run pending migrations", name: "db migrate" }, { description: "Push schema changes (development)", name: "db push" }, { description: "Show migration status", name: "db status" }, + { description: "Manage languages and translations", name: "i18n " }, ] as const; export type PublicCommandName = (typeof PUBLIC_COMMANDS)[number]["name"]; @@ -28,8 +28,8 @@ export const describeCommand = (name: PublicCommandName): string => const EXAMPLES = [ "vitnode dev", - "vitnode plugin create blog", "vitnode build --analyze", + "vitnode db migrate --yes", ]; /** `vitnode` and `vitnode --help`: short on purpose - the docs hold the rest. */ diff --git a/packages/vitnode/scripts/cli/index.test.ts b/packages/vitnode/scripts/cli/index.test.ts index 5fb08b14e..fae03945f 100644 --- a/packages/vitnode/scripts/cli/index.test.ts +++ b/packages/vitnode/scripts/cli/index.test.ts @@ -37,18 +37,18 @@ describe("vitnode", () => { dev Start the development environment build Build for production start Start the production server - plugin create Create a plugin plugin list List plugins plugin validate Validate plugins db generate Generate a migration db migrate Run pending migrations db push Push schema changes (development) db status Show migration status + i18n Manage languages and translations Examples vitnode dev - vitnode plugin create blog vitnode build --analyze + vitnode db migrate --yes Run vitnode --help for a command's options. " diff --git a/packages/vitnode/scripts/cli/plugins/create.ts b/packages/vitnode/scripts/cli/plugins/create.ts deleted file mode 100644 index 04168936d..000000000 --- a/packages/vitnode/scripts/cli/plugins/create.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; -import { dirname, join, relative } from "node:path"; - -import type { PackageJson } from "../project/packages"; -import type { TemplateFile } from "./template"; - -import { ConfigError, UserError } from "../errors"; -import { readPackageJson } from "../project/packages"; -import { toDisplayPath } from "../ui/format"; -import { isPluginPackage } from "./discover"; -import { validatePackageName, validatePluginName } from "./naming"; -import { - findWorkspaceRoot, - workspaceGlobs, - workspacePackageDirs, -} from "./workspace"; - -export interface PluginWorkspace { - /** Package name → directory, for every package the workspace declares. */ - packages: Map; - /** Where new plugins go. */ - pluginsDir: string; - /** Whether the workspace globs cover `pluginsDir`, i.e. pnpm will link it. */ - pluginsDirIsLinked: boolean; - root: string; -} - -/** - * The workspace a new plugin is created in, and where in it. - * - * Plugins live in `plugins/` - the folder VitNode's own repository and every - * generated monorepo use. A workspace that keeps its plugins elsewhere is - * followed instead: the folder its existing plugins are in wins. - */ -export const resolvePluginWorkspace = (cwd: string): PluginWorkspace => { - const root = findWorkspaceRoot(cwd); - - if (root === null) { - throw new ConfigError( - "Plugins are created inside a workspace, and none was found.", - { - hint: "Run this from a VitNode monorepo (one with pnpm-workspace.yaml or package.json workspaces).", - }, - ); - } - - const packages = new Map(); - const dirs = workspacePackageDirs(root); - for (const dir of dirs) { - const name = readPackageJson(dir)?.name; - if (name !== undefined) packages.set(name, dir); - } - - const existingPluginDir = dirs.find(dir => isPluginPackage(dir)); - const pluginsDir = - existingPluginDir === undefined - ? join(root, "plugins") - : dirname(existingPluginDir); - const relativeDir = toDisplayPath(relative(root, pluginsDir)); - - return { - packages, - pluginsDir, - pluginsDirIsLinked: workspaceGlobs(root).some( - glob => glob === `${relativeDir}/*` || glob === `${relativeDir}/**`, - ), - root, - }; -}; - -export interface PluginPlan { - description: string; - name: string; - packageName: string; - targetDir: string; - workspace: PluginWorkspace; -} - -/** - * Checks everything that could make creating the plugin fail or clobber - * something - before a single file is written. - */ -export const planPlugin = ({ - description, - name, - packageName, - workspace, -}: { - description: string; - name: string; - packageName: string; - workspace: PluginWorkspace; -}): PluginPlan => { - const nameProblem = validatePluginName(name); - if (nameProblem !== null) throw new UserError(nameProblem); - - const packageProblem = validatePackageName(packageName); - if (packageProblem !== null) throw new UserError(packageProblem); - - const existing = workspace.packages.get(packageName); - if (existing !== undefined) { - throw new UserError( - `A package named "${packageName}" already exists at ${toDisplayPath(relative(workspace.root, existing))}.`, - { - hint: "The package name is the plugin id, so it has to be unique - pick another name or --package-name.", - }, - ); - } - - const targetDir = join(workspace.pluginsDir, name); - if (existsSync(targetDir) && readdirSync(targetDir).length > 0) { - throw new UserError( - `${toDisplayPath(relative(workspace.root, targetDir))} already exists and is not empty.`, - { - hint: "VitNode never overwrites an existing plugin - choose another name.", - }, - ); - } - - return { description, name, packageName, targetDir, workspace }; -}; - -/** - * Dependency versions for a new plugin, taken from `@vitnode/core` itself, so - * a plugin is generated against the versions the core it depends on was built - * and tested with. Inside a workspace that contains core (VitNode's own - * repository) core and its config are linked rather than installed. - */ -export const pluginDependencyVersions = ( - core: null | PackageJson, - workspace: PluginWorkspace, -): Record => { - const version = core?.version ?? "latest"; - const linked = (name: string) => - workspace.packages.has(name) ? "workspace:*" : `^${version}`; - - return { - ...core?.peerDependencies, - ...core?.dependencies, - ...core?.devDependencies, - "@vitnode/config": linked("@vitnode/config"), - "@vitnode/core": linked("@vitnode/core"), - }; -}; - -/** - * Writes the files of one template group. `wx` refuses to replace anything, - * so even a file that appeared since {@link planPlugin} checked is kept. - */ -export const writeTemplateFiles = ( - targetDir: string, - files: readonly TemplateFile[], -): void => { - for (const file of files) { - const path = join(targetDir, file.path); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, file.content, { encoding: "utf8", flag: "wx" }); - } -}; diff --git a/packages/vitnode/scripts/cli/plugins/naming.test.ts b/packages/vitnode/scripts/cli/plugins/naming.test.ts deleted file mode 100644 index 5d46abe1f..000000000 --- a/packages/vitnode/scripts/cli/plugins/naming.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -// @vitest-environment node -import { describe, expect, it } from "vitest"; - -import { - defaultPackageName, - pluginApiVariableName, - pluginVariableName, - shortNameOf, - validatePackageName, - validatePluginName, -} from "./naming"; - -describe("validatePluginName", () => { - it.each(["blog", "event-calendar", "forum2"])("accepts %s", name => { - expect(validatePluginName(name)).toBeNull(); - }); - - it.each([ - ["", "required"], - ["Blog", "not a valid plugin name"], - ["my_blog", "not a valid plugin name"], - ["2fa", "not a valid plugin name"], - ["blog-", "not a valid plugin name"], - ["my--blog", "not a valid plugin name"], - ["@acme/blog", "not a valid plugin name"], - ["admin", "reserved"], - ["core", "reserved"], - ["login", "reserved"], - ["x".repeat(51), "under 50"], - ])("refuses %j (%s)", (name, reason) => { - expect(validatePluginName(name)).toContain(reason); - }); -}); - -describe("validatePackageName", () => { - it.each(["@acme/blog", "vitnode-plugin-blog", "@vitnode/blog"])( - "accepts %s", - name => { - expect(validatePackageName(name)).toBeNull(); - }, - ); - - it.each([ - ["@vitnode/core", "core package"], - ["Acme-Blog", "lowercase"], - ["acme blog", "not a valid npm package name"], - [".hidden", "not a valid npm package name"], - ["fs", "built-in"], - ["@acme/", "not a valid npm package name"], - ])("refuses %j (%s)", (name, reason) => { - expect(validatePackageName(name)).toContain(reason); - }); -}); - -describe("package naming", () => { - it("joins the workspace's plugin scope when its plugins share one", () => { - expect( - defaultPackageName("blog", ["@vitnode/example", "@vitnode/forum"]), - ).toBe("@vitnode/blog"); - }); - - it("falls back to an unscoped vitnode-plugin- name", () => { - expect(defaultPackageName("blog", [])).toBe("vitnode-plugin-blog"); - expect(defaultPackageName("blog", ["@a/x", "@b/y"])).toBe( - "vitnode-plugin-blog", - ); - }); - - it("derives the short name from a package name", () => { - expect(shortNameOf("@acme/blog")).toBe("blog"); - expect(shortNameOf("blog")).toBe("blog"); - }); - - it("derives identifiers for the generated factories", () => { - expect(pluginVariableName("event-calendar")).toBe("eventCalendarPlugin"); - expect(pluginVariableName("blog-plugin")).toBe("blogPlugin"); - expect(pluginApiVariableName("blog")).toBe("blogApiPlugin"); - }); -}); diff --git a/packages/vitnode/scripts/cli/plugins/naming.ts b/packages/vitnode/scripts/cli/plugins/naming.ts deleted file mode 100644 index e8a8964ad..000000000 --- a/packages/vitnode/scripts/cli/plugins/naming.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { builtinModules } from "node:module"; - -import { CORE_PLUGIN_ID } from "../../../src/framework/plugin-routes/core"; - -/** - * Folder names a plugin cannot take, because its example page is served at - * `/` and these are core's own top-level routes (or areas). A plugin - * named `admin` would fail its first build with a route collision; refusing it - * here says so before any file is written. - */ -export const RESERVED_PLUGIN_NAMES: ReadonlySet = new Set([ - "admin", - "api", - "core", - "discover", - "files", - "login", - "notifications", - "register", - "search", - "users", - "vitnode", -]); - -const NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; - -/** - * Why `name` cannot be a plugin's short name, or `null`. - * - * The short name is the folder under `plugins/`, the example route and the - * base of the package name, so it is held to the strictest of the three: - * lowercase kebab-case starting with a letter. - */ -export const validatePluginName = (name: string): null | string => { - if (name === "") return "A plugin name is required."; - if (name.length > 50) return "Keep the plugin name under 50 characters."; - if (!NAME_PATTERN.test(name)) { - return `"${name}" is not a valid plugin name. Use lowercase letters, digits and single dashes, starting with a letter - e.g. "blog" or "event-calendar".`; - } - if (RESERVED_PLUGIN_NAMES.has(name)) { - return `"${name}" is reserved by VitNode - core already serves /${name}.`; - } - - return null; -}; - -const PACKAGE_PATTERN = - /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; - -/** - * Why `name` cannot be the plugin's npm package name, or `null`. - * - * npm's own rules for new packages, plus VitNode's: the package name *is* the - * plugin id, and `@vitnode/core` belongs to core. - */ -export const validatePackageName = (name: string): null | string => { - if (name === "") return "A package name is required."; - if (name.length > 214) - return "npm package names are limited to 214 characters."; - if (name !== name.toLowerCase()) - return "npm package names must be lowercase."; - if (!PACKAGE_PATTERN.test(name)) { - return `"${name}" is not a valid npm package name.`; - } - if (name === CORE_PLUGIN_ID) - return `"${CORE_PLUGIN_ID}" is VitNode's core package.`; - if (!name.startsWith("@") && builtinModules.includes(name)) { - return `"${name}" is a Node.js built-in module.`; - } - - return null; -}; - -/** `@acme/my-blog` → `my-blog`; `blog` → `blog`. */ -export const shortNameOf = (packageName: string): string => - packageName.includes("/") - ? packageName.slice(packageName.indexOf("/") + 1) - : packageName; - -/** - * The package name a new plugin gets unless the developer types another. - * - * Follows the workspace: if its plugins share a scope (`@vitnode/blog`, - * `@vitnode/example`), the new one joins it. Otherwise it is unscoped and - * prefixed, the npm convention for an ecosystem's plugins. - */ -export const defaultPackageName = ( - name: string, - existingPluginIds: readonly string[], -): string => { - const scopes = new Set( - existingPluginIds - .filter(id => id.startsWith("@") && id.includes("/")) - .map(id => id.slice(0, id.indexOf("/"))), - ); - - return scopes.size === 1 - ? `${[...scopes][0]}/${name}` - : `vitnode-plugin-${name}`; -}; - -/** `my-blog` → `myBlogPlugin`; a leading digit gets a `vitnode` prefix. */ -export const pluginVariableName = (name: string): string => { - const camel = name - .split(/[^A-Za-z0-9]+/) - .filter(Boolean) - .map((part, index) => - index === 0 ? part : `${part[0].toUpperCase()}${part.slice(1)}`, - ) - .join(""); - const safe = /^[A-Za-z]/.test(camel) ? camel : `vitnode${camel}`; - const stem = safe.replace(/plugin$/i, ""); - - return `${stem === "" ? safe : stem}Plugin`; -}; - -export const pluginApiVariableName = (name: string): string => - pluginVariableName(name).replace(/Plugin$/, "ApiPlugin"); - -/** `event-calendar` → `Event calendar`. */ -export const titleOf = (name: string): string => { - const words = name.replaceAll("-", " "); - - return `${words[0]?.toUpperCase() ?? ""}${words.slice(1)}`; -}; diff --git a/packages/vitnode/scripts/cli/plugins/template.ts b/packages/vitnode/scripts/cli/plugins/template.ts deleted file mode 100644 index ecbc5487d..000000000 --- a/packages/vitnode/scripts/cli/plugins/template.ts +++ /dev/null @@ -1,471 +0,0 @@ -import type { PackageJson } from "../project/packages"; - -import { pluginApiVariableName, pluginVariableName, titleOf } from "./naming"; - -export interface PluginTemplateInput { - description: string; - /** Short name: folder, route and the base of the variable names. */ - name: string; - packageName: string; - /** Versions for every dependency the template names. */ - versions: Record; -} - -const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; - -const pick = (versions: Record, names: readonly string[]) => - Object.fromEntries(names.map(name => [name, versions[name] ?? "*"] as const)); - -/** Exactly what the repository's own plugins depend on - and nothing more. */ -const DEPENDENCIES = [ - "@hono/zod-openapi", - "@tanstack/react-form", - "@vitnode/core", - "drizzle-kit", - "drizzle-orm", - "hono", - "react", - "react-dom", - "use-intl", - "zod", -] as const; - -const DEV_DEPENDENCIES = [ - "@swc/cli", - "@swc/core", - "@types/react", - "@types/react-dom", - "@vitnode/config", - "eslint", - "tsc-alias", - "typescript", - "vitest", -] as const; - -export const pluginPackageJson = ({ - description, - packageName, - versions, -}: PluginTemplateInput): PackageJson & Record => ({ - name: packageName, - version: "0.1.0", - description, - private: true, - type: "module", - exports: { - // Strings are copied, not compiled, so they are exported from source. - "./locales/*.json": "./src/locales/*.json", - "./*": { - import: "./dist/src/*.js", - types: "./dist/src/*.d.ts", - default: "./dist/src/*.js", - }, - }, - scripts: { - "build:plugins": "vitnode build", - dev: "vitnode dev", - lint: "eslint .", - "lint:fix": "eslint . --fix", - test: "vitest run", - "test:watch": "vitest", - }, - dependencies: pick(versions, DEPENDENCIES), - devDependencies: pick(versions, DEV_DEPENDENCIES), -}); - -const constTemplate = ({ packageName }: PluginTemplateInput) => - `export const CONFIG_PLUGIN = { - pluginId: "${packageName}" as const, -}; -`; - -const routesTemplate = ({ name }: PluginTemplateInput) => - `import { definePluginRoutes, lazy, page } from "@vitnode/core/routing"; - -import { CONFIG_PLUGIN } from "./const"; - -export const routes = definePluginRoutes([ - page("/${name}", { - component: lazy(() => import("./pages/home-page")), - messages: [\`\${CONFIG_PLUGIN.pluginId}.home\`], - }), -]); -`; - -const pageTemplate = ({ packageName }: PluginTemplateInput) => - `import type { PluginRoutePageProps } from "@vitnode/core/routing"; - -import { definePluginRoute } from "@vitnode/core/routing"; -import { fetcher } from "@vitnode/core/tanstack/fetcher"; -import { useTranslations } from "use-intl"; - -import { CONFIG_PLUGIN } from "@/const"; - -interface HelloMessage { - message: string; -} - -export const route = definePluginRoute({ - load: async () => { - const response = await fetcher({ - plugin: CONFIG_PLUGIN.pluginId, - method: "get", - module: "hello", - path: "/", - }); - - return await response.json(); - }, -}); - -const HomePage = ({ loaderData }: PluginRoutePageProps) => { - const t = useTranslations("${packageName}"); - - return ( -
-

- {t("home.title")} -

- -

- {t("home.desc")} -

- -
- {t("home.api")} - {loaderData.message} -
-
- ); -}; - -export default HomePage; -`; - -const messagesTemplate = ({ name, packageName }: PluginTemplateInput) => - json({ - [packageName]: { - home: { - api: "Your plugin's API answered:", - desc: "This page ships inside the plugin and is served by every app that installs it.", - title: `Hello from ${titleOf(name)}`, - }, - }, - }); - -const messagesBarrelTemplate = () => - `import type { LocaleMessagesMap } from "@vitnode/core/lib/i18n/types"; - -const messages: LocaleMessagesMap = { - en: async () => await import("./en.json", { with: { type: "json" } }), -}; - -export default messages; -`; - -const configTemplate = ({ name, packageName }: PluginTemplateInput) => - `import { buildPlugin } from "@vitnode/core/lib/plugin"; - -import { CONFIG_PLUGIN } from "@/const"; - -import messages from "./locales"; -import { routes } from "./routes"; - -export const ${pluginVariableName(name)} = () => - buildPlugin({ - ...CONFIG_PLUGIN, - localeFiles: { - en: "${packageName}/locales/en.json", - }, - messages, - routes, - }); -`; - -const apiRouteTemplate = () => - `import { z } from "@hono/zod-openapi"; -import { buildRoute } from "@vitnode/core/api/lib/route"; - -import { CONFIG_PLUGIN } from "@/const"; - -export const helloRoute = buildRoute({ - pluginId: CONFIG_PLUGIN.pluginId, - route: { - method: "get", - path: "/", - responses: { - 200: { - content: { - "application/json": { - schema: z.object({ message: z.string() }), - }, - }, - description: "A greeting from the plugin.", - }, - }, - }, - handler: c => c.json({ message: \`Hello from \${CONFIG_PLUGIN.pluginId}!\` }), -}); -`; - -const apiModuleTemplate = () => - `import { buildModule } from "@vitnode/core/api/lib/module"; - -import { CONFIG_PLUGIN } from "@/const"; - -import { helloRoute } from "./hello.route"; - -export const helloModule = buildModule({ - pluginId: CONFIG_PLUGIN.pluginId, - name: "hello", - routes: [helloRoute], -}); -`; - -const apiModuleTestTemplate = ({ packageName }: PluginTemplateInput) => - `import { describe, expect, it } from "vitest"; - -import { helloModule } from "./hello.module"; - -describe("hello module", () => { - it("answers GET / with a greeting from the plugin", async () => { - const response = await helloModule.hono.request("/"); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - message: "Hello from ${packageName}!", - }); - }); -}); -`; - -const apiConfigTemplate = ({ name }: PluginTemplateInput) => - `import type { ApiPluginContract } from "@vitnode/core/api/lib/plugin"; - -import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; - -import { helloModule } from "@/api/modules/hello/hello.module"; -import { CONFIG_PLUGIN } from "@/const"; - -export const ${pluginApiVariableName(name)} = () => - buildApiPlugin({ - pluginId: CONFIG_PLUGIN.pluginId, - modules: [helloModule], - }); - -/** What an app's generated api-registry.gen.ts imports to type this plugin's routes. */ -export type VitNodeApiPlugin = ApiPluginContract< - ReturnType ->; -`; - -const globalTypesTemplate = () => - `/// - -import core from "@vitnode/core/locales/en.json" with { type: "json" }; -import plugin from "./src/locales/en.json" with { type: "json" }; - -declare module "use-intl" { - interface AppConfig { - Messages: typeof plugin & typeof core; - } -} -`; - -const readmeTemplate = ({ - description, - name, - packageName, -}: PluginTemplateInput) => - `# ${titleOf(name)} - -${description} - -A VitNode plugin. It adds a page at \`/${name}\`, served from \`src/pages/home-page.tsx\`, which loads its text from the plugin's own API (\`GET /api/${packageName}/hello\`). - -## Develop - -\`\`\`bash -vitnode dev # rebuild dist/ on every change -vitnode build # one-off build -vitnode plugin validate ${name} -\`\`\` - -Docs: https://vitnode.com/docs/dev/plugins -`; - -const TSCONFIG = { - $schema: "https://json.schemastore.org/tsconfig", - extends: "@vitnode/config/tsconfig", - compilerOptions: { - target: "ESNext", - module: "esnext", - moduleResolution: "bundler", - rootDir: "./", - outDir: "./dist", - incremental: false, - jsx: "react-jsx", - emitDeclarationOnly: true, - declaration: true, - declarationMap: true, - paths: { "@/*": ["./src/*"] }, - }, - exclude: ["node_modules"], - include: ["types", "src", "global.d.ts", "vitest.config.ts"], -}; - -const TSCONFIG_BUILD = { - $schema: "https://json.schemastore.org/tsconfig", - extends: "./tsconfig.json", - exclude: [ - "node_modules", - "vitest.config.ts", - "**/*.test.ts", - "**/*.test.tsx", - "**/*.test-d.ts", - ], -}; - -const SWCRC = { - $schema: "https://swc.rs/schema.json", - exclude: ["\\.test\\.tsx?$", "\\.test-d\\.ts$", "^src/tests/"], - minify: true, - jsc: { - baseUrl: "./", - target: "esnext", - paths: { "@/*": ["./src/*"] }, - parser: { syntax: "typescript", tsx: true }, - transform: { react: { runtime: "automatic" } }, - }, - module: { type: "nodenext", strict: true, resolveFully: true }, -}; - -const NPMIGNORE = `/src/* -!/src/locales -!/src/locales/** - -/node_modules -/.turbo -/.swcrc -/global.d.ts -/types -/tsconfig.json -/tsconfig.build.json -/vitest.config.ts -`; - -const VITEST_CONFIG = `import { resolve } from "node:path"; -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - exclude: ["**/node_modules/**", "**/dist/**"], - passWithNoTests: true, - }, - resolve: { - alias: { - "@": resolve(import.meta.dirname, "./src"), - }, - }, -}); -`; - -const ESLINT_CONFIG = `import eslintVitNode from "@vitnode/config/eslint"; -import eslintVitNodeReact from "@vitnode/config/eslint.react"; - -export default [ - ...eslintVitNode, - ...eslintVitNodeReact, - { - languageOptions: { - parserOptions: { - project: "./tsconfig.json", - tsconfigRootDir: import.meta.dirname, - }, - }, - }, -]; -`; - -export type TemplateGroup = - | "definition" - | "documentation" - | "package" - | "structure" - | "tests" - | "translations"; - -export interface TemplateFile { - content: string; - group: TemplateGroup; - path: string; -} - -/** - * The canonical VitNode plugin: one page, one API endpoint feeding it, its - * strings, and a test of the endpoint - the smallest plugin that exercises - * every layer, with the same structure and build setup as the plugins in - * VitNode's own repository. - */ -export const pluginTemplate = (input: PluginTemplateInput): TemplateFile[] => [ - { - content: json(pluginPackageJson(input)), - group: "package", - path: "package.json", - }, - { content: json(TSCONFIG), group: "package", path: "tsconfig.json" }, - { - content: json(TSCONFIG_BUILD), - group: "package", - path: "tsconfig.build.json", - }, - { content: json(SWCRC), group: "package", path: ".swcrc" }, - { content: NPMIGNORE, group: "package", path: ".npmignore" }, - { content: ESLINT_CONFIG, group: "package", path: "eslint.config.mjs" }, - { content: globalTypesTemplate(), group: "package", path: "global.d.ts" }, - { content: constTemplate(input), group: "definition", path: "src/const.ts" }, - { - content: configTemplate(input), - group: "definition", - path: "src/config.tsx", - }, - { - content: apiConfigTemplate(input), - group: "definition", - path: "src/config.api.ts", - }, - { content: routesTemplate(input), group: "structure", path: "src/routes.ts" }, - { - content: pageTemplate(input), - group: "structure", - path: "src/pages/home-page.tsx", - }, - { - content: apiModuleTemplate(), - group: "structure", - path: "src/api/modules/hello/hello.module.ts", - }, - { - content: apiRouteTemplate(), - group: "structure", - path: "src/api/modules/hello/hello.route.ts", - }, - { - content: messagesTemplate(input), - group: "translations", - path: "src/locales/en.json", - }, - { - content: messagesBarrelTemplate(), - group: "translations", - path: "src/locales/index.ts", - }, - { content: VITEST_CONFIG, group: "tests", path: "vitest.config.ts" }, - { - content: apiModuleTestTemplate(input), - group: "tests", - path: "src/api/modules/hello/hello.module.test.ts", - }, - { content: readmeTemplate(input), group: "documentation", path: "README.md" }, -]; diff --git a/packages/vitnode/scripts/cli/program.ts b/packages/vitnode/scripts/cli/program.ts index 423279595..20769011b 100644 --- a/packages/vitnode/scripts/cli/program.ts +++ b/packages/vitnode/scripts/cli/program.ts @@ -95,20 +95,10 @@ export const createProgram = (hooks: ProgramHooks): Command => { const plugin = program .command("plugin") - .description("Create, list and validate plugins"); - - outputOptions(plugin.command("create")) - .description(describe("plugin create")) - .argument("[name]", "plugin name, e.g. blog") - .option("--package-name ", "npm package name (default: derived)") - .option("--description ", "one-line description") - .option("-y, --yes", "accept the defaults instead of asking") - .action( - run( - async () => - (await import("./commands/plugin-create")).runPluginCreateCommand, - ([name]) => ({ name: name as string | undefined }), - ), + .description("List and validate plugins") + .addHelpText( + "after", + "\nCreate a plugin with: npx create-vitnode-app --plugin ", ); outputOptions(plugin.command("list")) @@ -167,6 +157,7 @@ export const createProgram = (hooks: ProgramHooks): Command => { run(async () => (await import("./commands/db")).runDbStatusCommand), ); + registerI18nCommands(program, run); registerLegacyCommands(program, run); return program; @@ -179,12 +170,89 @@ export const createProgram = (hooks: ProgramHooks): Command => { * deployment guides - but left out of the root help, which lists the commands a * developer should reach for today. */ -const registerLegacyCommands = ( - program: Command, - run: ( - load: () => Promise>, - ) => (...actionArgs: unknown[]) => Promise, -) => { +type Run = ( + load: () => Promise>, + fromArguments?: (values: unknown[]) => Partial, +) => (...actionArgs: unknown[]) => Promise; + +/** + * `vitnode i18n `, plus each command under its older `i18n:` + * name - hidden, but parsed by the same definition, so the two spellings + * cannot accept different flags. + */ +const registerI18nCommands = (program: Command, run: Run) => { + const handlers = async () => import("./commands/i18n"); + const i18n = program + .command("i18n") + .description(describeCommand("i18n ")); + + const define = ( + name: string, + legacyName: string, + build: (command: Command) => Command, + ) => { + build(outputOptions(i18n.command(name))); + build(outputOptions(program.command(legacyName, { hidden: true }))); + }; + + define("check", "i18n:check", command => + command + .description("Find missing, unknown and unloaded translations") + .option("--ci", "also fail on missing keys") + .action(run(async () => (await handlers()).runI18nCheckCommand)), + ); + + define("create", "i18n:create", command => + command + .description("Add a language") + .argument("[code]", "locale code, e.g. pl or pt-BR") + .argument("[name...]", "language name, e.g. Polski") + .action( + run( + async () => (await handlers()).runI18nCreateCommand, + ([code, name]) => ({ + code: code as string | undefined, + name: name as string[] | undefined, + }), + ), + ), + ); + + define("delete", "i18n:delete", command => + command + .description("Remove a language") + .argument("[code]", "locale code to remove") + .option("-y, --yes", "remove without asking") + .action( + run( + async () => (await handlers()).runI18nDeleteCommand, + ([code]) => ({ code: code as string | undefined }), + ), + ), + ); + + define("update", "i18n:update", command => + command + .description("Sync translation files with the default language") + .action(run(async () => (await handlers()).runI18nUpdateCommand)), + ); + + define("update-ai", "i18n:update:ai", command => + command + .description("Translate missing strings with an AI model") + .argument("[codes...]", "locale codes (default: ask, or every language)") + .option("--model ", "AI model id from vitnode.api.config.ts") + .option("--concurrency ", "model calls in parallel") + .action( + run( + async () => (await handlers()).runI18nUpdateAiCommand, + ([codes]) => ({ codes: codes as string[] | undefined }), + ), + ), + ); +}; + +const registerLegacyCommands = (program: Command, run: Run) => { const legacy = async () => import("./commands/legacy"); outputOptions(program.command("db:prepare", { hidden: true })) @@ -195,36 +263,4 @@ const registerLegacyCommands = ( .description("Same as db:prepare; --generate only generates") .option("--generate", "only generate migrations") .action(run(async () => (await legacy()).runMigrateCommand)); - - program - .command("i18n:check", { hidden: true }) - .description("Find missing and unused translation keys") - .option("--ci", "exit with an error when keys are missing") - .action(run(async () => (await legacy()).runI18nCheckCommand)); - - program - .command("i18n:create", { hidden: true }) - .description("Add a language") - .argument("[code]") - .argument("[name...]") - .action(run(async () => (await legacy()).runI18nCreateCommand)); - - program - .command("i18n:delete", { hidden: true }) - .description("Remove a language") - .argument("[code]") - .action(run(async () => (await legacy()).runI18nDeleteCommand)); - - program - .command("i18n:update", { hidden: true }) - .description("Sync translation files with the default language") - .action(run(async () => (await legacy()).runI18nUpdateCommand)); - - program - .command("i18n:update:ai", { hidden: true }) - .description("Translate missing keys with AI") - .argument("[codes...]") - .option("--model ", "model id") - .option("--concurrency ", "parallel requests") - .action(run(async () => (await legacy()).runI18nUpdateAiCommand)); }; diff --git a/packages/vitnode/scripts/cli/project/packages.ts b/packages/vitnode/scripts/cli/project/packages.ts index cfc17f37f..a19f7dcf6 100644 --- a/packages/vitnode/scripts/cli/project/packages.ts +++ b/packages/vitnode/scripts/cli/project/packages.ts @@ -12,6 +12,7 @@ export interface PackageJson { devDependencies?: Record; exports?: unknown; name?: string; + packageManager?: string; peerDependencies?: Record; private?: boolean; scripts?: Record; diff --git a/packages/vitnode/scripts/cli/project/processes.ts b/packages/vitnode/scripts/cli/project/processes.ts index 379394e55..1f777e91b 100644 --- a/packages/vitnode/scripts/cli/project/processes.ts +++ b/packages/vitnode/scripts/cli/project/processes.ts @@ -117,22 +117,32 @@ const KILL_TIMEOUT_MS = 5000; export class ProcessGroup { private readonly children = new Set(); + /** + * One promise per child, created the moment it is spawned - so a child that + * exits before anyone asks (a server crashing during boot) is still seen. + */ + private readonly exits: Promise[] = []; + /** Resolves with the exit code of whichever child exits first. */ async firstExit(): Promise { - return new Promise(resolve => { - for (const child of this.children) { - child.once("exit", code => { - resolve(code ?? 0); - }); - } - }); + return Promise.race(this.exits); } spawn(options: RunProcessOptions): ChildProcess { const child = spawnChild(options, "inherit"); this.children.add(child); - child.once("exit", () => this.children.delete(child)); - child.once("error", () => this.children.delete(child)); + this.exits.push( + new Promise(resolve => { + child.once("exit", code => { + this.children.delete(child); + resolve(code ?? 1); + }); + child.once("error", () => { + this.children.delete(child); + resolve(1); + }); + }), + ); return child; } @@ -163,8 +173,4 @@ export class ProcessGroup { ); this.children.clear(); } - - get size(): number { - return this.children.size; - } } diff --git a/packages/vitnode/scripts/cli/project/project.ts b/packages/vitnode/scripts/cli/project/project.ts index e7fa7d7e9..2a90b7f79 100644 --- a/packages/vitnode/scripts/cli/project/project.ts +++ b/packages/vitnode/scripts/cli/project/project.ts @@ -45,6 +45,10 @@ const DRIZZLE_CONFIGS = [ "drizzle.config.mjs", ]; +/** The Drizzle config a project uses, whichever extension it chose. */ +export const findDrizzleConfig = (root: string): null | string => + firstExisting(root, DRIZZLE_CONFIGS); + const firstExisting = (root: string, files: readonly string[]) => { const found = files.find(file => existsSync(join(root, file))); diff --git a/packages/vitnode/scripts/cli/project/runtime.test.ts b/packages/vitnode/scripts/cli/project/runtime.test.ts new file mode 100644 index 000000000..dd1e2ded0 --- /dev/null +++ b/packages/vitnode/scripts/cli/project/runtime.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment node +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { detectRuntime, runtimeExecutable } from "./runtime"; + +const node = { ...process.versions, bun: undefined } as NodeJS.ProcessVersions; +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "vitnode-runtime-")); + mkdirSync(join(root, "apps", "api"), { recursive: true }); + writeFileSync(join(root, "apps", "api", "package.json"), "{}"); +}); + +afterEach(() => { + rmSync(root, { force: true, recursive: true }); +}); + +const api = () => join(root, "apps", "api"); + +describe("detectRuntime", () => { + it("is Bun when Bun started the script, even though vitnode runs on Node", () => { + expect( + detectRuntime( + api(), + { npm_config_user_agent: "bun/1.3.14 npm/? node/v24" }, + node, + ), + ).toBe("bun"); + }); + + it("is Bun when the workspace declares Bun as its package manager", () => { + writeFileSync( + join(root, "package.json"), + JSON.stringify({ packageManager: "bun@1.3.14" }), + ); + + expect(detectRuntime(api(), {}, node)).toBe("bun"); + }); + + it("is Bun when the workspace has a Bun lockfile", () => { + writeFileSync(join(root, "bun.lock"), ""); + + expect(detectRuntime(api(), {}, node)).toBe("bun"); + }); + + it("is Node for a pnpm, npm or Yarn project", () => { + writeFileSync( + join(root, "package.json"), + JSON.stringify({ packageManager: "pnpm@11.9.0" }), + ); + + expect( + detectRuntime(api(), { npm_config_user_agent: "pnpm/11.9.0" }, node), + ).toBe("node"); + }); + + it("is Bun when the CLI itself runs under Bun", () => { + expect(detectRuntime(api(), {}, { ...node, bun: "1.3.14" })).toBe("bun"); + }); +}); + +describe("runtimeExecutable", () => { + it("uses Node itself for Node, and bun from the PATH for Bun", () => { + expect(runtimeExecutable("node", node)).toBe(process.execPath); + expect(runtimeExecutable("bun", node)).toBe("bun"); + expect(runtimeExecutable("bun", { ...node, bun: "1.3.14" })).toBe( + process.execPath, + ); + }); +}); diff --git a/packages/vitnode/scripts/cli/project/runtime.ts b/packages/vitnode/scripts/cli/project/runtime.ts new file mode 100644 index 000000000..d1b4d9b15 --- /dev/null +++ b/packages/vitnode/scripts/cli/project/runtime.ts @@ -0,0 +1,62 @@ +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +import type { Env } from "../context"; + +import { readPackageJson } from "./packages"; + +/** + * What a standalone API app runs on. + * + * A Bun project runs its TypeScript entry directly (`bun --hot`, `bun + * src/index.ts`); a Node project compiles it with `tsc` and runs `dist`. This is + * the same split `create-vitnode-app` generates, so the CLI follows the project + * rather than asking. + */ +export type Runtime = "bun" | "node"; + +const OTHER_LOCKFILES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock"]; +const BUN_LOCKFILES = ["bun.lock", "bun.lockb"]; + +/** + * Bun when the CLI itself runs under Bun, when Bun started the script that + * runs it, or when the project (or its workspace) is a Bun project - a + * `packageManager: "bun@..."` field or a Bun lockfile, whichever the closest + * folder declares first. + */ +export const detectRuntime = ( + root: string, + env: Env, + versions: NodeJS.ProcessVersions = process.versions, +): Runtime => { + if (versions.bun !== undefined) return "bun"; + if (env.npm_config_user_agent?.startsWith("bun/")) return "bun"; + + let current = resolve(root); + for (;;) { + const manager = readPackageJson(current)?.packageManager; + if (manager !== undefined) + return manager.startsWith("bun@") ? "bun" : "node"; + if (BUN_LOCKFILES.some(file => existsSync(join(current, file)))) { + return "bun"; + } + if (OTHER_LOCKFILES.some(file => existsSync(join(current, file)))) { + return "node"; + } + + const parent = dirname(current); + if (parent === current) return "node"; + current = parent; + } +}; + +/** + * The executable for a runtime: this very process when it already is that + * runtime, otherwise `bun` from the PATH - spawned without a shell, which + * Windows resolves to `bun.exe` on its own. + */ +export const runtimeExecutable = ( + runtime: Runtime, + versions: NodeJS.ProcessVersions = process.versions, +): string => + runtime === "node" || versions.bun !== undefined ? process.execPath : "bun"; diff --git a/packages/vitnode/scripts/cli/start/server-entry.ts b/packages/vitnode/scripts/cli/start/server-entry.ts index 12e62a7fc..ca70d054d 100644 --- a/packages/vitnode/scripts/cli/start/server-entry.ts +++ b/packages/vitnode/scripts/cli/start/server-entry.ts @@ -2,12 +2,15 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { Project } from "../project/project"; +import type { Runtime } from "../project/runtime"; import { ConfigError } from "../errors"; export interface ServerEntry { defaultPort: number; entry: string; + /** Which runtime runs `entry`. */ + runtime: Runtime; } /** Nitro presets whose output is a Node server `vitnode start` can run. */ @@ -19,19 +22,26 @@ const NODE_PRESETS = new Set(["node", "node-cluster", "node-server"]); * For an app that is Nitro's `.output/nitro.json`, which names the server * entry and the preset it was built for - so a build for Vercel is refused * here with a reason, instead of being started as if it were a Node server. - * For a standalone API it is the `dist/index.js` its own `tsc` build writes. + * For a standalone API on Node it is the `dist/index.js` its `tsc` build + * writes; on Bun it is `src/index.ts`, which Bun runs as it is. */ -export const resolveServerEntry = (project: Project): ServerEntry => { +export const resolveServerEntry = ( + project: Project, + runtime: Runtime = "node", +): ServerEntry => { const notBuilt = () => new ConfigError("No production build found.", { hint: "Run vitnode build first.", }); if (project.kind === "api") { - const entry = join(project.root, "dist", "index.js"); + const entry = + runtime === "bun" + ? join(project.root, "src", "index.ts") + : join(project.root, "dist", "index.js"); if (!existsSync(entry)) throw notBuilt(); - return { defaultPort: 8000, entry }; + return { defaultPort: 8000, entry, runtime }; } if (project.kind === "package") { @@ -57,11 +67,13 @@ export const resolveServerEntry = (project: Project): ServerEntry => { } const entry = join(output, manifest.serverEntry ?? "server/index.mjs"); - if (existsSync(entry)) return { defaultPort: 3000, entry }; + if (existsSync(entry)) return { defaultPort: 3000, entry, runtime: "node" }; } const fallback = join(output, "server", "index.mjs"); - if (existsSync(fallback)) return { defaultPort: 3000, entry: fallback }; + if (existsSync(fallback)) { + return { defaultPort: 3000, entry: fallback, runtime: "node" }; + } throw notBuilt(); }; diff --git a/packages/vitnode/scripts/cli/start/wait-for-port.ts b/packages/vitnode/scripts/cli/start/wait-for-port.ts index 55aac78f5..5d44e38f5 100644 --- a/packages/vitnode/scripts/cli/start/wait-for-port.ts +++ b/packages/vitnode/scripts/cli/start/wait-for-port.ts @@ -1,6 +1,9 @@ import { connect } from "node:net"; -const canConnect = async (port: number, host: string): Promise => +export const canConnect = async ( + port: number, + host: string, +): Promise => new Promise(resolve => { const socket = connect({ host, port }); const done = (result: boolean) => { diff --git a/packages/vitnode/scripts/cli/testing.ts b/packages/vitnode/scripts/cli/testing.ts index baa405e8a..dbe5bb8a2 100644 --- a/packages/vitnode/scripts/cli/testing.ts +++ b/packages/vitnode/scripts/cli/testing.ts @@ -117,6 +117,18 @@ export const createScriptedPrompter = (answers: { return Promise.resolve(confirms.shift() ?? true); }, + multiSelect: async (message, choices) => { + asked.push(message); + + return Promise.resolve( + choices.filter(choice => choice.checked === true).map(c => c.value), + ); + }, + select: async (message, choices, options) => { + asked.push(message); + + return Promise.resolve(options?.default ?? choices[0].value); + }, text: async (message, options) => { asked.push(message); // An empty answer is Enter on the suggested default, as in a terminal. diff --git a/packages/vitnode/scripts/cli/ui/prompts.ts b/packages/vitnode/scripts/cli/ui/prompts.ts index 672c33cc8..5ba7fe8ad 100644 --- a/packages/vitnode/scripts/cli/ui/prompts.ts +++ b/packages/vitnode/scripts/cli/ui/prompts.ts @@ -2,11 +2,28 @@ import type { Ui } from "./ui"; import { CliError, EXIT_CODE, UserError } from "../errors"; +export interface PromptChoice { + checked?: boolean; + name: string; + value: T; +} + export interface Prompter { confirm: ( message: string, options?: { default?: boolean }, ) => Promise; + /** Pick any number; `required` refuses an empty answer. */ + multiSelect: ( + message: string, + choices: readonly PromptChoice[], + ) => Promise; + /** Pick exactly one. */ + select: ( + message: string, + choices: readonly PromptChoice[], + options?: { default?: T }, + ) => Promise; text: ( message: string, options?: { @@ -59,6 +76,28 @@ export const createPrompter = (ui: Ui): Prompter => { return confirm({ default: options.default, message, theme }); }), + multiSelect: async (message, choices) => + guard(async () => { + const { checkbox } = await import("@inquirer/prompts"); + + return checkbox({ + choices: choices.map(choice => ({ ...choice })), + message, + required: true, + theme, + }); + }), + select: async (message, choices, options = {}) => + guard(async () => { + const { select } = await import("@inquirer/prompts"); + + return select({ + choices: choices.map(choice => ({ ...choice })), + default: options.default, + message, + theme, + }); + }), text: async (message, options = {}) => guard(async () => { const { input } = await import("@inquirer/prompts"); diff --git a/packages/vitnode/scripts/database-bootstrap.test.ts b/packages/vitnode/scripts/database-bootstrap.test.ts index 50e6cc8c0..d28a53278 100644 --- a/packages/vitnode/scripts/database-bootstrap.test.ts +++ b/packages/vitnode/scripts/database-bootstrap.test.ts @@ -20,6 +20,7 @@ import { readDrizzleConfig, runMigrations, runWithMigrationLock, + withConfigFlag, } from "./prepare-database.js"; const scriptsRoot = import.meta.dirname; @@ -179,6 +180,35 @@ describe("what decides whether work is pending", () => { ); }); + it("reads whichever drizzle.config extension the project uses, and names it to drizzle-kit", async () => { + const root = mkdtempSync(join(tmpdir(), "vitnode-drizzle-mts-")); + + try { + writeFileSync( + join(root, "drizzle.config.mts"), + 'export default { out: "./db", migrations: { table: "journal" } };\n', + ); + + expect(await readDrizzleConfig(root)).toMatchObject({ + migrationsFolder: join(root, "db"), + migrationsTable: "journal", + }); + expect(withConfigFlag(["generate", "--explain"], root)).toEqual([ + "generate", + "--config", + "drizzle.config.mts", + "--explain", + ]); + expect(withConfigFlag(["push", "--config", "x.ts"], root)).toEqual([ + "push", + "--config", + "x.ts", + ]); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + it("ensures text-search configs before applying migrations", () => { const applyStep = bootstrap.slice( bootstrap.indexOf("export const runMigrations"), diff --git a/packages/vitnode/scripts/i18n-check.test.ts b/packages/vitnode/scripts/i18n-check.test.ts index dd47a0dfc..e4efdfc34 100644 --- a/packages/vitnode/scripts/i18n-check.test.ts +++ b/packages/vitnode/scripts/i18n-check.test.ts @@ -2,8 +2,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ValidationError } from "./cli/errors"; +import { createTestContext } from "./cli/testing"; import { i18nCheck } from "./i18n-check"; const writeApp = ( @@ -36,38 +38,47 @@ const writeApp = ( describe("i18nCheck with an app-owned namespace", () => { let root: string; - let output: string[]; - let exit: ReturnType; beforeEach(() => { root = mkdtempSync(join(tmpdir(), "vitnode-i18n-check-")); - output = []; - vi.spyOn(process, "cwd").mockReturnValue(root); - vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { - output.push(args.join(" ")); - }); - exit = vi - .spyOn(process, "exit") - .mockImplementation((() => undefined) as never); }); afterEach(() => { - vi.restoreAllMocks(); rmSync(root, { force: true, recursive: true }); }); + const check = async (ci = false) => { + const { context, runtime } = createTestContext({ cwd: root }); + const result = await i18nCheck({ ci, cwd: root, ui: context.ui }).catch( + (error: unknown) => error, + ); + + return { report: runtime.output(), result }; + }; + it("lists keys the app's Polish file is missing and fails under --ci", async () => { writeApp(root, { en: { site: { hero: { title: "Title", cta: "Explore" } } }, pl: { site: { hero: { title: "Tytuł" } } }, }); - await i18nCheck("--ci"); + const { report, result } = await check(true); - const report = output.join("\n"); expect(report).toContain("site · pl: 1 key(s) missing"); expect(report).toContain("site.hero.cta"); - expect(exit).toHaveBeenCalledWith(1); + expect(result).toBeInstanceOf(ValidationError); + }); + + it("only warns about missing keys without --ci", async () => { + writeApp(root, { + en: { site: { hero: { title: "Title", cta: "Explore" } } }, + pl: { site: { hero: { title: "Tytuł" } } }, + }); + + const { report, result } = await check(); + + expect(result).toBe(0); + expect(report).toContain("! 2 issue(s), 1 untranslated key(s)."); }); it("reports a complete translation", async () => { @@ -76,9 +87,9 @@ describe("i18nCheck with an app-owned namespace", () => { pl: { site: { hero: { title: "Tytuł" } } }, }); - await i18nCheck(); + const { report, result } = await check(); - const report = output.join("\n"); + expect(result).toBe(0); expect(report).toContain("site · pl: complete"); expect(report).not.toContain("never loaded"); }); @@ -86,11 +97,11 @@ describe("i18nCheck with an app-owned namespace", () => { it("errors when the namespace has no default-locale file", async () => { writeApp(root, { pl: { site: { hero: { title: "Tytuł" } } } }); - await i18nCheck(); + const { report, result } = await check(); - expect(output.join("\n")).toContain( + expect(report).toContain( 'site: no "en" messages - create src/locales/site/en.json', ); - expect(exit).toHaveBeenCalledWith(1); + expect(result).toBeInstanceOf(ValidationError); }); }); diff --git a/packages/vitnode/scripts/i18n-check.ts b/packages/vitnode/scripts/i18n-check.ts index 30a4fce3f..b8ffd520a 100644 --- a/packages/vitnode/scripts/i18n-check.ts +++ b/packages/vitnode/scripts/i18n-check.ts @@ -1,19 +1,21 @@ -/* eslint-disable no-console */ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; +import type { Ui } from "./cli/ui/ui.js"; + +import { EXIT_CODE, ValidationError } from "./cli/errors.js"; import { getConfig } from "./get-config.js"; -import { appOwnedIds, appScope, packageLocaleFiles } from "./i18n-shared.js"; +import { + appOwnedIds, + appScope, + noConfigError, + packageLocaleFiles, +} from "./i18n-shared.js"; import { findRepoRoot } from "./shared/file-utils.js"; const CORE_PLUGIN_ID = "@vitnode/core"; const MAX_LISTED_KEYS = 8; -const dim = (value: string) => `\x1b[90m${value}\x1b[0m`; -const red = (value: string) => `\x1b[31m${value}\x1b[0m`; -const yellow = (value: string) => `\x1b[33m${value}\x1b[0m`; -const green = (value: string) => `\x1b[32m${value}\x1b[0m`; - /** Flattens a message tree into the dotted leaf paths translators care about. */ const flattenKeys = (value: unknown, prefix = ""): string[] => { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -25,13 +27,16 @@ const flattenKeys = (value: unknown, prefix = ""): string[] => { ); }; -const readKeys = (filePath: string): null | string[] => { +const readKeys = ( + filePath: string, + onError: (message: string) => void, +): null | string[] => { if (!existsSync(filePath)) return null; try { return flattenKeys(JSON.parse(readFileSync(filePath, "utf-8"))); } catch (error) { - console.error(red(` Could not parse ${filePath}: ${String(error)}`)); + onError(` Could not parse ${filePath}: ${String(error)}`); return []; } @@ -66,26 +71,51 @@ const readAppLocaleFiles = (appDir: string) => { return files; }; -export const i18nCheck = async (flag?: string) => { - const isCi = flag === "--ci"; - const appDir = process.cwd(); +/** + * `vitnode i18n check` - which translations are missing, unknown or never + * loaded. + * + * A missing or unloadable file always fails. Key-level gaps fall back to the + * default locale at runtime, so they only fail with `ci` - the switch a + * pipeline uses to hold translations to the same bar as the build. + */ +export const i18nCheck = async ({ + ci = false, + cwd, + ui, +}: { + ci?: boolean; + cwd: string; + ui: Ui; +}): Promise => { + const { colors } = ui; + const red = colors.error; + const yellow = colors.warning; + const green = colors.success; + const dim = colors.muted; + const say = (text: string) => { + ui.line(text); + }; + const appDir = cwd; const repoRoot = findRepoRoot(appDir); - const webConfig = await getConfig({ optional: true }); - const apiConfig = await getConfig({ optional: true, type: "api.config" }); + const webConfig = await getConfig({ baseDir: cwd, optional: true }); + const apiConfig = await getConfig({ + baseDir: cwd, + optional: true, + type: "api.config", + }); // The app's own message loaders live in the server-only config now, because // the shared one is browser-safe. Read from both, so an installation still on // the old shape - loaders inside `i18n.messages` - is measured correctly. const serverConfig = await getConfig({ + baseDir: cwd, optional: true, type: "server.config", }); const config = webConfig ?? apiConfig; - if (!config) { - console.error(red("No vitnode.config.ts or vitnode.api.config.ts found.")); - process.exit(1); - } + if (!config) throw noConfigError(); // Check against the trees the app actually uses: an API-only app is measured // on email strings alone, a single app on both. @@ -113,9 +143,10 @@ export const i18nCheck = async (flag?: string) => { ...new Set([...declared, ...appFiles.map(file => file.locale)]), ].filter(locale => locale !== defaultLocale); - console.log( - `\x1b[34m[VitNode]\x1b[0m Checking ${packageIds.length} package(s) and ${appIds.size} app namespace(s) against ${locales.length || "no"} extra locale(s), default ${dim(defaultLocale)}.`, + ui.note( + `Checking ${packageIds.length} package(s) and ${appIds.size} app namespace(s) against ${locales.length || "no"} extra locale(s), default ${defaultLocale}.`, ); + ui.line(); let missingTotal = 0; let problems = 0; @@ -133,12 +164,14 @@ export const i18nCheck = async (flag?: string) => { repoRoot, scope, }) - .map(readKeys) + .map(file => readKeys(file, message => say(red(message)))) .filter((keys): keys is string[] => keys !== null); const override = appFiles.find( file => file.pluginId === pluginId && file.locale === locale, ); - const fromApp = override ? readKeys(override.path) : null; + const fromApp = override + ? readKeys(override.path, message => say(red(message))) + : null; if (fromPackage.length === 0 && !fromApp) return null; @@ -151,7 +184,7 @@ export const i18nCheck = async (flag?: string) => { if (!baseKeys && appIds.has(pluginId)) { errors += 1; problems += 1; - console.log( + say( red( ` ${pluginId}: no "${defaultLocale}" messages - create src/locales/${pluginId}/${defaultLocale}.json, every other language is checked against it`, ), @@ -169,11 +202,11 @@ export const i18nCheck = async (flag?: string) => { .length > 0; if (installed) { - console.log( + say( dim(` ${pluginId}: no strings for this app - nothing to translate`), ); } else { - console.log( + say( yellow( ` ${pluginId}: no "${defaultLocale}" messages found - is the package installed?`, ), @@ -193,13 +226,13 @@ export const i18nCheck = async (flag?: string) => { if (declaredLocales.has(locale)) { errors += 1; problems += 1; - console.log( + say( red( ` ${pluginId} · ${locale}: no locale file - create src/locales/${pluginId}/${locale}.json`, ), ); } else { - console.log( + say( dim( ` ${pluginId} · ${locale}: not translated, falls back to ${defaultLocale}`, ), @@ -214,42 +247,38 @@ export const i18nCheck = async (flag?: string) => { const unknown = localeKeys.filter(key => !known.has(key)); if (missing.length === 0 && unknown.length === 0) { - console.log(green(` ${pluginId} · ${locale}: complete`)); + say(green(` ${pluginId} · ${locale}: complete`)); continue; } if (missing.length > 0) { missingTotal += missing.length; problems += 1; - console.log( + say( yellow( ` ${pluginId} · ${locale}: ${missing.length} key(s) missing, falling back to ${defaultLocale}`, ), ); for (const key of missing.slice(0, MAX_LISTED_KEYS)) { - console.log(dim(` ${key}`)); + say(dim(` ${key}`)); } if (missing.length > MAX_LISTED_KEYS) { - console.log( - dim(` ... and ${missing.length - MAX_LISTED_KEYS} more`), - ); + say(dim(` ... and ${missing.length - MAX_LISTED_KEYS} more`)); } } if (unknown.length > 0) { problems += 1; - console.log( + say( yellow( ` ${pluginId} · ${locale}: ${unknown.length} key(s) unknown to ${defaultLocale} - typo or leftover?`, ), ); for (const key of unknown.slice(0, MAX_LISTED_KEYS)) { - console.log(dim(` ${key}`)); + say(dim(` ${key}`)); } if (unknown.length > MAX_LISTED_KEYS) { - console.log( - dim(` ... and ${unknown.length - MAX_LISTED_KEYS} more`), - ); + say(dim(` ... and ${unknown.length - MAX_LISTED_KEYS} more`)); } } } @@ -264,7 +293,7 @@ export const i18nCheck = async (flag?: string) => { if (!wired) { errors += 1; problems += 1; - console.log( + say( red( ` ${location} is never loaded - add \`"${file.pluginId}": () => import("./${file.pluginId}/${file.locale}.json")\` under \`"${file.locale}"\` in \`src/locales/app.ts\`.`, ), @@ -272,23 +301,33 @@ export const i18nCheck = async (flag?: string) => { } else if (declared.length > 0 && !declared.includes(file.locale)) { errors += 1; problems += 1; - console.log( - red(` ${location} uses a locale that is not in \`i18n.locales\`.`), - ); + say(red(` ${location} uses a locale that is not in \`i18n.locales\`.`)); } } + ui.line(); + if (problems === 0) { - console.log(green(" Everything is translated. Nice.")); - process.exit(0); + ui.success("Everything is translated. Nice."); + ui.line(); + + return EXIT_CODE.ok; } - console.log( - `\x1b[34m[VitNode]\x1b[0m ${problems} issue(s)` + - (errors > 0 ? red(`, ${errors} error(s)`) : "") + - `, ${missingTotal} untranslated key(s).`, - ); + const summary = `${problems} issue(s)${errors > 0 ? `, ${errors} error(s)` : ""}, ${missingTotal} untranslated key(s).`; // Missing/unloadable files always fail; key-level gaps only fail under --ci. - process.exit(errors > 0 || isCi ? 1 : 0); + if (errors > 0 || ci) { + throw new ValidationError(summary, { + hint: + errors > 0 + ? undefined + : "--ci fails on missing keys; without it they only warn, and fall back to the default locale.", + }); + } + + ui.warning(summary); + ui.line(); + + return EXIT_CODE.ok; }; diff --git a/packages/vitnode/scripts/i18n-create.ts b/packages/vitnode/scripts/i18n-create.ts index fcb32e322..67ac17e47 100644 --- a/packages/vitnode/scripts/i18n-create.ts +++ b/packages/vitnode/scripts/i18n-create.ts @@ -1,20 +1,16 @@ -/* eslint-disable no-console */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, relative } from "node:path"; +import type { I18nContext } from "./i18n-shared.js"; + +import { EXIT_CODE } from "./cli/errors.js"; import { getConfig } from "./get-config.js"; import { appScope, CORE_PLUGIN_ID, - createReadline, - cyan, - dim, effectiveDefaultTree, findI18nSourceFile, - green, - prefix, - type Readline, - red, + noConfigError, resolveField, } from "./i18n-shared.js"; import { findRepoRoot } from "./shared/file-utils.js"; @@ -293,19 +289,40 @@ ${entries} `; }; -export const i18nCreate = async () => { - const appDir = process.cwd(); +/** + * `vitnode i18n create [code] [name...]` - adds a language: one translation + * file per package, seeded with the default language's strings so every key + * is there to translate, then wired into the app's config. + * + * Anything given on the command line skips its question, so the command is + * scriptable and never waits on a terminal nobody is at. + */ +export const i18nCreate = async ({ + code: providedCode, + context, + name: providedName, +}: { + code?: string; + context: I18nContext; + name?: string; +}): Promise => { + const { cwd, ui } = context; + const { colors } = ui; + const green = colors.success; + const dim = colors.muted; + const appDir = cwd; // Load both configs: their presence tells us the app's shape (frontend, API, // or both), which decides how much of each package to seed. - const webConfig = await getConfig({ optional: true }); - const apiConfig = await getConfig({ optional: true, type: "api.config" }); + const webConfig = await getConfig({ baseDir: cwd, optional: true }); + const apiConfig = await getConfig({ + baseDir: cwd, + optional: true, + type: "api.config", + }); const config = webConfig ?? apiConfig; - if (!config) { - console.error(red("No vitnode.config.ts or vitnode.api.config.ts found.")); - process.exit(1); - } + if (!config) throw noConfigError(); // An API-only app seeds email strings alone; a single app gets both trees. const scope = appScope({ api: apiConfig !== null, web: webConfig !== null }); @@ -344,43 +361,21 @@ export const i18nCreate = async () => { const validateName = (value: string): null | string => value ? null : "A language name is required."; - // `vitnode i18n:create [code] [name...]` - anything supplied on the command - // line skips its prompt, so the command is scriptable and never blocks on a - // non-interactive stdin. - const [argCode, ...argNameParts] = process.argv.slice(3); - const argName = argNameParts.join(" ").trim() || undefined; - const isInteractive = process.stdin.isTTY ?? false; - const needsPrompt = argCode === undefined || argName === undefined; - const rl: null | Readline = - isInteractive && needsPrompt ? createReadline() : null; - const missing = 'Run: vitnode i18n:create ""'; - - if (rl) { - console.log(`${prefix} Add a language. Press Ctrl+C to abort.\n`); - } - - let code: string; - let name: string; - try { - code = await resolveField({ - missingMessage: `Missing locale code. ${missing}`, - provided: argCode, - question: cyan("? Locale code (e.g. pl, de, pt-BR): "), - rl, - validate: validateCode, - }); - name = await resolveField({ - missingMessage: `Missing language name. ${missing}`, - provided: argName, - question: cyan("? Language name (e.g. Polski, Deutsch): "), - rl, - validate: validateName, - }); - } finally { - rl?.close(); - } - - console.log(); + const missing = 'Run: vitnode i18n create ""'; + const code = await resolveField({ + context, + missingMessage: `Missing locale code. ${missing}`, + provided: providedCode, + question: "Locale code (e.g. pl, de, pt-BR)", + validate: validateCode, + }); + const name = await resolveField({ + context, + missingMessage: `Missing language name. ${missing}`, + provided: providedName?.trim() === "" ? undefined : providedName?.trim(), + question: "Language name (e.g. Polski, Deutsch)", + validate: validateName, + }); // 1. Seed one override file per package with that package's default-locale // strings for the trees this app uses, so every key is present to @@ -431,10 +426,10 @@ export const i18nCreate = async () => { wiredPluginIds.push(pluginId); } - for (const file of created) console.log(green(` created ${file}`)); - for (const file of skipped) console.log(dim(` exists ${file}`)); + for (const file of created) ui.line(green(` created ${file}`)); + for (const file of skipped) ui.line(dim(` exists ${file}`)); for (const pluginId of empty) { - console.log(dim(` skipped ${pluginId} (no strings for this app)`)); + ui.line(dim(` skipped ${pluginId} (no strings for this app)`)); } // 2. Wire the loaders into the app's own message map, which is where a @@ -453,7 +448,7 @@ export const i18nCreate = async () => { if (wired) { writeFileSync(appMessagesPath, wired); - console.log(green(` updated ${APP_MESSAGES_FILE}`)); + ui.line(green(` updated ${APP_MESSAGES_FILE}`)); wroteAppMessages = true; } } @@ -473,13 +468,12 @@ export const i18nCreate = async () => { pluginIds: wiredPluginIds, }), ); - console.log(green(` created ${relative(appDir, target)}`)); + ui.line(green(` created ${relative(appDir, target)}`)); const builder = isApiOnly ? "buildApiConfig" : "buildConfig"; - console.log( - `\n${prefix} Import it into your config so the app picks it up:\n` + - dim(' import { i18n } from "./i18n";\n') + - dim(` ${builder}({ i18n, /* ... */ });`), - ); + ui.line(); + ui.info("Import it into your config so the app picks it up:"); + ui.line(dim(' import { i18n } from "./i18n";')); + ui.line(dim(` ${builder}({ i18n, /* ... */ });`)); } else { const original = readFileSync(sourceFile, "utf-8"); const withLocale = addLocaleToConfig(original, { code, name }); @@ -493,15 +487,18 @@ export const i18nCreate = async () => { if (wired) { writeFileSync(sourceFile, wired); - console.log(green(` updated ${relative(appDir, sourceFile)}`)); + ui.line(green(` updated ${relative(appDir, sourceFile)}`)); } else { // The config is shaped in a way we will not edit blindly - show the // exact lines to add instead of risking a broken file. - console.log( - `\n${prefix} Couldn't edit ${relative(appDir, sourceFile)} automatically. Add:\n` + - dim( - ` locales: [{ code: ${stringLiteral(code)}, name: ${stringLiteral(name)} }, /* ... */]`, - ), + ui.line(); + ui.warning( + `Couldn't edit ${relative(appDir, sourceFile)} automatically. Add:`, + ); + ui.line( + dim( + ` locales: [{ code: ${stringLiteral(code)}, name: ${stringLiteral(name)} }, /* ... */]`, + ), ); } @@ -509,15 +506,18 @@ export const i18nCreate = async () => { const messages = wiredPluginIds .map(id => ` "${id}": () => import("./${id}/${code}.json"),`) .join("\n"); - console.log( - `\n${prefix} And register the files in ${APP_MESSAGES_FILE}:\n` + - dim(` "${code}": {\n${messages}\n },`), - ); + ui.line(); + ui.info(`And register the files in ${APP_MESSAGES_FILE}:`); + ui.line(dim(` "${code}": {\n${messages}\n },`)); } } - console.log( - `\n${prefix} ${green(name)} (${code}) added. Translate the files above, then run ${cyan("vitnode i18n:check")}.`, + ui.line(); + ui.success(`${name} (${code}) added.`); + ui.note( + `Translate the files above, then run ${colors.command("vitnode i18n check")}.`, ); - process.exit(0); + ui.line(); + + return EXIT_CODE.ok; }; diff --git a/packages/vitnode/scripts/i18n-delete.ts b/packages/vitnode/scripts/i18n-delete.ts index e9939556c..7217b016b 100644 --- a/packages/vitnode/scripts/i18n-delete.ts +++ b/packages/vitnode/scripts/i18n-delete.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import { existsSync, readdirSync, @@ -9,20 +8,16 @@ import { } from "node:fs"; import { dirname, join, relative } from "node:path"; +import type { I18nContext } from "./i18n-shared.js"; + +import { EXIT_CODE } from "./cli/errors.js"; +import { requireConfirmation } from "./cli/ui/prompts.js"; import { getConfig } from "./get-config.js"; import { - askConfirm, - createReadline, - cyan, - dim, findI18nSourceFile, - green, listAppLocaleFiles, - prefix, - type Readline, - red, + noConfigError, resolveField, - yellow, } from "./i18n-shared.js"; const escapeRegExp = (value: string) => @@ -76,17 +71,30 @@ export const removeMessagesFromConfig = ( return source.replace(re, ""); }; -export const i18nDelete = async () => { - const appDir = process.cwd(); - - const webConfig = await getConfig({ optional: true }); +/** + * `vitnode i18n delete [code]` - removes a language: its translation files, + * and its entries in the i18n config. Asks before deleting anything; in a + * script, `yes` is that answer. + */ +export const i18nDelete = async ({ + code: provided, + context, + yes = false, +}: { + code?: string; + context: I18nContext; + yes?: boolean; +}): Promise => { + const { cwd, prompter, ui } = context; + const { colors } = ui; + const appDir = cwd; + + const webConfig = await getConfig({ baseDir: cwd, optional: true }); const config = - webConfig ?? (await getConfig({ optional: true, type: "api.config" })); + webConfig ?? + (await getConfig({ baseDir: cwd, optional: true, type: "api.config" })); - if (!config) { - console.error(red("No vitnode.config.ts or vitnode.api.config.ts found.")); - process.exit(1); - } + if (!config) throw noConfigError(); const defaultLocale = config.i18n?.defaultLocale ?? "en"; const existingLocales = (config.i18n?.locales ?? []).map(locale => ({ @@ -113,11 +121,6 @@ export const i18nDelete = async () => { return null; }; - const argCode = process.argv[3]; - const isInteractive = process.stdin.isTTY ?? false; - // Open readline when interactive - both to prompt for a missing code and to - // confirm before deleting. - const rl: null | Readline = isInteractive ? createReadline() : null; const sourceFile = findI18nSourceFile(appDir); const headingFor = (value: string): string => { const label = existingLocales.find(locale => locale.code === value)?.name; @@ -125,38 +128,36 @@ export const i18nDelete = async () => { return label ? `${label} (${value})` : value; }; - let code = ""; - let confirmed = true; - try { - code = await resolveField({ - missingMessage: "Missing locale code. Run: vitnode i18n:delete ", - provided: argCode, - question: cyan("? Locale code to remove: "), - rl, - validate: validateCode, - }); - - // Confirm before touching anything - deleting is not easily undone. - if (rl) { - console.log(`\n${prefix} About to remove ${yellow(headingFor(code))}:`); - for (const file of appFiles.filter(f => f.locale === code)) { - console.log(dim(` delete ${relative(appDir, file.path)}`)); - } - if (sourceFile) { - console.log(dim(` update ${relative(appDir, sourceFile)}`)); - } - confirmed = await askConfirm(rl, cyan("\n? Remove it? [y/N] ")); - } - } finally { - rl?.close(); + const code = await resolveField({ + context, + missingMessage: "Missing locale code. Run: vitnode i18n delete ", + provided, + question: "Locale code to remove", + validate: validateCode, + }); + + // Show exactly what goes before anything does - deleting is not easily + // undone. + ui.section(`About to remove ${headingFor(code)}`); + for (const file of appFiles.filter(f => f.locale === code)) { + ui.note(`delete ${relative(appDir, file.path)}`); } + if (sourceFile) ui.note(`update ${relative(appDir, sourceFile)}`); + ui.line(); + + const confirmed = await requireConfirmation({ + message: `Remove ${headingFor(code)}?`, + prompter, + ui, + yes, + }); if (!confirmed) { - console.log(dim("\nAborted, nothing was removed.")); - process.exit(0); - } + ui.note("Aborted, nothing was removed."); + ui.line(); - console.log(); + return EXIT_CODE.ok; + } // 1. Delete the override files, then any directory they leave empty. const localesRoot = join(appDir, "src", "locales"); @@ -173,7 +174,7 @@ export const i18nDelete = async () => { }; for (const file of appFiles.filter(f => f.locale === code)) { unlinkSync(file.path); - console.log(green(` deleted ${relative(appDir, file.path)}`)); + ui.line(colors.success(` deleted ${relative(appDir, file.path)}`)); pruneEmptyDirs(dirname(file.path)); } @@ -186,10 +187,13 @@ export const i18nDelete = async () => { ); if (updated !== original) { writeFileSync(sourceFile, updated); - console.log(green(` updated ${relative(appDir, sourceFile)}`)); + ui.line(colors.success(` updated ${relative(appDir, sourceFile)}`)); } } - console.log(`\n${prefix} ${green(headingFor(code))} removed.`); - process.exit(0); + ui.line(); + ui.success(`${headingFor(code)} removed.`); + ui.line(); + + return EXIT_CODE.ok; }; diff --git a/packages/vitnode/scripts/i18n-shared.ts b/packages/vitnode/scripts/i18n-shared.ts index 983d8fe10..822c3fb87 100644 --- a/packages/vitnode/scripts/i18n-shared.ts +++ b/packages/vitnode/scripts/i18n-shared.ts @@ -1,10 +1,11 @@ -/* eslint-disable no-console */ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { stdin as input, stdout as output } from "node:process"; -import { createInterface } from "node:readline/promises"; + +import type { Prompter } from "./cli/ui/prompts.js"; +import type { Ui } from "./cli/ui/ui.js"; import { deepMerge } from "../src/lib/i18n/deep-merge.js"; +import { ConfigError, UserError } from "./cli/errors.js"; import { findPackagePath } from "./shared/file-utils.js"; export const CORE_PLUGIN_ID = "@vitnode/core"; @@ -144,79 +145,52 @@ export const effectiveDefaultTree = ( appOverrideTree(appDir, pluginId, defaultLocale), ); -export const dim = (value: string) => `\x1b[90m${value}\x1b[0m`; -export const red = (value: string) => `\x1b[31m${value}\x1b[0m`; -export const green = (value: string) => `\x1b[32m${value}\x1b[0m`; -export const cyan = (value: string) => `\x1b[36m${value}\x1b[0m`; -export const yellow = (value: string) => `\x1b[33m${value}\x1b[0m`; -export const prefix = "\x1b[34m[VitNode]\x1b[0m"; - -// `createInterface` is overloaded, which makes `ReturnType` resolve to `never`; go through a plain factory instead. -export const createReadline = () => createInterface({ input, output }); -export type Readline = ReturnType; - -/** Re-prompts until the answer passes `validate` (which returns an error or null). */ -export const askQuestion = async ( - rl: Readline, - question: string, - validate: (value: string) => null | string, -): Promise => { - for (;;) { - const value = (await rl.question(question)).trim(); - const error = validate(value); - if (error) { - console.log(red(` ${error}`)); - continue; - } - - return value; - } -}; - -/** A yes/no prompt that defaults to no. */ -export const askConfirm = async ( - rl: Readline, - question: string, -): Promise => { - const answer = (await rl.question(question)).trim().toLowerCase(); +/** What every i18n command runs with: where, how to talk, how to ask. */ +export interface I18nContext { + cwd: string; + prompter: Prompter; + ui: Ui; +} - return answer === "y" || answer === "yes"; -}; +/** The error every i18n command gives when there is no VitNode config here. */ +export const noConfigError = () => + new ConfigError("No vitnode.config.ts or vitnode.api.config.ts found.", { + hint: "Run i18n commands in a VitNode app.", + }); /** - * Takes a value from the command line when provided, otherwise prompts for it. - * A value given inline is validated once and hard-fails; a missing value with - * no interactive prompt (`rl === null`) hard-fails with `missingMessage`. + * Takes a value from the command line when provided, otherwise asks for it. + * + * A value given inline is validated once and refused outright; a missing value + * with nobody to ask is refused with `missingMessage` - a script or CI job + * never waits on a prompt. */ export const resolveField = async ({ + context: { prompter, ui }, missingMessage, provided, question, - rl, validate, }: { + context: I18nContext; missingMessage: string; provided: string | undefined; question: string; - rl: null | Readline; validate: (value: string) => null | string; }): Promise => { if (provided !== undefined) { const error = validate(provided); - if (error) { - console.error(red(error)); - process.exit(1); - } + if (error) throw new UserError(error); return provided; } - if (!rl) { - console.error(red(missingMessage)); - process.exit(1); - } + if (!ui.interactive) throw new UserError(missingMessage); - return askQuestion(rl, question, validate); + return ( + await prompter.text(question, { + validate: value => validate(value.trim()) ?? true, + }) + ).trim(); }; /** Finds the source file that declares the app's i18n config, if any. */ diff --git a/packages/vitnode/scripts/i18n-update-ai.ts b/packages/vitnode/scripts/i18n-update-ai.ts index 0b56a31a5..da0963b3e 100644 --- a/packages/vitnode/scripts/i18n-update-ai.ts +++ b/packages/vitnode/scripts/i18n-update-ai.ts @@ -1,5 +1,3 @@ -/* eslint-disable no-console */ -import { checkbox, select } from "@inquirer/prompts"; import { generateText, type LanguageModel, Output } from "ai"; import { config as loadEnv } from "dotenv"; import { writeFileSync } from "node:fs"; @@ -7,19 +5,16 @@ import { dirname, join, relative } from "node:path"; import { z } from "zod"; import type { AIModelDefinition } from "../src/api/models/ai.js"; +import type { I18nContext } from "./i18n-shared.js"; +import { EXIT_CODE, RuntimeError, UserError } from "./cli/errors.js"; import { findConfigFile, getConfig } from "./get-config.js"; import { appScope, - cyan, - dim, effectiveDefaultTree, - green, listAppLocaleFiles, - prefix, + noConfigError, readJsonTree, - red, - yellow, } from "./i18n-shared.js"; import { reconcileTree } from "./i18n-update.js"; import { findRepoRoot } from "./shared/file-utils.js"; @@ -239,19 +234,42 @@ const translateBatch = async ({ return result; }; -export const i18nUpdateAi = async () => { - const appDir = process.cwd(); +/** + * `vitnode i18n update-ai [codes...]` - translates every string a language + * file still has in the default language, with an AI model from the API + * config. Languages and model come from the command line or, in a terminal, + * from a question; a script that names neither gets every language and the + * default model only if it named the languages. + */ +export const i18nUpdateAi = async ({ + codes = [], + concurrency: requestedConcurrency, + context, + model: requestedModel, +}: { + codes?: readonly string[]; + concurrency?: number; + context: I18nContext; + model?: string; +}): Promise => { + const { cwd, prompter, ui } = context; + const { colors } = ui; + const green = colors.success; + const dim = colors.muted; + const cyan = colors.command; + const appDir = cwd; // Both configs describe the app's shape; the AI models come from the API // config specifically, since AI is configured there (`ai.models`). - const webConfig = await getConfig({ optional: true }); - const apiConfig = await getConfig({ optional: true, type: "api.config" }); + const webConfig = await getConfig({ baseDir: cwd, optional: true }); + const apiConfig = await getConfig({ + baseDir: cwd, + optional: true, + type: "api.config", + }); const config = webConfig ?? apiConfig; - if (!config) { - console.error(red("No vitnode.config.ts or vitnode.api.config.ts found.")); - process.exit(1); - } + if (!config) throw noConfigError(); // The AI models live in the API config. In a monorepo the API app is usually // a sibling of the app you run this from (e.g. `apps/web` next to `apps/api`), @@ -290,12 +308,9 @@ export const i18nUpdateAi = async () => { } if (models.length === 0) { - console.error( - red( - "No AI models configured. Add an `ai.models` entry to vitnode.api.config.ts (searched this app and one level up).", - ), - ); - process.exit(1); + throw new UserError("No AI models configured.", { + hint: "Add an `ai.models` entry to vitnode.api.config.ts (searched this app and one level up).", + }); } const scope = appScope({ api: apiConfig !== null, web: webConfig !== null }); @@ -319,10 +334,12 @@ export const i18nUpdateAi = async () => { ); if (appFiles.length === 0) { - console.log( - `${prefix} No translation files to translate. Run ${cyan("vitnode i18n:create")} first.`, + ui.note( + `No translation files to translate. Run ${cyan("vitnode i18n create")} first.`, ); - process.exit(0); + ui.line(); + + return EXIT_CODE.ok; } const byLocale = new Map(); @@ -333,114 +350,85 @@ export const i18nUpdateAi = async () => { } const locales = [...byLocale.keys()].sort((a, b) => a.localeCompare(b)); - // `vitnode i18n:update:ai [code...] [--model ]` - anything supplied on the - // command line skips its prompt, so the command is scriptable and never blocks - // on a non-interactive stdin. - const rawArgs = process.argv.slice(3); - let argModelId: string | undefined; - let argConcurrency: number | undefined; - const argLocales: string[] = []; - for (let i = 0; i < rawArgs.length; i += 1) { - const arg = rawArgs[i]; - if (arg === "--model") { - argModelId = rawArgs[i + 1]; - i += 1; - } else if (arg.startsWith("--model=")) { - argModelId = arg.slice("--model=".length); - } else if (arg === "--concurrency") { - argConcurrency = Number(rawArgs[i + 1]); - i += 1; - } else if (arg.startsWith("--concurrency=")) { - argConcurrency = Number(arg.slice("--concurrency=".length)); - } else { - argLocales.push(...arg.split(/[\s,]+/).filter(Boolean)); - } - } - - const concurrency = - argConcurrency && Number.isInteger(argConcurrency) && argConcurrency > 0 - ? argConcurrency - : DEFAULT_CONCURRENCY; + // Anything given on the command line skips its question, so the command is + // scriptable and never waits on a terminal nobody is at. + const argLocales = codes.flatMap(code => + code.split(/[\s,]+/).filter(Boolean), + ); - const isInteractive = process.stdin.isTTY ?? false; + if ( + requestedConcurrency !== undefined && + (!Number.isInteger(requestedConcurrency) || requestedConcurrency < 1) + ) { + throw new UserError("--concurrency must be a whole number above 0."); + } + const concurrency = requestedConcurrency ?? DEFAULT_CONCURRENCY; + // 1. Which languages to translate from English - every one, ticked, so + // Enter takes them all. let selectedLocales: string[]; - let selectedModel: AIModelDefinition; - try { - // 1. Which languages to translate from English - a multi-select ticked in - // full, so pressing Enter takes them all. - if (argLocales.length > 0) { - const unknown = argLocales.filter(code => !byLocale.has(code)); - if (unknown.length > 0) { - console.error(red(`No translation files for: ${unknown.join(", ")}.`)); - process.exit(1); - } - selectedLocales = [...new Set(argLocales)]; - } else if (isInteractive) { - selectedLocales = await checkbox({ - choices: locales.map(code => ({ - checked: true, - name: `${localeNames.get(code) ?? code} ${dim(`(${code})`)}`, - value: code, - })), - message: "Which languages should be translated from English?", - required: true, + if (argLocales.length > 0) { + const unknown = argLocales.filter(code => !byLocale.has(code)); + if (unknown.length > 0) { + throw new UserError(`No translation files for: ${unknown.join(", ")}.`, { + hint: `Translation files exist for: ${locales.join(", ")}.`, }); - } else { - console.error( - red( - "Missing languages. Run: vitnode i18n:update:ai [--model ]", - ), - ); - process.exit(1); } + selectedLocales = [...new Set(argLocales)]; + } else if (ui.interactive) { + selectedLocales = await prompter.multiSelect( + "Which languages should be translated from English?", + locales.map(code => ({ + checked: true, + name: `${localeNames.get(code) ?? code} ${dim(`(${code})`)}`, + value: code, + })), + ); + } else { + throw new UserError("Missing languages.", { + hint: "Run: vitnode i18n update-ai [--model ]", + }); + } - // 2. Which model to translate with - a single-select defaulting to the - // first entry, the default model. - if (argModelId !== undefined) { - const found = models.find(entry => entry.id === argModelId); - if (!found) { - console.error( - red( - `AI model "${argModelId}" is not defined in vitnode.api.config.ts.`, - ), - ); - process.exit(1); - } - selectedModel = found; - } else if (isInteractive) { - const modelId = await select({ - choices: models.map(entry => ({ - name: `${entry.name} ${dim(toModelId(entry.model))}`, - value: entry.id, - })), - default: models[0].id, - message: - "Which AI model should translate? (from vitnode.api.config.ts)", - }); - // `select` only ever returns an id we passed in, so this always resolves. - selectedModel = models.find(entry => entry.id === modelId) ?? models[0]; - } else { - // Non-interactive with no `--model`: fall back to the default (first). - selectedModel = models[0]; - } - } catch (error) { - // Ctrl+C / Esc out of a prompt: exit quietly rather than dump a stack trace. - if (error instanceof Error && error.name === "ExitPromptError") { - console.log(dim("\nCancelled.")); - process.exit(0); + // 2. Which model to translate with - the first entry is the default. + let selectedModel: AIModelDefinition; + if (requestedModel !== undefined) { + const found = models.find(entry => entry.id === requestedModel); + if (!found) { + throw new UserError( + `AI model "${requestedModel}" is not defined in vitnode.api.config.ts.`, + { + hint: `Defined models: ${models.map(entry => entry.id).join(", ")}.`, + }, + ); } - throw error; + selectedModel = found; + } else if (ui.interactive) { + const modelId = await prompter.select( + "Which AI model should translate? (from vitnode.api.config.ts)", + models.map(entry => ({ + name: `${entry.name} ${dim(toModelId(entry.model))}`, + value: entry.id, + })), + { default: models[0].id }, + ); + // `select` only ever returns an id we passed in, so this always resolves. + selectedModel = models.find(entry => entry.id === modelId) ?? models[0]; + } else { + selectedModel = models[0]; } if (selectedLocales.length === 0) { - console.log(`${prefix} No languages selected.`); - process.exit(0); + ui.note("No languages selected."); + ui.line(); + + return EXIT_CODE.ok; } - console.log( - `\n${prefix} Translating with ${green(selectedModel.name)} ${dim(`(${toModelId(selectedModel.model)})`)} into: ${selectedLocales.map(code => cyan(code)).join(", ")}\n`, + ui.info( + `Translating with ${green(selectedModel.name)} ${dim(`(${toModelId(selectedModel.model)})`)} into: ${selectedLocales.map(code => cyan(code)).join(", ")}`, ); + ui.line(); // The English tree per package is the same for every locale, so cache it. const englishCache = new Map>(); @@ -470,7 +458,7 @@ export const i18nUpdateAi = async () => { // No source of truth (package ships nothing for this scope, or is not // installed). Translating nothing would be misleading, so skip it. if (Object.keys(english).length === 0) { - console.log( + ui.line( dim(` skipped ${location} - no "${defaultLocale}" source strings`), ); continue; @@ -503,7 +491,7 @@ export const i18nUpdateAi = async () => { for (const batch of chunk(sources, BATCH_SIZE)) { tasks.push({ languageName, locale, sources: batch }); } - console.log( + ui.line( ` ${cyan(locale)} ${leaves.length} string(s)${leaves.length === sources.length ? "" : dim(` (${sources.length} unique)`)}`, ); } @@ -517,12 +505,12 @@ export const i18nUpdateAi = async () => { (sum, task) => sum + task.sources.length, 0, ); - console.log( - dim( - ` ${totalSources} unique string(s) in ${tasks.length} batch(es), up to ${Math.min(concurrency, tasks.length)} in parallel\n`, - ), + ui.note( + `${totalSources} unique string(s) in ${tasks.length} batch(es), up to ${Math.min(concurrency, tasks.length)} in parallel`, ); + ui.line(); + const progress = ui.task(`Translating ${tasks.length} batch(es)`); let done = 0; const results = await mapPool(tasks, concurrency, async task => { let translations = new Map(); @@ -540,13 +528,11 @@ export const i18nUpdateAi = async () => { error = batchError; } done += 1; - process.stdout.write( - `\r ${dim(`translated ${done}/${tasks.length} batch(es)`)}`, - ); return { error, locale: task.locale, translations }; }); - process.stdout.write("\n\n"); + progress.succeed(`Translated ${done}/${tasks.length} batch(es)`); + ui.line(); for (const result of results) { const map = @@ -576,44 +562,45 @@ export const i18nUpdateAi = async () => { writeFileSync(job.file.path, `${JSON.stringify(updated, null, 2)}\n`); filesChanged += 1; translatedTotal += entries.length; - console.log( + ui.line( entries.length === 0 ? `${green(` synced ${job.location}`)} ${dim("(structure only)")}` : `${green(` updated ${job.location}`)} ${dim(`→ ${job.locale} +${entries.length}`)}`, ); } else { - console.log(dim(` ok ${job.location}`)); + ui.line(dim(` ok ${job.location}`)); } notTranslated += job.untranslated.length - entries.length; } // 5. Surface any batches that never succeeded - their strings stay in English // for a re-run - rather than failing the whole command. - if (failures.length > 0) { - const first = failures[0]; - console.log( - `\n${prefix} ${yellow(`${failures.length} batch(es) failed`)} after ${MAX_ATTEMPTS} attempts - ${notTranslated} string(s) left in English.`, - ); - console.log( - red(` ${first instanceof Error ? first.message : String(first)}`), - ); - console.log( - dim( - " Check your AI provider credentials (e.g. AI_GATEWAY_API_KEY) or rate limits, then re-run to fill the rest.", - ), + ui.line(); + + if (filesChanged > 0) { + ui.success( + `${filesChanged} file(s) updated, ${translatedTotal} string(s) translated.`, ); } - if (filesChanged === 0 && failures.length === 0) { - console.log(green("\n Everything is already translated.")); - process.exit(0); - } + // Failed batches leave their strings in English for a re-run - the files + // that did translate are already written - but the command still fails, so + // a script notices. + if (failures.length > 0) { + const first = failures[0]; - if (filesChanged > 0) { - console.log( - `\n${prefix} ${green(`${filesChanged} file(s) updated`)}, ${yellow(String(translatedTotal))} string(s) translated.`, + throw new RuntimeError( + `${failures.length} batch(es) failed after ${MAX_ATTEMPTS} attempts - ${notTranslated} string(s) left in English.`, + { + cause: first, + details: [first instanceof Error ? first.message : String(first)], + hint: "Check your AI provider credentials (e.g. AI_GATEWAY_API_KEY) or rate limits, then re-run to fill the rest.", + }, ); } - process.exit(failures.length > 0 ? 1 : 0); + if (filesChanged === 0) ui.success("Everything is already translated."); + ui.line(); + + return EXIT_CODE.ok; }; diff --git a/packages/vitnode/scripts/i18n-update.ts b/packages/vitnode/scripts/i18n-update.ts index 2d52999ba..6b18fef5e 100644 --- a/packages/vitnode/scripts/i18n-update.ts +++ b/packages/vitnode/scripts/i18n-update.ts @@ -1,19 +1,17 @@ -/* eslint-disable no-console */ import { writeFileSync } from "node:fs"; import { relative } from "node:path"; +import type { Ui } from "./cli/ui/ui.js"; + +import { EXIT_CODE } from "./cli/errors.js"; import { getConfig } from "./get-config.js"; import { appScope, - dim, effectiveDefaultTree, flattenKeys, - green, listAppLocaleFiles, - prefix, + noConfigError, readJsonTree, - red, - yellow, } from "./i18n-shared.js"; import { findRepoRoot } from "./shared/file-utils.js"; @@ -47,17 +45,33 @@ export const reconcileTree = ( return merge(english, current) as Record; }; -export const i18nUpdate = async () => { - const appDir = process.cwd(); - - const webConfig = await getConfig({ optional: true }); - const apiConfig = await getConfig({ optional: true, type: "api.config" }); +/** + * `vitnode i18n update` - brings every translation file in line with the + * default locale: new keys are seeded in the default language, removed keys + * are dropped, existing translations are kept. + */ +export const i18nUpdate = async ({ + cwd, + ui, +}: { + cwd: string; + ui: Ui; +}): Promise => { + const { colors } = ui; + const green = colors.success; + const red = colors.error; + const dim = colors.muted; + const appDir = cwd; + + const webConfig = await getConfig({ baseDir: cwd, optional: true }); + const apiConfig = await getConfig({ + baseDir: cwd, + optional: true, + type: "api.config", + }); const config = webConfig ?? apiConfig; - if (!config) { - console.error(red("No vitnode.config.ts or vitnode.api.config.ts found.")); - process.exit(1); - } + if (!config) throw noConfigError(); const scope = appScope({ api: apiConfig !== null, web: webConfig !== null }); const defaultLocale = config.i18n?.defaultLocale ?? "en"; @@ -77,8 +91,10 @@ export const i18nUpdate = async () => { ); if (appFiles.length === 0) { - console.log(`${prefix} No translation files to update.`); - process.exit(0); + ui.note("No translation files to update."); + ui.line(); + + return EXIT_CODE.ok; } // The English tree per package is the same for every locale, so cache it. @@ -98,9 +114,10 @@ export const i18nUpdate = async () => { return tree; }; - console.log( - `${prefix} Syncing ${appFiles.length} translation file(s) against ${dim(defaultLocale)}.`, + ui.note( + `Syncing ${appFiles.length} translation file(s) against ${defaultLocale}.`, ); + ui.line(); let addedTotal = 0; let removedTotal = 0; @@ -113,7 +130,7 @@ export const i18nUpdate = async () => { // No source of truth (package ships nothing for this scope, or is not // installed). Reconciling would empty the file, so leave it as it is. if (Object.keys(english).length === 0) { - console.log( + ui.line( dim(` skipped ${location} - no "${defaultLocale}" source strings`), ); continue; @@ -126,7 +143,7 @@ export const i18nUpdate = async () => { const removed = [...currentKeys].filter(key => !englishKeys.has(key)); if (added.length === 0 && removed.length === 0) { - console.log(dim(` ok ${location}`)); + ui.line(dim(` ok ${location}`)); continue; } @@ -138,30 +155,39 @@ export const i18nUpdate = async () => { addedTotal += added.length; removedTotal += removed.length; - console.log( + ui.line( `${green(` updated ${location}`)} ${dim(`+${added.length} -${removed.length}`)}`, ); for (const key of added.slice(0, MAX_LISTED_KEYS)) { - console.log(green(` + ${key}`)); + ui.line(green(` + ${key}`)); } for (const key of removed.slice(0, MAX_LISTED_KEYS - added.length)) { - console.log(red(` - ${key}`)); + ui.line(red(` - ${key}`)); } const shown = Math.min(added.length, MAX_LISTED_KEYS) + removed.length; if (added.length + removed.length > shown) { - console.log( + ui.line( dim(` ... and ${added.length + removed.length - shown} more`), ); } } + ui.line(); + if (changed === 0) { - console.log(green(" Every translation is already in sync.")); - process.exit(0); + ui.success("Every translation is already in sync."); + ui.line(); + + return EXIT_CODE.ok; } - console.log( - `\n${prefix} ${green(`${changed} file(s) updated`)}: ${yellow(`+${addedTotal}`)} added, ${yellow(`-${removedTotal}`)} removed. Translate the added keys, then run vitnode i18n:check.`, + ui.success( + `${changed} file(s) updated: +${addedTotal} added, -${removedTotal} removed.`, ); - process.exit(0); + ui.note( + `Translate the added keys, then run ${colors.command("vitnode i18n check")}.`, + ); + ui.line(); + + return EXIT_CODE.ok; }; diff --git a/packages/vitnode/scripts/prepare-database.ts b/packages/vitnode/scripts/prepare-database.ts index b953ae553..74d02ee16 100644 --- a/packages/vitnode/scripts/prepare-database.ts +++ b/packages/vitnode/scripts/prepare-database.ts @@ -2,8 +2,7 @@ import { count, sql } from "drizzle-orm"; import { migrate } from "drizzle-orm/postgres-js/migrator"; import { createJiti } from "jiti"; -import { existsSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { join, relative, resolve } from "node:path"; import postgres from "postgres"; import { core_admin_permissions } from "@/database/admins.js"; @@ -18,6 +17,7 @@ import type { VitNodeApiConfig } from "../src/vitnode.config.js"; import { RuntimeError } from "./cli/errors.js"; import { resolveBin } from "./cli/project/packages.js"; import { runProcess } from "./cli/project/processes.js"; +import { findDrizzleConfig } from "./cli/project/project.js"; import { getConfig } from "./get-config.js"; /** @@ -25,6 +25,24 @@ import { getConfig } from "./get-config.js"; * script in between - the app needs `drizzle-kit` installed, not a * `"drizzle-kit"` entry in its `package.json`. */ +/** + * Names the project's Drizzle config explicitly - `.mts`, `.js` or `.mjs` as + * much as `.ts` - so drizzle-kit and the CLI never read two different files. + */ +export const withConfigFlag = ( + args: readonly string[], + root: string, +): string[] => { + const config = findDrizzleConfig(root); + if (config === null || args.includes("--config") || args.length === 0) { + return [...args]; + } + + const [command, ...rest] = args; + + return [command, "--config", relative(root, config), ...rest]; +}; + export const runDrizzleKit = async ( args: readonly string[], { @@ -33,7 +51,7 @@ export const runDrizzleKit = async ( }: { capture?: boolean; root?: string } = {}, ) => runProcess({ - args: [resolveBin(root, "drizzle-kit"), ...args], + args: [resolveBin(root, "drizzle-kit"), ...withConfigFlag(args, root)], capture, command: process.execPath, cwd: root, @@ -73,7 +91,7 @@ export interface DrizzleProjectConfig { export const readDrizzleConfig = async ( root: string = process.cwd(), ): Promise => { - const configPath = join(root, "drizzle.config.ts"); + const configPath = findDrizzleConfig(root); const config: DrizzleProjectConfig = { dialect: null, migrationsFolder: join(root, "migrations"), @@ -81,7 +99,7 @@ export const readDrizzleConfig = async ( migrationsTable: "__drizzle_migrations", }; - if (!existsSync(configPath)) return config; + if (configPath === null) return config; try { const jiti = createJiti(import.meta.url, { interopDefault: true }); diff --git a/packages/vitnode/scripts/scripts.ts b/packages/vitnode/scripts/scripts.ts index eae79640e..da55302ac 100644 --- a/packages/vitnode/scripts/scripts.ts +++ b/packages/vitnode/scripts/scripts.ts @@ -29,9 +29,17 @@ const code = await runCli(process.argv.slice(2), { version: readCliVersion(), }); +/** Resolves once everything written to `stream` so far has been flushed. */ +const drain = async (stream: NodeJS.WriteStream) => + new Promise(resolve => { + stream.write("", () => { + resolve(); + }); + }); + // Exit explicitly - a database client or a file watcher left open by a -// command must not keep the process alive - but only once stdout has drained, -// so piped output is never cut short. -process.stdout.write("", () => { - process.exit(code); -}); +// command must not keep the process alive - but only once both streams have +// drained: errors and captured compiler output go to stderr, and a pipe or a +// CI log must never lose the end of them. +await Promise.all([drain(process.stdout), drain(process.stderr)]); +process.exit(code); From cb839e66d8ee9ab891cfd6f8b810361b11bdc89d Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Tue, 6 Oct 2026 15:53:27 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(api):=20=E2=9C=A8=20Add=20hostname=20c?= =?UTF-8?q?onfiguration=20from=20environment=20variables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/index.ts | 1 + apps/web/content/docs/dev/cli/build.mdx | 175 +++++------------ apps/web/content/docs/dev/cli/bundle-size.mdx | 118 ++++++++++++ apps/web/content/docs/dev/cli/ci.mdx | 57 +++--- apps/web/content/docs/dev/cli/database.mdx | 63 +++--- apps/web/content/docs/dev/cli/dev.mdx | 119 ++++++++++++ apps/web/content/docs/dev/cli/index.mdx | 181 ++++-------------- apps/web/content/docs/dev/cli/meta.json | 13 +- apps/web/content/docs/dev/cli/output.mdx | 61 ++++++ apps/web/content/docs/dev/cli/plugins.mdx | 84 ++++---- .../copy-of-vitnode-app/api-bun/src/index.ts | 1 + .../copy-of-vitnode-app/api/src/index.ts | 1 + .../vitnode/scripts/cli/commands/db.test.ts | 18 ++ packages/vitnode/scripts/cli/commands/db.ts | 17 +- .../vitnode/scripts/cli/commands/dev.test.ts | 16 +- packages/vitnode/scripts/cli/commands/dev.ts | 17 +- .../scripts/cli/commands/plugin-list.ts | 2 +- .../scripts/cli/commands/plugin-validate.ts | 2 +- .../scripts/cli/commands/start.test.ts | 21 ++ .../vitnode/scripts/cli/commands/start.ts | 21 +- packages/vitnode/scripts/cli/db/database.ts | 2 +- .../scripts/database-bootstrap.test.ts | 2 +- packages/vitnode/scripts/prepare-database.ts | 26 ++- 23 files changed, 621 insertions(+), 397 deletions(-) create mode 100644 apps/web/content/docs/dev/cli/bundle-size.mdx create mode 100644 apps/web/content/docs/dev/cli/dev.mdx create mode 100644 apps/web/content/docs/dev/cli/output.mdx diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c1b723b38..ea7fdbf3c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -67,6 +67,7 @@ app.get( const server = serve( { fetch: app.fetch, + hostname: process.env.HOST, port: Number(process.env.PORT ?? 8000), websocket: { server: wss, diff --git a/apps/web/content/docs/dev/cli/build.mdx b/apps/web/content/docs/dev/cli/build.mdx index 4c88ec482..a1a11adfc 100644 --- a/apps/web/content/docs/dev/cli/build.mdx +++ b/apps/web/content/docs/dev/cli/build.mdx @@ -1,16 +1,19 @@ --- -title: Build for production -description: Build a VitNode app with vitnode build and read its bundle sizes, size warnings, bundle analysis and changes since the previous build. +title: Build and start for production +description: Build a VitNode app, API app or plugin with vitnode build, then run the production server with vitnode start. icon: Package --- -`vitnode build` runs your app's real Vite production build, then reports every file it wrote with raw and gzip sizes, warns about oversized client bundles, and compares the result with your previous build. +`vitnode build` runs your project's real production build and reports the size of every file it wrote. `vitnode start` then runs the server that build produced, and tells you once it accepts connections. ```bash vitnode build +vitnode start ``` -```txt +## Build an app + +```txt title="vitnode build (shortened)" ◆ VitNode Production build @@ -28,160 +31,86 @@ Client JS .output/public ▲ assets/custom-emoji-BPTfd_f5.js 820.6 kB 163.2 kB ! assets/editor-CUh_DSIT.js 319.4 kB 101.0 kB ✓ assets/search-DxtCMbN7.js 223.9 kB 69.1 kB - ✓ assets/index-B6Na7-xY.js 82.4 kB 26.9 kB … 4721 more not shown (--verbose lists all) - 4731 files, 18.1 MB, gzip 4.5 MB Server .output/server - File Size - ────────────────────────── - ✓ index.mjs 1.2 MB + File Size + ────────────────── + ✓ index.mjs 1.2 MB ✓ Built in 1m 6s + Run vitnode start to serve it. ``` -The steps are the build environments your app really has - for a TanStack Start app with Nitro: client, server, then Nitro's server output. Plugin routes and registries are generated by VitNode's Vite plugin while the configuration loads. - -In a plugin package, `vitnode build` compiles the package into `dist` instead. In an API app, it runs `tsc` and `tsc-alias`. - -## Read the sizes - -| Column | Meaning | -| ------ | ------------------------------------------------------------------- | -| Size | The file on disk, after minification | -| gzip | What a browser downloads with gzip - client JavaScript and CSS only | -| brotli | Brotli at quality 11, with `--analyze`, for the largest files | +The steps are the build environments your app really has. For a TanStack Start app with Nitro, that is the client, the server, then Nitro's packaged server. VitNode's Vite plugin generates plugin routes and registries while the configuration loads. -Sizes use decimal units (`1 kB` = 1000 bytes), like Vite and browser dev tools. Server files are never compressed - nobody downloads them. +The symbols mark how a file compares with its size budget: `✓` is fine, `!` is worth a look and `▲` is large. From the second build on, you also see what grew or shrank since the last one. [Bundle size report](/docs/dev/cli/bundle-size) explains the thresholds, the comparison and `--analyze`. -## Size colors and thresholds +## Build an API app or a plugin -Each file gets a symbol and a color. Client JavaScript is held to the strictest budget, because every visitor downloads, parses and runs it. A server bundle is read once at boot, so its thresholds are much looser. +The same command builds other project types: -| File type | `✓` good | `✓` normal | `!` warning | `▲` large | -| --------- | -------- | ---------- | ----------- | --------- | -| Client JS | ≤ 100 kB | ≤ 250 kB | ≤ 500 kB | > 500 kB | -| CSS | ≤ 50 kB | ≤ 100 kB | ≤ 250 kB | > 250 kB | -| Assets | ≤ 100 kB | ≤ 500 kB | ≤ 1 MB | > 1 MB | -| Server | ≤ 1 MB | ≤ 5 MB | ≤ 20 MB | > 20 MB | +| Project | What `vitnode build` does | +| -------------- | --------------------------------------------------------------------------- | +| API app (Node) | Compiles with `tsc` and `tsc-alias` into `dist`, then lists the output | +| API app (Bun) | Nothing: Bun runs `src/index.ts` directly | +| Plugin package | Emits type declarations, compiles with `swc` and rewrites aliases in `dist` | -The `large` line for client JavaScript is Vite's own 500 kB chunk warning. - -## Build warnings - -Oversized client files get a short, grouped warning with what to try: +## When a build fails ```txt -Warnings -▲ admin-D2kL8aQ1.js is larger than the recommended 500.0 kB for a client chunk. - assets/admin-D2kL8aQ1.js 612.4 kB - Consider: - • @tiptap/core makes up 41.2% of admin-D2kL8aQ1.js - import it with import() where it is needed - • load heavy libraries with dynamic import() where they are used - • give pages their own chunk: component: lazy(() => import(...)) in routes.ts - • lazy-load heavy UI such as editors and dialogs with React.lazy + Suspense -``` - -Client chunks warn from 250 kB; CSS and assets only when they are large. Server files never warn. - - - Size warnings are recommendations. A build only fails when the build itself - fails. - - -## Compare with the previous build - -From the second build on, `vitnode build` shows what changed: +✖ Build failed + Plugin @vitnode/blog + File plugins/blog/src/pages/post.tsx:42:9 + Step vite:oxc -```txt -Bundle changes - File Size Change - ──────────────────────────────────────────────────── - assets/editor-H2kq9ZZ1.js 428.6 kB +86.1 kB +25.1% ▲ - assets/admin-Dk2L8aQ1.js 612.4 kB -12.8 kB -2.0% - assets/new-page-Ab12cdEf.js 4.1 kB +4.1 kB new - Client JS total 18.1 MB (+77.4 kB) + Missing export: title ``` -`▲` marks a file that grew by more than 10% and more than 10 kB. Changes under 1 kB are left out. +`Plugin` appears when the failing file belongs to a VitNode plugin package, and `File` and `Step` when the bundler reported them. Below them you get the bundler's message and a code frame, then the last 60 lines of build output. Run with `--verbose` for the full output and stack trace. -Files are matched by what they contain, not by name, so a new content hash does not break the comparison: - -- an entry chunk is matched by the module it is built from (`src/pages/editor.tsx`) -- a shared chunk by its name and the module that makes up most of it -- an asset by its source file - -The previous sizes live in `node_modules/.cache/vitnode/build-snapshot.json`. Nothing is written to your repository. A missing or unreadable snapshot only skips the comparison - it never fails the build. - - - A shared chunk with no entry module is matched by its name and largest module. - If a refactor changes which module dominates it, it shows up as one file - removed and one added. - - -## Analyze a bundle +## Start the production server ```bash -vitnode build --analyze +vitnode start ``` ```txt -Bundle analysis - - assets/custom-emoji-BPTfd_f5.js 820.6 kB - @tiptap/extension-emoji ≈ 422.3 kB 51.5% - @tiptap/core ≈ 120.7 kB 14.7% - prosemirror-view ≈ 117.9 kB 14.4% - application code ≈ 50.8 kB 6.2% - other ≈ 57.9 kB 7.1% -``` - -`--analyze` breaks the five largest client chunks down by package, and names a dominating package in the warnings. It uses the module sizes the bundler records for every chunk. Those are measured before minification, so the percentages are exact and the sizes (`≈`) are that share of the file on disk. Workspace packages show by name; your app's own files show as `application code`. - -The analysis is printed in the terminal - nothing opens in a browser. - -## Output modes - -| Command | Use it for | -| ------------------------- | ------------------------------------------------------------ | -| `vitnode build` | Your terminal: symbols, colors, spinners when interactive | -| `vitnode build --plain` | Logs and CI: `[OK]`/`[WARN]` lines, no color, no animation | -| `vitnode build --verbose` | Debugging: Vite's own log, every file, and full stack traces | +◆ VitNode + Production -```txt title="vitnode build --plain" -VitNode - Production build -[OK] Building client (21.7s) -[OK] Building server (12.8s) -[WARN] custom-emoji-BPTfd_f5.js is larger than the recommended 500.0 kB for a client chunk. -[OK] Built in 1m 6s + ● Running http://localhost:3000 ``` -Without `--verbose`, messages that Vite and its plugins print are held back, so they do not break the progress lines. The CLI counts them for you, and prints them if the build fails. +`vitnode start` is not a second server. It runs your build's own entry, exactly as `node ` would, and adds two things: "Running" appears only once the server accepts connections, and Ctrl+C gives the server time to close cleanly. -## When a build fails +| Project | Entry it runs | Default port | +| -------------- | ----------------------------------------------------------------- | ------------ | +| App | Nitro's server entry, usually `.output/server/index.mjs`, on Node | `3000` | +| API app (Node) | `dist/index.js` on Node | `8000` | +| API app (Bun) | `src/index.ts` on Bun | `8000` | -```txt -✖ Build failed - Plugin @vitnode/blog - File plugins/blog/src/pages/post.tsx:42:9 - Step vite:oxc +`--port` and `--host` set the `PORT` and `HOST` variables your server already reads. The port falls back to `PORT`, then the default above. `NODE_ENV` is set to `production` unless you set it yourself. - Missing export: title -``` +`vitnode start` stops with an error when: -`Plugin` appears when the failing file belongs to a VitNode plugin, `File` and `Step` when the bundler reported them. The original message is always shown as-is. Run with `--verbose` for the full stack trace. +- there is no build yet: run `vitnode build` first +- the app was built for a hosting platform, such as the `vercel` Nitro preset, because that platform runs it +- something already listens on the port +- the runtime is missing, for example Bun on a server that only has Node +- the server exits before it is ready, or does not listen within 60 seconds -## Learn more +## Next steps - diff --git a/apps/web/content/docs/dev/cli/bundle-size.mdx b/apps/web/content/docs/dev/cli/bundle-size.mdx new file mode 100644 index 000000000..3e16976f9 --- /dev/null +++ b/apps/web/content/docs/dev/cli/bundle-size.mdx @@ -0,0 +1,118 @@ +--- +title: Bundle size report +description: Read the bundle size report from vitnode build, including size thresholds, warnings, the comparison with your previous build and --analyze. +icon: Gauge +--- + +After an app build, `vitnode build` reports every file it wrote, warns about oversized client bundles and compares the result with your previous build. This page explains each part of that report. To run a build, see [Build and start for production](/docs/dev/cli/build). + +The report prints in this order: **Output**, **Bundle changes**, **Bundle analysis** (with `--analyze`) and **Warnings**. + +## Output + +Files are grouped as client JavaScript, CSS, assets, server files and other files. Each group shows its largest files: 10 for client JavaScript, 5 for the rest. Run with `--verbose` to list every file. + +| Column | Meaning | +| ------ | --------------------------------------------------------------------------- | +| Size | The file on disk, after minification | +| gzip | What a browser downloads with gzip, for client JavaScript and CSS only | +| brotli | Brotli at quality 11, with `--analyze`, for the 25 largest JS and CSS files | + +Sizes use decimal units (`1 kB` = 1000 bytes), like Vite and browser dev tools. Server files are never compressed, because nobody downloads them. + +## Size thresholds + +Every file gets a symbol from its type and size. Client JavaScript has the strictest budget, because every visitor downloads, parses and runs it. A server bundle is read once at boot, so its limits are much looser. + +| File type | `✓` good | `✓` normal | `!` warning | `▲` large | +| ------------- | -------- | ---------- | ----------- | --------- | +| Client JS | ≤ 100 kB | ≤ 250 kB | ≤ 500 kB | > 500 kB | +| CSS | ≤ 50 kB | ≤ 100 kB | ≤ 250 kB | > 250 kB | +| Assets, other | ≤ 100 kB | ≤ 500 kB | ≤ 1 MB | > 1 MB | +| Server | ≤ 1 MB | ≤ 5 MB | ≤ 20 MB | > 20 MB | + +The 500 kB line for client JavaScript matches Vite's own chunk size warning. + +## Warnings + +Client chunks get a warning from 250 kB. CSS and assets get one only when they are large. Server files never do. Each warning says what to try: + +```txt +Warnings +▲ admin-D2kL8aQ1.js is larger than the recommended 500.0 kB for a client chunk. + assets/admin-D2kL8aQ1.js 612.4 kB + Consider: + • @tiptap/core makes up 41.2% of admin-D2kL8aQ1.js - import it with import() where it is needed + • load heavy libraries with dynamic import() where they are used + • give pages their own chunk: component: lazy(() => import(...)) in routes.ts + • lazy-load heavy UI such as editors and dialogs with React.lazy + Suspense +``` + + + Size warnings are recommendations. A build fails only when the build itself + fails. + + +## Bundle changes + +From the second build on, the report shows how client JavaScript, CSS and assets changed: + +```txt +Bundle changes + File Size Change + ────────────────────────────────────────────────────── + assets/editor-H2kq9ZZ1.js 428.6 kB +86.1 kB +25.1% ▲ + assets/admin-Dk2L8aQ1.js 612.4 kB -12.8 kB -2.0% + assets/new-page-Ab12cdEf.js 4.1 kB +4.1 kB new + Client JS total 18.1 MB (+77.4 kB) +``` + +- `▲` marks a file that grew by more than 10% and by more than 10 kB. +- Files that changed by less than 1 kB are left out. New and removed files are always listed. +- The 10 biggest changes are shown; `--verbose` shows all of them. + +Files are matched by what they contain, not by name, so a new content hash does not break the comparison: + +- an entry chunk is matched by the module it is built from, such as `src/pages/editor.tsx` +- a shared chunk is matched by its name and the module that makes up most of it +- an asset is matched by its source file + +A shared chunk with no entry module can show up as one file removed and one added when a refactor changes which module makes up most of it. + +The previous sizes are stored in `node_modules/.cache/vitnode/build-snapshot.json`, never in your repository. A missing or unreadable snapshot only skips the comparison. + +## Analyze a bundle + +```bash +vitnode build --analyze +``` + +```txt +Bundle analysis + + assets/custom-emoji-BPTfd_f5.js 820.6 kB + @tiptap/extension-emoji ≈ 422.3 kB 51.5% + @tiptap/core ≈ 120.7 kB 14.7% + prosemirror-view ≈ 117.9 kB 14.4% + application code ≈ 50.8 kB 6.2% + other ≈ 57.9 kB 7.1% +``` + +`--analyze` breaks the five largest client chunks down by package, showing up to six contributors each. It uses the module sizes the bundler records, measured before minification. The percentages are exact, and each size (`≈`) is that share of the file on disk. Workspace packages show by name, and your app's own files show as `application code`. + +A package that makes up at least 30% of a chunk is also named in that chunk's warning. The analysis prints in the terminal; nothing opens in a browser. + +## Next steps + + + + + diff --git a/apps/web/content/docs/dev/cli/ci.mdx b/apps/web/content/docs/dev/cli/ci.mdx index 6b84bfa28..d88504b9b 100644 --- a/apps/web/content/docs/dev/cli/ci.mdx +++ b/apps/web/content/docs/dev/cli/ci.mdx @@ -1,16 +1,18 @@ --- title: Use the CLI in CI -description: Validate plugins, build with readable logs and apply migrations from GitHub Actions, Docker and other CI pipelines with the VitNode CLI. +description: Validate plugins, build with readable logs and apply migrations with the VitNode CLI in GitHub Actions, Docker and other pipelines. icon: Workflow --- +The VitNode CLI runs in pipelines without changes: it never waits for input there, and every command exits with a non-zero code when it fails, so a pipeline stops at the first problem. + ```bash -pnpm vitnode plugin validate -pnpm vitnode build --plain -pnpm vitnode db migrate --yes +vitnode plugin validate +vitnode build --plain +vitnode db migrate --yes ``` -Run them in your app's folder. Every command exits with a non-zero code when it fails, so a pipeline stops at the first problem. +Run each command in your app's folder, so the CLI finds the app's config. ## GitHub Actions @@ -35,52 +37,49 @@ jobs: - run: pnpm --filter web exec vitnode build --plain ``` -Run `vitnode` in the app folder (`--filter web` above), so it finds the app's config. +`--filter web` runs `vitnode` in the `web` app's folder. Plugins are built first, because validation and the app build both read their `dist` output. + +To compare bundle sizes between runs, cache `node_modules/.cache/vitnode`. That is where `vitnode build` keeps the previous build's sizes. ## Migrate during deployment ```bash -POSTGRES_URL=postgresql://... pnpm vitnode db migrate --yes -pnpm vitnode start +POSTGRES_URL=postgresql://... vitnode db migrate --yes +vitnode start ``` -Run migrations once per deployment, before the new version starts. `--yes` is required: without an interactive terminal, the CLI never waits for an answer. +Run migrations once per deployment, before the new version starts. `--yes` is required, because the CLI never waits for an answer without an interactive terminal. If two instances start at once, the second waits for the first instead of racing it. -## Non-interactive behavior +## What changes in a pipeline -The CLI detects where it runs. Under CI (`CI`, `GITHUB_ACTIONS` and similar), with piped output, or when stdin is not a terminal: +Under CI, with piped output, or when stdin is not a terminal, the CLI: -- no spinners, animations or cursor movement -- no prompts - a command that needs confirmation exits with code `2` and names the flag to pass -- colors only when `FORCE_COLOR` is set; `NO_COLOR` always turns them off +- shows no spinners, animations or cursor movement +- never prompts: a command that needs a confirmation exits with code `2` and names the flag to pass, such as `--yes` -`--plain` goes further and gives stable, prefixed lines for log parsers: +`--plain` gives stable, prefixed lines that are easy to grep: -```txt +```txt title="vitnode build --plain (excerpt)" VitNode - Production build +[OK] Configuration loaded, plugin routes generated (1.5s) [OK] Building client (21.7s) [OK] Building server (12.8s) [WARN] admin-D2kL8aQ1.js is larger than the recommended 500.0 kB for a client chunk. -[OK] Built in 1m 6s ``` -Bundle size warnings never fail a build. The bundle comparison reads the previous sizes from `node_modules/.cache/vitnode` - cache that folder between runs to compare builds in CI. - -## Exit codes +Size warnings never fail a build. [Output and exit codes](/docs/dev/cli/output) lists every exit code and how colors are chosen. -| Code | Meaning | -| ---- | -------------------------------------------------------------------------- | -| `0` | Success | -| `1` | Failed: build error, invalid plugin, migration error, unreachable database | -| `2` | Wrong command line, or a required `--yes` was not passed | - -## Learn more +## Next steps - + diff --git a/apps/web/content/docs/dev/cli/database.mdx b/apps/web/content/docs/dev/cli/database.mdx index 61e413123..fd2e377ad 100644 --- a/apps/web/content/docs/dev/cli/database.mdx +++ b/apps/web/content/docs/dev/cli/database.mdx @@ -1,28 +1,28 @@ --- title: Database commands -description: Generate and apply database migrations, check migration status, and push schema changes in development with the VitNode CLI. +description: Generate and apply database migrations, check migration status and push schema changes in development with vitnode db. icon: Database --- +The `vitnode db` commands manage your database schema through migrations: files that describe each change, which you commit so every database gets the same changes in the same order. + ```bash vitnode db generate # write a migration for your schema changes -vitnode db migrate # apply pending migrations +vitnode db migrate # apply pending migrations and seed initial data vitnode db status # check the connection and what is pending -vitnode db push # sync the schema directly - development only +vitnode db push # sync the schema directly, development only ``` -Run them in the app that owns the database - the one with `drizzle.config.ts`. They use your app's own Drizzle configuration and connection. +Run them in the app that owns the database, the one with `drizzle.config.ts`. They use your app's own Drizzle configuration, connection and migration table. ## Generate, migrate or push? -| Command | Changes | Use it | +| Command | What it changes | Use it | | ------------- | ----------------------------- | ---------------------------------------- | | `db generate` | Writes a migration file | After changing tables, before committing | | `db migrate` | Applies migration files | Everywhere: development, CI, production | | `db push` | Changes the database directly | Quick experiments in development | -Migrations are files you commit, so every database gets the same changes in the same order. `db push` skips the files - useful while you experiment, never for a shared or production database. - ## Generate a migration ```bash @@ -44,12 +44,15 @@ Schema changes detected ✓ Generating migration 3.1s ✓ 20261005233216_notifications + Apply it with vitnode db migrate. ``` -The changes come from drizzle-kit's own plan, so they are exactly what the migration contains. If drizzle-kit has to ask whether a column was renamed, the command hands the terminal over to it. In a non-interactive terminal it stops instead of guessing. +The list comes from drizzle-kit's own plan, so it is exactly what the migration contains. With no changes, the command prints `No schema changes - nothing to generate.` and exits with `0`. + +When drizzle-kit cannot tell whether a column was renamed or replaced, the command lists those changes and hands the terminal to drizzle-kit so you can answer. Without an interactive terminal it stops with exit code `2` instead of guessing. - Migrations are generated from the plugins' build output. After changing a + Migrations are generated from each plugin's build output. After changing a plugin's tables, run `vitnode build` in the plugin, then `vitnode db generate`. @@ -78,7 +81,11 @@ Pending migrations ✓ Database is up to date. ``` -`db migrate` applies migrations in one transaction, then seeds the data every VitNode installation needs (languages, roles, permissions). A lock makes two processes wait for each other instead of racing. If a migration fails, nothing is applied and the error shows Postgres' own details. +`db migrate` applies all pending migrations in one transaction, so a failed migration leaves nothing half-applied, and the error shows Postgres' own details. It then seeds the data every VitNode installation needs, such as languages, roles and permissions. + +The seed also runs when no migration is pending. That way a language you add to your config gets its row without a new migration, and `db migrate` stays safe to run on every deployment. + +If another process is already preparing the same database, for example a second container starting at the same time, `db migrate` waits for it to finish instead of racing it. Without an interactive terminal, confirm with `--yes`: @@ -86,7 +93,7 @@ Without an interactive terminal, confirm with `--yes`: vitnode db migrate --yes ``` -Without `--yes`, a non-interactive run exits with code `2` instead of waiting. +Without `--yes`, a non-interactive run with pending migrations exits with code `2` instead of waiting for an answer. ## Check the status @@ -110,9 +117,9 @@ Pending ○ 20261005233216_notifications ``` -`db status` only reads. It connects with your app's configuration - the status is never guessed from the config alone - and exits with code `1` when the database is unreachable. It also warns about an applied migration whose file changed since it was applied. +`db status` only reads. It always connects, so the status is never guessed from the config alone. It warns about an applied migration whose file changed since it ran, or whose file is missing. When the database does not answer within 10 seconds, it exits with code `1`. -## Push the schema (development) +## Push the schema in development ```bash vitnode db push @@ -134,27 +141,29 @@ Data loss ◇ Apply 2 changes, including 1 that deletes data? No ``` -`db push` shows every change before applying it, and names each change that deletes data. Deleting data always needs a separate confirmation - from a script, pass `--accept-data-loss` together with `--yes`. +`db push` changes the database without writing a migration. It shows every change first and names each one that deletes data. The data-loss question defaults to No. + +From a script, pass `--yes` to apply and `--accept-data-loss` to allow changes that delete data. Without `--accept-data-loss`, a non-interactive run that would delete data exits with code `2`. - When `NODE_ENV` is `production`, `vitnode db push` stops before connecting. - Use `db generate` and `db migrate` instead. `--force` overrides the check, if - you really mean to push to that database. + When `NODE_ENV` is `production`, `vitnode db push` stops before connecting and + exits with code `2`. Use `db generate` and `db migrate` instead. Pass + `--force` only if you really mean to push to that database. ## Options -| Command | Option | Effect | -| ------------- | -------------------- | ----------------------------------------------- | -| `db generate` | `--name ` | Name the migration folder | -| `db migrate` | `-y, --yes` | Apply without asking | -| `db push` | `-y, --yes` | Apply without asking | -| `db push` | `--accept-data-loss` | Allow changes that delete data without a prompt | -| `db push` | `--force` | Allow pushing when `NODE_ENV` is `production` | +| Command | Option | Effect | +| ------------- | -------------------- | --------------------------------------------- | +| `db generate` | `--name ` | Name the migration folder | +| `db migrate` | `-y, --yes` | Apply without asking | +| `db push` | `-y, --yes` | Apply without asking | +| `db push` | `--accept-data-loss` | Allow changes that delete data | +| `db push` | `--force` | Allow pushing when `NODE_ENV` is `production` | -All of them also accept `--plain` and `--verbose`. +Every command also accepts `--plain` and `--verbose`. With `--verbose`, the change lists are not shortened and a failure shows the complete drizzle-kit output. -## Learn more +## Next steps diff --git a/apps/web/content/docs/dev/cli/dev.mdx b/apps/web/content/docs/dev/cli/dev.mdx new file mode 100644 index 000000000..de32298df --- /dev/null +++ b/apps/web/content/docs/dev/cli/dev.mdx @@ -0,0 +1,119 @@ +--- +title: Run in development +description: Start a VitNode app, API app or plugin package in development with vitnode dev, including database setup, ports, hosts and keyboard shortcuts. +icon: Play +--- + +`vitnode dev` starts whatever the current folder is: an app gets a Vite dev server, an API app gets a watching Node or Bun process, and a plugin package gets its compilers in watch mode. Run it from the project's folder. + +```bash +vitnode dev +``` + +## Start an app + +```txt +◆ VitNode + Development + + ✓ Loading configuration 31ms + ✓ Checking database schema 1.2s + ✓ Database connected up to date + ✓ 2 plugins loaded + ✓ Starting dev server 2.6s + + Web http://localhost:3000 + AdminCP http://localhost:3000/admin + API http://localhost:3000/api +──────────────────────────────────────── +GET / 200 180ms +GET /api/@vitnode/core/users/session 200 6ms +HMR src/routes/_main/index.tsx +``` + +Below the line, the CLI logs page loads, API calls, server functions and every non-GET request, plus each hot update (`HMR`). Requests for scripts, styles and images are left out so the log stays readable. + +The URLs are the ones Vite actually bound. The `API` row appears when the app also serves the API. + +### Choose the port and host + +The port is the first one set of: `--port`, the `PORT` variable, `server.port` in `vite.config.ts`, then `3000`. + +```bash +vitnode dev --port 4000 +vitnode dev --host # listen on every address +vitnode dev --host 127.0.0.1 # listen on one address +``` + +With `--host`, a `Network` row shows the address other devices on your network can open, which is handy for testing on a phone. + +### Keyboard shortcuts + +In an interactive terminal, type a key and press Enter: + +| Key | Action | +| --- | ------------- | +| `o` | Open the site | +| `a` | Open AdminCP | +| `r` | Restart | +| `q` | Quit | + +Ctrl+C closes the dev server and every watcher it started. + +## Database setup before the server starts + +When the app owns the database (it has both a `drizzle.config.ts` and `src/vitnode.api.config.ts`), `vitnode dev` prepares the database before anything serves a request: + +1. It compares your schema with the last migration and generates a migration when they differ. +2. It applies pending migrations. +3. It seeds the data every installation needs: languages, roles and permissions. + +The server starts only after this finishes, so your first page load never hits a half-built schema. When the terminal is not interactive and drizzle-kit needs to ask whether a column was renamed, the CLI skips generation with a warning. Run `vitnode db generate` yourself to answer it. See [Database commands](/docs/dev/cli/database). + +## Start an API app + +In a folder with `src/vitnode.api.config.ts` and no Vite config, `vitnode dev` runs your API with a file watcher: + +```txt + API http://localhost:8000/api + Runtime Node (tsx watch) +``` + +The port is `--port`, then `PORT`, then `8000`. The CLI passes it to your API as `PORT`. `--host` is passed as `HOST`, and a bare `--host` means `0.0.0.0`. Generated API apps read both variables. + +The API runs on Bun, with `bun --hot`, when any of these is true: + +- you started the script with Bun (`bun run dev`) +- the nearest `packageManager` field says `bun@...` +- the nearest lockfile is `bun.lock` or `bun.lockb` + +Otherwise it runs on Node with `tsx watch`. + +## Develop a plugin package + +In a plugin package, `vitnode dev` regenerates the plugin's API registry and runs `tsc`, `swc` and `tsc-alias` in watch mode. Apps that use the plugin import its `dist` folder, so they reload as you save. + +```txt +◆ VitNode + Plugin development - @acme/blog + + ✓ API registry generated + ● Watching sources - apps using this plugin reload as dist/ changes +``` + +If one compiler crashes, the CLI stops the others and exits with code `1`. + +## Next steps + + + + + diff --git a/apps/web/content/docs/dev/cli/index.mdx b/apps/web/content/docs/dev/cli/index.mdx index 0a3ade2c0..cb90075ec 100644 --- a/apps/web/content/docs/dev/cli/index.mdx +++ b/apps/web/content/docs/dev/cli/index.mdx @@ -1,13 +1,12 @@ --- title: VitNode CLI -description: The vitnode command runs, builds and starts your app, creates and validates plugins, and manages database migrations. +description: The vitnode command runs, builds and starts VitNode apps, validates plugins, manages database migrations and translations. icon: SquareTerminal --- import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -import { TypeTable } from 'fumadocs-ui/components/type-table' -`vitnode` is the command you run VitNode with. It ships with `@vitnode/core`, so every VitNode app and plugin already has it. +`vitnode` is the command-line tool for VitNode projects. It ships with `@vitnode/core`, so every VitNode app and plugin already has it. You do not install anything extra. @@ -25,167 +24,59 @@ npx vitnode dev -Generated projects call it from their `package.json` scripts, so `pnpm dev`, `pnpm build` and `pnpm start` already go through it. +Generated projects already call it from their `package.json` scripts, so `pnpm dev` and `pnpm start` run `vitnode dev` and `vitnode start` for you. ## Commands -| Command | What it does | -| ------------------------- | ------------------------------------------------ | -| `vitnode dev` | Start the development environment | -| `vitnode build` | Build for production and report bundle sizes | -| `vitnode start` | Start the production server | -| `vitnode plugin list` | List the plugins your app uses | -| `vitnode plugin validate` | Check plugins the way your app will load them | -| `vitnode db generate` | Generate a migration from schema changes | -| `vitnode db migrate` | Apply pending migrations | -| `vitnode db push` | Push the schema directly (development only) | -| `vitnode db status` | Show the connection and migration status | -| `vitnode i18n check` | Find missing, unknown and unloaded translations | -| `vitnode i18n create` | Add a language | -| `vitnode i18n delete` | Remove a language | -| `vitnode i18n update` | Sync translation files with the default language | -| `vitnode i18n update-ai` | Translate missing strings with an AI model | +| Command | What it does | Guide | +| ------------------------- | ---------------------------------------------- | --------------------------------------- | +| `vitnode dev` | Start the development environment | [Run in development](/docs/dev/cli/dev) | +| `vitnode build` | Build for production and report bundle sizes | [Build and start](/docs/dev/cli/build) | +| `vitnode start` | Start the production server | [Build and start](/docs/dev/cli/build) | +| `vitnode plugin list` | List the plugins your app uses | [Plugins](/docs/dev/cli/plugins) | +| `vitnode plugin validate` | Check plugins the way your app will load them | [Plugins](/docs/dev/cli/plugins) | +| `vitnode db generate` | Write a migration for schema changes | [Database](/docs/dev/cli/database) | +| `vitnode db migrate` | Apply pending migrations and seed initial data | [Database](/docs/dev/cli/database) | +| `vitnode db status` | Show the connection and what is pending | [Database](/docs/dev/cli/database) | +| `vitnode db push` | Sync the schema directly, in development only | [Database](/docs/dev/cli/database) | +| `vitnode i18n ` | Add, check, sync and translate languages | [Internationalization](/docs/dev/i18n) | -Create plugins with `create-vitnode-app --plugin` - see [Create a plugin](/docs/dev/plugins/create). +To create a plugin, use the project generator: `create-vitnode-app --plugin`. See [Create a plugin](/docs/dev/plugins/create). -Run `vitnode` alone for a short overview, or `vitnode --help` for a command's options. +Run `vitnode` alone (or `vitnode --help`) for a short overview, `vitnode --help` for a command's options, and `vitnode --version` for the installed `@vitnode/core` version. -## What the folder decides +## How the CLI picks your project -`dev`, `build` and `start` work on the project in the current folder: +`dev`, `build` and `start` act on the project in the current folder. The CLI walks up to the nearest `package.json` and checks, in this order: -| Folder | `vitnode dev` | `vitnode build` | `vitnode start` | -| ------------------------------------------------- | ------------------------------------ | --------------------------------------- | -------------------------- | -| App (`vite.config.ts`) | Database bootstrap, then Vite | Vite production build + size report | `.output/server/index.mjs` | -| API app on Node (`src/vitnode.api.config.ts`) | Database bootstrap, then `tsx watch` | `tsc` + `tsc-alias` | `dist/index.js` | -| API app on Bun | Database bootstrap, then `bun --hot` | Nothing to compile | `bun src/index.ts` | -| Plugin package (`tsconfig.build.json` + `.swcrc`) | Compilers in watch mode | Types, JavaScript and aliases to `dist` | - | +| The folder has | It is a | +| -------------------------------------- | -------------- | +| `vite.config.ts` (or `.mts`, `.js`...) | App | +| `src/vitnode.api.config.ts` | API app | +| `tsconfig.build.json` and `.swcrc` | Plugin package | -An API app runs on Bun when Bun runs the script (`bun run dev`), or when the project or its workspace uses Bun (`packageManager: "bun@..."` or a `bun.lock`). Otherwise it runs on Node. +Run it at a monorepo root and the CLI stops with a hint to `cd` into your app, such as `apps/web`, instead of guessing which project you meant. -The database bootstrap runs only in an app that owns the schema - one with a `drizzle.config.ts`. It generates a migration for schema changes, applies pending migrations and seeds initial data, just like `vitnode db:prepare`. +## Works with your tools, not instead of them -## Start development +The CLI drives the tools your project already uses: Vite through its JavaScript API, TanStack Start through your `vite.config.ts`, and your project's own `drizzle-kit`. You can still run `vite` or `drizzle-kit` directly. `vitnode` adds the database bootstrap, plugin checks and readable output around them. -```bash -vitnode dev -``` - -```txt -◆ VitNode - Development - - ✓ Loading configuration 31ms - ✓ Database connected up to date - ✓ 2 plugins loaded - ✓ Starting dev server 2.6s - - Web http://localhost:3000 - AdminCP http://localhost:3000/admin - API http://localhost:3000/api -──────────────────────────────────────── -GET / 200 180ms -GET /api/@vitnode/core/users/session 200 6ms -HMR src/routes/_main/index.tsx -``` - -The URLs are the ones Vite actually bound. The port is `--port`, then `PORT`, then `server.port` in `vite.config.ts`, then `3000`. - -In an interactive terminal, type a shortcut and press Enter: - -| Key | Action | -| --- | ------------- | -| `o` | Open the site | -| `a` | Open AdminCP | -| `r` | Restart | -| `c` | Clear | -| `q` | Quit | - -Ctrl+C closes the server and every watcher it started. - -## Start the production server - -```bash -vitnode build -vitnode start -``` - -```txt -◆ VitNode - Production - - ● Running http://localhost:3000 -``` - -`vitnode start` runs the server your build produced with Node - it is not a second server. "Running" appears only once the server accepts connections. `--port` and `--host` set `PORT` and `HOST`, the variables the server already reads. A build for a hosting platform (for example the `vercel` Nitro preset) is refused, because that platform runs it. - -## Output options - - - -Colors follow `NO_COLOR` and `FORCE_COLOR`. In CI, piped output and other non-interactive terminals, the CLI never shows spinners or prompts - see [CI](/docs/dev/cli/ci). - -## Exit codes - -| Code | Meaning | -| ----- | ----------------------------------------------------------------------- | -| `0` | Success | -| `1` | The command failed: a build error, invalid plugin, unreachable database | -| `2` | The command line is wrong, or a confirmation flag is missing | -| `130` | Cancelled with Ctrl+C at a prompt | - -## Vite, Drizzle Kit and TanStack Start - -The CLI drives these tools - through Vite's JavaScript API, your project's own `drizzle-kit`, and your `vite.config.ts` - instead of replacing them. You can still run `vite` or `drizzle-kit` directly; `vitnode` adds the database bootstrap, plugin checks and readable output around them. - -## Older commands - -These still work for existing projects and deployment scripts: - -| Command | Same as | -| ------------------------------------------------- | --------------------------------------------------------------- | -| `vitnode db:prepare` | Generate, migrate and seed - what `vitnode dev` runs | -| `vitnode migrate` | `vitnode db:prepare`; `--generate` only generates | -| `vitnode i18n:check` and the other `i18n:*` names | `vitnode i18n check`, `create`, `delete`, `update`, `update-ai` | - -## Learn more +## Next steps - diff --git a/apps/web/content/docs/dev/cli/meta.json b/apps/web/content/docs/dev/cli/meta.json index 322ddfd9b..89974ce14 100644 --- a/apps/web/content/docs/dev/cli/meta.json +++ b/apps/web/content/docs/dev/cli/meta.json @@ -1,6 +1,15 @@ { "title": "CLI", - "description": "Run, build, start, create plugins and manage migrations with the vitnode command.", + "description": "Run, build and start VitNode apps, validate plugins and manage migrations with the vitnode command.", "icon": "SquareTerminal", - "pages": ["index", "build", "plugins", "database", "ci"] + "pages": [ + "index", + "dev", + "build", + "bundle-size", + "plugins", + "database", + "ci", + "output" + ] } diff --git a/apps/web/content/docs/dev/cli/output.mdx b/apps/web/content/docs/dev/cli/output.mdx new file mode 100644 index 000000000..e7ef6f112 --- /dev/null +++ b/apps/web/content/docs/dev/cli/output.mdx @@ -0,0 +1,61 @@ +--- +title: Output and exit codes +description: Reference for VitNode CLI output options, colors, non-interactive behavior and exit codes. +icon: ScrollText +--- + +Every `vitnode` command accepts the same output options and uses the same exit codes. Use this page to tune the output for logs or debugging, or to react to a failure in a script. + +## Output options + +| Option | Effect | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `--plain` | Line-based output without colors, symbols or animation. Status lines start with `[OK]`, `[WARN]`, `[FAIL]`, `[ERROR]`, `[INFO]` or `[SKIP]`. | +| `--verbose` | Show the underlying tool output (Vite, compilers), every file and change, and full stack traces | +| `-h, --help` | Show the command and its options | +| `-v, --version` | Print the installed `@vitnode/core` version | + +Setting `VITNODE_DEBUG=1` turns on `--verbose` for every command. + +Without `--verbose`, messages that Vite and its plugins print during a build are held back, so they do not break the progress lines. The CLI tells you how many it hid, and prints the last 60 lines of captured output if the command fails. + +## Interactive and non-interactive terminals + +The CLI treats a terminal as interactive when stdin and stdout are both terminals, `TERM` is not `dumb`, `--plain` is not set, and no CI variable is set. It checks `CI`, `CONTINUOUS_INTEGRATION`, `BUILD_NUMBER`, `RUN_ID`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE` and `TF_BUILD`. A value of `0` or `false` counts as unset. + +In a non-interactive terminal, the CLI never shows spinners or prompts. A command that needs an answer exits with code `2` instead of waiting. For a confirmation, the error names the flag that answers it, such as `--yes`. + +## Colors + +Colors are on when stdout is a terminal and `TERM` is not `dumb`. + +- `NO_COLOR` with any non-empty value turns colors off. +- `FORCE_COLOR` turns them on even in piped output, unless its value is `0` or `false`. +- `--plain` turns them off whatever the variables say. + +On a Linux console (`TERM=linux`) and older Windows consoles, the CLI uses plain ASCII symbols instead of `✓` and `▲`. + +## Exit codes + +| Code | Meaning | +| ----- | ------------------------------------------------------------------------------------------ | +| `0` | Success, including a confirmation you declined | +| `1` | The command failed: a build error, invalid plugin, migration error or unreachable database | +| `2` | The command needs a change from you: see below | +| `130` | Cancelled with Ctrl+C at a prompt | + +Exit code `2` means nothing ran because the input was not usable as given: + +- an unknown command or option (the CLI suggests the closest match) +- an invalid value, such as a port outside 1-65535 or an unknown plugin name +- a missing `--yes` or `--accept-data-loss` in a non-interactive terminal +- a drizzle-kit rename question in a non-interactive terminal +- `vitnode db push` with `NODE_ENV=production` and no `--force` + +Ctrl+C while `vitnode dev` or `vitnode start` is running +stops the server cleanly and exits with `0`. + +## Related + +- [Use the CLI in CI](/docs/dev/cli/ci) +- [VitNode CLI](/docs/dev/cli) diff --git a/apps/web/content/docs/dev/cli/plugins.mdx b/apps/web/content/docs/dev/cli/plugins.mdx index 9a069f14f..704f4dc5e 100644 --- a/apps/web/content/docs/dev/cli/plugins.mdx +++ b/apps/web/content/docs/dev/cli/plugins.mdx @@ -1,22 +1,17 @@ --- title: List and validate plugins -description: List the plugins a VitNode app uses with vitnode plugin list, and validate them with vitnode plugin validate before a build or in CI. +description: List the plugins a VitNode app uses with vitnode plugin list, and check them with vitnode plugin validate before a build or in CI. icon: Puzzle --- -```bash -vitnode plugin list -vitnode plugin validate blog -``` +`vitnode plugin list` shows which plugins your app uses and where they come from. `vitnode plugin validate` loads each plugin the way your app will, so a broken plugin fails here with a clear message instead of halfway through a build. - - New plugins come from the generator, which also registers, installs and builds - them: `pnpm create vitnode-app@canary --plugin`. See [Create a - plugin](/docs/dev/plugins/create). - +To create a plugin, use the generator, which also registers, installs and builds it. See [Create a plugin](/docs/dev/plugins/create). ## List plugins +Run it from your app, or from anywhere in its workspace: + ```bash vitnode plugin list ``` @@ -25,32 +20,37 @@ vitnode plugin list ◆ VitNode Plugins - Plugin Version Source - ────────────────────────────────────────────── - @vitnode/blog 2.0.0-canary.13 workspace - @acme/search 1.1.2 package - @acme/forum (not configured) 0.1.0 workspace + Plugin Version Source + ─────────────────────────────────────────────────────────── + @vitnode/blog 2.0.0-canary.13 workspace + @acme/search 1.1.2 package + @acme/forum (not configured) 0.1.0 workspace - 2 plugins configured in apps/web + 2 plugins configured in this app ``` -The list comes from your app's `vitnode.config.ts`, read by the same loader the app's build uses. Workspace plugins the app does not use yet are listed as `not configured`. +The list comes from your app's `vitnode.config.ts`, read by the same loader the app's build uses. -| Source | Meaning | -| ----------- | ---------------------------- | -| `workspace` | A package in this repository | -| `package` | Installed from a registry | +| Value | Meaning | +| ------------------ | ------------------------------------------------------------- | +| `workspace` | A package in this repository | +| `package` | Installed from a registry into `node_modules` | +| `(not configured)` | A workspace plugin your app does not use yet | +| `not installed` | The app's config names the plugin, but the package is missing | -`--verbose` adds each plugin's path and description. Run the command from an app, or from anywhere in its workspace. +`--verbose` adds each plugin's path and description. ## Validate plugins ```bash -vitnode plugin validate # every plugin - or the one you are in -vitnode plugin validate blog # by name, package name or folder +vitnode plugin validate # every plugin, or the one you are in +vitnode plugin validate blog # one plugin, by id, package name or folder ``` ```txt +◆ VitNode + Validate plugins + @vitnode/blog plugins/blog ✓ Package 2.0.0-canary.13 ✓ Plugin definition @@ -63,18 +63,20 @@ vitnode plugin validate blog # by name, package name or folder ✓ 1 plugin valid ``` -Validation loads the plugin's build output - the files your app imports - through VitNode's own loaders. A check runs only when the plugin has that part. +Validation reads the plugin's build output, the `dist` files your app imports, so build the plugin first. Each check runs only when the plugin has that part: + +| Check | Fails when | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| Package | `package.json` is missing, `name` is not a valid plugin id, `type` is not `module`, or `exports` lacks `./*` or `./config` | +| Build output | `dist/src/config.js` is missing: build the plugin first | +| Plugin definition | `dist/src/config.js` exports no `buildPlugin()` factory, or its `pluginId` is not the package name | +| API definition | `dist/src/config.api.js` exports no working `buildApiPlugin()` factory, or its `pluginId` does not match | +| Routes | The route tree does not compile, or a `lazy(() => import(...))` page file is missing | +| Translations | A locale file is unreadable, lacks the plugin's top-level key, or a route's `messages` namespace is missing | +| AdminCP navigation | A nav item requires a permission the plugin does not register in `permissionStaff.admin` | +| Database schema | A schema module fails to load, or two modules declare the same table | -| Check | Fails when | -| ------------------ | ----------------------------------------------------------------------------------------------------------- | -| Package | `name` is not a valid plugin id, `type` is not `module`, or `exports` does not expose `dist` | -| Build output | `dist/src/config.js` is missing - build the plugin first | -| Plugin definition | `config.tsx` exports no `buildPlugin()` factory, or its `pluginId` is not the package name | -| API definition | `buildApiPlugin()` rejects the configuration, or its `pluginId` does not match | -| Routes | The route tree does not compile, or a `lazy(() => import(...))` page file is missing | -| Translations | A locale file is unreadable, lacks the plugin's top-level key, or a route's `messages` namespace is missing | -| AdminCP navigation | A nav item requires a permission the plugin does not register in `permissionStaff.admin` | -| Database schema | A schema module fails to load, or two modules declare the same table | +A translation file that is only missing some keys gets a `!` warning, not a failure, so a half-translated language does not block a release. A failure says what is wrong and where: @@ -87,18 +89,18 @@ A failure says what is wrong and where: ✖ 1 plugin failed validation: @acme/blog ``` -`vitnode plugin validate` exits with code `1` when any plugin is invalid, so it can gate a CI job. +`vitnode plugin validate` exits with code `1` when any plugin is invalid, so it can gate a CI job. An unknown plugin name exits with code `2` and lists the plugins it knows. -## Learn more +## Next steps diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-bun/src/index.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-bun/src/index.ts index 2f8f7c77b..775179719 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api-bun/src/index.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-bun/src/index.ts @@ -12,6 +12,7 @@ VitNodeAPI({ }); export default { + hostname: process.env.HOST, port: Number(process.env.PORT ?? 8000), fetch: app.fetch, }; diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api/src/index.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api/src/index.ts index b71131987..571ad9363 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api/src/index.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api/src/index.ts @@ -15,6 +15,7 @@ VitNodeAPI({ const server = serve( { fetch: app.fetch, + hostname: process.env.HOST, port: Number(process.env.PORT ?? 8000), }, info => { diff --git a/packages/vitnode/scripts/cli/commands/db.test.ts b/packages/vitnode/scripts/cli/commands/db.test.ts index 5e53bb093..9ef32c2e4 100644 --- a/packages/vitnode/scripts/cli/commands/db.test.ts +++ b/packages/vitnode/scripts/cli/commands/db.test.ts @@ -36,6 +36,7 @@ const migration = (name: string): LocalMigration => ({ interface FakeDatabase { applied: string[]; + applyCalls: number; closed: number; kitCalls: string[][]; services: DatabaseServices; @@ -60,6 +61,7 @@ const fakeDatabase = ({ } = {}): FakeDatabase => { const state: FakeDatabase = { applied: [...journal], + applyCalls: 0, closed: 0, kitCalls: [], services: undefined as unknown as DatabaseServices, @@ -69,6 +71,7 @@ const fakeDatabase = ({ const handle: DatabaseHandle = { apply: async () => { await Promise.resolve(); + state.applyCalls += 1; if (applyError) throw applyError; state.applied = local.map(m => m.name); }, @@ -245,6 +248,21 @@ describe("vitnode db migrate", () => { expect(db.closed).toBe(1); }); + it("still ensures initial data when nothing is pending", async () => { + const db = fakeDatabase({ + journal: ["0001_init"], + local: [migration("0001_init")], + }); + + await runDbMigrateCommand( + context().context, + {}, + { services: () => db.services }, + ); + + expect(db.applyCalls).toBe(1); + }); + it("lists pending migrations and applies them with --yes", async () => { const db = fakeDatabase({ journal: ["0001_init"], diff --git a/packages/vitnode/scripts/cli/commands/db.ts b/packages/vitnode/scripts/cli/commands/db.ts index d375101bb..271668a6f 100644 --- a/packages/vitnode/scripts/cli/commands/db.ts +++ b/packages/vitnode/scripts/cli/commands/db.ts @@ -201,8 +201,17 @@ export const runDbMigrateCommand = async ( table: config.migrationsTable, }); const { pending } = computeMigrationState(local, journal); + const apply = async (label: string) => + ui.runTask(label, async () => + withQuietOutput(ui.verbose, async () => + handle.apply(message => { + ui.note(message); + }), + ), + ); if (pending.length === 0) { + await apply("Ensuring initial data"); ui.success("Database is up to date."); ui.line(); @@ -228,13 +237,7 @@ export const runDbMigrateCommand = async ( return EXIT_CODE.ok; } - await ui.runTask("Applying migrations", async () => - withQuietOutput(ui.verbose, async () => - handle.apply(message => { - ui.note(message); - }), - ), - ); + await apply("Applying migrations"); pending.forEach(migration => { ui.success(migration.name); }); diff --git a/packages/vitnode/scripts/cli/commands/dev.test.ts b/packages/vitnode/scripts/cli/commands/dev.test.ts index 6b29f87e9..7ec058a0c 100644 --- a/packages/vitnode/scripts/cli/commands/dev.test.ts +++ b/packages/vitnode/scripts/cli/commands/dev.test.ts @@ -140,7 +140,7 @@ describe("vitnode dev in an API app", () => { }); const start = async ( - options: { port?: string }, + options: { host?: boolean | string; port?: string }, env: Record = {}, ) => { const group = fakeGroup(); @@ -160,6 +160,20 @@ describe("vitnode dev in an API app", () => { expect(output).toMatch(/API\s+http:\/\/localhost:9000\/api/); }); + it("passes --host to the API as HOST", async () => { + const { output, spawned } = await start({ host: "127.0.0.1" }); + + expect(spawned.env?.HOST).toBe("127.0.0.1"); + expect(output).toMatch(/API\s+http:\/\/127\.0\.0\.1:8000\/api/); + }); + + it("treats a bare --host as every address", async () => { + const { output, spawned } = await start({ host: true }); + + expect(spawned.env?.HOST).toBe("0.0.0.0"); + expect(output).toMatch(/API\s+http:\/\/localhost:8000\/api/); + }); + it("runs a Node project through tsx watch", async () => { const { spawned } = await start({}); diff --git a/packages/vitnode/scripts/cli/commands/dev.ts b/packages/vitnode/scripts/cli/commands/dev.ts index 632c40cfb..43e30460a 100644 --- a/packages/vitnode/scripts/cli/commands/dev.ts +++ b/packages/vitnode/scripts/cli/commands/dev.ts @@ -20,7 +20,7 @@ import { loadConfiguredPluginIds } from "../plugins/discover"; import { detectProject } from "../project/project"; import { detectRuntime } from "../project/runtime"; import { plural } from "../ui/format"; -import { parsePort } from "./start"; +import { displayHost, parsePort } from "./start"; export interface DevOptions extends OutputOptions { host?: boolean | string; @@ -35,6 +35,9 @@ export interface DevDeps { openUrl?: (url: string) => void; } +const apiHost = (host: DevOptions["host"]): string | undefined => + host === true ? "0.0.0.0" : host === false ? undefined : host; + /** Whether `vitnode dev` should prepare this project's database first. */ const ownsDatabase = (project: Project) => project.drizzleConfig !== null && project.hasApi; @@ -91,10 +94,14 @@ export const runDevCommand = async ( if (project.kind === "api") { // The API reads PORT itself, so `--port` reaches it the same way. const port = parsePort(options.port ?? env.PORT, 8000); + const host = apiHost(options.host) ?? env.HOST; const runtime = detectRuntime(project.root, env); ui.line(); ui.keyValue([ - ["API", ui.colors.command(`http://localhost:${String(port)}/api`)], + [ + "API", + ui.colors.command(`http://${displayHost(host)}:${String(port)}/api`), + ], [ "Runtime", ui.colors.muted(runtime === "bun" ? "Bun (--hot)" : "Node (tsx watch)"), @@ -106,7 +113,11 @@ export const runDevCommand = async ( group: deps.group, processes: apiWatchers(runtime).map(watcher => toProcess(project, watcher, { - env: { ...env, PORT: String(port) }, + env: { + ...env, + PORT: String(port), + ...(host === undefined ? {} : { HOST: host }), + }, runtime, }), ), diff --git a/packages/vitnode/scripts/cli/commands/plugin-list.ts b/packages/vitnode/scripts/cli/commands/plugin-list.ts index f370120be..e6a0a1ae5 100644 --- a/packages/vitnode/scripts/cli/commands/plugin-list.ts +++ b/packages/vitnode/scripts/cli/commands/plugin-list.ts @@ -23,7 +23,7 @@ export const runPluginListCommand = async ( : "No plugins configured yet.", ); ui.note( - `Create one with ${ui.colors.command("vitnode plugin create ")}.`, + `Create one with ${ui.colors.command("npx create-vitnode-app --plugin ")}.`, ); ui.line(); diff --git a/packages/vitnode/scripts/cli/commands/plugin-validate.ts b/packages/vitnode/scripts/cli/commands/plugin-validate.ts index 9467f7fa2..962448104 100644 --- a/packages/vitnode/scripts/cli/commands/plugin-validate.ts +++ b/packages/vitnode/scripts/cli/commands/plugin-validate.ts @@ -88,7 +88,7 @@ export const runPluginValidateCommand = async ( throw new UserError(`No plugin named "${name}" was found.`, { hint: plugins.length === 0 - ? "Create one with vitnode plugin create ." + ? "Create one with npx create-vitnode-app --plugin ." : `Known plugins: ${plugins.map(plugin => plugin.id).join(", ")}`, }); } diff --git a/packages/vitnode/scripts/cli/commands/start.test.ts b/packages/vitnode/scripts/cli/commands/start.test.ts index 17df6a19e..f005289b2 100644 --- a/packages/vitnode/scripts/cli/commands/start.test.ts +++ b/packages/vitnode/scripts/cli/commands/start.test.ts @@ -182,6 +182,27 @@ describe("vitnode start", () => { } }); + it("fails at once when the runtime is missing, instead of waiting for the port", async () => { + rmSync(join(root, "vite.config.ts")); + write("src/vitnode.api.config.ts", "export default {};"); + write("src/index.ts", ""); + write("bun.lock", ""); + const { context, runtime } = createTestContext({ + cwd: root, + env: { PATH: join(root, "empty-bin") }, + }); + const startedAt = Date.now(); + + const error = (await runStartCommand(context, { + port: String(await freePort()), + }).catch((thrown: unknown) => thrown)) as RuntimeError; + + expect(error).toBeInstanceOf(RuntimeError); + expect(error.message).toContain("Could not start the server with bun"); + expect(Date.now() - startedAt).toBeLessThan(10_000); + expect(runtime.output()).not.toContain("Running"); + }, 20_000); + it("fails - without claiming it is running - when the server dies during boot", async () => { write(".output/server/index.mjs", "process.exit(3);"); const { context, runtime } = createTestContext({ cwd: root }); diff --git a/packages/vitnode/scripts/cli/commands/start.ts b/packages/vitnode/scripts/cli/commands/start.ts index 61f0eb415..024099f10 100644 --- a/packages/vitnode/scripts/cli/commands/start.ts +++ b/packages/vitnode/scripts/cli/commands/start.ts @@ -68,10 +68,11 @@ export const runStartCommand = async ( }); } + const command = runtimeExecutable(runtime); const group = new ProcessGroup(); const child = group.spawn({ args: [entry], - command: runtimeExecutable(runtime), + command, cwd: project.root, env: { ...env, @@ -82,9 +83,14 @@ export const runStartCommand = async ( }); let exitCode: null | number = null; + let spawnError = null as Error | null; child.once("exit", code => { exitCode = code ?? 1; }); + child.once("error", error => { + spawnError = error; + exitCode = 1; + }); const ready = await waitForPort({ host: shownHost, @@ -92,6 +98,19 @@ export const runStartCommand = async ( port, }); + if (spawnError !== null) { + await group.stop(); + throw new RuntimeError( + `Could not start the server with ${command}: ${spawnError.message}`, + { + hint: + runtime === "bun" + ? "This project runs on Bun - install it on this host, or start the build with Node." + : undefined, + }, + ); + } + if (!ready || exitCode !== null) { await group.stop(); throw new RuntimeError( diff --git a/packages/vitnode/scripts/cli/db/database.ts b/packages/vitnode/scripts/cli/db/database.ts index 19d6630a5..2bb78456d 100644 --- a/packages/vitnode/scripts/cli/db/database.ts +++ b/packages/vitnode/scripts/cli/db/database.ts @@ -127,7 +127,7 @@ export const createDatabaseServices = (root: string): DatabaseServices => ({ async () => { await bootstrap.runMigrations({ config: apiConfig, - migrationsFolder: config.migrationsFolder, + drizzle: config, }); await bootstrap.initialDataForDatabase(apiConfig); }, diff --git a/packages/vitnode/scripts/database-bootstrap.test.ts b/packages/vitnode/scripts/database-bootstrap.test.ts index d28a53278..25248fc43 100644 --- a/packages/vitnode/scripts/database-bootstrap.test.ts +++ b/packages/vitnode/scripts/database-bootstrap.test.ts @@ -176,7 +176,7 @@ describe("what decides whether work is pending", () => { } expect(bootstrap).toMatch( - /migrate\(config\.dbProvider, \{ migrationsFolder/, + /migrate\(config\.dbProvider, \{\s+migrationsFolder,\s+migrationsSchema,\s+migrationsTable,/, ); }); diff --git a/packages/vitnode/scripts/prepare-database.ts b/packages/vitnode/scripts/prepare-database.ts index 74d02ee16..e31f08776 100644 --- a/packages/vitnode/scripts/prepare-database.ts +++ b/packages/vitnode/scripts/prepare-database.ts @@ -129,13 +129,6 @@ export const readDrizzleConfig = async ( return config; }; -// Reads the migrations output folder from the app's `drizzle.config.ts` (`out`), -// falling back to `./migrations` so the in-process migrator points at the same -// files `drizzle-kit generate` writes. -export const getMigrationsFolder = async ( - root: string = process.cwd(), -): Promise => (await readDrizzleConfig(root)).migrationsFolder; - // Every `regconfig` literal referenced by the generated `search_vector` column // (see SEARCH_TEXT_CONFIGS) has to exist on the target database before the // column is created - Postgres resolves all branches of the `CASE`, even ones no @@ -229,10 +222,10 @@ export const describePostgresError = (err: unknown): string[] => { export const runMigrations = async ({ config: given, - migrationsFolder: folder, + drizzle, }: { config?: VitNodeApiConfig; - migrationsFolder?: string; + drizzle?: DrizzleProjectConfig; } = {}) => { const config = given ?? (await getConfig({ type: "api.config" })); @@ -240,16 +233,21 @@ export const runMigrations = async ({ // generated `search_vector` column (0017/0018) can resolve every `regconfig`. await ensureSearchTextConfigs(config.dbProvider); - const migrationsFolder = folder ?? (await getMigrationsFolder()); + const { migrationsFolder, migrationsSchema, migrationsTable } = + drizzle ?? (await readDrizzleConfig()); try { // Run migrations in-process instead of shelling out to `drizzle-kit migrate`: // that CLI swallows the underlying Postgres error and just exits 1, which // makes failures impossible to diagnose. The in-process migrator throws the - // real error, which we log in full below. Both use the same - // `drizzle.__drizzle_migrations` table, so this resumes exactly where - // `drizzle-kit` left off. - await migrate(config.dbProvider, { migrationsFolder }); + // real error, which we log in full below. Both use the journal table + // `drizzle.config.ts` names, so this resumes exactly where `drizzle-kit` + // left off. + await migrate(config.dbProvider, { + migrationsFolder, + migrationsSchema, + migrationsTable, + }); } catch (err) { // The in-process migrator throws Postgres' own error, with every field // that explains it - kept whole rather than squashed into one line. From 0655f48e1015aaf4eee81a18de26a27f93068eb4 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Tue, 6 Oct 2026 16:10:32 +0200 Subject: [PATCH 4/4] =?UTF-8?q?refactor(i18n):=20=E2=99=BB=EF=B8=8F=20stre?= =?UTF-8?q?amline=20config=20loading=20in=20i18n=20scripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/package.json | 1 - .../src/create/create-package-json.ts | 2 -- .../vitnode/scripts/cli/plugins/discover.ts | 4 ++-- packages/vitnode/scripts/i18n-check.ts | 18 ++++++------------ packages/vitnode/scripts/i18n-create.ts | 10 ++++------ packages/vitnode/scripts/i18n-update-ai.ts | 10 ++++------ packages/vitnode/scripts/i18n-update.ts | 10 ++++------ 7 files changed, 20 insertions(+), 35 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 012b9b5d8..039789d6f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,7 +11,6 @@ "dev:email": "email dev --dir src/emails", "build": "vitnode build", "start": "vitnode start", - "drizzle-kit": "drizzle-kit", "lint": "eslint .", "lint:fix": "eslint . --fix", "i18n:create": "vitnode i18n create", diff --git a/packages/create-vitnode-app/src/create/create-package-json.ts b/packages/create-vitnode-app/src/create/create-package-json.ts index 90944440b..b1934a6ef 100644 --- a/packages/create-vitnode-app/src/create/create-package-json.ts +++ b/packages/create-vitnode-app/src/create/create-package-json.ts @@ -126,7 +126,6 @@ export const apiScripts = ( ...i18nScripts, ...withIf(eslint, eslintScripts), ...withIf(docker && onlyApi, { "docker:dev": dockerDevScript(appName) }), - "drizzle-kit": "drizzle-kit", }; }; @@ -170,7 +169,6 @@ export const singleAppScripts = ( ...i18nScripts, ...withIf(eslint, eslintScripts), ...withIf(docker, { "docker:dev": dockerDevScript(appName) }), - "drizzle-kit": "drizzle-kit", }); export const webScripts = (eslint: boolean) => ({ diff --git a/packages/vitnode/scripts/cli/plugins/discover.ts b/packages/vitnode/scripts/cli/plugins/discover.ts index 6cffe09cb..3c60cfe6c 100644 --- a/packages/vitnode/scripts/cli/plugins/discover.ts +++ b/packages/vitnode/scripts/cli/plugins/discover.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { dirname, join, relative, sep } from "node:path"; import type { PackageJson } from "../project/packages"; @@ -92,7 +92,7 @@ const sourceOf = (dir: string, workspaceRoot: null | string): PluginSource => { return workspaceRoot !== null && !inNodeModules && - !relative(workspaceRoot, dir).startsWith("..") + !relative(realpathSync(workspaceRoot), realpathSync(dir)).startsWith("..") ? "workspace" : "package"; }; diff --git a/packages/vitnode/scripts/i18n-check.ts b/packages/vitnode/scripts/i18n-check.ts index b8ffd520a..0b3f0085a 100644 --- a/packages/vitnode/scripts/i18n-check.ts +++ b/packages/vitnode/scripts/i18n-check.ts @@ -99,20 +99,14 @@ export const i18nCheck = async ({ const appDir = cwd; const repoRoot = findRepoRoot(appDir); - const webConfig = await getConfig({ baseDir: cwd, optional: true }); - const apiConfig = await getConfig({ - baseDir: cwd, - optional: true, - type: "api.config", - }); // The app's own message loaders live in the server-only config now, because // the shared one is browser-safe. Read from both, so an installation still on // the old shape - loaders inside `i18n.messages` - is measured correctly. - const serverConfig = await getConfig({ - baseDir: cwd, - optional: true, - type: "server.config", - }); + const [webConfig, apiConfig, serverConfig] = await Promise.all([ + getConfig({ baseDir: cwd, optional: true }), + getConfig({ baseDir: cwd, optional: true, type: "api.config" }), + getConfig({ baseDir: cwd, optional: true, type: "server.config" }), + ]); const config = webConfig ?? apiConfig; if (!config) throw noConfigError(); @@ -298,7 +292,7 @@ export const i18nCheck = async ({ ` ${location} is never loaded - add \`"${file.pluginId}": () => import("./${file.pluginId}/${file.locale}.json")\` under \`"${file.locale}"\` in \`src/locales/app.ts\`.`, ), ); - } else if (declared.length > 0 && !declared.includes(file.locale)) { + } else if (declared.length > 0 && !declaredLocales.has(file.locale)) { errors += 1; problems += 1; say(red(` ${location} uses a locale that is not in \`i18n.locales\`.`)); diff --git a/packages/vitnode/scripts/i18n-create.ts b/packages/vitnode/scripts/i18n-create.ts index 67ac17e47..2cd535c1d 100644 --- a/packages/vitnode/scripts/i18n-create.ts +++ b/packages/vitnode/scripts/i18n-create.ts @@ -314,12 +314,10 @@ export const i18nCreate = async ({ // Load both configs: their presence tells us the app's shape (frontend, API, // or both), which decides how much of each package to seed. - const webConfig = await getConfig({ baseDir: cwd, optional: true }); - const apiConfig = await getConfig({ - baseDir: cwd, - optional: true, - type: "api.config", - }); + const [webConfig, apiConfig] = await Promise.all([ + getConfig({ baseDir: cwd, optional: true }), + getConfig({ baseDir: cwd, optional: true, type: "api.config" }), + ]); const config = webConfig ?? apiConfig; if (!config) throw noConfigError(); diff --git a/packages/vitnode/scripts/i18n-update-ai.ts b/packages/vitnode/scripts/i18n-update-ai.ts index da0963b3e..529a1b276 100644 --- a/packages/vitnode/scripts/i18n-update-ai.ts +++ b/packages/vitnode/scripts/i18n-update-ai.ts @@ -261,12 +261,10 @@ export const i18nUpdateAi = async ({ // Both configs describe the app's shape; the AI models come from the API // config specifically, since AI is configured there (`ai.models`). - const webConfig = await getConfig({ baseDir: cwd, optional: true }); - const apiConfig = await getConfig({ - baseDir: cwd, - optional: true, - type: "api.config", - }); + const [webConfig, apiConfig] = await Promise.all([ + getConfig({ baseDir: cwd, optional: true }), + getConfig({ baseDir: cwd, optional: true, type: "api.config" }), + ]); const config = webConfig ?? apiConfig; if (!config) throw noConfigError(); diff --git a/packages/vitnode/scripts/i18n-update.ts b/packages/vitnode/scripts/i18n-update.ts index 6b18fef5e..9377f6000 100644 --- a/packages/vitnode/scripts/i18n-update.ts +++ b/packages/vitnode/scripts/i18n-update.ts @@ -63,12 +63,10 @@ export const i18nUpdate = async ({ const dim = colors.muted; const appDir = cwd; - const webConfig = await getConfig({ baseDir: cwd, optional: true }); - const apiConfig = await getConfig({ - baseDir: cwd, - optional: true, - type: "api.config", - }); + const [webConfig, apiConfig] = await Promise.all([ + getConfig({ baseDir: cwd, optional: true }), + getConfig({ baseDir: cwd, optional: true, type: "api.config" }), + ]); const config = webConfig ?? apiConfig; if (!config) throw noConfigError();