From 38873fc7990bf6cb37ce8bff14d2ef4f55aa0cc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 17:18:45 +0000 Subject: [PATCH 01/51] =?UTF-8?q?feat(cli):=20=E2=9C=A8=20vitnode=20genera?= =?UTF-8?q?te=20for=20plugin=20API=20modules,=20routes=20and=20services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `vitnode generate` (alias `g`) with `module`, `route` and `service`: - A shared planner holds every create/update in memory, formats it with the project's own oxfmt, prints the real diff under --dry-run, re-checks files before writing and rolls back a failed batch. - Sources are read with oxc-parser and edited with span-based insertions, so comments, options and the VitNodeApiPlugin contract are preserved. Module and route registrations stay explicit; unsupported structures stop before writing with exact manual steps. - Project resolution: the current plugin, --project , or the only/selected API plugin in the workspace. - Routes separate file name from HTTP path, refuse method/path collisions, return labelled sample data for GET and 501 for unimplemented writes. - Admin routes require an explicit staff permission, validated against permissionStaff; --declare-permission adds it and its AdminCP labels. - Services are plain factories over explicit dependencies. Docs: CLI reference plus how-tos for modules, endpoints, admin endpoints and services. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CnAiFZ3bExobTQrGyYnt3P --- apps/web/content/docs/dev/cli/generate.mdx | 144 ++++ apps/web/content/docs/dev/cli/index.mdx | 5 +- apps/web/content/docs/dev/cli/meta.json | 3 +- .../docs/dev/plugins/api/admin-routes.mdx | 129 +++ .../docs/dev/plugins/api/generate-module.mdx | 143 ++++ .../docs/dev/plugins/api/generate-route.mdx | 179 +++++ .../content/docs/dev/plugins/api/meta.json | 11 +- .../content/docs/dev/plugins/api/modules.mdx | 4 + .../content/docs/dev/plugins/api/routes.mdx | 4 + .../content/docs/dev/plugins/api/services.mdx | 126 +++ packages/vitnode/package.json | 1 + .../vitnode/scripts/cli/commands/generate.ts | 601 ++++++++++++++ .../vitnode/scripts/cli/generate/api-tree.ts | 429 ++++++++++ packages/vitnode/scripts/cli/generate/ast.ts | 493 ++++++++++++ packages/vitnode/scripts/cli/generate/diff.ts | 128 +++ .../vitnode/scripts/cli/generate/format.ts | 114 +++ .../scripts/cli/generate/generators.ts | 756 ++++++++++++++++++ .../vitnode/scripts/cli/generate/names.ts | 151 ++++ packages/vitnode/scripts/cli/generate/plan.ts | 231 ++++++ .../vitnode/scripts/cli/generate/project.ts | 178 +++++ .../vitnode/scripts/cli/generate/templates.ts | 242 ++++++ packages/vitnode/scripts/cli/program.ts | 83 ++ pnpm-lock.yaml | 3 + 23 files changed, 4153 insertions(+), 5 deletions(-) create mode 100644 apps/web/content/docs/dev/cli/generate.mdx create mode 100644 apps/web/content/docs/dev/plugins/api/admin-routes.mdx create mode 100644 apps/web/content/docs/dev/plugins/api/generate-module.mdx create mode 100644 apps/web/content/docs/dev/plugins/api/generate-route.mdx create mode 100644 apps/web/content/docs/dev/plugins/api/services.mdx create mode 100644 packages/vitnode/scripts/cli/commands/generate.ts create mode 100644 packages/vitnode/scripts/cli/generate/api-tree.ts create mode 100644 packages/vitnode/scripts/cli/generate/ast.ts create mode 100644 packages/vitnode/scripts/cli/generate/diff.ts create mode 100644 packages/vitnode/scripts/cli/generate/format.ts create mode 100644 packages/vitnode/scripts/cli/generate/generators.ts create mode 100644 packages/vitnode/scripts/cli/generate/names.ts create mode 100644 packages/vitnode/scripts/cli/generate/plan.ts create mode 100644 packages/vitnode/scripts/cli/generate/project.ts create mode 100644 packages/vitnode/scripts/cli/generate/templates.ts diff --git a/apps/web/content/docs/dev/cli/generate.mdx b/apps/web/content/docs/dev/cli/generate.mdx new file mode 100644 index 0000000000..e2e390bfc0 --- /dev/null +++ b/apps/web/content/docs/dev/cli/generate.mdx @@ -0,0 +1,144 @@ +--- +title: Generate commands +description: Reference for vitnode generate - scaffold plugin API modules, typed routes, admin routes and services, with dry runs, collision checks and explicit registration. +icon: Wand +--- + +`vitnode generate` (alias `vitnode g`) writes plugin API code and wires it into the plugin's explicit registrations: a module into `buildApiPlugin({ modules })` or its parent's `modules`, a route into its module's `routes`. It edits your files in place and never discovers files at runtime, so what you see in `src/config.api.ts` stays the whole truth. + +| Command | Creates | Guide | +| -------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------- | +| `vitnode g module ` | `.module.ts`, registered in `config.api.ts` or a parent | [Generate a module](/docs/dev/plugins/api/generate-module) | +| `vitnode g route /` | `.route.ts`, added to the module's `routes` | [Generate an endpoint](/docs/dev/plugins/api/generate-route) | +| `vitnode g route admin/...` | The same, guarded by a staff permission | [Protect an admin endpoint](/docs/dev/plugins/api/admin-routes) | +| `vitnode g service ` | `.service.ts`, an empty service factory | [Add a service](/docs/dev/plugins/api/services) | + +## Where to run it + +Run it inside the plugin, through the plugin's own `vitnode`: + +```bash +cd plugins/site-notes +pnpm vitnode g module notes +``` + +The workspace root does not depend on `@vitnode/core`, so `pnpm vitnode` is not found there. From the root, point pnpm at the plugin: + +```bash +pnpm --filter site-notes exec vitnode g module notes +``` + +### Which plugin it writes to + +| You run it | Target | +| ---------------------------------------- | -------------------------------------------------------------------- | +| With `--project ` | The workspace plugin with exactly that package name, or that folder | +| Inside a plugin | That plugin | +| Anywhere else in the workspace (an app) | The only plugin with a `src/config.api.ts`, or a choice when there are several | + +With several candidates and no terminal to ask (CI, a script), the command stops with exit code `2` and lists them. Only plugins are supported targets for now: a plugin needs `src/config.api.ts` and the bound builders in `src/api/builders.ts`, which `create-vitnode-app --plugin` writes. When `builders.ts` is missing but `src/manifest.gen.ts` exists, the generator creates it. + +## Options + +Every generate command accepts: + +| Option | Description | +| ----------------------- | --------------------------------------------------------------------- | +| `--project ` | The plugin to write into, by exact package name or folder | +| `--dry-run` | Print the full diff of every planned change and write nothing | +| `--plain`, `--verbose` | Output style, as for every command. See [Output](/docs/dev/cli/output) | + +`vitnode g module `: + +| Option | Description | +| ----------- | ----------------------------------------------------------------- | +| `--admin` | Create it in the admin module tree: `notes` becomes `admin/notes` | +| `--parents` | Also create missing parent modules | + +`vitnode g route /`: + +| Option | Default | Description | +| ------------------------------------ | --------------- | --------------------------------------------------------------------------- | +| `-m, --method ` | From the name | `get`, `post`, `put`, `patch` or `delete` | +| `--path ` | From the name | Path relative to the module, in OpenAPI syntax: `/`, `/{id}`, `/{id}/archive` | +| `--id-type ` | `string` | Schema of every path parameter: `string`, or `number` for positive integers | +| `--admin` | - | Put the route in the admin module tree | +| `--permission ` | - | Staff permission that guards an admin route, e.g. `notes.can_create` | +| `--declare-permission` | - | Declare that permission in `config.api.ts` (and label it) when it is missing | +| `--parents` | - | Also create missing modules | + +`vitnode g service ` takes `--admin` with the same meaning as for modules. + +### Route name defaults + +The route's file name and its URL are separate. These names imply a method and a path; any other name needs `--method` and `--path` (or answers them in an interactive terminal), because `archive.route.ts` is not served at `/archive` unless you say so. + +| Name | Method | Path | +| -------- | -------- | ------- | +| `list` | `GET` | `/` | +| `show` | `GET` | `/{id}` | +| `create` | `POST` | `/` | +| `update` | `PATCH` | `/{id}` | +| `delete` | `DELETE` | `/{id}` | + +### What a generated route does + +- **`GET`** answers `200` with sample data, `{ "sample": true, "message": "..." }` plus any path parameters, and a schema that matches it exactly. Replace both. +- **Every other method** validates its path parameters and a JSON body (an empty `z.object({})` for you to fill in) and answers `501 Not Implemented`. It never pretends a write succeeded. +- **Admin routes** add `adminStaffPermission` and a `403` response. A staff permission decides who may call the route; it does not check access to a particular record. Do that in the handler when it matters. + +## Safety rules + +- **Dry run first.** `--dry-run` resolves, validates and formats everything, then prints the exact diff instead of writing. +- **No overwrites.** An existing file, an identifier already declared in the target file, a module name already registered, or a route with the same method and path in the module stops the command before anything is written. There is no `--force`. +- **Names are kept, not rewritten.** Module and route names are lower-case kebab-case, because a module name is its URL segment. `myNotes` is refused with a suggestion (`my-notes`); `..`, absolute paths and backslashes are refused outright. +- **Edits are surgical.** The generator parses your files and inserts text at exact positions, so comments, options and the `VitNodeApiPlugin` type stay as they were. New imports go in alphabetical order within their group. +- **Formatting follows the project.** Generated files are formatted with the project's own oxfmt and config. A file you already edited is formatted only if it was already formatted, so the diff never touches code the generator did not add. +- **Writes are checked and rolled back.** Every file is re-read just before writing; if one changed meanwhile, nothing is written. If a write fails halfway, the files already written are restored. A crash in the middle of the final renames can still leave some files written, which is why the summary lists them all. + +## Supported source patterns + +The generator reads source, never runs it. It follows these patterns: + +```ts title="src/config.api.ts" +buildApiPlugin({ + manifest, + modules: [notesModule, adminModule], // an array literal + permissionStaff: { admin: { notes: ["can_create"] } }, // object and array literals +}); +``` + +```ts title="src/api/modules/admin/admin.module.ts" +import { adminNotesModule } from "./notes/notes.module"; // imported with @/ or a relative path + +export const adminModule = buildModule({ + name: "admin", // a string literal + routes: [], // an array literal + modules: [adminNotesModule], +}); +``` + +Module and route entries must be identifiers that resolve to a `const x = buildModule({...})` or `buildRoute({...})` in the plugin. `buildContentAdminModule(...)` and `buildContentPublicModule(...)` are recognized as the generated `content` module. + +Anything else - a spread, a variable holding the array, a computed name, a module from another package - stops the command before writing and prints the exact lines to add by hand. A route whose method or path cannot be read is reported as a warning, since the collision check could not cover it. + +## After generating + +- **Types.** The plugin's own pages see new routes at once: its `types/api-registry.gen.d.ts` imports `VitNodeApiPlugin` from `src/config.api.ts`, and nothing in it needs regenerating. An app sees them once the plugin's declarations are rebuilt, which `vitnode dev` does on save; otherwise run `vitnode build` in the plugin. See [How the fetcher knows your routes](/docs/dev/plugins/api/modules#how-the-fetcher-knows-your-routes). +- **URLs.** A plugin is mounted at `/{plugin id}` under the app's API base path, `/api` in the starter apps, so `notes/list` in `site-notes` answers `GET /api/site-notes/notes`. +- **Checks.** Run `vitnode plugin validate` after a build to check the plugin the way an app loads it. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/cli/index.mdx b/apps/web/content/docs/dev/cli/index.mdx index 4df40b9bb6..ac8456b830 100644 --- a/apps/web/content/docs/dev/cli/index.mdx +++ b/apps/web/content/docs/dev/cli/index.mdx @@ -1,6 +1,6 @@ --- title: CLI -description: The vitnode command runs, builds and starts VitNode apps, validates plugins, manages database migrations and translations. +description: The vitnode command runs, builds and starts VitNode apps, generates plugin API code, validates plugins, manages database migrations and translations. icon: SquareTerminal --- @@ -24,7 +24,7 @@ npx vitnode dev -Run it inside an app, an API app or a plugin folder, through your package manager. The workspace root of a monorepo does not depend on `@vitnode/core`, so `vitnode` is not found there. Generated projects already call it from their `package.json` scripts, so `pnpm dev` and `pnpm start` run `vitnode dev` and `vitnode start` for you. +Run it inside an app, an API app or a plugin folder, through your package manager. The workspace root of a monorepo does not depend on `@vitnode/core`, so `vitnode` is not found there; use `pnpm --filter exec vitnode ...` to run it in a package from the root. 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 @@ -35,6 +35,7 @@ Run it inside an app, an API app or a plugin folder, through your package manage | `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 generate ` | Scaffold plugin API modules, routes, services | [Generate](/docs/dev/cli/generate) | | `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) | diff --git a/apps/web/content/docs/dev/cli/meta.json b/apps/web/content/docs/dev/cli/meta.json index 89974ce14c..f28e5d5b3a 100644 --- a/apps/web/content/docs/dev/cli/meta.json +++ b/apps/web/content/docs/dev/cli/meta.json @@ -1,6 +1,6 @@ { "title": "CLI", - "description": "Run, build and start VitNode apps, validate plugins and manage migrations with the vitnode command.", + "description": "Run, build and start VitNode apps, generate plugin API code, validate plugins and manage migrations with the vitnode command.", "icon": "SquareTerminal", "pages": [ "index", @@ -8,6 +8,7 @@ "build", "bundle-size", "plugins", + "generate", "database", "ci", "output" diff --git a/apps/web/content/docs/dev/plugins/api/admin-routes.mdx b/apps/web/content/docs/dev/plugins/api/admin-routes.mdx new file mode 100644 index 0000000000..76280a2453 --- /dev/null +++ b/apps/web/content/docs/dev/plugins/api/admin-routes.mdx @@ -0,0 +1,129 @@ +--- +title: Protect an admin endpoint +description: Generate a VitNode admin API route guarded by a staff permission, declare the permission and its AdminCP labels, and see what the guard does and does not check. +icon: ShieldCheck +--- + +Admin endpoints live in the plugin's `admin` module tree, so their URLs contain `/admin/` and need an AdminCP session. Each one also needs a staff permission, which `adminStaffPermission` checks before the handler runs. `vitnode g route` puts both in place. In this guide you add `POST /api/site-notes/admin/notes`, guarded by **Create notes**. + +## Before you begin + +- A plugin made with [Create plugin](/docs/dev/plugins/create), with a terminal in `plugins/site-notes`. +- To try the guard with a restricted account, follow [Permissions](/docs/dev/working-with-users/staff-permissions), which explains staff permissions in depth. + + + + + +### Preview the route + +`--admin` puts the route in the admin module tree: `notes/create` becomes `admin/notes/create`, the same as writing that path yourself. It never adds a guard to a public module. + +```bash +pnpm vitnode g route notes/create --admin --permission notes.can_create --parents --dry-run +``` + +```txt +✖ The staff permission notes.can_create is not declared. + Nobody can be granted a permission the plugin does not declare in src/config.api.ts: + permissionStaff: { admin: { notes: ["can_create"] } } + + Pass --declare-permission to add it (and its labels), or pick a declared one. +``` + +The generator checks the permission against `buildApiPlugin({ permissionStaff })` before writing anything. Every admin route needs one: leave out `--permission` and an interactive terminal offers the declared ones, CI gets an error. + + + + + +### Declare the permission and generate + +Add `--declare-permission`, and drop `--dry-run` once the diff looks right: + +```bash +pnpm vitnode g route notes/create --admin --permission notes.can_create --parents --declare-permission +``` + +```txt + ✓ created src/api/modules/admin/admin.module.ts module "admin"; register adminNotesModule in module "admin" + ✓ created src/api/modules/admin/notes/notes.module.ts module "admin/notes"; register createNotesRoute in module "admin/notes" + ✓ created src/api/modules/admin/notes/create.route.ts POST /admin/notes + ✓ updated src/config.api.ts declare the admin permission notes.can_create; register adminModule in buildApiPlugin + ✓ updated src/locales/en.json AdminCP labels for notes.can_create +``` + +`--parents` created the `admin` and `admin/notes` modules. `config.api.ts` now declares the permission: + +```ts title="plugins/site-notes/src/config.api.ts" +modules: [helloModule, adminModule], +permissionStaff: { admin: { notes: ["can_create"] } }, +``` + +and `en.json` gained the labels the AdminCP permission editor shows, `"site-notes:notes": "Notes"` and `"site-notes:notes:can_create": "Create notes"`. The generator only rewrites `en.json` when it is plain two-space JSON; otherwise it prints the keys to add by hand. + + + + + +### Read the guard + +The route carries the permission and documents the `403`: + +```ts title="plugins/site-notes/src/api/modules/admin/notes/create.route.ts" +/** + * POST /admin/notes - a generated scaffold that answers 501 until implemented. + * + * Describe the input, add the success response to `responses`, then replace + * the `throw` with the real operation. + * + * The staff permission decides who may call it at all. It does not check + * access to a particular record - do that in the handler when it matters. + */ +export const createNotesRoute = buildRoute({ + adminStaffPermission: { module: "notes", permission: "can_create" }, + route: { + method: "post", + path: "/", + // ... + responses: { + 403: { description: "Requires the notes.can_create staff permission." }, + 501: { description: "Not implemented yet." }, + }, + }, + // ... +}); +``` + +The last paragraph of that comment matters for every write. `can_edit` lets an administrator call `PATCH /admin/notes/{id}`; whether they may edit *that* note, for example one owned by another team, is a decision your handler makes. See [Check a permission in code](/docs/dev/working-with-users/staff-permissions#check-a-permission-in-code). + + + + + +## Check the result + +Generate a second admin route. The permission module and the `admin/notes` module now exist, so only the new pieces are added: + +```bash +pnpm vitnode g route admin/notes/delete --permission notes.can_delete --declare-permission +``` + +The diff appends `"can_delete"` to `notes: [...]` in `permissionStaff`, adds the label, and adds `deleteNotesRoute` to `admin/notes`. Build the plugin and run `pnpm vitnode plugin validate` to check it the way an app loads it. + +Until you implement them, both routes answer `501` to an administrator holding the permission and `403` to one who does not. Try that with the temporary `fetcher` button from [Permissions](/docs/dev/working-with-users/staff-permissions#check-the-result). + +## Related + + + + + diff --git a/apps/web/content/docs/dev/plugins/api/generate-module.mdx b/apps/web/content/docs/dev/plugins/api/generate-module.mdx new file mode 100644 index 0000000000..bd1748918c --- /dev/null +++ b/apps/web/content/docs/dev/plugins/api/generate-module.mdx @@ -0,0 +1,143 @@ +--- +title: Generate a module +description: Create a VitNode API module with vitnode generate module, preview the change with a dry run, and nest a child module inside a parent. +icon: Box +--- + +`vitnode g module` creates an API module and registers it for you: a top-level module in `buildApiPlugin({ modules })`, a child module in its parent's `modules`. Use it whenever a plugin needs a new URL segment, such as `/notes`, for a group of routes. + +## Before you begin + +- A plugin made with [Create plugin](/docs/dev/plugins/create), such as `site-notes`. The [API modules](/docs/dev/plugins/api/modules) lesson builds the same `notes` module by hand; if you followed it, pick another name below, such as `tags`. +- A terminal in the plugin's folder, `plugins/site-notes`. From the workspace root, use `pnpm --filter site-notes exec vitnode ...` instead of `pnpm vitnode ...`. + + + + + +### Preview the change + +Add `--dry-run` to see exactly what the generator would write, without writing it: + +```bash +pnpm vitnode g module notes --dry-run +``` + +```txt +--- /dev/null ++++ b/src/api/modules/notes/notes.module.ts +@@ -0,0 +1,6 @@ ++import { buildModule } from "@/api/builders"; ++ ++export const notesModule = buildModule({ ++ name: "notes", ++ routes: [], ++}); + +--- a/src/config.api.ts ++++ b/src/config.api.ts +@@ -5,11 +5,12 @@ + import { manifest } from "@/manifest.gen"; + + import { helloModule } from "./api/modules/hello/hello.module"; ++import { notesModule } from "./api/modules/notes/notes.module"; + + export const siteNotesApiPlugin = () => + buildApiPlugin({ + manifest, +- modules: [helloModule], ++ modules: [helloModule, notesModule], + }); +``` + +The import follows the style the file already uses, here a relative path, and the rest of `config.api.ts`, including the `VitNodeApiPlugin` type, stays as it was. + + + + + +### Generate the module + +Run the same command without `--dry-run`: + +```bash +pnpm vitnode g module notes +``` + +```txt +◆ VitNode + Generate a module + + Plugin site-notes . + + ✓ created src/api/modules/notes/notes.module.ts module "notes" + ✓ updated src/config.api.ts register notesModule in buildApiPlugin + + Module notes is mounted at /site-notes/notes under the app's API base path (/api in the starter apps). + It has no routes yet, so there is nothing to call. Add one with: + vitnode g route notes/list --method get +``` + +An empty module has no endpoint: `/api/site-notes/notes` answers `404` until you add a route. + + + + + +### Nest a child module + +A module path with a slash creates a child inside its parent. The parent has to exist, or you pass `--parents` to create it too: + +```bash +pnpm vitnode g module reports/weekly --parents +``` + +This creates `src/api/modules/reports/reports.module.ts` (registered in `config.api.ts`) and `src/api/modules/reports/weekly/weekly.module.ts`, registered only in the parent: + +```ts title="plugins/site-notes/src/api/modules/reports/reports.module.ts" +import { buildModule } from "@/api/builders"; + +import { reportsWeeklyModule } from "./weekly/weekly.module"; + +export const reportsModule = buildModule({ + name: "reports", + routes: [], + modules: [reportsWeeklyModule], +}); +``` + +Its routes are served under `/api/site-notes/reports/weekly`, and the fetcher calls them with `module: "reports/weekly"`. Without `--parents`, a missing parent stops the command with a hint, or asks first in an interactive terminal. + + + + + +## Check the result + +Run the generator for the same module again. It refuses, because the module is now registered: + +```bash +pnpm vitnode g module notes +``` + +```txt +✖ Module "notes" already exists. + Declared as notesModule in src/api/modules/notes/notes.module.ts. +``` + +The generator never overwrites a file or a registration. Admin modules work the same way under `admin/`; [Protect an admin endpoint](/docs/dev/plugins/api/admin-routes) creates them together with a guarded route. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/plugins/api/generate-route.mdx b/apps/web/content/docs/dev/plugins/api/generate-route.mdx new file mode 100644 index 0000000000..5437bab54b --- /dev/null +++ b/apps/web/content/docs/dev/plugins/api/generate-route.mdx @@ -0,0 +1,179 @@ +--- +title: Generate an endpoint +description: Add typed VitNode API routes with vitnode generate route - a GET starter with sample data, a path parameter, a 501 write scaffold - and call them with the fetcher. +icon: Route +--- + +`vitnode g route` creates a typed route file and adds it to its module's `routes`. The route's name (`show.route.ts`) and its URL (`/{id}`) are separate: common names imply a path, any other name asks for one. In this guide you add three endpoints to the `notes` module of `site-notes` and call one from a page. + +## Before you begin + +- The `notes` module from [Generate a module](/docs/dev/plugins/api/generate-module), and `pnpm dev` running at the workspace root. +- A terminal in `plugins/site-notes`. + + + + + +### Add a list endpoint + +```bash +pnpm vitnode g route notes/list --method get +``` + +`list` implies `GET /`. The new `src/api/modules/notes/list.route.ts` answers with sample data that its schema describes exactly, so nothing mistakes it for real notes: + +```ts title="plugins/site-notes/src/api/modules/notes/list.route.ts" +import { z } from "@hono/zod-openapi"; + +import { buildRoute } from "@/api/builders"; + +/** + * GET /notes - a generated starter that answers with sample data. + * + * Replace the 200 schema and the handler with the real response. + */ +export const listNotesRoute = buildRoute({ + route: { + method: "get", + path: "/", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ sample: z.literal(true), message: z.string() }), + }, + }, + description: "Sample data from a generated route.", + }, + }, + }, + handler: c => + c.json( + { + sample: true as const, + message: + "Sample data from list.route.ts. Replace it with the real response.", + }, + 200, + ), +}); +``` + +`notes.module.ts` now imports it and lists it: `routes: [listNotesRoute]`. + + + + + +### Add a route with a path parameter + +```bash +pnpm vitnode g route notes/show --path '/{id}' --id-type number +``` + +Paths use OpenAPI syntax. Parameters are strings by default; `--id-type number` validates them as positive integers instead (`z.coerce.number().int().positive()`), so `/notes/abc` is refused with `400` before the handler runs. Quote the path so your shell leaves the braces alone. + + + + + +### Add a write scaffold + +```bash +pnpm vitnode g route notes/create +``` + +`create` implies `POST /`. A write route validates its input but has no operation yet, so it answers `501 Not Implemented` instead of pretending to succeed: + +```ts title="plugins/site-notes/src/api/modules/notes/create.route.ts" +handler: () => { + throw new HTTPException(501, { + message: "POST /notes is not implemented yet. Implement it in create.route.ts.", + }); +}, +``` + +Fill in the body schema (`z.object({})` to start with), add the success response to `responses`, and replace the `throw` with the real insert. The [Database](/docs/dev/database) lesson shows one. + + + + + +### Call it from a page + +The generator printed a `fetcher` call for each route. Use the one for `show` in a temporary component, `src/pages/check-notes.tsx`: + +```tsx title="plugins/site-notes/src/pages/check-notes.tsx" +import { Button } from "@vitnode/core/components/ui/button"; +import { fetcher } from "@vitnode/core/tanstack/fetcher"; +import { SearchIcon } from "lucide-react"; +import { useState } from "react"; + +export const CheckNotes = () => { + const [result, setResult] = useState(); + const [isPending, setIsPending] = useState(false); + + const onClick = async () => { + setIsPending(true); + try { + const response = await fetcher({ + plugin: "site-notes", + method: "get", + module: "notes", + path: "/{id}", + args: { params: { id: 7 } }, + }); + setResult(JSON.stringify(await response.json())); + } finally { + setIsPending(false); + } + }; + + return ( +
+ + {result ? {result} : null} +
+ ); +}; +``` + +Render `` in `src/pages/home-page.tsx`. TypeScript checks the call against the route: `id` must be a number, and a path the module does not have, such as `/missing`, is a compile error. + +
+ +
+ +## Check the result + +Open `/site-notes` and press **Show note 7**: + +```json +{ "sample": true, "id": 7, "message": "Sample data from show.route.ts. Replace it with the real response." } +``` + +Then remove `CheckNotes`. Two safety checks are worth knowing: + +- Generating a route the module already answers, such as `pnpm vitnode g route notes/all --method get --path /`, stops with `✖ GET /notes already exists.` and writes nothing. +- A name without a default, such as `notes/archive`, needs `--method` and `--path` in CI. In an interactive terminal the generator asks for them. + +The plugin's own pages see new routes at once. An app sees them after the plugin's declarations rebuild, which `pnpm dev` does on save. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/plugins/api/meta.json b/apps/web/content/docs/dev/plugins/api/meta.json index 224dcc25a8..92ea4840cf 100644 --- a/apps/web/content/docs/dev/plugins/api/meta.json +++ b/apps/web/content/docs/dev/plugins/api/meta.json @@ -1,6 +1,13 @@ { "title": "REST API", - "description": "Build typed Hono modules and routes inside your VitNode plugin", + "description": "Build typed Hono modules and routes inside your VitNode plugin, by hand or with vitnode generate", "icon": "Network", - "pages": ["modules", "routes"] + "pages": [ + "modules", + "routes", + "generate-module", + "generate-route", + "admin-routes", + "services" + ] } diff --git a/apps/web/content/docs/dev/plugins/api/modules.mdx b/apps/web/content/docs/dev/plugins/api/modules.mdx index 4bba4b7452..2bc3bf8ede 100644 --- a/apps/web/content/docs/dev/plugins/api/modules.mdx +++ b/apps/web/content/docs/dev/plugins/api/modules.mdx @@ -6,6 +6,10 @@ icon: Box An API module groups a plugin's Hono routes under one URL segment. In this lesson you add a `notes` module to `site-notes` whose endpoint returns two sample notes. A later lesson swaps the sample data for a database table. + + `pnpm vitnode g module notes` creates and registers an empty module in one step. See [Generate a module](/docs/dev/plugins/api/generate-module). This lesson writes it by hand so you see every part. + + ## Before you begin - The `site-notes` plugin from [Create plugin](/docs/dev/plugins/create), with `pnpm dev` running at the workspace root. `dev` rebuilds the plugin every time you save. diff --git a/apps/web/content/docs/dev/plugins/api/routes.mdx b/apps/web/content/docs/dev/plugins/api/routes.mdx index cc2b171177..ebea9db710 100644 --- a/apps/web/content/docs/dev/plugins/api/routes.mdx +++ b/apps/web/content/docs/dev/plugins/api/routes.mdx @@ -6,6 +6,10 @@ icon: Route Each route declares its inputs and outputs with Zod, and VitNode validates the request before your handler runs. In this lesson you add `GET /api/site-notes/notes/{id}`, which returns one note, refuses an id that is not a positive number, and answers `404` when no note has that id. + + `pnpm vitnode g route notes/show --path '/{id}' --id-type number` scaffolds this route and registers it. See [Generate an endpoint](/docs/dev/plugins/api/generate-route). + + ## Before you begin - The `notes` module from [API modules](/docs/dev/plugins/api/modules), still returning the sample notes in `sample-notes.ts`. diff --git a/apps/web/content/docs/dev/plugins/api/services.mdx b/apps/web/content/docs/dev/plugins/api/services.mdx new file mode 100644 index 0000000000..748a44c1c0 --- /dev/null +++ b/apps/web/content/docs/dev/plugins/api/services.mdx @@ -0,0 +1,126 @@ +--- +title: Add a service +description: Generate a plain service factory for a VitNode API module, give it explicit dependencies, and call it from a route or a background job without the Hono context. +icon: Workflow +--- + +A service holds a module's business operations - list the notes, publish one - as plain functions with explicit inputs. Routes keep the HTTP work: validating the request, checking permissions, choosing a status code. `vitnode g service` creates the file; there is no base class, no decorator and no container to register it with. + +## Before you begin + +- The `notes` module from [Generate a module](/docs/dev/plugins/api/generate-module), with a terminal in `plugins/site-notes`. + + + + + +### Generate the service + +```bash +pnpm vitnode g service notes +``` + +This creates `src/api/modules/notes/notes.service.ts` next to `notes.module.ts` and changes nothing else. Existing routes keep working as they are: + +```ts title="plugins/site-notes/src/api/modules/notes/notes.service.ts" +import type { EnvVariablesVitNode } from "@vitnode/core/api/middlewares/global.middleware"; + +/** What the notes service needs, passed in by whoever builds it. */ +export interface NotesServiceDeps { + /** In a route: `c.get("db")`. */ + db: EnvVariablesVitNode["db"]; +} + +/** + * Business operations for the `notes` module. + * + * Generated empty - it has no operations yet. ... + */ +export const createNotesService = (deps: NotesServiceDeps) => ({ deps }); +``` + +The scaffold has no operations, and says so. + + + + + +### Add an operation + +Replace the factory's body with real operations. This one uses the `site_notes` table from the [Database](/docs/dev/database) lesson; type the input with `z.infer` from the route's schema instead of writing a second interface: + +```ts title="plugins/site-notes/src/api/modules/notes/notes.service.ts" +import type { EnvVariablesVitNode } from "@vitnode/core/api/middlewares/global.middleware"; + +import { desc } from "drizzle-orm"; + +import { site_notes } from "@/database/notes"; + +export interface NotesServiceDeps { + db: EnvVariablesVitNode["db"]; +} + +export const createNotesService = ({ db }: NotesServiceDeps) => ({ + list: async ({ limit }: { limit: number }) => + db.select().from(site_notes).orderBy(desc(site_notes.id)).limit(limit), +}); +``` + + + + + +### Call it from a route + +Build the service inside the handler, from what the request holds: + +```ts title="plugins/site-notes/src/api/modules/notes/list.route.ts" +handler: async c => { + const notes = await createNotesService({ db: c.get("db") }).list({ limit: 20 }); + + return c.json(notes, 200); +}, +``` + +Update the route's `200` schema to match what `list` returns, as for any route. + + + + + +## Check the result + +Call `GET /api/site-notes/notes` with the `fetcher` button from [Generate an endpoint](/docs/dev/plugins/api/generate-route#call-it-from-a-page): the response is now the rows from `site_notes`. TypeScript checks the route's schema against what the service returns. + +## Where dependencies come from + +Build a service where its dependencies are, and pass them in: + +| Dependency | Lifetime | Where it comes from | +| -------------------------------------------- | -------------------------------- | --------------------------------------------------------------------- | +| The database client | Shared by the whole app | `c.get("db")` in a route, or in a cron or queue handler, which gets a context too | +| The acting user | One request | Read in the route, passed as an argument such as `actor: { id }` | +| Permission decisions | One request | Checked in the route (`adminStaffPermission`, `checkStaffPermission`) | +| A transaction | One operation | Opened by the caller and passed in place of `db` | + +- **Pass values, not the context.** `createNotesService({ db })` works in a route, a [cron job](/docs/dev/cron), a queue task or a script alike, and a caller can hand it a transaction instead of `db`. A service that takes the whole context ties every caller to it. +- **Keep request state out of module scope.** A service built once at import time would share one user, or one transaction, between requests. Building it per call is cheap: it is an object of functions. +- **Pass the actor explicitly** when an operation depends on who does it, rather than reading a session inside the service. +- **Errors.** Throw `HTTPException` from `hono/http-exception` for an outcome the route should answer with a status, as VitNode's own services do. There is no separate error hierarchy to learn. + +The generator never rewrites existing handlers to use a service: which route should call which operation, with which arguments, is your decision. + +## Related + + + + + diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 5553a8eb3b..5fe53311e3 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -288,6 +288,7 @@ "jose": "^6.2.15", "motion": "^14.10.1", "ocache": "^0.3.1", + "oxc-parser": "^0.148.0", "picocolors": "^1.1.1", "postgres": "^4.1.0", "rate-limiter-flexible": "^11.3.0", diff --git a/packages/vitnode/scripts/cli/commands/generate.ts b/packages/vitnode/scripts/cli/commands/generate.ts new file mode 100644 index 0000000000..4a33f3d544 --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/generate.ts @@ -0,0 +1,601 @@ +import type { CommandContext, OutputOptions } from "../context"; +import type { GenerateContext } from "../generate/generators"; +import type { PermissionRef } from "../generate/names"; +import type { FileChange } from "../generate/plan"; +import type { GeneratorProject } from "../generate/project"; +import type { HttpMethod, ParamType } from "../generate/templates"; +import type { Ui } from "../ui/ui"; + +import { EXIT_CODE, UserError } from "../errors"; +import { lookupModule, UnsupportedStructureError } from "../generate/api-tree"; +import { unifiedDiff } from "../generate/diff"; +import { formatPlan } from "../generate/format"; +import { + endpointPath, + ensureBuilders, + hasContentModules, + pathParams, + planModule, + planPermission, + planRoute, + planService, + readAdminPermissions, +} from "../generate/generators"; +import { + moduleIdentifier, + parseLogicalPath, + parsePermission, + routeIdentifier, +} from "../generate/names"; +import { applyPlan, GenerationPlan } from "../generate/plan"; +import { resolveGeneratorProject } from "../generate/project"; +import { plural } from "../ui/format"; + +export interface GenerateCommonOptions extends OutputOptions { + dryRun?: boolean; + project?: string; +} + +export interface GenerateModuleOptions extends GenerateCommonOptions { + admin?: boolean; + name?: string; + parents?: boolean; +} + +export interface GenerateRouteOptions extends GenerateCommonOptions { + admin?: boolean; + declarePermission?: boolean; + idType?: string; + method?: string; + parents?: boolean; + path?: string; + permission?: string; + target?: string; +} + +export interface GenerateServiceOptions extends GenerateCommonOptions { + admin?: boolean; + name?: string; +} + +const METHODS: readonly HttpMethod[] = [ + "get", + "post", + "put", + "patch", + "delete", +]; + +/** + * What a route name implies when `--method` or `--path` is left out. Any + * other name has no default path: `report.route.ts` is not served at + * `/report` unless someone says so. + */ +export const ROUTE_DEFAULTS: Record< + string, + { method: HttpMethod; path: string } +> = { + create: { method: "post", path: "/" }, + delete: { method: "delete", path: "/{id}" }, + list: { method: "get", path: "/" }, + show: { method: "get", path: "/{id}" }, + update: { method: "patch", path: "/{id}" }, +}; + +const PATH_SEGMENT = /^(?:[A-Za-z0-9._~-]+|\{[A-Za-z_][A-Za-z0-9_]*\})$/; + +/** Validates an OpenAPI route path: `/`, `/{id}`, `/{id}/archive`. */ +export const assertRoutePath = (path: string): string => { + const problem = (reason: string) => + new UserError(`"${path}" is not a supported route path: ${reason}.`, { + details: [ + "Use OpenAPI parameter syntax relative to the module: /, /{id}, /{id}/archive.", + "Each segment is plain text or one {param}; :id, *, {id?} and a{b} are not supported.", + ], + }); + + if (!path.startsWith("/")) throw problem("it must start with /"); + if (path === "/") return path; + if (path.endsWith("/")) throw problem("it must not end with /"); + + for (const segment of path.slice(1).split("/")) { + if (!PATH_SEGMENT.test(segment)) + throw problem(`"${segment}" is not a plain segment or a {param}`); + } + + const params = pathParams(path); + if (new Set(params).size !== params.length) + throw problem("a parameter appears twice"); + if (params.some(param => param === "sample" || param === "message")) { + throw problem( + "sample and message are reserved by the generated starter response", + ); + } + + return path; +}; + +const withAdmin = (path: string[], admin: boolean | undefined): string[] => { + if (!admin) return path; + if (path[0] === "admin") { + throw new UserError( + `"${path.join("/")}" is already in the admin module tree.`, + { + hint: "Drop --admin, or drop the admin/ prefix.", + }, + ); + } + + return ["admin", ...path]; +}; + +/** Asks before creating missing parent modules, where someone can be asked. */ +const resolveParents = async ( + context: CommandContext, + ctx: GenerateContext, + modulePath: readonly string[], + requiredDepth: number, + parents: boolean, +): Promise => { + if (parents || !context.ui.interactive) return parents; + + const lookup = lookupModule(ctx.plan, ctx.project, modulePath); + if (lookup.status === "found" || lookup.depth >= requiredDepth) return false; + + const missing = modulePath + .slice(lookup.depth, requiredDepth) + .map((_, index) => modulePath.slice(0, lookup.depth + index + 1).join("/")); + + return context.prompter.confirm( + `Create the missing ${plural(missing.length, "module")} ${missing.join(", ")}?`, + { default: true }, + ); +}; + +const renderDiff = (ui: Ui, change: FileChange) => { + for (const line of unifiedDiff(change.path, change.before, change.after)) { + if (ui.mode === "plain") ui.line(line); + else if (line.startsWith("+++") || line.startsWith("---")) + ui.line(ui.colors.bold(line)); + else if (line.startsWith("@@")) ui.line(ui.colors.command(line)); + else if (line.startsWith("+")) ui.line(ui.colors.success(line)); + else if (line.startsWith("-")) ui.line(ui.colors.error(line)); + else ui.line(line); + } +}; + +const renderChanges = ( + ui: Ui, + changes: readonly FileChange[], + dryRun: boolean, +) => { + for (const change of changes) { + const verb = + change.kind === "create" + ? dryRun + ? "would create" + : "created" + : dryRun + ? "would update" + : "updated"; + const text = `${verb.padEnd(12)} ${change.path}`; + const detail = change.reasons.join("; "); + + if (dryRun) ui.info(`${text} ${ui.colors.muted(detail)}`); + else ui.success(text, detail); + } +}; + +/** + * Runs a planned generation: format, then either print the diff (`--dry-run`) + * or write it, then the summary and what to do next. + */ +const finish = async ( + context: CommandContext, + ctx: GenerateContext, + { dryRun }: GenerateCommonOptions, + next: (ui: Ui) => void, +): Promise => { + const { ui } = context; + const formatting = await formatPlan(ctx.plan, ctx.plan.changes()); + const changes = ctx.plan.changes(); + + if (formatting.skipped !== undefined) { + ctx.notes.push(`Generated code was not formatted: ${formatting.skipped}.`); + } + + ui.keyValue([ + ["Plugin", `${ctx.project.id} ${ui.colors.muted(ctx.project.display)}`], + ]); + ui.line(); + + if (dryRun) { + for (const change of changes) { + renderDiff(ui, change); + ui.line(); + } + renderChanges(ui, changes, true); + } else { + applyPlan(ctx.plan.root, changes); + renderChanges(ui, changes, false); + } + + ui.line(); + ctx.warnings.forEach(warning => { + ui.warning(warning); + }); + ctx.notes.forEach(note => { + ui.note(note); + }); + if (ctx.warnings.length + ctx.notes.length > 0) ui.line(); + + if (dryRun) { + ui.note( + `Dry run: ${plural(changes.filter(change => change.kind === "create").length, "file")} to create, ${String(changes.filter(change => change.kind === "update").length)} to update. Nothing was written.`, + ); + ui.line(); + + return EXIT_CODE.ok; + } + + next(ui); + ui.line(); + + return EXIT_CODE.ok; +}; + +const start = async ( + context: CommandContext, + title: string, + options: GenerateCommonOptions, +): Promise => { + context.ui.header(options.dryRun ? `${title} (dry run)` : title); + + const project: GeneratorProject = await resolveGeneratorProject({ + cwd: context.cwd, + project: options.project, + prompter: context.prompter, + ui: context.ui, + }); + const ctx: GenerateContext = { + notes: [], + plan: new GenerationPlan(project.root), + project, + warnings: [], + }; + ensureBuilders(ctx); + + return ctx; +}; + +/** Re-throws an unsupported-structure error with the steps to do it by hand. */ +const withManualSteps = async ( + action: () => Promise | T, + manual: string[], +): Promise => { + try { + return await action(); + } catch (error) { + if (error instanceof UnsupportedStructureError) + throw error.withManual(manual); + throw error; + } +}; + +const mountPath = (project: GeneratorProject, path: string) => + `/${project.id}${path}`; + +const typesNote = (ui: Ui) => { + ui.note( + "Types: the plugin's own code sees the change at once. An app sees it once the plugin's declarations are rebuilt - vitnode dev does that on save, otherwise run vitnode build in the plugin.", + ); +}; + +export const runGenerateModuleCommand = async ( + context: CommandContext, + options: GenerateModuleOptions, +): Promise => { + if (options.name === undefined) + throw new UserError("Name the module, e.g. vitnode g module notes."); + const path = withAdmin( + parseLogicalPath(options.name, "module name"), + options.admin, + ); + const ctx = await start(context, "Generate a module", options); + const leaf = path[path.length - 1] ?? ""; + const identifier = moduleIdentifier(path); + await withManualSteps(async () => { + const parents = await resolveParents( + context, + ctx, + path, + path.length - 1, + options.parents ?? false, + ); + planModule(ctx, path, { parents }); + }, [ + `Create ${leaf}/${leaf}.module.ts next to the parent module (src/api/modules/ for a top-level one):`, + ` export const ${identifier} = buildModule({ name: ${JSON.stringify(leaf)}, routes: [] });`, + path.length === 1 + ? `Import ${identifier} in src/config.api.ts and add it to buildApiPlugin({ modules }).` + : `Import ${identifier} in the "${path.slice(0, -1).join("/")}" module and add it to its modules.`, + ]); + + return finish(context, ctx, options, ui => { + ui.note( + `Module ${path.join("/")} is mounted at ${mountPath(ctx.project, `/${path.join("/")}`)} under the app's API base path (/api in the starter apps).`, + ); + ui.note("It has no routes yet, so there is nothing to call. Add one with:"); + ui.line( + ` ${ui.colors.command(`vitnode g route ${path.join("/")}/list --method get`)}`, + ); + }); +}; + +const pickMethod = async ( + context: CommandContext, + name: string, + method: string | undefined, +): Promise => { + if (method !== undefined) { + const lower = method.toLowerCase(); + const known = METHODS.find(item => item === lower); + if (known === undefined) { + throw new UserError(`"${method}" is not a supported HTTP method.`, { + hint: `Use one of: ${METHODS.join(", ")}.`, + }); + } + + return known; + } + + const fallback = ROUTE_DEFAULTS[name]?.method; + if (fallback !== undefined) return fallback; + + if (!context.ui.interactive) { + throw new UserError(`The route "${name}" needs --method.`, { + details: [ + `Only ${Object.keys(ROUTE_DEFAULTS).join(", ")} imply a method.`, + ], + }); + } + + return context.prompter.select( + "HTTP method", + METHODS.map(item => ({ name: item.toUpperCase(), value: item })), + ); +}; + +const pickPath = async ( + context: CommandContext, + name: string, + path: string | undefined, +): Promise => { + if (path !== undefined) return assertRoutePath(path); + + const fallback = ROUTE_DEFAULTS[name]?.path; + if (fallback !== undefined) return fallback; + + if (!context.ui.interactive) { + throw new UserError(`The route "${name}" needs --path.`, { + details: [ + `Only ${Object.keys(ROUTE_DEFAULTS).join(", ")} imply a path; a file name is not a URL.`, + "Pass it relative to the module, e.g. --path / or --path '/{id}/archive'.", + ], + }); + } + + return assertRoutePath( + await context.prompter.text( + "Path, relative to the module (e.g. / or /{id})", + { + validate: value => { + try { + assertRoutePath(value); + + return true; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }, + ), + ); +}; + +const pickPermission = async ( + context: CommandContext, + ctx: GenerateContext, + permission: string | undefined, +): Promise => { + if (permission !== undefined) return parsePermission(permission); + + const declarations = readAdminPermissions(ctx); + const declared = + declarations.status === "resolved" + ? [...declarations.modules].flatMap(([module, names]) => + names.map(name => `${module}.${name}`), + ) + : []; + + if (!context.ui.interactive || declared.length === 0) { + throw new UserError( + "Admin routes need a staff permission: pass --permission .", + { + details: [ + "Every route under admin/ is checked against one, e.g. --permission notes.can_create.", + declared.length === 0 + ? "This plugin declares none yet - add --declare-permission to declare it as well." + : `Declared: ${declared.join(", ")}`, + ], + }, + ); + } + + return parsePermission( + await context.prompter.select( + "Staff permission required to call it", + declared.map(value => ({ name: value, value })), + ), + ); +}; + +const fetcherExample = ( + project: GeneratorProject, + modulePath: readonly string[], + method: HttpMethod, + path: string, + paramType: ParamType, +): string[] => { + const params = pathParams(path); + const sample = paramType === "number" ? "1" : '"1"'; + const args = [ + params.length === 0 + ? null + : `params: { ${params.map(param => `${param}: ${sample}`).join(", ")} }`, + method === "post" || method === "put" || method === "patch" + ? "body: {}" + : null, + ].filter(part => part !== null); + + return [ + 'import { fetcher } from "@vitnode/core/tanstack/fetcher";', + "", + "const response = await fetcher({", + ` plugin: ${JSON.stringify(project.id)},`, + ` method: ${JSON.stringify(method)},`, + ` module: ${JSON.stringify(modulePath.join("/"))},`, + ` path: ${JSON.stringify(path)},`, + ...(args.length === 0 ? [] : [` args: { ${args.join(", ")} },`]), + "});", + ]; +}; + +export const runGenerateRouteCommand = async ( + context: CommandContext, + options: GenerateRouteOptions, +): Promise => { + if (options.target === undefined) { + throw new UserError( + "Name the route as /, e.g. vitnode g route notes/list.", + ); + } + + const segments = parseLogicalPath(options.target, "route"); + if (segments.length < 2) { + throw new UserError(`"${options.target}" names no module.`, { + details: [ + "Write the route as /, e.g. notes/list or admin/notes/create.", + ], + }); + } + + const name = segments[segments.length - 1] ?? ""; + const modulePath = withAdmin(segments.slice(0, -1), options.admin); + const isAdmin = modulePath[0] === "admin"; + + if ( + !isAdmin && + (options.permission !== undefined || options.declarePermission) + ) { + throw new UserError("--permission only applies to admin routes.", { + details: [ + "An admin staff permission guards routes in the admin module tree, which also requires an AdminCP session.", + "Generate the route under admin/ (or with --admin) instead of guarding a public module.", + ], + }); + } + + const idType = options.idType ?? "string"; + if (idType !== "string" && idType !== "number") { + throw new UserError(`--id-type must be string or number, not "${idType}".`); + } + + const ctx = await start(context, "Generate a route", options); + const method = await pickMethod(context, name, options.method); + const path = await pickPath(context, name, options.path); + const permission = isAdmin + ? await pickPermission(context, ctx, options.permission) + : undefined; + const parents = await resolveParents( + context, + ctx, + modulePath, + modulePath.length, + options.parents ?? false, + ); + + if (permission !== undefined) { + planPermission(ctx, permission, { + contentModules: hasContentModules(ctx), + declare: options.declarePermission ?? false, + }); + } + + const { file } = await withManualSteps( + () => + planRoute(ctx, modulePath, { + method, + name, + paramType: idType, + parents, + path, + permission, + }), + [ + `Create ${name}.route.ts next to the "${modulePath.join("/")}" module file, exporting`, + ` ${routeIdentifier(name, modulePath)} = buildRoute({ route: { method: ${JSON.stringify(method)}, path: ${JSON.stringify(path)}, ... }, handler })`, + `Import it in the module file and add it to buildModule({ routes }).`, + ], + ); + + return finish(context, ctx, options, ui => { + const endpoint = endpointPath(modulePath, path); + ui.note( + `${method.toUpperCase()} ${mountPath(ctx.project, endpoint)} under the app's API base path (/api in the starter apps).`, + ); + ui.note( + method === "get" + ? `It answers with sample data. Replace the schema and handler in ${file}.` + : `It validates the request and answers 501 Not Implemented until you implement it in ${file}.`, + ); + if (permission !== undefined) { + ui.note( + `Guarded by the ${permission.module}.${permission.permission} staff permission. That decides who may call it, not which records they may touch.`, + ); + } + ui.line(); + ui.note("Call it from a page:"); + fetcherExample(ctx.project, modulePath, method, path, idType).forEach( + line => { + ui.line(line === "" ? "" : ` ${ui.colors.command(line)}`); + }, + ); + ui.line(); + typesNote(ui); + }); +}; + +export const runGenerateServiceCommand = async ( + context: CommandContext, + options: GenerateServiceOptions, +): Promise => { + if (options.name === undefined) + throw new UserError("Name the module, e.g. vitnode g service notes."); + const path = withAdmin( + parseLogicalPath(options.name, "module name"), + options.admin, + ); + const ctx = await start(context, "Generate a service", options); + const { factory, file } = planService(ctx, path); + + return finish(context, ctx, options, ui => { + ui.note( + `${factory} has no operations yet. Add them in ${file}, then build it in a route:`, + ); + ui.line( + ` ${ui.colors.command(`const service = ${factory}({ db: c.get("db") });`)}`, + ); + ui.note("Existing routes were not changed."); + }); +}; diff --git a/packages/vitnode/scripts/cli/generate/api-tree.ts b/packages/vitnode/scripts/cli/generate/api-tree.ts new file mode 100644 index 0000000000..a8108aee6e --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/api-tree.ts @@ -0,0 +1,429 @@ +import type { + ArrayExpression, + CallExpression, + Expression, + ObjectExpression, +} from "oxc-parser"; + +import { posix } from "node:path"; + +import type { ParsedSource } from "./ast"; +import type { GenerationPlan } from "./plan"; +import type { GeneratorProject } from "./project"; + +import { ConfigError } from "../errors"; +import { + arrayValue, + findCalls, + findConstInit, + getProperty, + hasSpread, + listImports, + objectArgument, + parseSource, + stringValue, + unwrap, +} from "./ast"; +import { API_CONFIG } from "./project"; + +/** Module builders whose module name is fixed by VitNode. */ +const KNOWN_MODULE_BUILDERS: Record = { + buildContentAdminModule: "content", + buildContentPublicModule: "content", +}; + +/** A `buildModule({...})` call the generator can read and edit. */ +export interface ModuleSource { + /** The `buildModule` call. */ + call: CallExpression; + /** Directory of the declaring file, relative to the project root. */ + dir: string; + exportName: string; + /** Relative to the project root. */ + file: string; + name: string; + options: ObjectExpression; + parsed: ParsedSource; +} + +/** One entry of a `modules` array. */ +export type ModuleEntry = + | { kind: "fixed"; label: string; name: string } + | { kind: "module"; source: ModuleSource } + | { kind: "unresolved"; label: string; reason: string }; + +/** The place modules are registered: the plugin root or a parent module. */ +export interface ModuleContainer { + /** The `modules` array, or `null` when the property does not exist yet. */ + array: ArrayExpression | null; + entries: ModuleEntry[]; + /** Relative path of the file holding the array. */ + file: string; + /** The options object the `modules` property belongs to. */ + options: ObjectExpression; + parsed: ParsedSource; +} + +/** Manual wiring instructions for a structure the generator will not edit. */ +export class UnsupportedStructureError extends ConfigError { + constructor(message: string, details: string[], manual: string[] = []) { + super(message, { + details: [ + ...details, + "Nothing was written.", + ...(manual.length === 0 + ? ["Wire it by hand, or simplify the structure to a direct call."] + : ["To wire it by hand:", ...manual.map(line => ` ${line}`)]), + ], + hint: 'Supported: buildApiPlugin({ modules: [...] }) and buildModule({ name: "...", routes: [...], modules: [...] }) with imported or local const identifiers.', + }); + this.reason = details; + this.summary = message; + } + readonly reason: string[]; + readonly summary: string; + + /** The same error, with the exact manual steps for what the command meant to do. */ + withManual(manual: string[]): UnsupportedStructureError { + return new UnsupportedStructureError(this.summary, this.reason, manual); + } +} + +export const readParsed = ( + plan: GenerationPlan, + file: string, +): ParsedSource => { + const source = plan.read(file); + if (source === null) { + throw new ConfigError(`${file} does not exist.`); + } + + return parseSource(file, source); +}; + +const label = (parsed: ParsedSource, node: { end: number; start: number }) => { + const text = parsed.source.slice(node.start, node.end).replace(/\s+/g, " "); + + return text.length > 60 ? `${text.slice(0, 57)}...` : text; +}; + +const EXTENSIONS = [".ts", ".tsx", "/index.ts", "/index.tsx"]; + +/** Resolves an import specifier to a project file, for `@/` and relative paths. */ +export const resolveSpecifier = ( + plan: GenerationPlan, + project: GeneratorProject, + fromFile: string, + specifier: string, +): null | string => { + let base: string; + if (specifier.startsWith("@/") && project.alias) { + base = posix.join("src", specifier.slice(2)); + } else if (specifier.startsWith(".")) { + base = posix.join(posix.dirname(fromFile), specifier); + } else { + return null; + } + + base = base.replace(/\.(?:js|ts)$/, ""); + + return ( + EXTENSIONS.map(ext => `${base}${ext}`).find(file => plan.exists(file)) ?? + null + ); +}; + +/** The import specifier `fromFile` should use to import `toFile`, in the project's style. */ +export const specifierFor = ( + project: GeneratorProject, + fromFile: string, + toFile: string, + preferAlias: boolean, +): string => { + const target = toFile.replace(/\.tsx?$/, ""); + if (preferAlias && project.alias && target.startsWith("src/")) { + return `@/${target.slice(4)}`; + } + + const rel = posix.relative(posix.dirname(fromFile), target); + + return rel.startsWith(".") ? rel : `./${rel}`; +}; + +/** Reads a `buildModule({...})` declaration by export name, or why it cannot. */ +export const readModuleSource = ( + plan: GenerationPlan, + file: string, + exportName: string, +): ModuleSource | string => { + const parsed = readParsed(plan, file); + const init = findConstInit(parsed.program, exportName); + + if (init === null) return `${file} does not declare const ${exportName}`; + + const call = unwrap(init); + if ( + call.type !== "CallExpression" || + call.callee.type !== "Identifier" || + call.callee.name !== "buildModule" + ) { + return `${exportName} in ${file} is not a direct buildModule({...}) call`; + } + + const options = objectArgument(call); + if (options === null) + return `buildModule in ${file} is not called with an object literal`; + + const name = stringValue(getProperty(options, "name")); + if (name === null) + return `the name of ${exportName} in ${file} is not a string literal`; + + return { + call, + dir: posix.dirname(file), + exportName, + file, + name, + options, + parsed, + }; +}; + +const entryOf = ( + plan: GenerationPlan, + project: GeneratorProject, + parsed: ParsedSource, + element: Expression, +): ModuleEntry => { + const value = unwrap(element); + + if ( + value.type === "CallExpression" && + value.callee.type === "Identifier" && + value.callee.name in KNOWN_MODULE_BUILDERS + ) { + return { + kind: "fixed", + label: label(parsed, value), + name: KNOWN_MODULE_BUILDERS[value.callee.name] ?? "", + }; + } + + if (value.type !== "Identifier") { + return { + kind: "unresolved", + label: label(parsed, value), + reason: "not a plain identifier", + }; + } + + const local = findConstInit(parsed.program, value.name); + if (local !== null) { + const source = readModuleSource(plan, parsed.path, value.name); + + return typeof source === "string" + ? { kind: "unresolved", label: value.name, reason: source } + : { kind: "module", source }; + } + + const imported = listImports(parsed.program) + .flatMap(info => + info.locals.map(local => ({ ...local, source: info.source })), + ) + .find(local => local.local === value.name); + + if (imported === undefined || imported.imported === null) { + return { + kind: "unresolved", + label: value.name, + reason: "its declaration was not found", + }; + } + + const file = resolveSpecifier(plan, project, parsed.path, imported.source); + if (file === null) { + return { + kind: "unresolved", + label: value.name, + reason: `"${imported.source}" does not resolve to a file in this plugin`, + }; + } + + const source = readModuleSource(plan, file, imported.imported); + + return typeof source === "string" + ? { kind: "unresolved", label: value.name, reason: source } + : { kind: "module", source }; +}; + +const containerOf = ( + plan: GenerationPlan, + project: GeneratorProject, + parsed: ParsedSource, + options: ObjectExpression, + where: string, +): ModuleContainer => { + const property = getProperty(options, "modules"); + + if (property === null) { + if (hasSpread(options)) { + throw new UnsupportedStructureError( + `${where} spreads other options, so its modules cannot be found safely.`, + [`File: ${parsed.path}`], + ); + } + + return { array: null, entries: [], file: parsed.path, options, parsed }; + } + + const array = arrayValue(property); + if (array === null) { + throw new UnsupportedStructureError( + `The modules of ${where} are not an array literal.`, + [`File: ${parsed.path}`, `Found: ${label(parsed, property.value)}`], + ); + } + + const entries = array.elements.flatMap(element => { + if (element === null) return []; + if (element.type === "SpreadElement") { + return [ + { + kind: "unresolved" as const, + label: label(parsed, element), + reason: "a spread element", + }, + ]; + } + + return [entryOf(plan, project, parsed, element)]; + }); + + return { array, entries, file: parsed.path, options, parsed }; +}; + +/** The `buildApiPlugin({...})` call in `src/config.api.ts` and its modules. */ +export const readRootContainer = ( + plan: GenerationPlan, + project: GeneratorProject, +): ModuleContainer => { + const parsed = readParsed(plan, API_CONFIG); + const calls = findCalls(parsed.program, "buildApiPlugin"); + + if (calls.length !== 1 || calls[0] === undefined) { + throw new UnsupportedStructureError( + calls.length === 0 + ? `${API_CONFIG} has no buildApiPlugin({...}) call.` + : `${API_CONFIG} calls buildApiPlugin more than once.`, + [ + "The generator registers top-level modules in that one call's modules array.", + ], + ); + } + + const options = objectArgument(calls[0]); + if (options === null) { + throw new UnsupportedStructureError( + `buildApiPlugin in ${API_CONFIG} is not called with an object literal.`, + [], + ); + } + + return containerOf(plan, project, parsed, options, "buildApiPlugin"); +}; + +export const readModuleContainer = ( + plan: GenerationPlan, + project: GeneratorProject, + module: ModuleSource, +): ModuleContainer => + containerOf( + plan, + project, + module.parsed, + module.options, + `module "${module.name}"`, + ); + +export type ModuleLookup = + | { + /** The deepest existing ancestor's container, at index `depth - 1` of the path. */ + container: ModuleContainer; + /** How many leading segments of the path exist. */ + depth: number; + /** The existing ancestors, outermost first. */ + found: ModuleSource[]; + status: "missing"; + } + | { container: ModuleContainer; module: ModuleSource; status: "found" }; + +/** + * Follows a logical module path (`admin/notes`) from `buildApiPlugin` down + * through each `buildModule`'s `modules`, reading source only - no plugin code + * runs. + * + * A segment registered by something the generator cannot read (a computed + * call, a spread) is reported, never guessed at. + */ +export const lookupModule = ( + plan: GenerationPlan, + project: GeneratorProject, + path: readonly string[], +): ModuleLookup => { + let container = readRootContainer(plan, project); + const found: ModuleSource[] = []; + + for (const [index, segment] of path.entries()) { + const matches = container.entries.filter( + (entry): entry is Exclude => + (entry.kind === "module" && entry.source.name === segment) || + (entry.kind === "fixed" && entry.name === segment), + ); + const where = + index === 0 ? API_CONFIG : `module "${path.slice(0, index).join("/")}"`; + + if (matches.length > 1) { + throw new UnsupportedStructureError( + `"${segment}" is registered more than once in ${where}.`, + [`File: ${container.file}`], + ); + } + + const match = matches[0]; + + if (match?.kind === "fixed") { + throw new UnsupportedStructureError( + `"${path.slice(0, index + 1).join("/")}" is generated by ${match.label}.`, + [ + "Generated Content Engine modules cannot take hand-written routes or children.", + ], + ); + } + + if (match === undefined) { + const unresolved = container.entries.filter( + entry => entry.kind === "unresolved", + ); + if (unresolved.length > 0) { + throw new UnsupportedStructureError( + `Could not tell whether "${segment}" already exists in ${where}.`, + unresolved.map(entry => + entry.kind === "unresolved" + ? `${entry.label}: ${entry.reason}` + : "", + ), + ); + } + + return { container, depth: index, found, status: "missing" }; + } + + found.push(match.source); + if (index === path.length - 1) { + return { container, module: match.source, status: "found" }; + } + container = readModuleContainer(plan, project, match.source); + } + + return { container, depth: path.length, found, status: "missing" }; +}; diff --git a/packages/vitnode/scripts/cli/generate/ast.ts b/packages/vitnode/scripts/cli/generate/ast.ts new file mode 100644 index 0000000000..c097b4629d --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/ast.ts @@ -0,0 +1,493 @@ +import type { + ArrayExpression, + CallExpression, + Expression, + ImportDeclaration, + ObjectExpression, + ObjectProperty, + Program, +} from "oxc-parser"; + +import { parseSync } from "oxc-parser"; + +import { ConfigError } from "../errors"; + +/** + * The generator reads source with oxc's parser and changes it with text edits + * at the exact spans the parser reports. Nothing is printed back from the + * tree, so comments, quotes, blank lines and every option the author wrote + * stay byte for byte; only the inserted text is new. + */ +export interface Edit { + end: number; + start: number; + text: string; +} + +export interface ParsedSource { + path: string; + program: Program; + source: string; +} + +export const parseSource = (path: string, source: string): ParsedSource => { + const result = parseSync(path, source, { + lang: path.endsWith(".tsx") ? "tsx" : "ts", + sourceType: "module", + }); + + if (result.errors.length > 0) { + throw new ConfigError(`${path} could not be parsed.`, { + details: result.errors.slice(0, 3).map(error => error.message), + hint: "Fix the syntax error first - the generator only edits files it can read.", + }); + } + + return { path, program: result.program, source }; +}; + +export const applyEdits = (source: string, edits: readonly Edit[]): string => + [...edits] + .sort((a, b) => b.start - a.start || b.end - a.end) + .reduce( + (text, edit) => + text.slice(0, edit.start) + edit.text + text.slice(edit.end), + source, + ); + +interface AnyNode { + end: number; + start: number; + type: string; +} + +const isNode = (value: unknown): value is AnyNode => + typeof value === "object" && + value !== null && + typeof (value as { type?: unknown }).type === "string" && + typeof (value as { start?: unknown }).start === "number"; + +/** Every node below `root`, depth first. */ +export const walk = (root: unknown, visit: (node: AnyNode) => void): void => { + if (Array.isArray(root)) { + for (const item of root) walk(item, visit); + + return; + } + if (!isNode(root)) return; + + visit(root); + for (const [key, value] of Object.entries(root)) { + if (key === "parent") continue; + if (typeof value === "object" && value !== null) walk(value, visit); + } +}; + +/** Calls of a plain identifier, e.g. every `buildModule(...)`. */ +export const findCalls = ( + program: Program, + callee: string, +): CallExpression[] => { + const calls: CallExpression[] = []; + walk(program, node => { + if (node.type !== "CallExpression") return; + const call = node as unknown as CallExpression; + if (call.callee.type === "Identifier" && call.callee.name === callee) { + calls.push(call); + } + }); + + return calls; +}; + +/** Strips `as const`, `satisfies X` and parentheses around an expression. */ +export const unwrap = (node: Expression): Expression => { + let current: Expression = node; + for (;;) { + if ( + current.type === "TSAsExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "ParenthesizedExpression" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } else { + return current; + } + } +}; + +export const objectArgument = ( + call: CallExpression, +): null | ObjectExpression => { + const [first] = call.arguments; + if (first === undefined || first.type === "SpreadElement") return null; + const value = unwrap(first); + + return value.type === "ObjectExpression" ? value : null; +}; + +/** A plain `key: value` (or shorthand) property, skipping spreads and computed keys. */ +export const getProperty = ( + object: ObjectExpression, + key: string, +): null | ObjectProperty => { + for (const property of object.properties) { + if (property.type !== "Property" || property.computed) continue; + const name = + property.key.type === "Identifier" + ? property.key.name + : property.key.type === "Literal" + ? String(property.key.value) + : null; + if (name === key) return property; + } + + return null; +}; + +export const hasSpread = (object: ObjectExpression): boolean => + object.properties.some(property => property.type === "SpreadElement"); + +/** The string a property holds, when it is a plain string literal. */ +export const stringValue = (property: null | ObjectProperty): null | string => { + if (property === null) return null; + const value = unwrap(property.value); + if (value.type === "Literal" && typeof value.value === "string") { + return value.value; + } + if ( + value.type === "TemplateLiteral" && + value.expressions.length === 0 && + value.quasis.length === 1 + ) { + return value.quasis[0]?.value.cooked ?? null; + } + + return null; +}; + +export const arrayValue = ( + property: null | ObjectProperty, +): ArrayExpression | null => { + if (property === null) return null; + const value = unwrap(property.value); + + return value.type === "ArrayExpression" ? value : null; +}; + +export const objectValue = ( + property: null | ObjectProperty, +): null | ObjectExpression => { + if (property === null) return null; + const value = unwrap(property.value); + + return value.type === "ObjectExpression" ? value : null; +}; + +const lineStart = (source: string, index: number) => + source.lastIndexOf("\n", index - 1) + 1; + +const indentAt = (source: string, index: number): string => + /^[ \t]*/.exec(source.slice(lineStart(source, index)))?.[0] ?? ""; + +/** The index just past the comma that follows `index`, if only whitespace separates them. */ +const commaAfter = (source: string, index: number): null | number => { + const match = /^\s*,/.exec(source.slice(index)); + + return match === null ? null : index + match[0].length; +}; + +/** + * Appends `text` as the last element of an array literal, in the array's own + * layout: on one line when it is written on one line, as a new line with the + * elements' indentation otherwise. Comments between elements stay put. + */ +export const appendToArray = ( + source: string, + array: ArrayExpression, + text: string, +): Edit[] => { + const elements = array.elements.filter( + (element): element is NonNullable => element !== null, + ); + const last = elements[elements.length - 1]; + + if (last === undefined) { + return [{ end: array.end - 1, start: array.start + 1, text }]; + } + + const inner = source.slice(array.start, array.end); + const comma = commaAfter(source, last.end); + + if (!inner.includes("\n")) { + return comma === null + ? [{ end: last.end, start: last.end, text: `, ${text}` }] + : [{ end: comma, start: comma, text: ` ${text}` }]; + } + + const closing = array.end - 1; + const closingLineStart = lineStart(source, closing); + const closingOnOwnLine = + source.slice(closingLineStart, closing).trim() === ""; + const indent = indentAt(source, last.start); + const edits: Edit[] = + comma === null ? [{ end: last.end, start: last.end, text: "," }] : []; + + edits.push( + closingOnOwnLine + ? { + end: closingLineStart, + start: closingLineStart, + text: `${indent}${text},\n`, + } + : { end: closing, start: closing, text: `,\n${indent}${text}` }, + ); + + return edits; +}; + +/** + * Adds `key: value` to an object literal: after the property named `after` + * when given, otherwise as the last property. + */ +export const addProperty = ( + source: string, + object: ObjectExpression, + text: string, + after?: string, +): Edit[] => { + const properties = object.properties; + const anchor = + (after === undefined ? null : getProperty(object, after)) ?? + properties[properties.length - 1]; + + if (anchor === undefined) { + const inner = source.slice(object.start + 1, object.end - 1); + + return [ + { + end: object.end - 1, + start: object.start + 1, + text: inner.includes("\n") + ? `\n${indentAt(source, object.start)} ${text},\n${indentAt(source, object.start)}` + : ` ${text} `, + }, + ]; + } + + const multiline = source.slice(object.start, object.end).includes("\n"); + const comma = commaAfter(source, anchor.end); + + if (!multiline) { + return comma === null + ? [{ end: anchor.end, start: anchor.end, text: `, ${text}` }] + : [{ end: comma, start: comma, text: ` ${text},` }]; + } + + const indent = indentAt(source, anchor.start); + // After the anchor's line, so a comment trailing the anchor stays with it. + const from = comma ?? anchor.end; + const newline = source.indexOf("\n", from); + const lineEnd = + newline === -1 || newline > object.end ? object.end - 1 : newline; + const edits: Edit[] = + comma === null ? [{ end: anchor.end, start: anchor.end, text: "," }] : []; + edits.push({ end: lineEnd, start: lineEnd, text: `\n${indent}${text},` }); + + return edits; +}; + +export interface ImportInfo { + declaration: ImportDeclaration; + isType: boolean; + /** Local names bound by this import. */ + locals: { imported: null | string; isType: boolean; local: string }[]; + source: string; +} + +export const listImports = (program: Program): ImportInfo[] => + program.body.flatMap(statement => { + if (statement.type !== "ImportDeclaration") return []; + + return [ + { + declaration: statement, + isType: statement.importKind === "type", + locals: statement.specifiers.map(specifier => ({ + imported: + specifier.type === "ImportSpecifier" + ? specifier.imported.type === "Identifier" + ? specifier.imported.name + : specifier.imported.value + : null, + isType: + statement.importKind === "type" || + (specifier.type === "ImportSpecifier" && + specifier.importKind === "type"), + local: specifier.local.name, + })), + source: statement.source.value, + }, + ]; + }); + +/** Every name declared at the top level of a module: imports, consts, functions, types. */ +export const topLevelNames = (program: Program): Set => { + const names = new Set(); + + for (const info of listImports(program)) { + for (const { local } of info.locals) names.add(local); + } + + for (const statement of program.body) { + const declaration = + statement.type === "ExportNamedDeclaration" + ? statement.declaration + : statement; + if (declaration === null || declaration === undefined) continue; + + if (declaration.type === "VariableDeclaration") { + for (const declarator of declaration.declarations) { + if (declarator.id.type === "Identifier") names.add(declarator.id.name); + } + } else if ( + (declaration.type === "FunctionDeclaration" || + declaration.type === "ClassDeclaration" || + declaration.type === "TSInterfaceDeclaration" || + declaration.type === "TSTypeAliasDeclaration" || + declaration.type === "TSEnumDeclaration") && + declaration.id + ) { + names.add(declaration.id.name); + } + } + + return names; +}; + +/** The initializer of `const name = ...` at the top level, exported or not. */ +export const findConstInit = ( + program: Program, + name: string, +): Expression | null => { + for (const statement of program.body) { + const declaration = + statement.type === "ExportNamedDeclaration" + ? statement.declaration + : statement; + if (declaration?.type !== "VariableDeclaration") continue; + + for (const declarator of declaration.declarations) { + if ( + declarator.id.type === "Identifier" && + declarator.id.name === name && + declarator.init + ) { + return declarator.init; + } + } + } + + return null; +}; + +/** Whether `name` is exported (as a declaration or in an export list). */ +export const isExported = (program: Program, name: string): boolean => + program.body.some(statement => { + if (statement.type !== "ExportNamedDeclaration") return false; + if (statement.declaration?.type === "VariableDeclaration") { + return statement.declaration.declarations.some( + declarator => + declarator.id.type === "Identifier" && declarator.id.name === name, + ); + } + + return statement.specifiers.some( + specifier => + specifier.exported.type === "Identifier" && + specifier.exported.name === name, + ); + }); + +const compareSpecifiers = (a: string, b: string): number => + a.localeCompare(b, "en", { numeric: true, sensitivity: "base" }); + +const importKind = (specifier: string): number => + specifier.startsWith(".") ? 2 : specifier.startsWith("@/") ? 1 : 0; + +/** + * Adds `import { name } from "specifier"` - into an existing value import of + * the same specifier when there is one, otherwise as a new declaration after + * the last import of the same kind (package, `@/` alias, relative). + */ +export const addNamedImport = ( + parsed: ParsedSource, + specifier: string, + name: string, + { type = false }: { type?: boolean } = {}, +): Edit[] => { + const { program, source } = parsed; + const imports = listImports(program); + + const existing = imports.find( + info => + info.source === specifier && + info.isType === type && + info.declaration.specifiers.every(s => s.type === "ImportSpecifier") && + info.declaration.specifiers.length > 0, + ); + + if (existing !== undefined) { + if (existing.locals.some(local => local.local === name)) return []; + const last = + existing.declaration.specifiers[ + existing.declaration.specifiers.length - 1 + ]; + if (last === undefined) return []; + + const comma = commaAfter(source, last.end); + + return [ + comma === null + ? { end: last.end, start: last.end, text: `, ${name}` } + : { end: comma, start: comma, text: ` ${name},` }, + ]; + } + + const statement = `import ${type ? "type " : ""}{ ${name} } from ${JSON.stringify(specifier)};`; + const kind = importKind(specifier); + const sameKind = imports.filter( + info => info.isType === type && importKind(info.source) === kind, + ); + + // Alphabetical within its group, as the repository's import sorting expects. + const before = sameKind.find( + info => compareSpecifiers(info.source, specifier) > 0, + ); + if (before !== undefined) { + return [ + { + end: before.declaration.start, + start: before.declaration.start, + text: `${statement}\n`, + }, + ]; + } + + const anchor = sameKind[sameKind.length - 1] ?? imports[imports.length - 1]; + + if (anchor === undefined) { + return [{ end: 0, start: 0, text: `${statement}\n\n` }]; + } + + const separator = sameKind.length > 0 ? "\n" : "\n\n"; + + return [ + { + end: anchor.declaration.end, + start: anchor.declaration.end, + text: `${separator}${statement}`, + }, + ]; +}; diff --git a/packages/vitnode/scripts/cli/generate/diff.ts b/packages/vitnode/scripts/cli/generate/diff.ts new file mode 100644 index 0000000000..8d889f918f --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/diff.ts @@ -0,0 +1,128 @@ +interface Op { + kind: " " | "+" | "-"; + line: string; +} + +const MAX_CELLS = 4_000_000; + +const splitLines = (text: string): string[] => { + const lines = text.split("\n"); + if (lines[lines.length - 1] === "") lines.pop(); + + return lines; +}; + +/** A line-level edit script, by longest common subsequence. */ +const diffLines = (a: readonly string[], b: readonly string[]): Op[] => { + // Trim the common head and tail first: a generator's edit is a few lines in + // a file that is otherwise unchanged. + let head = 0; + while (head < a.length && head < b.length && a[head] === b[head]) head += 1; + let tail = 0; + while ( + tail < a.length - head && + tail < b.length - head && + a[a.length - 1 - tail] === b[b.length - 1 - tail] + ) { + tail += 1; + } + + const midA = a.slice(head, a.length - tail); + const midB = b.slice(head, b.length - tail); + const ops: Op[] = a.slice(0, head).map(line => ({ kind: " ", line })); + + if ((midA.length + 1) * (midB.length + 1) > MAX_CELLS) { + ops.push(...midA.map(line => ({ kind: "-" as const, line }))); + ops.push(...midB.map(line => ({ kind: "+" as const, line }))); + } else { + const width = midB.length + 1; + const table = new Uint32Array((midA.length + 1) * width); + for (let i = midA.length - 1; i >= 0; i -= 1) { + for (let j = midB.length - 1; j >= 0; j -= 1) { + table[i * width + j] = + midA[i] === midB[j] + ? (table[(i + 1) * width + j + 1] ?? 0) + 1 + : Math.max( + table[(i + 1) * width + j] ?? 0, + table[i * width + j + 1] ?? 0, + ); + } + } + + let i = 0; + let j = 0; + while (i < midA.length && j < midB.length) { + if (midA[i] === midB[j]) { + ops.push({ kind: " ", line: midA[i] ?? "" }); + i += 1; + j += 1; + } else if ( + (table[(i + 1) * width + j] ?? 0) >= (table[i * width + j + 1] ?? 0) + ) { + ops.push({ kind: "-", line: midA[i] ?? "" }); + i += 1; + } else { + ops.push({ kind: "+", line: midB[j] ?? "" }); + j += 1; + } + } + while (i < midA.length) ops.push({ kind: "-", line: midA[i++] ?? "" }); + while (j < midB.length) ops.push({ kind: "+", line: midB[j++] ?? "" }); + } + + ops.push( + ...a.slice(a.length - tail).map(line => ({ kind: " " as const, line })), + ); + + return ops; +}; + +/** + * A unified diff of one file, `git diff` style, with three lines of context. + * A created file is diffed against nothing. + */ +export const unifiedDiff = ( + path: string, + before: null | string, + after: string, + context = 3, +): string[] => { + const ops = diffLines(splitLines(before ?? ""), splitLines(after)); + const out = [ + `--- ${before === null ? "/dev/null" : `a/${path}`}`, + `+++ b/${path}`, + ]; + + const changed = ops.flatMap((op, index) => (op.kind === " " ? [] : [index])); + if (changed.length === 0) return out; + + // Group changed lines whose context windows touch into hunks. + const hunks: [number, number][] = []; + for (const index of changed) { + const start = Math.max(0, index - context); + const end = Math.min(ops.length, index + context + 1); + const last = hunks[hunks.length - 1]; + if (last !== undefined && start <= last[1]) + last[1] = Math.max(last[1], end); + else hunks.push([start, end]); + } + + for (const [start, end] of hunks) { + let oldLine = 1; + let newLine = 1; + for (const op of ops.slice(0, start)) { + if (op.kind !== "+") oldLine += 1; + if (op.kind !== "-") newLine += 1; + } + const slice = ops.slice(start, end); + const oldCount = slice.filter(op => op.kind !== "+").length; + const newCount = slice.filter(op => op.kind !== "-").length; + + out.push( + `@@ -${String(oldCount === 0 ? oldLine - 1 : oldLine)},${String(oldCount)} +${String(newCount === 0 ? newLine - 1 : newLine)},${String(newCount)} @@`, + ); + out.push(...slice.map(op => `${op.kind}${op.line}`)); + } + + return out; +}; diff --git a/packages/vitnode/scripts/cli/generate/format.ts b/packages/vitnode/scripts/cli/generate/format.ts new file mode 100644 index 0000000000..3946db3e99 --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/format.ts @@ -0,0 +1,114 @@ +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import type { FileChange, GenerationPlan } from "./plan"; + +import { errorMessage } from "../errors"; + +type FormatFn = ( + fileName: string, + source: string, + options?: Record, +) => Promise<{ code: string; errors: unknown[] }>; + +const CONFIG_FILES = [ + "oxfmt.config.ts", + "oxfmt.config.mts", + "oxfmt.config.js", + "oxfmt.config.mjs", + ".oxfmtrc.json", +]; + +/** The closest oxfmt config at or above `from`. */ +const findConfig = (from: string): null | string => { + let current = resolve(from); + + for (;;) { + const found = CONFIG_FILES.map(file => join(current, file)).find(file => + existsSync(file), + ); + if (found !== undefined) return found; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +}; + +const loadConfig = async (file: string): Promise> => { + if (file.endsWith(".json")) { + return JSON.parse(readFileSync(file, "utf8")) as Record; + } + + const { createJiti } = await import("jiti"); + const loaded = await createJiti(pathToFileURL(file).href).import<{ + default?: Record; + }>(file); + + return loaded.default ?? {}; +}; + +export interface FormatOutcome { + /** Why the generated code was left as written, when it was. */ + skipped?: string; +} + +/** + * Formats the planned files with the project's own oxfmt and config, the way + * `pnpm format` would - so a generated file passes `format:check` as written. + * + * A created file is always formatted. An updated file only when the original + * was already formatted: otherwise formatting it would also rewrite code the + * generator did not touch, and the diff would show changes nobody asked for. + * Without oxfmt the generated code keeps VitNode's default style. + */ +export const formatPlan = async ( + plan: GenerationPlan, + changes: readonly FileChange[], +): Promise => { + let format: FormatFn; + let options: Record = {}; + + try { + const oxfmtPath = createRequire(join(plan.root, "package.json")).resolve( + "oxfmt", + ); + ({ format } = (await import(pathToFileURL(oxfmtPath).href)) as { + format: FormatFn; + }); + } catch { + return { skipped: "oxfmt is not installed in this project" }; + } + + const configFile = findConfig(plan.root); + if (configFile !== null) { + try { + const { ignorePatterns: _ignored, ...rest } = + await loadConfig(configFile); + options = rest; + } catch (error) { + return { + skipped: `the oxfmt config could not be loaded (${errorMessage(error).split("\n")[0] ?? ""})`, + }; + } + } + + for (const change of changes) { + if (!/\.(?:ts|tsx|mts|cts|js|mjs)$/.test(change.path)) continue; + + if (change.before !== null) { + const original = await format(change.path, change.before, options); + if (original.errors.length > 0 || original.code !== change.before) { + continue; + } + } + + const formatted = await format(change.path, change.after, options); + if (formatted.errors.length === 0) { + plan.replaceContent(change.path, formatted.code); + } + } + + return {}; +}; diff --git a/packages/vitnode/scripts/cli/generate/generators.ts b/packages/vitnode/scripts/cli/generate/generators.ts new file mode 100644 index 0000000000..050d3687a1 --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/generators.ts @@ -0,0 +1,756 @@ +import { posix } from "node:path"; + +import type { ModuleContainer, ModuleSource } from "./api-tree"; +import type { Edit, ParsedSource } from "./ast"; +import type { PermissionRef } from "./names"; +import type { GenerationPlan } from "./plan"; +import type { GeneratorProject } from "./project"; +import type { HttpMethod, ParamType } from "./templates"; + +import { ConfigError, UserError } from "../errors"; +import { + lookupModule, + readModuleContainer, + readModuleSource, + readParsed, + readRootContainer, + resolveSpecifier, + specifierFor, + UnsupportedStructureError, +} from "./api-tree"; +import { + addNamedImport, + addProperty, + appendToArray, + applyEdits, + arrayValue, + findConstInit, + getProperty, + hasSpread, + isExported, + listImports, + objectArgument, + objectValue, + stringValue, + topLevelNames, + unwrap, +} from "./ast"; +import { + moduleIdentifier, + permissionLabel, + routeIdentifier, + serviceFactoryIdentifier, +} from "./names"; +import { API_BUILDERS, API_CONFIG, MODULES_DIR } from "./project"; +import { + buildersTemplate, + builtModuleTemplate, + routeTemplate, + serviceTemplate, +} from "./templates"; + +export interface GenerateContext { + /** Lines worth showing after the summary: what to do next, what was not checked. */ + notes: string[]; + plan: GenerationPlan; + project: GeneratorProject; + warnings: string[]; +} + +const MANIFEST = "src/manifest.gen.ts"; + +/** + * The plugin's bound `buildModule` and `buildRoute` (`src/api/builders.ts`), + * which every generated file imports. Created from the generated manifest + * when missing; an existing file that lacks either export is reported, never + * rewritten. + */ +export const ensureBuilders = (ctx: GenerateContext): void => { + const { plan, project } = ctx; + + if (plan.exists(API_BUILDERS)) { + const parsed = readParsed(plan, API_BUILDERS); + const missing = ["buildModule", "buildRoute"].filter( + name => !isExported(parsed.program, name), + ); + if (missing.length > 0) { + throw new ConfigError( + `${API_BUILDERS} does not export ${missing.join(" and ")}.`, + { + details: [ + "Generated files import the plugin-bound builders from it. Add them:", + " const apiBuilders = bindApiPlugin(manifest);", + " export const buildModule = apiBuilders.buildModule;", + " export const buildRoute = apiBuilders.buildRoute;", + ], + }, + ); + } + + return; + } + + const manifest = plan.read(MANIFEST); + if ( + manifest === null || + !isExported(readParsed(plan, MANIFEST).program, "manifest") + ) { + throw new ConfigError( + `${API_BUILDERS} is missing, and so is the plugin's manifest.`, + { + details: [ + `The builders bind to the plugin id through ${MANIFEST}, which vitnode build and vitnode dev write.`, + ], + hint: "Run vitnode build in the plugin once, then generate again.", + }, + ); + } + + plan.create( + API_BUILDERS, + buildersTemplate(specifierFor(project, API_BUILDERS, MANIFEST, true)), + "the plugin-bound buildModule and buildRoute", + ); +}; + +const buildersSpecifier = (project: GeneratorProject, file: string) => + specifierFor(project, file, API_BUILDERS, true); + +const assertFreeName = (parsed: ParsedSource, name: string) => { + if (topLevelNames(parsed.program).has(name)) { + throw new UserError(`${parsed.path} already declares ${name}.`, { + details: [ + "The generator does not shadow or rename existing identifiers.", + ], + }); + } +}; + +/** Whether the root config imports its modules with `@/` or a relative path. */ +const prefersAlias = (parsed: ParsedSource): boolean => { + const moduleImports = listImports(parsed.program).filter(info => + /(?:^@\/|^\.\/)api\/modules\//.test(info.source), + ); + if (moduleImports.length === 0) return true; + + return ( + moduleImports.filter(info => info.source.startsWith("@/")).length >= + moduleImports.length / 2 + ); +}; + +/** + * Imports `identifier` from `file` into the container's file and appends it to + * its `modules` array - creating the property after `routes` (or last, in + * `buildApiPlugin`) when it does not exist. + */ +const registerModule = ( + ctx: GenerateContext, + container: ModuleContainer, + identifier: string, + file: string, + where: string, +) => { + const { parsed } = container; + assertFreeName(parsed, identifier); + + const isRoot = container.file === API_CONFIG; + const specifier = specifierFor( + ctx.project, + container.file, + file, + isRoot && prefersAlias(parsed), + ); + const edits: Edit[] = [...addNamedImport(parsed, specifier, identifier)]; + + if (container.array === null) { + edits.push( + ...addProperty( + parsed.source, + container.options, + `modules: [${identifier}]`, + isRoot ? undefined : "routes", + ), + ); + } else { + edits.push(...appendToArray(parsed.source, container.array, identifier)); + } + + ctx.plan.update( + container.file, + applyEdits(parsed.source, edits), + `register ${identifier} in ${where}`, + ); +}; + +export interface ModuleOptions { + /** Create missing parent modules instead of failing. */ + parents: boolean; +} + +/** + * Plans `src/api/modules//.module.ts` and its registration: in + * `buildApiPlugin` for a top-level module, in the parent's `modules` for a + * child. Missing parents are an error unless `parents` is set. + */ +export const planModule = ( + ctx: GenerateContext, + path: readonly string[], + { parents }: ModuleOptions, +): ModuleSource => { + const { plan, project } = ctx; + const lookup = lookupModule(plan, project, path); + + if (lookup.status === "found") { + throw new UserError(`Module "${path.join("/")}" already exists.`, { + details: [ + `Declared as ${lookup.module.exportName} in ${lookup.module.file}.`, + ], + }); + } + + if (lookup.depth < path.length - 1 && !parents) { + const missing = path.slice(0, lookup.depth + 1).join("/"); + throw new UserError(`The parent module "${missing}" does not exist.`, { + details: [ + `"${path.join("/")}" is registered inside its parent, so the parent has to exist first.`, + ], + hint: `Create it with vitnode g module ${missing}, or pass --parents to create every missing parent.`, + }); + } + + let container = lookup.container; + let dir = + lookup.found.length === 0 + ? MODULES_DIR + : (lookup.found[lookup.found.length - 1]?.dir ?? MODULES_DIR); + let created: ModuleSource | null = null; + + for (let index = lookup.depth; index < path.length; index += 1) { + const segment = path[index] ?? ""; + const modulePath = path.slice(0, index + 1); + const identifier = moduleIdentifier(modulePath); + const file = posix.join(dir, segment, `${segment}.module.ts`); + + plan.create( + file, + builtModuleTemplate({ + builders: buildersSpecifier(project, file), + identifier, + name: segment, + }), + `module "${modulePath.join("/")}"`, + ); + + registerModule( + ctx, + container, + identifier, + file, + index === 0 + ? "buildApiPlugin" + : `module "${path.slice(0, index).join("/")}"`, + ); + + const source = readModuleSource(plan, file, identifier); + if (typeof source === "string") { + throw new ConfigError( + `The generated module could not be read back: ${source}.`, + ); + } + + created = source; + container = readModuleContainer(plan, project, created); + dir = created.dir; + } + + if (created === null) throw new ConfigError("No module was planned."); + + return created; +}; + +/** `/notes/{id}` → `/notes/{}`, so two spellings of one parameter collide. */ +const effectivePath = (path: string) => + path.replace(/\{[^}]*\}/g, "{}").replace(/\/+$/, "") || "/"; + +interface ExistingRoute { + label: string; + method: null | string; + path: null | string; +} + +/** Reads each entry of a module's `routes` array back to its method and path. */ +const existingRoutes = ( + ctx: GenerateContext, + module: ModuleSource, +): ExistingRoute[] => { + const routes = arrayValue(getProperty(module.options, "routes")); + if (routes === null) return []; + + const imports = listImports(module.parsed.program).flatMap(info => + info.locals.map(local => ({ ...local, source: info.source })), + ); + + return routes.elements.flatMap(element => { + if (element === null || element.type === "SpreadElement") { + return [{ label: "a spread element", method: null, path: null }]; + } + const value = unwrap(element); + if (value.type !== "Identifier") { + return [ + { + label: module.parsed.source.slice(value.start, value.end), + method: null, + path: null, + }, + ]; + } + + let parsed: null | ParsedSource = null; + let name = value.name; + if (findConstInit(module.parsed.program, name) !== null) { + parsed = module.parsed; + } else { + const imported = imports.find(local => local.local === name); + const file = + imported === undefined + ? null + : resolveSpecifier( + ctx.plan, + ctx.project, + module.file, + imported.source, + ); + if (file !== null && imported?.imported) { + parsed = readParsed(ctx.plan, file); + name = imported.imported; + } + } + + const init = parsed === null ? null : findConstInit(parsed.program, name); + const call = init === null ? null : unwrap(init); + const options = + call?.type === "CallExpression" && + call.callee.type === "Identifier" && + call.callee.name === "buildRoute" + ? objectArgument(call) + : null; + const route = + options === null ? null : objectValue(getProperty(options, "route")); + + return [ + { + label: value.name, + method: + route === null ? null : stringValue(getProperty(route, "method")), + path: route === null ? null : stringValue(getProperty(route, "path")), + }, + ]; + }); +}; + +export interface RouteOptions { + method: HttpMethod; + name: string; + paramType: ParamType; + parents: boolean; + path: string; + permission?: PermissionRef; +} + +/** Path parameter names of an OpenAPI path, `/{id}/notes/{noteId}` → `["id", "noteId"]`. */ +export const pathParams = (path: string): string[] => + [...path.matchAll(/\{([^}]+)\}/g)].map(match => match[1] ?? ""); + +/** The mount path of a route, for messages: `/notes/{id}`. */ +export const endpointPath = (modulePath: readonly string[], path: string) => + `/${modulePath.join("/")}${path === "/" ? "" : path}`; + +/** + * Plans `/.route.ts` and its entry in the module's `routes`. + * + * Refused before anything is written when the file or identifier exists, or + * when an existing route of the module answers the same method and path. + */ +export const planRoute = ( + ctx: GenerateContext, + modulePath: readonly string[], + options: RouteOptions, +): { file: string; identifier: string } => { + const { plan, project } = ctx; + const lookup = lookupModule(plan, project, modulePath); + + let module: ModuleSource; + if (lookup.status === "found") { + module = lookup.module; + } else if (options.parents) { + module = planModule(ctx, modulePath, { parents: true }); + } else { + throw new UserError(`Module "${modulePath.join("/")}" does not exist.`, { + hint: `Create it with vitnode g module ${modulePath.join("/")}, or pass --parents to create the missing modules.`, + }); + } + + const routesProperty = getProperty(module.options, "routes"); + const routes = arrayValue(routesProperty); + if (routesProperty !== null && routes === null) { + throw new UnsupportedStructureError( + `The routes of module "${modulePath.join("/")}" are not an array literal.`, + [`File: ${module.file}`], + ); + } + if (routesProperty === null && hasSpread(module.options)) { + throw new UnsupportedStructureError( + `Module "${modulePath.join("/")}" spreads other options, so its routes cannot be found safely.`, + [`File: ${module.file}`], + ); + } + + const endpoint = `${options.method.toUpperCase()} ${endpointPath(modulePath, options.path)}`; + const unchecked: string[] = []; + + for (const route of existingRoutes(ctx, module)) { + if (route.method === null || route.path === null) { + unchecked.push(route.label); + continue; + } + if ( + route.method.toLowerCase() === options.method && + effectivePath(route.path) === effectivePath(options.path) + ) { + throw new UserError(`${endpoint} already exists.`, { + details: [ + `${route.label} in module "${modulePath.join("/")}" answers ${route.method.toUpperCase()} ${route.path}.`, + ], + hint: "Pick another --path or --method.", + }); + } + } + + if (unchecked.length > 0) { + ctx.warnings.push( + `Could not read the method and path of ${unchecked.join(", ")} in module "${modulePath.join("/")}" - check them for a collision with ${endpoint}.`, + ); + } + + const identifier = routeIdentifier(options.name, modulePath); + const file = posix.join(module.dir, `${options.name}.route.ts`); + assertFreeName(module.parsed, identifier); + + plan.create( + file, + routeTemplate({ + builders: buildersSpecifier(project, file), + endpoint, + fileName: `${options.name}.route.ts`, + identifier, + method: options.method, + params: pathParams(options.path), + paramType: options.paramType, + path: options.path, + permission: options.permission, + }), + endpoint, + ); + + const edits: Edit[] = [ + ...addNamedImport( + module.parsed, + specifierFor(project, module.file, file, false), + identifier, + ), + ...(routes === null + ? addProperty( + module.parsed.source, + module.options, + `routes: [${identifier}]`, + "name", + ) + : appendToArray(module.parsed.source, routes, identifier)), + ]; + plan.update( + module.file, + applyEdits(module.parsed.source, edits), + `register ${identifier} in module "${modulePath.join("/")}"`, + ); + + return { file, identifier }; +}; + +export type PermissionDeclarations = + | { modules: Map; status: "resolved" } + | { reason: string; status: "unresolved" }; + +/** The admin staff permissions `buildApiPlugin({ permissionStaff })` declares, read statically. */ +export const readAdminPermissions = ( + ctx: GenerateContext, +): PermissionDeclarations => { + const root = readRootContainer(ctx.plan, ctx.project); + const permissionStaff = getProperty(root.options, "permissionStaff"); + + if (permissionStaff === null) { + return hasSpread(root.options) + ? { reason: "buildApiPlugin spreads other options", status: "unresolved" } + : { modules: new Map(), status: "resolved" }; + } + + const staff = objectValue(permissionStaff); + if (staff === null) { + return { + reason: "permissionStaff is not an object literal", + status: "unresolved", + }; + } + + const admin = getProperty(staff, "admin"); + if (admin === null) { + return hasSpread(staff) + ? { reason: "permissionStaff spreads other values", status: "unresolved" } + : { modules: new Map(), status: "resolved" }; + } + + const adminObject = objectValue(admin); + if (adminObject === null || hasSpread(adminObject)) { + return { + reason: "permissionStaff.admin is not a plain object literal", + status: "unresolved", + }; + } + + const modules = new Map(); + for (const property of adminObject.properties) { + if (property.type !== "Property" || property.computed) { + return { + reason: "permissionStaff.admin has a computed key", + status: "unresolved", + }; + } + const key = + property.key.type === "Identifier" + ? property.key.name + : property.key.type === "Literal" + ? String(property.key.value) + : null; + const entries = arrayValue(property); + if (key === null || entries === null) { + return { + reason: `permissionStaff.admin.${key ?? "?"} is not an array literal`, + status: "unresolved", + }; + } + + const names: string[] = []; + for (const element of entries.elements) { + const value = + element === null || element.type === "SpreadElement" + ? null + : unwrap(element); + if (value?.type === "Literal" && typeof value.value === "string") { + names.push(value.value); + } else if (value?.type === "ObjectExpression") { + const permission = stringValue(getProperty(value, "permission")); + if (permission === null) { + return { + reason: `an entry of permissionStaff.admin.${key} has no literal permission`, + status: "unresolved", + }; + } + names.push(permission); + } else { + return { + reason: `permissionStaff.admin.${key} has an entry that is not a literal`, + status: "unresolved", + }; + } + } + modules.set(key, names); + } + + return { modules, status: "resolved" }; +}; + +const LOCALE = "src/locales/en.json"; + +/** Adds the AdminCP labels of a declared permission to `src/locales/en.json`, when it is safe to. */ +const planPermissionLabels = (ctx: GenerateContext, ref: PermissionRef) => { + const source = ctx.plan.read(LOCALE); + const keys = { + [`${ctx.project.id}:${ref.module}`]: ref.module + .replace(/[-_]+/g, " ") + .replace(/^./, char => char.toUpperCase()), + [`${ctx.project.id}:${ref.module}:${ref.permission}`]: permissionLabel(ref), + }; + const manual = `Add the AdminCP labels to the root of ${LOCALE}: ${Object.entries( + keys, + ) + .map(([key, value]) => `"${key}": "${value}"`) + .join(", ")}.`; + + if (source === null) { + ctx.notes.push(manual); + + return; + } + + let messages: Record; + try { + messages = JSON.parse(source) as Record; + } catch { + ctx.warnings.push(`${LOCALE} is not valid JSON. ${manual}`); + + return; + } + + // Rewritten only when it is already in the canonical layout, so the change + // is exactly the added keys. + if (`${JSON.stringify(messages, null, 2)}\n` !== source) { + ctx.notes.push(manual); + + return; + } + + const missing = Object.entries(keys).filter(([key]) => !(key in messages)); + if (missing.length === 0) return; + + ctx.plan.update( + LOCALE, + `${JSON.stringify({ ...messages, ...Object.fromEntries(missing) }, null, 2)}\n`, + `AdminCP labels for ${ref.module}.${ref.permission}`, + ); +}; + +/** + * Makes sure `ref` is a declared admin staff permission. + * + * Declared: nothing to do. Missing: added to `permissionStaff.admin` (and its + * labels to `en.json`) when `declare` is set, a usage error otherwise. + * Declarations the generator cannot read are never guessed at. + */ +export const planPermission = ( + ctx: GenerateContext, + ref: PermissionRef, + { declare, contentModules }: { contentModules: boolean; declare: boolean }, +): void => { + const declarations = readAdminPermissions(ctx); + const wanted = `${ref.module}.${ref.permission}`; + const snippet = `permissionStaff: { admin: { ${ref.module}: [${JSON.stringify(ref.permission)}] } }`; + + if (declarations.status === "unresolved") { + if (declare) { + throw new UnsupportedStructureError( + `Cannot declare ${wanted}: ${declarations.reason}.`, + [ + `Add "${ref.permission}" to permissionStaff.admin.${ref.module} in ${API_CONFIG} by hand.`, + ], + ); + } + ctx.warnings.push( + `Could not verify that ${wanted} is declared (${declarations.reason}). Check ${API_CONFIG}.`, + ); + + return; + } + + if (declarations.modules.get(ref.module)?.includes(ref.permission) === true) + return; + + if (!declare) { + if (contentModules && !declarations.modules.has(ref.module)) { + ctx.warnings.push( + `${wanted} is not declared in ${API_CONFIG}. If a content type generates the "${ref.module}" permission module, this is fine; otherwise nobody can be granted it. Run vitnode plugin validate after building to check.`, + ); + + return; + } + + throw new UserError(`The staff permission ${wanted} is not declared.`, { + details: [ + `Nobody can be granted a permission the plugin does not declare in ${API_CONFIG}:`, + ` ${snippet}`, + ], + hint: "Pass --declare-permission to add it (and its labels), or pick a declared one.", + }); + } + + const root = readRootContainer(ctx.plan, ctx.project); + const { parsed } = root; + const edits: Edit[] = []; + const staff = objectValue(getProperty(root.options, "permissionStaff")); + const admin = + staff === null ? null : objectValue(getProperty(staff, "admin")); + const moduleEntries = + admin === null ? null : arrayValue(getProperty(admin, ref.module)); + const key = /^[A-Za-z_$][\w$]*$/.test(ref.module) + ? ref.module + : JSON.stringify(ref.module); + + if (staff === null) { + edits.push(...addProperty(parsed.source, root.options, snippet)); + } else if (admin === null) { + edits.push( + ...addProperty( + parsed.source, + staff, + `admin: { ${key}: [${JSON.stringify(ref.permission)}] }`, + ), + ); + } else if (moduleEntries === null) { + edits.push( + ...addProperty( + parsed.source, + admin, + `${key}: [${JSON.stringify(ref.permission)}]`, + ), + ); + } else { + edits.push( + ...appendToArray( + parsed.source, + moduleEntries, + JSON.stringify(ref.permission), + ), + ); + } + + ctx.plan.update( + API_CONFIG, + applyEdits(parsed.source, edits), + `declare the admin permission ${wanted}`, + ); + planPermissionLabels(ctx, ref); +}; + +/** Whether the plugin's admin tree mounts generated Content Engine modules. */ +export const hasContentModules = (ctx: GenerateContext): boolean => { + const lookup = lookupModule(ctx.plan, ctx.project, ["admin"]); + if (lookup.status !== "found") return false; + + return readModuleContainer(ctx.plan, ctx.project, lookup.module).entries.some( + entry => entry.kind === "fixed", + ); +}; + +/** Plans `/.service.ts`: an empty service factory. */ +export const planService = ( + ctx: GenerateContext, + modulePath: readonly string[], +): { factory: string; file: string } => { + const lookup = lookupModule(ctx.plan, ctx.project, modulePath); + if (lookup.status !== "found") { + throw new UserError(`Module "${modulePath.join("/")}" does not exist.`, { + hint: `Create it first with vitnode g module ${modulePath.join("/")}.`, + }); + } + + const leaf = modulePath[modulePath.length - 1] ?? ""; + const file = posix.join(lookup.module.dir, `${leaf}.service.ts`); + const factory = serviceFactoryIdentifier(modulePath); + + ctx.plan.create( + file, + serviceTemplate({ factory, module: modulePath.join("/") }), + `service for module "${modulePath.join("/")}"`, + ); + + return { factory, file }; +}; diff --git a/packages/vitnode/scripts/cli/generate/names.ts b/packages/vitnode/scripts/cli/generate/names.ts new file mode 100644 index 0000000000..e965eb5b63 --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/names.ts @@ -0,0 +1,151 @@ +import { UserError } from "../errors"; + +/** + * One path segment of a module or route name: lower-case kebab-case, starting + * with a letter. A module's segment is also its URL segment, so the generator + * never rewrites one into another spelling - `myNotes` is refused with a + * suggestion rather than silently served at `/my-notes`. + */ +const SEGMENT = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; + +/** `myNotes`, `My_Notes` or `my notes` → `my-notes`, for a suggestion only. */ +const suggestKebab = (value: string): string => + value + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/[^A-Za-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase(); + +export const assertSegment = (value: string, what: string): string => { + if (SEGMENT.test(value)) return value; + + const suggestion = suggestKebab(value); + + throw new UserError(`"${value}" is not a valid ${what}.`, { + details: [ + "Use lower-case kebab-case that starts with a letter, e.g. notes or site-notes.", + `A module name is also its URL segment, so VitNode does not rename it for you.`, + ], + hint: + suggestion !== "" && SEGMENT.test(suggestion) && suggestion !== value + ? `Did you mean "${suggestion}"?` + : undefined, + }); +}; + +/** + * Splits `admin/notes/list` into kebab-case segments, refusing anything that + * could escape the project: absolute paths, `..`, backslashes, empty parts. + */ +export const parseLogicalPath = (input: string, what: string): string[] => { + const trimmed = input.trim(); + + if (trimmed === "") throw new UserError(`The ${what} is empty.`); + + if ( + trimmed.startsWith("/") || + trimmed.startsWith("~") || + /^[A-Za-z]:/.test(trimmed) || + trimmed.includes("\\") + ) { + throw new UserError(`"${input}" looks like a file path, not a ${what}.`, { + details: [ + "Name modules by their logical path inside the plugin, e.g. notes or admin/notes.", + "The generator decides where the files go: src/api/modules//.", + ], + }); + } + + const segments = trimmed.split("/"); + + if ( + segments.some( + segment => segment === "" || segment === "." || segment === "..", + ) + ) { + throw new UserError(`"${input}" is not a valid ${what}.`, { + details: [ + "Segments are separated by a single /, and . or .. are not allowed.", + ], + }); + } + + return segments.map(segment => assertSegment(segment, `${what} segment`)); +}; + +/** `site-notes` → `siteNotes`. */ +export const camelCase = (kebab: string): string => + kebab.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase()); + +/** `site-notes` → `SiteNotes`. */ +export const pascalCase = (kebab: string): string => { + const camel = camelCase(kebab); + + return `${camel[0]?.toUpperCase() ?? ""}${camel.slice(1)}`; +}; + +/** `["admin", "notes"]` → `adminNotesModule`; `["notes"]` → `notesModule`. */ +export const moduleIdentifier = (modulePath: readonly string[]): string => + `${camelCase(modulePath.join("-"))}Module`; + +/** `list` in `admin/notes` → `listNotesRoute`. */ +export const routeIdentifier = ( + routeName: string, + modulePath: readonly string[], +): string => + `${camelCase(routeName)}${pascalCase(modulePath[modulePath.length - 1] ?? "")}Route`; + +/** `notes` → `createNotesService`. */ +export const serviceFactoryIdentifier = ( + modulePath: readonly string[], +): string => + `create${pascalCase(modulePath[modulePath.length - 1] ?? "")}Service`; + +/** `site-notes` → `Site notes`, for labels and descriptions. */ +export const humanize = (kebab: string): string => { + const words = kebab.replace(/[-_]+/g, " ").trim(); + + return `${words[0]?.toUpperCase() ?? ""}${words.slice(1)}`; +}; + +/** A staff permission written as `module.permission`, e.g. `notes.can_create`. */ +export interface PermissionRef { + module: string; + permission: string; +} + +const PERMISSION_PART = /^[a-z][a-z0-9_-]*$/; + +export const parsePermission = (input: string): PermissionRef => { + const parts = input.trim().split("."); + const [module, permission] = parts; + + if ( + parts.length !== 2 || + module === undefined || + permission === undefined || + !PERMISSION_PART.test(module) || + !PERMISSION_PART.test(permission) + ) { + throw new UserError(`"${input}" is not a valid staff permission.`, { + details: [ + "Write it as ., e.g. notes.can_create.", + "Both parts are lower-case letters, digits, - or _. The plugin id is implied.", + ], + }); + } + + return { module, permission }; +}; + +/** `notes` + `can_create` → `Create notes`; anything else is humanized as is. */ +export const permissionLabel = ({ + module, + permission, +}: PermissionRef): string => { + const verb = /^can_(.+)$/.exec(permission)?.[1]; + + return verb === undefined + ? humanize(permission) + : `${humanize(verb)} ${humanize(module).toLowerCase()}`; +}; diff --git a/packages/vitnode/scripts/cli/generate/plan.ts b/packages/vitnode/scripts/cli/generate/plan.ts new file mode 100644 index 0000000000..8583265a60 --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/plan.ts @@ -0,0 +1,231 @@ +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; + +import { ConfigError, RuntimeError, UserError } from "../errors"; + +/** One file the generator wants to create or change. */ +export interface FileChange { + /** The proposed contents. */ + after: string; + /** What is on disk now; `null` for a file the plan creates. */ + before: null | string; + kind: "create" | "update"; + /** Relative to the project root, with `/` separators. */ + path: string; + /** Why the file is touched, one line per reason, in the order they were planned. */ + reasons: string[]; +} + +const toPosix = (path: string) => path.split(sep).join("/"); + +/** + * Everything a generator intends to write, held in memory until the whole + * command has been resolved, validated and formatted. + * + * Generators read through the plan, so a later step sees what an earlier one + * proposed - a module created a moment ago can already receive its first + * route - and nothing touches the disk until {@link applyPlan}. + */ +export class GenerationPlan { + constructor(root: string) { + this.root = resolve(root); + } + private readonly disk = new Map(); + private readonly overlay = new Map< + string, + { content: string; reasons: string[] } + >(); + readonly root: string; + + /** The relative path, refusing anything that would land outside the project. */ + private normalize(path: string): string { + const absolute = resolve(this.root, path); + const rel = relative(this.root, absolute); + + if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { + throw new UserError( + `Refusing to write ${path}: it is outside the project at ${this.root}.`, + ); + } + + return toPosix(rel); + } + + private readDisk(path: string): null | string { + if (!this.disk.has(path)) { + const absolute = join(this.root, path); + this.disk.set( + path, + existsSync(absolute) ? readFileSync(absolute, "utf8") : null, + ); + } + + return this.disk.get(path) ?? null; + } + + /** Every planned change, creates first, in the order they were planned. */ + changes(): FileChange[] { + const changes: FileChange[] = []; + + for (const [path, { content, reasons }] of this.overlay) { + const before = this.readDisk(path); + if (before === content) continue; + changes.push({ + after: content, + before, + kind: before === null ? "create" : "update", + path, + reasons, + }); + } + + return changes.sort((a, b) => + a.kind === b.kind ? 0 : a.kind === "create" ? -1 : 1, + ); + } + + /** Adds a new file. Never overwrites: an existing file is an error. */ + create(path: string, content: string, reason: string): void { + const rel = this.normalize(path); + + if (this.read(rel) !== null) { + throw new UserError(`${rel} already exists.`, { + details: [ + "The generator never overwrites files. Pick another name, or move the existing file first.", + ], + }); + } + + this.overlay.set(rel, { content, reasons: [reason] }); + } + + exists(path: string): boolean { + return this.read(path) !== null; + } + + /** The planned contents of a file, or what is on disk, or `null`. */ + read(path: string): null | string { + const rel = this.normalize(path); + + return this.overlay.get(rel)?.content ?? this.readDisk(rel); + } + + /** Replaces contents without adding a reason, e.g. after formatting. */ + replaceContent(path: string, content: string): void { + const rel = this.normalize(path); + const existing = this.overlay.get(rel); + if (existing !== undefined) existing.content = content; + } + + /** Replaces the planned contents of an existing (or already planned) file. */ + update(path: string, content: string, reason: string): void { + const rel = this.normalize(path); + const existing = this.overlay.get(rel); + + if (existing === undefined && this.readDisk(rel) === null) { + throw new ConfigError(`Cannot update ${rel}: it does not exist.`); + } + + this.overlay.set(rel, { + content, + reasons: [...(existing?.reasons ?? []), reason], + }); + } +} + +/** + * Writes a plan's changes, as safely as a file system allows. + * + * 1. Every file the plan updates is re-read: if anything changed it since the + * plan read it (an editor save, a parallel generator), nothing is written. + * 2. Every new content is written to a temporary file next to its target. + * 3. The temporaries are renamed over their targets one by one. If one fails, + * the targets already replaced are restored from the originals and the + * created files and folders removed. + * + * Each rename is atomic; the whole batch is not. A crash between two renames + * can leave some files written - which is why the summary lists them all. + */ +export const applyPlan = ( + root: string, + changes: readonly FileChange[], +): void => { + for (const change of changes) { + const absolute = join(root, change.path); + const current = existsSync(absolute) + ? readFileSync(absolute, "utf8") + : null; + + if (current !== change.before) { + throw new RuntimeError( + `${change.path} changed while the generator was running. Nothing was written.`, + { hint: "Run the command again - it re-reads every file." }, + ); + } + } + + const createdDirs: string[] = []; + const ensureDir = (dir: string) => { + const missing: string[] = []; + let current = dir; + while (!existsSync(current)) { + missing.unshift(current); + current = dirname(current); + } + for (const path of missing) { + mkdirSync(path); + createdDirs.push(path); + } + }; + + const stamp = `${String(process.pid)}-${Date.now().toString(36)}`; + const staged: { target: string; temp: string }[] = []; + const applied: FileChange[] = []; + + const rollback = () => { + for (const { temp } of staged) rmSync(temp, { force: true }); + for (const change of applied.reverse()) { + const target = join(root, change.path); + if (change.before === null) rmSync(target, { force: true }); + else writeFileSync(target, change.before, "utf8"); + } + for (const dir of createdDirs.reverse()) { + try { + rmdirSync(dir); + } catch { + // Not empty: something else wrote there meanwhile. Leave it. + } + } + }; + + try { + for (const change of changes) { + const target = join(root, change.path); + ensureDir(dirname(target)); + const temp = `${target}.vitnode-${stamp}.tmp`; + writeFileSync(temp, change.after, "utf8"); + staged.push({ target, temp }); + } + + for (const [index, { target, temp }] of staged.entries()) { + renameSync(temp, target); + const change = changes[index]; + if (change !== undefined) applied.push(change); + } + staged.length = 0; + } catch (error) { + rollback(); + throw new RuntimeError( + "Writing the generated files failed. The changes already written were rolled back.", + { cause: error }, + ); + } +}; diff --git a/packages/vitnode/scripts/cli/generate/project.ts b/packages/vitnode/scripts/cli/generate/project.ts new file mode 100644 index 0000000000..3ca122ca7f --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/project.ts @@ -0,0 +1,178 @@ +import { existsSync, readFileSync } from "node:fs"; +import { isAbsolute, join, relative, resolve } from "node:path"; + +import type { Prompter } from "../ui/prompts"; +import type { Ui } from "../ui/ui"; + +import { ConfigError, UserError } from "../errors"; +import { isPluginPackage } from "../plugins/discover"; +import { findWorkspaceRoot, workspacePackageDirs } from "../plugins/workspace"; +import { findPackageRoot, readPackageJson } from "../project/packages"; +import { toDisplayPath } from "../ui/format"; + +export const API_CONFIG = "src/config.api.ts"; +export const API_BUILDERS = "src/api/builders.ts"; +export const MODULES_DIR = "src/api/modules"; + +/** The plugin a generator writes into. */ +export interface GeneratorProject { + /** Whether `@/*` maps to `src/*` in the plugin's tsconfig. */ + alias: boolean; + /** Where the project is, relative to where the command ran. */ + display: string; + /** The package name, which VitNode uses as the plugin id. */ + id: string; + root: string; +} + +/** Only plugins with an API half (`src/config.api.ts`) can take API code. */ +const hasApi = (dir: string) => existsSync(join(dir, API_CONFIG)); + +const describe = (cwd: string, root: string): GeneratorProject => { + const tsconfig = join(root, "tsconfig.json"); + const alias = + existsSync(tsconfig) && + /["']@\/\*["']\s*:/.test(readFileSync(tsconfig, "utf8")); + + return { + alias, + display: toDisplayPath(relative(cwd, root)) || ".", + id: readPackageJson(root)?.name ?? root, + root, + }; +}; + +const requireApi = (cwd: string, root: string): GeneratorProject => { + const project = describe(cwd, root); + + if (!hasApi(root)) { + throw new ConfigError( + `${project.id} has no API: ${API_CONFIG} is missing.`, + { + details: [ + "The generator adds modules to the buildApiPlugin({ modules }) call in src/config.api.ts.", + "Create that file first - a plugin made with create-vitnode-app --plugin already has it.", + ], + }, + ); + } + + return project; +}; + +/** Plugins in the workspace that have an API half, for choosing a target. */ +const eligiblePlugins = (cwd: string): string[] => { + const workspaceRoot = findWorkspaceRoot(cwd); + if (workspaceRoot === null) return []; + + return workspacePackageDirs(workspaceRoot).filter( + dir => isPluginPackage(dir) && hasApi(dir), + ); +}; + +/** + * The plugin a `vitnode generate` command targets: + * + * 1. `--project`, as an exact package name of a workspace plugin or a path to + * a plugin folder. + * 2. The plugin the command runs in. + * 3. Otherwise (the workspace root, an app folder) the only plugin with an API + * in the workspace, a choice when the terminal can ask, or a usage error + * listing the candidates when it cannot. + * + * Host applications are not targets yet: their API modules are not registered + * through `buildApiPlugin`, and the generator never guesses a registration. + */ +export const resolveGeneratorProject = async ({ + cwd, + project, + prompter, + ui, +}: { + cwd: string; + project?: string; + prompter: Prompter; + ui: Ui; +}): Promise => { + if (project !== undefined) { + const byName = eligiblePluginsOrAll(cwd).find( + dir => readPackageJson(dir)?.name === project, + ); + if (byName !== undefined) return requireApi(cwd, byName); + + const asPath = isAbsolute(project) ? project : resolve(cwd, project); + if (existsSync(join(asPath, "package.json"))) { + if (!isPluginPackage(asPath)) { + throw new UserError(`${project} is not a VitNode plugin package.`, { + details: [ + "API generators currently write into plugins: a package that depends on @vitnode/core and has src/config.tsx.", + ], + }); + } + + return requireApi(cwd, asPath); + } + + const candidates = eligiblePlugins(cwd).map( + dir => readPackageJson(dir)?.name ?? dir, + ); + throw new UserError( + `No plugin named or located at "${project}" was found.`, + { + hint: + candidates.length === 0 + ? "Pass the plugin's exact package name or the path to its folder." + : `Plugins with an API here: ${candidates.join(", ")}`, + }, + ); + } + + const packageRoot = findPackageRoot(cwd); + if (packageRoot !== null && isPluginPackage(packageRoot)) { + return requireApi(cwd, packageRoot); + } + + const candidates = eligiblePlugins(cwd); + + if (candidates.length === 1 && candidates[0] !== undefined) { + const only = describe(cwd, candidates[0]); + ui.note( + `Using ${only.id} (${only.display}), the only plugin with an API in this workspace.`, + ); + + return only; + } + + if (candidates.length === 0) { + throw new ConfigError("No plugin to generate into was found.", { + details: [ + "Run the command inside a plugin folder, or pass --project .", + "API generators write into plugins with a src/config.api.ts.", + ], + hint: "Create a plugin with npx create-vitnode-app --plugin .", + }); + } + + const choices = candidates.map(dir => { + const info = describe(cwd, dir); + + return { name: `${info.id} ${ui.colors.muted(info.display)}`, value: dir }; + }); + + if (!ui.interactive) { + throw new UserError("More than one plugin could take this code.", { + details: choices.map(choice => ` ${choice.name}`), + hint: "Pass --project to choose one.", + }); + } + + return describe(cwd, await prompter.select("Which plugin?", choices)); +}; + +const eligiblePluginsOrAll = (cwd: string): string[] => { + const workspaceRoot = findWorkspaceRoot(cwd); + + return workspaceRoot === null + ? [] + : workspacePackageDirs(workspaceRoot).filter(dir => isPluginPackage(dir)); +}; diff --git a/packages/vitnode/scripts/cli/generate/templates.ts b/packages/vitnode/scripts/cli/generate/templates.ts new file mode 100644 index 0000000000..2f4afee1e5 --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/templates.ts @@ -0,0 +1,242 @@ +/** + * Source templates for `vitnode generate`. + * + * Every template is plain text with only the imports it uses. None annotates a + * builder's result with a broader type or casts it: the module and route + * literals - names, methods, paths - are what the fetcher infers calls from. + */ + +export type HttpMethod = "delete" | "get" | "patch" | "post" | "put"; + +export const builtModuleTemplate = ({ + builders, + identifier, + name, +}: { + builders: string; + identifier: string; + name: string; +}): string => `import { buildModule } from ${JSON.stringify(builders)}; + +export const ${identifier} = buildModule({ + name: ${JSON.stringify(name)}, + routes: [], +}); +`; + +export const buildersTemplate = (manifest: string): string => + `import { bindApiPlugin } from "@vitnode/core/api/lib/bind-api-plugin"; + +import { manifest } from ${JSON.stringify(manifest)}; + +const apiBuilders = bindApiPlugin(manifest); + +export const buildModule = apiBuilders.buildModule; +export const buildRoute = apiBuilders.buildRoute; +`; + +export type ParamType = "number" | "string"; + +const paramSchema = (type: ParamType) => + type === "number" ? "z.coerce.number().int().positive()" : "z.string()"; + +const paramsObject = (params: readonly string[], type: ParamType) => + `z.object({ ${params.map(param => `${param}: ${paramSchema(type)}`).join(", ")} })`; + +const permissionLine = (permission?: { module: string; permission: string }) => + permission === undefined + ? "" + : ` adminStaffPermission: { module: ${JSON.stringify(permission.module)}, permission: ${JSON.stringify(permission.permission)} },\n`; + +const forbiddenResponse = (permission?: { + module: string; + permission: string; +}) => + permission === undefined + ? "" + : ` 403: { description: ${JSON.stringify(`Requires the ${permission.module}.${permission.permission} staff permission.`)} },\n`; + +export interface RouteTemplateOptions { + builders: string; + /** e.g. `GET /notes/{id}`, for comments and messages. */ + endpoint: string; + /** `list.route.ts`, named in the scaffold's messages. */ + fileName: string; + identifier: string; + method: HttpMethod; + params: readonly string[]; + paramType: ParamType; + path: string; + permission?: { module: string; permission: string }; +} + +/** + * A `GET` starter that answers with clearly labelled sample data. The schema + * matches the sample exactly, `sample: true` included, so nothing reads it as + * real data by accident. + */ +const sampleGetTemplate = (options: RouteTemplateOptions): string => { + const { + builders, + endpoint, + fileName, + identifier, + params, + paramType, + path, + permission, + } = options; + const message = `Sample data from ${fileName}. Replace it with the real response.`; + const paramFields = params + .map( + param => + `${param}: ${paramType === "number" ? "z.number()" : "z.string()"}, `, + ) + .join(""); + const handler = + params.length === 0 + ? ` handler: c => + c.json( + { sample: true as const, message: ${JSON.stringify(message)} }, + 200, + ),` + : ` handler: c => { + const { ${params.join(", ")} } = c.req.valid("param"); + + return c.json( + { sample: true as const, ${params.join(", ")}, message: ${JSON.stringify(message)} }, + 200, + ); + },`; + + return `import { z } from "@hono/zod-openapi"; + +import { buildRoute } from ${JSON.stringify(builders)}; + +/** + * ${endpoint} - a generated starter that answers with sample data. + * + * Replace the 200 schema and the handler with the real response. + */ +export const ${identifier} = buildRoute({ +${permissionLine(permission)} route: { + method: "get", + path: ${JSON.stringify(path)}, +${params.length === 0 ? "" : ` request: {\n params: ${paramsObject(params, paramType)},\n },\n`} responses: { + 200: { + content: { + "application/json": { + schema: z.object({ sample: z.literal(true), ${paramFields}message: z.string() }), + }, + }, + description: "Sample data from a generated route.", + }, +${forbiddenResponse(permission)} }, + }, +${handler} +}); +`; +}; + +/** + * A write (or any non-GET) scaffold: the contract and validation are real, + * the operation is not, so it answers `501` - never a made-up success. + */ +const notImplementedTemplate = (options: RouteTemplateOptions): string => { + const { + builders, + endpoint, + fileName, + identifier, + method, + params, + paramType, + path, + permission, + } = options; + const hasBody = method === "post" || method === "put" || method === "patch"; + const request = [ + params.length === 0 + ? null + : ` params: ${paramsObject(params, paramType)},`, + hasBody + ? ` body: { + required: true, + content: { + // Describe the fields this endpoint accepts. + "application/json": { schema: z.object({}) }, + }, + },` + : null, + ].filter(line => line !== null); + const ownership = + permission === undefined + ? "" + : ` + * + * The staff permission decides who may call it at all. It does not check + * access to a particular record - do that in the handler when it matters.`; + + return `${request.length > 0 ? 'import { z } from "@hono/zod-openapi";\n' : ""}import { HTTPException } from "hono/http-exception"; + +import { buildRoute } from ${JSON.stringify(builders)}; + +/** + * ${endpoint} - a generated scaffold that answers 501 until implemented. + * + * Describe the input, add the success response to \`responses\`, then replace + * the \`throw\` with the real operation.${ownership} + */ +export const ${identifier} = buildRoute({ +${permissionLine(permission)} route: { + method: ${JSON.stringify(method)}, + path: ${JSON.stringify(path)}, +${request.length === 0 ? "" : ` request: {\n${request.join("\n")}\n },\n`} responses: { +${forbiddenResponse(permission)} 501: { description: "Not implemented yet." }, + }, + }, + handler: () => { + throw new HTTPException(501, { + message: ${JSON.stringify(`${endpoint} is not implemented yet. Implement it in ${fileName}.`)}, + }); + }, +}); +`; +}; + +export const routeTemplate = (options: RouteTemplateOptions): string => + options.method === "get" + ? sampleGetTemplate(options) + : notImplementedTemplate(options); + +/** + * An empty service: a plain factory over explicit dependencies, with no + * operations yet. Routes build it per request with what they hold. + */ +export const serviceTemplate = ({ + factory, + module, +}: { + factory: string; + module: string; +}): string => { + const deps = `${factory.replace(/^create/, "")}Deps`; + + return `import type { EnvVariablesVitNode } from "@vitnode/core/api/middlewares/global.middleware"; + +/** What the ${module} service needs, passed in by whoever builds it. */ +export interface ${deps} { + /** In a route: \`c.get("db")\`. */ + db: EnvVariablesVitNode["db"]; +} + +/** + * Business operations for the \`${module}\` module. + * + * Generated empty - it has no operations yet. Add functions that take plain + * input (and the acting user when one matters), not the Hono context, and + * build the service where it is used: \`${factory}({ db: c.get("db") })\`. + */ +export const ${factory} = (deps: ${deps}) => ({ deps }); +`; +}; diff --git a/packages/vitnode/scripts/cli/program.ts b/packages/vitnode/scripts/cli/program.ts index 20769011b9..a79fc83525 100644 --- a/packages/vitnode/scripts/cli/program.ts +++ b/packages/vitnode/scripts/cli/program.ts @@ -121,6 +121,8 @@ export const createProgram = (hooks: ProgramHooks): Command => { ), ); + registerGenerateCommands(program, run); + const db = program .command("db") .description("Generate, apply and inspect database migrations"); @@ -264,3 +266,84 @@ const registerLegacyCommands = (program: Command, run: Run) => { .option("--generate", "only generate migrations") .action(run(async () => (await legacy()).runMigrateCommand)); }; + +/** + * `vitnode generate ` (alias `g`): scaffolds plugin API code and edits + * its explicit registrations. Every kind shares `--project` and `--dry-run`. + */ +const registerGenerateCommands = (program: Command, run: Run) => { + const handlers = async () => import("./commands/generate"); + const generate = program + .command("generate") + .alias("g") + .helpCommand(false) + .description("Generate plugin API modules, routes and services") + .addHelpText( + "after", + "\nExamples:\n vitnode g module notes\n vitnode g route notes/show --path '/{id}'\n vitnode g route admin/notes/create --permission notes.can_create --declare-permission\n vitnode g module notes --dry-run", + ); + + const common = (command: Command): Command => + outputOptions(command) + .option( + "--project ", + "plugin to generate into: exact package name or folder (default: the plugin you are in)", + ) + .option("--dry-run", "show the planned diff without writing anything"); + + common(generate.command("module")) + .description("Create an API module and register it") + .argument("", "module path, e.g. notes or admin/notes") + .option("--admin", "create it in the admin module tree (admin/)") + .option("--parents", "also create missing parent modules") + .action( + run( + async () => (await handlers()).runGenerateModuleCommand, + ([name]) => ({ name: name as string }), + ), + ); + + common(generate.command("route")) + .description("Create a typed route and add it to its module") + .argument("", "/, e.g. notes/list") + .option("-m, --method ", "get, post, put, patch or delete") + .option("--path ", "path relative to the module, e.g. / or /{id}") + .option( + "--id-type ", + "schema of path parameters: string or number", + "string", + ) + .option( + "--admin", + "put the route in the admin module tree (admin/)", + ) + .option( + "--permission ", + "admin staff permission that guards the route", + ) + .option( + "--declare-permission", + "declare the permission in config.api.ts when it is missing", + ) + .option("--parents", "also create missing modules") + .action( + run( + async () => (await handlers()).runGenerateRouteCommand, + ([target]) => ({ target: target as string }), + ), + ); + + common(generate.command("service")) + .description("Create an empty service factory in a module") + .argument("", "module path, e.g. notes") + .option( + "--admin", + "the module is in the admin module tree (admin/)", + ) + .action( + run( + async () => (await handlers()).runGenerateServiceCommand, + ([name]) => ({ name: name as string }), + ), + ); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b97da59e47..dcced41439 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -843,6 +843,9 @@ importers: ocache: specifier: ^0.3.1 version: 0.3.1 + oxc-parser: + specifier: ^0.148.0 + version: 0.148.0 picocolors: specifier: ^1.1.1 version: 1.1.1 From b1e47cb2c8d031e824ecef988212a25327727e6f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 17:23:27 +0000 Subject: [PATCH 02/51] =?UTF-8?q?feat(cli):=20=E2=9C=A8=20vitnode=20genera?= =?UTF-8?q?te=20resource=20--kind=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes the module, route and permission generators into one plan that writes a whole API resource or nothing: - `.schema.ts` with item/create/update Zod schemas and inferred types (fields from --fields, or an id-only scaffold that says so); - `.service.ts` with typed operation stubs that reject with 501 - no fabricated records or success values; - list/show/create/update/delete routes calling the service, with 400/404/501 in their contracts; - admin resources guard each operation with its own permission (can_view/can_create/can_edit/can_delete) from --permission-module. `--kind content` is refused with an explanation: Content Engine resources are not generated yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CnAiFZ3bExobTQrGyYnt3P --- apps/web/content/docs/dev/cli/generate.mdx | 17 +- apps/web/content/docs/dev/cli/index.mdx | 2 +- .../content/docs/dev/plugins/api/meta.json | 3 +- .../docs/dev/plugins/api/resources.mdx | 148 +++++++ .../vitnode/scripts/cli/commands/generate.ts | 132 +++++++ .../cli/generate/resource-templates.ts | 370 ++++++++++++++++++ .../vitnode/scripts/cli/generate/resource.ts | 250 ++++++++++++ packages/vitnode/scripts/cli/program.ts | 30 +- 8 files changed, 947 insertions(+), 5 deletions(-) create mode 100644 apps/web/content/docs/dev/plugins/api/resources.mdx create mode 100644 packages/vitnode/scripts/cli/generate/resource-templates.ts create mode 100644 packages/vitnode/scripts/cli/generate/resource.ts diff --git a/apps/web/content/docs/dev/cli/generate.mdx b/apps/web/content/docs/dev/cli/generate.mdx index e2e390bfc0..83d5f2bdd9 100644 --- a/apps/web/content/docs/dev/cli/generate.mdx +++ b/apps/web/content/docs/dev/cli/generate.mdx @@ -1,6 +1,6 @@ --- title: Generate commands -description: Reference for vitnode generate - scaffold plugin API modules, typed routes, admin routes and services, with dry runs, collision checks and explicit registration. +description: Reference for vitnode generate - scaffold plugin API modules, typed routes, admin routes, services and API resources, with dry runs, collision checks and explicit registration. icon: Wand --- @@ -12,6 +12,7 @@ icon: Wand | `vitnode g route /` | `.route.ts`, added to the module's `routes` | [Generate an endpoint](/docs/dev/plugins/api/generate-route) | | `vitnode g route admin/...` | The same, guarded by a staff permission | [Protect an admin endpoint](/docs/dev/plugins/api/admin-routes) | | `vitnode g service ` | `.service.ts`, an empty service factory | [Add a service](/docs/dev/plugins/api/services) | +| `vitnode g resource --kind api` | A module, schemas, service stubs and five CRUD routes | [Generate an API resource](/docs/dev/plugins/api/resources) | ## Where to run it @@ -69,6 +70,20 @@ Every generate command accepts: `vitnode g service ` takes `--admin` with the same meaning as for modules. +`vitnode g resource --kind api`: + +| Option | Default | Description | +| ------------------------------ | -------- | ------------------------------------------------------------------------------------------------- | +| `--kind ` | - | Required. `api` is the only kind for now; Content Engine resources are defined with `defineContentType` | +| `--fields ` | Asked, or none | `name:type` pairs, comma-separated: `string`, `number`, `int`, `boolean`; `name?:type` is optional | +| `--id-type ` | `string` | `string` or `number` | +| `--admin` | - | Generate it in the admin module tree | +| `--permission-module ` | Asked | Admin resources: `can_view` guards list and show, `can_create`, `can_edit` and `can_delete` the rest | +| `--declare-permission` | - | Declare the missing permissions and their labels | +| `--parents` | - | Also create missing parent modules | + +The resource's module must be new or have no routes. No table, migration or storage is generated: every operation answers `501` until you implement the service. + ### Route name defaults The route's file name and its URL are separate. These names imply a method and a path; any other name needs `--method` and `--path` (or answers them in an interactive terminal), because `archive.route.ts` is not served at `/archive` unless you say so. diff --git a/apps/web/content/docs/dev/cli/index.mdx b/apps/web/content/docs/dev/cli/index.mdx index ac8456b830..034e7de321 100644 --- a/apps/web/content/docs/dev/cli/index.mdx +++ b/apps/web/content/docs/dev/cli/index.mdx @@ -35,7 +35,7 @@ Run it inside an app, an API app or a plugin folder, through your package manage | `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 generate ` | Scaffold plugin API modules, routes, services | [Generate](/docs/dev/cli/generate) | +| `vitnode generate ` | Scaffold plugin API modules, routes, resources | [Generate](/docs/dev/cli/generate) | | `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) | diff --git a/apps/web/content/docs/dev/plugins/api/meta.json b/apps/web/content/docs/dev/plugins/api/meta.json index 92ea4840cf..0923d2fc4a 100644 --- a/apps/web/content/docs/dev/plugins/api/meta.json +++ b/apps/web/content/docs/dev/plugins/api/meta.json @@ -8,6 +8,7 @@ "generate-module", "generate-route", "admin-routes", - "services" + "services", + "resources" ] } diff --git a/apps/web/content/docs/dev/plugins/api/resources.mdx b/apps/web/content/docs/dev/plugins/api/resources.mdx new file mode 100644 index 0000000000..83f16cec28 --- /dev/null +++ b/apps/web/content/docs/dev/plugins/api/resources.mdx @@ -0,0 +1,148 @@ +--- +title: Generate an API resource +description: Scaffold a VitNode API resource - module, Zod schemas, service stubs and list, show, create, update and delete routes - in one step with vitnode generate resource. +icon: Layers +--- + +`vitnode g resource --kind api` writes everything a CRUD endpoint set needs, wired together: a module, shared Zod schemas, a service with one typed function per operation, and five routes that call it. Nothing is stored yet: every route answers `501 Not Implemented` until you implement the service, so the scaffold never returns made-up data. + +## Before you begin + +- A plugin made with [Create plugin](/docs/dev/plugins/create), with a terminal in `plugins/site-notes`, and no `notes` module yet. A resource needs a new or empty module. + + + + + +### Generate the resource + +Describe the fields with `--fields name:type` (`string`, `number`, `int`, `boolean`; a `?` after the name makes one optional), and pick the id type: + +```bash +pnpm vitnode g resource notes --kind api --fields "title:string,body:string,pinned?:boolean" --id-type number +``` + +```txt + ✓ created src/api/modules/notes/notes.module.ts module "notes"; register the notes resource routes + ✓ created src/api/modules/notes/notes.schema.ts schemas for the notes resource + ✓ created src/api/modules/notes/notes.service.ts service stubs for the notes resource + ✓ created src/api/modules/notes/list.route.ts GET /notes + ✓ created src/api/modules/notes/show.route.ts GET /notes/{id} + ✓ created src/api/modules/notes/create.route.ts POST /notes + ✓ created src/api/modules/notes/update.route.ts PATCH /notes/{id} + ✓ created src/api/modules/notes/delete.route.ts DELETE /notes/{id} + ✓ updated src/config.api.ts register notesModule in buildApiPlugin +``` + +Without `--fields`, an interactive terminal asks for them. In CI the schemas model only the `id`, and a comment in `notes.schema.ts` says to add the rest: the generator never guesses fields such as `title` from a name. The id is a string unless you pass `--id-type number`. + + + + + +### Read the contract + +`notes.schema.ts` is the single source of the resource's shape. The routes validate with it and the service's input types are inferred from it, so there are no parallel DTO interfaces: + +```ts title="plugins/site-notes/src/api/modules/notes/notes.schema.ts" +import { z } from "@hono/zod-openapi"; + +/** One notes entry, as the API returns it. */ +export const zodNotesItem = z.object({ + id: z.number().int().positive(), + title: z.string(), + body: z.string(), + pinned: z.boolean().optional(), +}); + +/** What creating a notes entry accepts. */ +export const zodNotesCreateBody = z.object({ + title: z.string(), + body: z.string(), + pinned: z.boolean().optional(), +}); + +/** What updating a notes entry accepts: any subset of the create fields. */ +export const zodNotesUpdateBody = zodNotesCreateBody.partial(); + +export type NotesItem = z.infer; +export type NotesCreateInput = z.infer; +export type NotesUpdateInput = z.infer; +``` + +| Route | Method and path | Success | Also answers | +| -------- | -------------------- | ------- | ------------------------------------ | +| `list` | `GET /notes` | `200` | `501` | +| `show` | `GET /notes/{id}` | `200` | `400` invalid id, `404`, `501` | +| `create` | `POST /notes` | `201` | `400` invalid body, `501` | +| `update` | `PATCH /notes/{id}` | `200` | `400`, `404`, `501` | +| `delete` | `DELETE /notes/{id}` | `204` | `400`, `404`, `501` | + + + + + +### Implement the service + +Each operation in `notes.service.ts` is a typed stub that rejects with `501`: + +```ts title="plugins/site-notes/src/api/modules/notes/notes.service.ts" +export const createNotesService = (deps: NotesServiceDeps) => ({ + deps, + list: async (): Promise => notImplemented("list"), + /** Resolves `null` when there is no entry with this id. */ + get: async (_id: number): Promise => notImplemented("get"), + // create, update, remove... +}); +``` + +Keep the signatures and replace the bodies, one operation at a time. The routes already turn `null` into `404` and `false` from `remove` into `404`. With a `site_notes` table from the [Database](/docs/dev/database) lesson, `list` becomes: + +```ts title="plugins/site-notes/src/api/modules/notes/notes.service.ts" +list: async (): Promise => + deps.db + .select({ id: site_notes.id, title: site_notes.title, body: site_notes.body }) + .from(site_notes), +``` + +The generator does not create the table or a migration. [Add a service](/docs/dev/plugins/api/services) explains where the service's dependencies come from. + + + + + +## Check the result + +Build the plugin and call `GET /notes` with the temporary `fetcher` button from [Generate an endpoint](/docs/dev/plugins/api/generate-route#call-it-from-a-page), using `module: "notes"` and `path: "/"`. Before you implement `list` it answers `501` with `notes: list is not implemented yet`; afterwards, the rows. A `POST` with `{ "title": 1 }` answers `400` either way, because validation runs before the service. + +## Admin resources + +`--admin` generates the resource under `admin/` and guards each operation with its own staff permission, so `can_create` never ends up guarding a delete: + +```bash +pnpm vitnode g resource notes --admin --kind api --permission-module notes --declare-permission --parents +``` + +| Operations | Permission | +| -------------- | ------------------ | +| `list`, `show` | `notes.can_view` | +| `create` | `notes.can_create` | +| `update` | `notes.can_edit` | +| `delete` | `notes.can_delete` | + +Without `--declare-permission` the command lists the permissions missing from `permissionStaff.admin` and writes nothing. With it, they are added to `config.api.ts` with their AdminCP labels. A staff permission decides who may call an operation, not which entries they may change: check ownership in the service or handler when it matters. + +## Related + + + + + diff --git a/packages/vitnode/scripts/cli/commands/generate.ts b/packages/vitnode/scripts/cli/commands/generate.ts index 4a33f3d544..a4c7385a6a 100644 --- a/packages/vitnode/scripts/cli/commands/generate.ts +++ b/packages/vitnode/scripts/cli/commands/generate.ts @@ -29,6 +29,8 @@ import { } from "../generate/names"; import { applyPlan, GenerationPlan } from "../generate/plan"; import { resolveGeneratorProject } from "../generate/project"; +import { planApiResource } from "../generate/resource"; +import { parseFields } from "../generate/resource-templates"; import { plural } from "../ui/format"; export interface GenerateCommonOptions extends OutputOptions { @@ -53,6 +55,17 @@ export interface GenerateRouteOptions extends GenerateCommonOptions { target?: string; } +export interface GenerateResourceOptions extends GenerateCommonOptions { + admin?: boolean; + declarePermission?: boolean; + fields?: string; + idType?: string; + kind?: string; + name?: string; + parents?: boolean; + permissionModule?: string; +} + export interface GenerateServiceOptions extends GenerateCommonOptions { admin?: boolean; name?: string; @@ -599,3 +612,122 @@ export const runGenerateServiceCommand = async ( ui.note("Existing routes were not changed."); }); }; + +export const runGenerateResourceCommand = async ( + context: CommandContext, + options: GenerateResourceOptions, +): Promise => { + if (options.name === undefined) { + throw new UserError( + "Name the resource, e.g. vitnode g resource notes --kind api.", + ); + } + if (options.kind !== "api") { + throw new UserError( + options.kind === undefined + ? "Pass --kind api." + : `--kind ${options.kind} is not available.`, + { + details: [ + "--kind api scaffolds a module, schemas, a service and CRUD routes.", + "Content Engine resources (--kind content) are not generated yet; define them with defineContentType.", + ], + }, + ); + } + + const path = withAdmin( + parseLogicalPath(options.name, "resource name"), + options.admin, + ); + const isAdmin = path[0] === "admin"; + const idType = options.idType ?? "string"; + if (idType !== "string" && idType !== "number") { + throw new UserError(`--id-type must be string or number, not "${idType}".`); + } + if ( + !isAdmin && + (options.permissionModule !== undefined || options.declarePermission) + ) { + throw new UserError( + "--permission-module only applies to admin resources.", + { + details: ["Generate the resource under admin/ (or with --admin)."], + }, + ); + } + + const ctx = await start(context, "Generate an API resource", options); + + let fieldsInput = options.fields; + if (fieldsInput === undefined && context.ui.interactive) { + fieldsInput = await context.prompter.text( + "Fields as name:type, comma-separated (string, number, int, boolean; name?: for optional). Empty for an id-only scaffold", + { + default: "", + validate: value => { + try { + parseFields(value); + + return true; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }, + ); + } + const fields = parseFields(fieldsInput ?? ""); + + let permissionModule = options.permissionModule; + if (isAdmin && permissionModule === undefined && context.ui.interactive) { + permissionModule = await context.prompter.text( + "Staff permission module (gets can_view, can_create, can_edit, can_delete)", + { default: path[path.length - 1] }, + ); + } + if (permissionModule !== undefined) + parsePermission(`${permissionModule}.can_view`); + + const parents = await resolveParents( + context, + ctx, + path, + path.length - 1, + options.parents ?? false, + ); + const { files, names } = planApiResource(ctx, path, { + fields, + idType, + parents, + permissions: + permissionModule === undefined + ? undefined + : { + declare: options.declarePermission ?? false, + module: permissionModule, + }, + }); + + if (fields.length === 0) { + ctx.notes.push( + `No --fields given: ${files.schema} models only the id. Add your fields there.`, + ); + } + + return finish(context, ctx, options, ui => { + ui.note( + `The ${names.label} resource is mounted at ${mountPath(ctx.project, `/${path.join("/")}`)} under the app's API base path (/api in the starter apps).`, + ); + ui.note( + `Every route answers 501 Not Implemented until you implement ${names.factory} in ${files.service}. No table or migration was created.`, + ); + if (permissionModule !== undefined) { + ui.note( + `Guards: list and show need ${permissionModule}.can_view, create ${permissionModule}.can_create, update ${permissionModule}.can_edit, delete ${permissionModule}.can_delete.`, + ); + } + ui.line(); + typesNote(ui); + }); +}; diff --git a/packages/vitnode/scripts/cli/generate/resource-templates.ts b/packages/vitnode/scripts/cli/generate/resource-templates.ts new file mode 100644 index 0000000000..f9c4270308 --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/resource-templates.ts @@ -0,0 +1,370 @@ +import type { ParamType } from "./templates"; + +import { UserError } from "../errors"; + +/** + * A resource field from `--fields`: `title:string`, `pinned:boolean`, + * `subtitle?:string`. Deliberately small - it describes a scaffold's shape, + * not a database column. + */ +export interface ResourceField { + name: string; + optional: boolean; + type: ResourceFieldType; +} + +export type ResourceFieldType = "boolean" | "int" | "number" | "string"; + +const FIELD_TYPES: Record = { + boolean: "z.boolean()", + int: "z.number().int()", + number: "z.number()", + string: "z.string()", +}; + +const FIELD_NAME = /^[a-z][A-Za-z0-9]*$/; + +/** Parses `title:string,body:string,pinned?:boolean`. Empty → no fields. */ +export const parseFields = (input: string): ResourceField[] => { + const trimmed = input.trim(); + if (trimmed === "") return []; + + const fields = trimmed.split(",").map(part => { + const match = /^\s*([^:?\s]+)(\?)?\s*:\s*(\S+)\s*$/.exec(part); + const [, name, optional, type] = match ?? []; + + if (name === undefined || type === undefined) { + throw new UserError(`"${part.trim()}" is not a field definition.`, { + details: [ + "Write fields as name:type, separated by commas, e.g. title:string,pinned?:boolean.", + ], + }); + } + if (!FIELD_NAME.test(name) || name === "id") { + throw new UserError(`"${name}" is not a supported field name.`, { + details: [ + "Use camelCase starting with a lower-case letter. id is added for you.", + ], + }); + } + if (!(type in FIELD_TYPES)) { + throw new UserError(`"${type}" is not a supported field type.`, { + hint: `Use one of: ${Object.keys(FIELD_TYPES).join(", ")}.`, + }); + } + + return { + name, + optional: optional === "?", + type: type as ResourceFieldType, + }; + }); + + const names = fields.map(field => field.name); + const duplicate = names.find((name, index) => names.indexOf(name) !== index); + if (duplicate !== undefined) { + throw new UserError(`The field "${duplicate}" is listed twice.`); + } + + return fields; +}; + +export interface ResourceNames { + /** `zodNotesCreateBody` */ + createSchema: string; + /** `NotesCreateInput` */ + createType: string; + /** `createNotesService` */ + factory: string; + /** `zodNotesItem` */ + itemSchema: string; + /** `NotesItem` */ + itemType: string; + /** `notes` or `admin/notes`, for messages. */ + label: string; + /** `notes`, for file names. */ + leaf: string; + /** `zodNotesUpdateBody` */ + updateSchema: string; + /** `NotesUpdateInput` */ + updateType: string; +} + +const idSchema = (type: ParamType) => + type === "number" ? "z.number().int().positive()" : "z.string()"; + +export const resourceSchemaTemplate = ({ + fields, + idType, + names, +}: { + fields: readonly ResourceField[]; + idType: ParamType; + names: ResourceNames; +}): string => { + const line = (field: ResourceField) => + ` ${field.name}: ${FIELD_TYPES[field.type]}${field.optional ? ".optional()" : ""},`; + const note = + fields.length === 0 + ? ` +// Generated without --fields: only the id is modelled. Add the fields your +// ${names.leaf} have to both schemas - the routes and the service follow them. +` + : ""; + + return `import { z } from "@hono/zod-openapi"; +${note} +/** One ${names.leaf} entry, as the API returns it. */ +export const ${names.itemSchema} = z.object({ + id: ${idSchema(idType)}, +${fields.map(line).join("\n")} +}); + +/** What creating a ${names.leaf} entry accepts. */ +export const ${names.createSchema} = z.object({ +${fields.map(line).join("\n")} +}); + +/** What updating a ${names.leaf} entry accepts: any subset of the create fields. */ +export const ${names.updateSchema} = ${names.createSchema}.partial(); + +export type ${names.itemType} = z.infer; +export type ${names.createType} = z.infer; +export type ${names.updateType} = z.infer; +`; +}; + +export const resourceServiceTemplate = ({ + idType, + names, + schemaSpecifier, +}: { + idType: ParamType; + names: ResourceNames; + schemaSpecifier: string; +}): string => { + const deps = `${names.factory.replace(/^create/, "")}Deps`; + const id = idType === "number" ? "number" : "string"; + // Union members in the order the repository's lint rule sorts them. + const maybeItem = [names.itemType, "null"] + .sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" })) + .join(" | "); + + return `import type { EnvVariablesVitNode } from "@vitnode/core/api/middlewares/global.middleware"; + +import { HTTPException } from "hono/http-exception"; + +import type { + ${names.createType}, + ${names.itemType}, + ${names.updateType}, +} from ${JSON.stringify(schemaSpecifier)}; + +/** What the ${names.label} service needs, passed in by whoever builds it. */ +export interface ${deps} { + /** In a route: \`c.get("db")\`. */ + db: EnvVariablesVitNode["db"]; +} + +/** Every operation answers 501 until it has a real implementation. */ +const notImplemented = async (operation: string): Promise => + Promise.reject( + new HTTPException(501, { + message: \`${names.label}: \${operation} is not implemented yet. Implement it in ${names.leaf}.service.ts.\`, + }), + ); + +/** + * Operations of the \`${names.label}\` resource. + * + * Generated as typed stubs with no storage behind them: each one rejects with + * 501 until you implement it - none returns made-up data. Keep the signatures + * and replace the bodies, e.g. with queries through \`deps.db\`. + */ +export const ${names.factory} = (deps: ${deps}) => ({ + deps, + list: async (): Promise<${names.itemType}[]> => notImplemented("list"), + /** Resolves \`null\` when there is no entry with this id. */ + get: async (_id: ${id}): Promise<${maybeItem}> => notImplemented("get"), + create: async (_input: ${names.createType}): Promise<${names.itemType}> => + notImplemented("create"), + /** Resolves \`null\` when there is no entry with this id. */ + update: async (_id: ${id}, _input: ${names.updateType}): Promise<${maybeItem}> => + notImplemented("update"), + /** Resolves \`false\` when there is no entry with this id. */ + remove: async (_id: ${id}): Promise => notImplemented("remove"), +}); +`; +}; + +export type ResourceOperation = + | "create" + | "delete" + | "list" + | "show" + | "update"; + +export const RESOURCE_OPERATIONS: readonly { + method: "delete" | "get" | "patch" | "post"; + name: ResourceOperation; + path: string; + /** The staff permission an admin resource checks for it. */ + permission: "can_create" | "can_delete" | "can_edit" | "can_view"; +}[] = [ + { method: "get", name: "list", path: "/", permission: "can_view" }, + { method: "get", name: "show", path: "/{id}", permission: "can_view" }, + { method: "post", name: "create", path: "/", permission: "can_create" }, + { method: "patch", name: "update", path: "/{id}", permission: "can_edit" }, + { method: "delete", name: "delete", path: "/{id}", permission: "can_delete" }, +]; + +export const resourceRouteTemplate = ({ + builders, + endpoint, + factorySpecifier, + identifier, + idType, + names, + operation, + permission, + schemaSpecifier, +}: { + builders: string; + endpoint: string; + factorySpecifier: string; + identifier: string; + idType: ParamType; + names: ResourceNames; + operation: ResourceOperation; + permission?: { module: string; permission: string }; + schemaSpecifier: string; +}): string => { + const hasId = + operation === "show" || operation === "update" || operation === "delete"; + const schemaImports = { + create: [names.createSchema, names.itemSchema], + delete: [], + list: [names.itemSchema], + show: [names.itemSchema], + update: [names.itemSchema, names.updateSchema], + }[operation]; + const method = + RESOURCE_OPERATIONS.find(item => item.name === operation)?.method ?? "get"; + const path = hasId ? "/{id}" : "/"; + const service = `${names.factory}({ db: c.get("db") })`; + const params = + idType === "number" + ? "z.object({ id: z.coerce.number().int().positive() })" + : "z.object({ id: z.string() })"; + const notFound = ` throw new HTTPException(404, { message: "No ${names.leaf} entry with this id." });`; + + const request = [ + hasId ? ` params: ${params},` : null, + operation === "create" || operation === "update" + ? ` body: { + required: true, + content: { + "application/json": { + schema: ${operation === "create" ? names.createSchema : names.updateSchema}, + }, + }, + },` + : null, + ].filter(line => line !== null); + + const success = { + create: ` 201: { + content: { "application/json": { schema: ${names.itemSchema} } }, + description: "The created entry.", + },`, + delete: ` 204: { description: "The entry was deleted." },`, + list: ` 200: { + content: { "application/json": { schema: z.array(${names.itemSchema}) } }, + description: "Every entry.", + },`, + show: ` 200: { + content: { "application/json": { schema: ${names.itemSchema} } }, + description: "The entry.", + },`, + update: ` 200: { + content: { "application/json": { schema: ${names.itemSchema} } }, + description: "The updated entry.", + },`, + }[operation]; + + const handler = { + create: ` handler: async c => { + const entry = await ${service}.create(c.req.valid("json")); + + return c.json(entry, 201); + },`, + delete: ` handler: async c => { + const { id } = c.req.valid("param"); + const deleted = await ${service}.remove(id); + if (!deleted) { + ${notFound} + } + + return c.body(null, 204); + },`, + list: ` handler: async c => c.json(await ${service}.list(), 200),`, + show: ` handler: async c => { + const { id } = c.req.valid("param"); + const entry = await ${service}.get(id); + if (!entry) { + ${notFound} + } + + return c.json(entry, 200); + },`, + update: ` handler: async c => { + const { id } = c.req.valid("param"); + const entry = await ${service}.update(id, c.req.valid("json")); + if (!entry) { + ${notFound} + } + + return c.json(entry, 200); + },`, + }[operation]; + + const usesZ = operation === "list" || hasId; + const usesHttpException = hasId; + const guard = + permission === undefined + ? "" + : ` adminStaffPermission: { module: ${JSON.stringify(permission.module)}, permission: ${JSON.stringify(permission.permission)} },\n`; + const ownership = + permission === undefined || + operation === "list" || + operation === "show" || + operation === "create" + ? "" + : ` + * + * The staff permission decides who may call it. Whether this caller may change + * this particular entry is for the service or handler to check.`; + + return `${usesZ ? 'import { z } from "@hono/zod-openapi";\n' : ""}${usesHttpException ? 'import { HTTPException } from "hono/http-exception";\n' : ""} +import { buildRoute } from ${JSON.stringify(builders)}; + +${schemaImports.length > 0 ? `import { ${schemaImports.join(", ")} } from ${JSON.stringify(schemaSpecifier)};\n` : ""}import { ${names.factory} } from ${JSON.stringify(factorySpecifier)}; + +/** + * ${endpoint} - part of the generated \`${names.label}\` resource. + * + * Answers 501 until \`${names.factory}().${operation === "delete" ? "remove" : operation === "show" ? "get" : operation}\` is implemented.${ownership} + */ +export const ${identifier} = buildRoute({ +${guard} route: { + method: ${JSON.stringify(method)}, + path: ${JSON.stringify(path)}, +${request.length === 0 ? "" : ` request: {\n${request.join("\n")}\n },\n`} responses: { +${success} +${permission === undefined ? "" : ` 403: { description: ${JSON.stringify(`Requires the ${permission.module}.${permission.permission} staff permission.`)} },\n`}${hasId ? ' 404: { description: "No entry with this id." },\n' : ""} 501: { description: "Not implemented yet." }, + }, + }, +${handler} +}); +`; +}; diff --git a/packages/vitnode/scripts/cli/generate/resource.ts b/packages/vitnode/scripts/cli/generate/resource.ts new file mode 100644 index 0000000000..7da4567847 --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/resource.ts @@ -0,0 +1,250 @@ +import { posix } from "node:path"; + +import type { ModuleSource } from "./api-tree"; +import type { GenerateContext } from "./generators"; +import type { ResourceField, ResourceNames } from "./resource-templates"; +import type { ParamType } from "./templates"; + +import { UserError } from "../errors"; +import { + lookupModule, + specifierFor, + UnsupportedStructureError, +} from "./api-tree"; +import { + addNamedImport, + addProperty, + appendToArray, + applyEdits, + arrayValue, + findConstInit, + getProperty, + objectArgument, + parseSource, + unwrap, +} from "./ast"; +import { + endpointPath, + planModule, + planPermission, + readAdminPermissions, +} from "./generators"; +import { pascalCase, routeIdentifier, serviceFactoryIdentifier } from "./names"; +import { API_BUILDERS } from "./project"; +import { + RESOURCE_OPERATIONS, + resourceRouteTemplate, + resourceSchemaTemplate, + resourceServiceTemplate, +} from "./resource-templates"; + +export interface ApiResourceOptions { + fields: readonly ResourceField[]; + idType: ParamType; + /** Create missing parent modules (`admin`). */ + parents: boolean; + /** For an admin resource: the permission module and whether to declare missing permissions. */ + permissions?: { declare: boolean; module: string }; +} + +export interface ApiResourceResult { + files: { routes: string[]; schema: string; service: string }; + names: ResourceNames; +} + +const namesFor = (modulePath: readonly string[]): ResourceNames => { + const leaf = modulePath[modulePath.length - 1] ?? ""; + const pascal = pascalCase(leaf); + + return { + createSchema: `zod${pascal}CreateBody`, + createType: `${pascal}CreateInput`, + factory: serviceFactoryIdentifier(modulePath), + itemSchema: `zod${pascal}Item`, + itemType: `${pascal}Item`, + label: modulePath.join("/"), + leaf, + updateSchema: `zod${pascal}UpdateBody`, + updateType: `${pascal}UpdateInput`, + }; +}; + +/** + * Plans a whole API resource in one go - the module (when missing), shared + * schemas, a service of typed stubs and list/show/create/update/delete routes + * - so either all of it is written or none of it. + * + * Nothing is persisted: every operation answers 501 until implemented, and no + * table or migration is created. + */ +export const planApiResource = ( + ctx: GenerateContext, + modulePath: readonly string[], + options: ApiResourceOptions, +): ApiResourceResult => { + const { plan, project } = ctx; + const names = namesFor(modulePath); + const isAdmin = modulePath[0] === "admin"; + + if (isAdmin && options.permissions === undefined) { + throw new UserError("An admin resource needs --permission-module.", { + details: [ + "Each operation is guarded by its own permission in that module:", + " list, show → can_view create → can_create update → can_edit delete → can_delete", + ], + }); + } + + if (options.permissions !== undefined) { + const { declare, module } = options.permissions; + const wanted = [ + ...new Set(RESOURCE_OPERATIONS.map(operation => operation.permission)), + ]; + const declarations = readAdminPermissions(ctx); + const declared = + declarations.status === "resolved" + ? (declarations.modules.get(module) ?? []) + : null; + const missing = + declared === null ? [] : wanted.filter(name => !declared.includes(name)); + + if (!declare && missing.length > 0) { + throw new UserError( + `${missing.map(name => `${module}.${name}`).join(", ")} ${missing.length === 1 ? "is" : "are"} not declared.`, + { + details: [ + `Nobody can be granted a permission the plugin does not declare in permissionStaff.admin.${module}.`, + ], + hint: "Pass --declare-permission to add them (and their labels).", + }, + ); + } + + for (const permission of wanted) { + planPermission( + ctx, + { module, permission }, + { contentModules: false, declare }, + ); + } + } + + const lookup = lookupModule(plan, project, modulePath); + let module: ModuleSource; + if (lookup.status === "found") { + module = lookup.module; + } else { + module = planModule(ctx, modulePath, { parents: options.parents }); + } + + const routesProperty = getProperty(module.options, "routes"); + const routes = arrayValue(routesProperty); + if (routesProperty !== null && routes === null) { + throw new UnsupportedStructureError( + `The routes of module "${names.label}" are not an array literal.`, + [`File: ${module.file}`], + ); + } + if (routes !== null && routes.elements.length > 0) { + throw new UserError(`Module "${names.label}" already has routes.`, { + details: [ + "A resource scaffold owns its module's list/show/create/update/delete routes.", + "Generate it into a new module, or add single routes with vitnode g route.", + ], + }); + } + + const schema = posix.join(module.dir, `${names.leaf}.schema.ts`); + const service = posix.join(module.dir, `${names.leaf}.service.ts`); + + plan.create( + schema, + resourceSchemaTemplate({ + fields: options.fields, + idType: options.idType, + names, + }), + `schemas for the ${names.label} resource`, + ); + plan.create( + service, + resourceServiceTemplate({ + idType: options.idType, + names, + schemaSpecifier: specifierFor(project, service, schema, false), + }), + `service stubs for the ${names.label} resource`, + ); + + const routeFiles: string[] = []; + const identifiers: string[] = []; + // Imports are added one at a time, re-reading the file in between, so each + // lands in its sorted place relative to the ones added before it. + let source = module.parsed.source; + + for (const operation of RESOURCE_OPERATIONS) { + const identifier = routeIdentifier(operation.name, modulePath); + const file = posix.join(module.dir, `${operation.name}.route.ts`); + const endpoint = `${operation.method.toUpperCase()} ${endpointPath(modulePath, operation.path)}`; + + plan.create( + file, + resourceRouteTemplate({ + builders: specifierFor(project, file, API_BUILDERS, true), + endpoint, + factorySpecifier: specifierFor(project, file, service, false), + identifier, + idType: options.idType, + names, + operation: operation.name, + permission: + options.permissions === undefined + ? undefined + : { + module: options.permissions.module, + permission: operation.permission, + }, + schemaSpecifier: specifierFor(project, file, schema, false), + }), + endpoint, + ); + routeFiles.push(file); + identifiers.push(identifier); + source = applyEdits( + source, + addNamedImport( + parseSource(module.file, source), + specifierFor(project, module.file, file, false), + identifier, + ), + ); + } + + const parsed = parseSource(module.file, source); + const init = findConstInit(parsed.program, module.exportName); + const call = init === null ? null : unwrap(init); + const moduleOptions = + call?.type === "CallExpression" ? objectArgument(call) : null; + if (moduleOptions === null) { + throw new UnsupportedStructureError( + `Module "${names.label}" could not be read back.`, + [`File: ${module.file}`], + ); + } + + const list = identifiers.join(", "); + const current = arrayValue(getProperty(moduleOptions, "routes")); + source = applyEdits( + source, + current === null + ? addProperty(source, moduleOptions, `routes: [${list}]`, "name") + : appendToArray(source, current, list), + ); + plan.update( + module.file, + source, + `register the ${names.label} resource routes`, + ); + + return { files: { routes: routeFiles, schema, service }, names }; +}; diff --git a/packages/vitnode/scripts/cli/program.ts b/packages/vitnode/scripts/cli/program.ts index a79fc83525..d9996d7565 100644 --- a/packages/vitnode/scripts/cli/program.ts +++ b/packages/vitnode/scripts/cli/program.ts @@ -277,10 +277,10 @@ const registerGenerateCommands = (program: Command, run: Run) => { .command("generate") .alias("g") .helpCommand(false) - .description("Generate plugin API modules, routes and services") + .description("Generate plugin API modules, routes, services and resources") .addHelpText( "after", - "\nExamples:\n vitnode g module notes\n vitnode g route notes/show --path '/{id}'\n vitnode g route admin/notes/create --permission notes.can_create --declare-permission\n vitnode g module notes --dry-run", + "\nExamples:\n vitnode g module notes\n vitnode g route notes/show --path '/{id}'\n vitnode g route admin/notes/create --permission notes.can_create --declare-permission\n vitnode g resource notes --kind api --fields title:string,body:string\n vitnode g module notes --dry-run", ); const common = (command: Command): Command => @@ -333,6 +333,32 @@ const registerGenerateCommands = (program: Command, run: Run) => { ), ); + common(generate.command("resource")) + .description("Create a module with schemas, a service and CRUD routes") + .argument("", "module path for the resource, e.g. notes") + .option("--kind ", "what to generate: api") + .option( + "--fields ", + "fields as name:type, e.g. title:string,pinned?:boolean", + ) + .option("--id-type ", "schema of the id: string or number", "string") + .option("--admin", "create it in the admin module tree (admin/)") + .option( + "--permission-module ", + "admin resources: permission module for can_view, can_create, can_edit and can_delete", + ) + .option( + "--declare-permission", + "declare missing permissions in config.api.ts", + ) + .option("--parents", "also create missing parent modules") + .action( + run( + async () => (await handlers()).runGenerateResourceCommand, + ([name]) => ({ name: name as string }), + ), + ); + common(generate.command("service")) .description("Create an empty service factory in a module") .argument("", "module path, e.g. notes") From ec0c1c57bc75e14256dcfe7f2bb16f8909fea149 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 17:33:49 +0000 Subject: [PATCH 03/51] =?UTF-8?q?feat(cli):=20=F0=9F=A9=BA=20vitnode=20doc?= =?UTF-8?q?tor=20and=20vitnode=20inspect=20routes|jobs|events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `vitnode doctor` runs the existing plugin validation, then checks API registration from source (unregistered modules/routes), the fetcher registry prerequisites, app registration of both plugin halves, declared staff permissions, duplicate endpoints and background work on nested modules. Checks that cannot run are reported as skipped, never as passed. - `vitnode inspect` lists routes with their declared staff permissions, cron jobs and queue tasks, and event listeners and WebSockets, read from build output without starting schedulers or touching a database. - buildRoute records its admin/moderator staff permissions as non-enumerable metadata (route-metadata.ts), so tooling can read them. - buildApiPlugin throws in development when a nested module declares cron jobs, events, queue tasks or WebSockets (still ignored in production, as before). Core and the bundled plugins have none. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CnAiFZ3bExobTQrGyYnt3P --- apps/web/content/docs/dev/cli/doctor.mdx | 101 +++++ apps/web/content/docs/dev/cli/index.mdx | 2 + apps/web/content/docs/dev/cli/meta.json | 1 + apps/web/content/docs/dev/cron/index.mdx | 2 +- apps/web/content/docs/dev/events/index.mdx | 2 +- .../vitnode/scripts/cli/commands/doctor.ts | 224 +++++++++++ .../vitnode/scripts/cli/commands/inspect.ts | 214 +++++++++++ .../scripts/cli/commands/plugin-validate.ts | 3 +- .../vitnode/scripts/cli/generate/api-tree.ts | 31 ++ .../scripts/cli/inspect/api-metadata.ts | 261 +++++++++++++ .../scripts/cli/inspect/static-checks.ts | 347 ++++++++++++++++++ packages/vitnode/scripts/cli/program.ts | 41 +++ packages/vitnode/src/api/lib/module.ts | 3 +- .../src/api/lib/nested-registrations.ts | 96 +++++ packages/vitnode/src/api/lib/plugin.ts | 23 +- .../vitnode/src/api/lib/route-metadata.ts | 49 +++ packages/vitnode/src/api/lib/route.ts | 12 +- 17 files changed, 1406 insertions(+), 6 deletions(-) create mode 100644 apps/web/content/docs/dev/cli/doctor.mdx create mode 100644 packages/vitnode/scripts/cli/commands/doctor.ts create mode 100644 packages/vitnode/scripts/cli/commands/inspect.ts create mode 100644 packages/vitnode/scripts/cli/inspect/api-metadata.ts create mode 100644 packages/vitnode/scripts/cli/inspect/static-checks.ts create mode 100644 packages/vitnode/src/api/lib/nested-registrations.ts create mode 100644 packages/vitnode/src/api/lib/route-metadata.ts diff --git a/apps/web/content/docs/dev/cli/doctor.mdx b/apps/web/content/docs/dev/cli/doctor.mdx new file mode 100644 index 0000000000..96d481ac6d --- /dev/null +++ b/apps/web/content/docs/dev/cli/doctor.mdx @@ -0,0 +1,101 @@ +--- +title: Doctor and inspect +description: Check VitNode plugins for registration, permission and configuration problems with vitnode doctor, and list their routes, jobs and event listeners with vitnode inspect. +icon: Stethoscope +--- + +`vitnode doctor` finds the mistakes that compile fine but break at runtime: a module nobody registered, a route guarded by a permission nobody can be granted, two routes on the same URL, a cron job that never runs. `vitnode inspect` lists what each plugin actually registers. Both read source and build output only: no server starts, no job runs and no database is needed. + +Run them inside an app to cover every plugin it configures plus the workspace's other plugins, or inside a plugin for just that one. Name one plugin by id, package name or folder: `pnpm vitnode doctor site-notes`. + +## Check plugins + +```bash +pnpm vitnode doctor +``` + +```txt +◆ VitNode + Doctor + + site-notes ../../plugins/site-notes + ✓ Package 0.1.0 + ✓ Plugin definition + ✓ API definition + ✓ Routes 1 route + ✓ Translations en + ! API registration + Module orphanModule (src/api/modules/orphan/orphan.module.ts) is not registered in any modules array. + ✓ Fetcher registry + ✓ App registration src/vitnode.api.config.ts + ✖ Staff permissions + POST /site-notes/admin/notes requires the admin permission notes.can_publish, which the plugin does not declare in permissionStaff.admin. + ✓ Endpoints 4 routes + ✖ Background registrations + cronJobs on nested module "admin/notes" (cleanup) never run: move them to the top-level module "admin". + + 8 checks passed, 1 warning, 2 failed, 0 skipped. + +✖ 1 plugin with problems: site-notes +``` + +The first checks are the ones [`vitnode plugin validate`](/docs/dev/cli/plugins#validate-plugins) runs, with the same rules. Doctor adds: + +| Check | Reads | Fails or warns when | +| ------------------------ | ------------------ | ---------------------------------------------------------------------------------------------------------------- | +| API registration | Source | Warns: a `buildModule` or `buildRoute` export under `src/api/modules` is not reached from `config.api.ts` | +| Fetcher registry | Source | Fails: `config.api.ts` does not export `type VitNodeApiPlugin`. Warns: `types/api-registry.gen.d.ts` is missing or not in `tsconfig.json` | +| App registration | Source | Fails: the app lists the plugin in `vitnode.config.ts` but no `vitnode.api.config.ts` imports `/config.api`. Warns the other way round | +| Staff permissions | Build output | Fails: a route's `adminStaffPermission` or `moderatorStaffPermission` is not declared. Warns: a route under `admin/` has no `adminStaffPermission` | +| Endpoints | Build output | Fails: two routes answer the same method and path; only the first one is reachable | +| Background registrations | Build output | Fails: cron jobs, events, queue tasks or WebSockets sit on a nested module, where they never run | + +A check that cannot run says so: without build output the build-output checks are `skipped`, and the definition and build checks fail with "run vitnode build". Nothing unchecked is reported as passing. When the source has entries the generator cannot follow, such as a spread in `modules`, **API registration** warns that its list may be incomplete. + +`vitnode doctor` exits with code `1` when any check fails, so it can gate CI after `pnpm build:plugins`. + +### Background work on nested modules + +`buildApiPlugin` collects content types and notification types from the whole module tree, but cron jobs, event listeners, queue tasks and WebSockets only from the modules passed to it directly. Production keeps ignoring nested ones, as it always has. In development (`NODE_ENV=development`), `buildApiPlugin` now throws with the same message doctor prints, so the mistake shows up the first time `pnpm dev` loads the API. + +## List what plugins register + +```bash +pnpm vitnode inspect routes site-notes +``` + +```txt + site-notes ../../plugins/site-notes + Method Path Staff permission + ────────────────────────────────────────────────────────── + GET /site-notes/hello - + GET /site-notes/notes - + POST /site-notes/admin/notes admin notes.can_create +``` + +| Command | Lists | +| ------------------------- | ------------------------------------------------------------ | +| `vitnode inspect routes` | Every route's method, path and declared staff permission | +| `vitnode inspect jobs` | Cron jobs with their schedule, and queue tasks, by module | +| `vitnode inspect events` | Event listeners by event, and WebSockets by id, by module | + +Paths include the plugin id and sit under the app's API base path, `/api` in the starter apps. Generated Content Engine routes are listed too, with the permissions the engine assigned. + +The **Staff permission** column shows what `buildRoute` was given in `adminStaffPermission` and `moderatorStaffPermission`. It is not proof of everything a route checks: a `checkStaffPermission` call or an ownership rule inside the handler, or a custom middleware, is invisible here. + +Both commands read each plugin's `dist` folder, so build plugins first (`pnpm build:plugins`, or `vitnode dev` running). They print names, schedules, paths and permission keys only: never environment values, secrets or request data. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/cli/index.mdx b/apps/web/content/docs/dev/cli/index.mdx index 034e7de321..14bcb8d7ca 100644 --- a/apps/web/content/docs/dev/cli/index.mdx +++ b/apps/web/content/docs/dev/cli/index.mdx @@ -36,6 +36,8 @@ Run it inside an app, an API app or a plugin folder, through your package manage | `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 generate ` | Scaffold plugin API modules, routes, resources | [Generate](/docs/dev/cli/generate) | +| `vitnode doctor` | Find registration and permission problems | [Doctor](/docs/dev/cli/doctor) | +| `vitnode inspect ` | List routes, jobs or event listeners | [Doctor](/docs/dev/cli/doctor) | | `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) | diff --git a/apps/web/content/docs/dev/cli/meta.json b/apps/web/content/docs/dev/cli/meta.json index f28e5d5b3a..9ae1f14ca6 100644 --- a/apps/web/content/docs/dev/cli/meta.json +++ b/apps/web/content/docs/dev/cli/meta.json @@ -9,6 +9,7 @@ "bundle-size", "plugins", "generate", + "doctor", "database", "ci", "output" diff --git a/apps/web/content/docs/dev/cron/index.mdx b/apps/web/content/docs/dev/cron/index.mdx index 678221d604..63b539e621 100644 --- a/apps/web/content/docs/dev/cron/index.mdx +++ b/apps/web/content/docs/dev/cron/index.mdx @@ -68,7 +68,7 @@ export const notesModule = buildModule({ Keep any other keys your module already has, such as `queueTasks` or `events`. -Only top-level modules are read. A job on a nested module, such as `admin/notes`, is never registered. +Only top-level modules are read. A job on a nested module, such as `admin/notes`, is never registered: `buildApiPlugin` throws in development to say so, and [`vitnode doctor`](/docs/dev/cli/doctor) reports it. diff --git a/apps/web/content/docs/dev/events/index.mdx b/apps/web/content/docs/dev/events/index.mdx index 11870fdbb8..31a25e87c0 100644 --- a/apps/web/content/docs/dev/events/index.mdx +++ b/apps/web/content/docs/dev/events/index.mdx @@ -107,7 +107,7 @@ export const notesModule = buildModule({ }); ``` -Listeners are collected only from top-level modules. On a nested module, such as `admin/notes`, a listener never runs, and nothing warns you. +Listeners are collected only from top-level modules. On a nested module, such as `admin/notes`, a listener never runs: `buildApiPlugin` throws in development to say so, and [`vitnode doctor`](/docs/dev/cli/doctor) reports it. Production ignores it silently. diff --git a/packages/vitnode/scripts/cli/commands/doctor.ts b/packages/vitnode/scripts/cli/commands/doctor.ts new file mode 100644 index 0000000000..1422457146 --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/doctor.ts @@ -0,0 +1,224 @@ +import { relative } from "node:path"; + +import type { CommandContext, OutputOptions } from "../context"; +import type { PluginApiLoad } from "../inspect/api-metadata"; +import type { LoadConfiguredPluginIds } from "../plugins/discover"; +import type { ModuleImporter, PluginCheck } from "../plugins/validate"; + +import { describeNestedRegistration } from "../../../src/api/lib/nested-registrations"; +import { EXIT_CODE, UserError, ValidationError } from "../errors"; +import { diagnosticTargets, loadPluginApi } from "../inspect/api-metadata"; +import { + appRegistrationCheck, + registrationCheck, + registryCheck, +} from "../inspect/static-checks"; +import { findAppRoot } from "../plugins/discover"; +import { validatePlugin } from "../plugins/validate"; +import { findWorkspaceRoot } from "../plugins/workspace"; +import { plural, toDisplayPath } from "../ui/format"; +import { renderCheck } from "./plugin-validate"; + +export interface DoctorOptions extends OutputOptions { + name?: string; +} + +const ADMIN_PATH = /(^|\/)admin(\/|$)/; + +/** Checks that need the plugin's built API definition. */ +const metadataChecks = (id: string, load: PluginApiLoad): PluginCheck[] => { + const names = ["Staff permissions", "Endpoints", "Background registrations"]; + + if (load.status !== "loaded") { + return names.map(name => ({ + details: load.status === "error" ? [load.reason] : [], + name, + status: load.status === "error" ? "error" : "skipped", + summary: load.status === "skipped" ? load.reason : undefined, + })); + } + + const { ignored, permissionStaff, routes } = load.metadata; + + // Staff permissions the routes declare, against what the plugin registers. + const undeclared: string[] = []; + const unguarded: string[] = []; + for (const route of routes) { + const label = `${route.method} /${id}${route.path}`; + for (const [type, ref] of [ + ["admin", route.admin], + ["moderator", route.moderator], + ] as const) { + if (ref === undefined || (ref.plugin !== undefined && ref.plugin !== id)) + continue; + if (!(permissionStaff[type][ref.module] ?? []).includes(ref.permission)) { + undeclared.push( + `${label} requires the ${type} permission ${ref.module}.${ref.permission}, which the plugin does not declare in permissionStaff.${type}.`, + ); + } + } + if (ADMIN_PATH.test(route.modulePath) && route.admin === undefined) { + unguarded.push( + `${label} is under admin/ but has no adminStaffPermission.`, + ); + } + } + + // Two routes answering the same method and path. + const seen = new Map(); + const duplicates: string[] = []; + for (const route of routes) { + const key = `${route.method} ${route.path.replace(/\{[^}]*\}/g, "{}")}`; + const label = `${route.method} /${id}${route.path}`; + const previous = seen.get(key); + if (previous === undefined) seen.set(key, label); + else + duplicates.push( + `${label} is declared twice (also ${previous}); only the first one answers.`, + ); + } + + return [ + undeclared.length > 0 + ? { + details: [...undeclared, ...unguarded], + name: names[0] ?? "", + status: "error", + } + : unguarded.length > 0 + ? { details: unguarded, name: names[0] ?? "", status: "warning" } + : { + details: [], + name: names[0] ?? "", + status: "ok", + summary: + "declared on buildRoute; checks inside handlers are not visible", + }, + duplicates.length > 0 + ? { details: duplicates, name: names[1] ?? "", status: "error" } + : { + details: [], + name: names[1] ?? "", + status: "ok", + summary: plural(routes.length, "route"), + }, + ignored.length > 0 + ? { + details: ignored.map(describeNestedRegistration), + name: names[2] ?? "", + status: "error", + } + : { details: [], name: names[2] ?? "", status: "ok" }, + ]; +}; + +/** + * `vitnode doctor`: every check VitNode can make about a plugin without + * starting it - the build-output validation `vitnode plugin validate` runs, + * plus API registration, the fetcher registry, staff permissions, duplicate + * endpoints and background work on nested modules. + * + * A check that cannot run (no build output, no API) is reported as skipped, + * never as passed. + */ +export const runDoctorCommand = async ( + { cwd, ui }: CommandContext, + { name }: DoctorOptions, + deps: { + importModule?: ModuleImporter; + loadIds?: LoadConfiguredPluginIds; + } = {}, +): Promise => { + ui.header("Doctor"); + + const targets = await diagnosticTargets(cwd, name, deps.loadIds); + if (name !== undefined && targets.length === 0) { + throw new UserError(`No plugin named "${name}" was found.`); + } + if (targets.length === 0) { + ui.note("No plugins to check."); + ui.line(); + + return EXIT_CODE.ok; + } + + const workspaceRoot = findWorkspaceRoot(cwd); + const appRoot = findAppRoot(cwd, workspaceRoot); + const totals = { error: 0, ok: 0, skipped: 0, warning: 0 }; + const failing: string[] = []; + + for (const [index, target] of targets.entries()) { + 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)}`, + ); + + const checks: PluginCheck[] = []; + if (target.root === null) { + checks.push({ + details: [ + `${target.id} is configured but not installed - run your package manager's install.`, + ], + name: "Package", + status: "error", + }); + } else { + checks.push( + ...( + await validatePlugin(target.root, { importModule: deps.importModule }) + ).checks, + ); + checks.push(registrationCheck(target.root)); + checks.push(registryCheck(target.root)); + checks.push( + appRegistrationCheck({ + appRoot, + configured: target.configured, + cwd, + id: target.id, + root: target.root, + workspaceRoot, + }), + ); + checks.push( + ...metadataChecks( + target.id, + await loadPluginApi(target.id, target.root, deps.importModule), + ), + ); + } + + checks.forEach(check => { + totals[check.status] += 1; + renderCheck(ui, check); + }); + if (checks.some(check => check.status === "error")) failing.push(target.id); + } + + ui.line(); + ui.note( + `${plural(totals.ok, "check")} passed, ${plural(totals.warning, "warning")}, ${String(totals.error)} failed, ${String(totals.skipped)} skipped.`, + ); + ui.line(); + + if (failing.length > 0) { + throw new ValidationError( + `${plural(failing.length, "plugin")} with problems: ${failing.join(", ")}`, + ); + } + + ui.success( + totals.warning > 0 + ? "No errors - see the warnings above" + : "Everything checked is fine", + ); + ui.line(); + + return EXIT_CODE.ok; +}; diff --git a/packages/vitnode/scripts/cli/commands/inspect.ts b/packages/vitnode/scripts/cli/commands/inspect.ts new file mode 100644 index 0000000000..6839a781ac --- /dev/null +++ b/packages/vitnode/scripts/cli/commands/inspect.ts @@ -0,0 +1,214 @@ +import { relative } from "node:path"; + +import type { CommandContext, OutputOptions } from "../context"; +import type { PluginApiLoad, PluginApiMetadata } from "../inspect/api-metadata"; +import type { LoadConfiguredPluginIds } from "../plugins/discover"; +import type { ModuleImporter } from "../plugins/validate"; +import type { Ui } from "../ui/ui"; + +import { describeNestedRegistration } from "../../../src/api/lib/nested-registrations"; +import { EXIT_CODE, UserError } from "../errors"; +import { diagnosticTargets, loadPluginApi } from "../inspect/api-metadata"; +import { plural, toDisplayPath } from "../ui/format"; + +export interface InspectOptions extends OutputOptions { + name?: string; +} + +export interface InspectDeps { + importModule?: ModuleImporter; + loadIds?: LoadConfiguredPluginIds; +} + +type Section = "events" | "jobs" | "routes"; + +const TITLES: Record = { + events: "Inspect event listeners", + jobs: "Inspect background jobs", + routes: "Inspect routes", +}; + +const permissionLabel = ( + pluginId: string, + type: string, + ref?: { module: string; permission: string; plugin?: string }, +) => + ref === undefined + ? null + : `${type} ${ref.plugin !== undefined && ref.plugin !== pluginId ? `${ref.plugin}:` : ""}${ref.module}.${ref.permission}`; + +const renderRoutes = (ui: Ui, id: string, metadata: PluginApiMetadata) => { + if (metadata.routes.length === 0) { + ui.note("No routes."); + + return; + } + + ui.table({ + columns: [ + { header: "Method" }, + { header: "Path" }, + { header: "Staff permission" }, + ], + rows: metadata.routes.map(route => [ + route.method, + `/${id}${route.path}`, + [ + permissionLabel(id, "admin", route.admin), + permissionLabel(id, "moderator", route.moderator), + ] + .filter(label => label !== null) + .join(", ") || ui.colors.muted("-"), + ]), + }); +}; + +const renderJobs = (ui: Ui, metadata: PluginApiMetadata) => { + if (metadata.jobs.length === 0) ui.note("No cron jobs or queue tasks."); + else { + ui.table({ + columns: [ + { header: "Kind" }, + { header: "Name" }, + { header: "Schedule" }, + { header: "Module" }, + ], + rows: metadata.jobs.map(job => [ + job.kind, + job.name, + job.schedule ?? ui.colors.muted("-"), + job.module, + ]), + }); + } +}; + +const renderListeners = (ui: Ui, metadata: PluginApiMetadata) => { + if (metadata.listeners.length === 0) + ui.note("No event listeners or WebSockets."); + else { + ui.table({ + columns: [ + { header: "Kind" }, + { header: "Event or id" }, + { header: "Module" }, + ], + rows: metadata.listeners.map(listener => [ + listener.kind, + listener.event, + listener.module, + ]), + }); + } +}; + +const renderIgnored = ( + ui: Ui, + metadata: PluginApiMetadata, + section: Section, +) => { + const kinds = + section === "jobs" + ? ["cronJobs", "queueTasks"] + : section === "events" + ? ["events", "webSockets"] + : []; + for (const entry of metadata.ignored.filter(item => + kinds.includes(item.kind), + )) { + ui.warning(describeNestedRegistration(entry)); + } +}; + +const run = async ( + section: Section, + { cwd, ui }: CommandContext, + { name }: InspectOptions, + deps: InspectDeps = {}, +): Promise => { + ui.header(TITLES[section]); + + const targets = await diagnosticTargets(cwd, name, deps.loadIds); + if (name !== undefined && targets.length === 0) { + throw new UserError(`No plugin named "${name}" was found.`, { + hint: "Pass the plugin's package name, or run the command inside the plugin.", + }); + } + if (targets.length === 0) { + ui.note("No plugins to inspect."); + ui.line(); + + return EXIT_CODE.ok; + } + + let failed = 0; + for (const [index, target] of targets.entries()) { + if (index > 0) ui.line(); + const load: PluginApiLoad = await loadPluginApi( + target.id, + target.root, + deps.importModule, + ); + 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 (load.status !== "loaded") { + if (load.status === "error") { + failed += 1; + ui.error(load.reason); + } else ui.note(`Skipped: ${load.reason}.`); + continue; + } + + if (section === "routes") renderRoutes(ui, target.id, load.metadata); + if (section === "jobs") renderJobs(ui, load.metadata); + if (section === "events") renderListeners(ui, load.metadata); + renderIgnored(ui, load.metadata, section); + } + + ui.line(); + if (section === "routes") { + ui.note( + "Paths are under the app's API base path (/api in the starter apps). Staff permissions are the ones declared on buildRoute; checks inside handlers or custom middleware are not listed.", + ); + } else { + ui.note( + "Read from each plugin's build output. Nothing was scheduled, run or sent.", + ); + } + ui.line(); + + if (failed > 0) { + ui.error(`${plural(failed, "plugin")} could not be loaded.`); + ui.line(); + + return EXIT_CODE.failure; + } + + return EXIT_CODE.ok; +}; + +export const runInspectRoutesCommand = async ( + context: CommandContext, + options: InspectOptions, + deps?: InspectDeps, +) => run("routes", context, options, deps); + +export const runInspectJobsCommand = async ( + context: CommandContext, + options: InspectOptions, + deps?: InspectDeps, +) => run("jobs", context, options, deps); + +export const runInspectEventsCommand = async ( + context: CommandContext, + options: InspectOptions, + deps?: InspectDeps, +) => run("events", context, options, deps); diff --git a/packages/vitnode/scripts/cli/commands/plugin-validate.ts b/packages/vitnode/scripts/cli/commands/plugin-validate.ts index 9624481044..963fcb72f9 100644 --- a/packages/vitnode/scripts/cli/commands/plugin-validate.ts +++ b/packages/vitnode/scripts/cli/commands/plugin-validate.ts @@ -30,7 +30,8 @@ export const matchesPlugin = (plugin: DiscoveredPlugin, query: string) => shortNameOf(plugin.id) === query || (plugin.root !== null && basename(plugin.root) === query); -const renderCheck = (ui: Ui, check: PluginCheck) => { +/** One check line and its details, as `plugin validate` and `doctor` print them. */ +export const renderCheck = (ui: Ui, check: PluginCheck) => { const summary = check.summary ?? ""; switch (check.status) { diff --git a/packages/vitnode/scripts/cli/generate/api-tree.ts b/packages/vitnode/scripts/cli/generate/api-tree.ts index a8108aee6e..ce3e9caf45 100644 --- a/packages/vitnode/scripts/cli/generate/api-tree.ts +++ b/packages/vitnode/scripts/cli/generate/api-tree.ts @@ -189,6 +189,37 @@ export const readModuleSource = ( }; }; +/** + * Where an identifier used in `parsed` is declared inside the plugin: a local + * `const`, or an import with `@/` or a relative path. A string explains why it + * could not be followed. + */ +export const resolveBinding = ( + plan: GenerationPlan, + project: GeneratorProject, + parsed: ParsedSource, + name: string, +): string | { exportName: string; file: string } => { + if (findConstInit(parsed.program, name) !== null) { + return { exportName: name, file: parsed.path }; + } + + const imported = listImports(parsed.program) + .flatMap(info => + info.locals.map(local => ({ ...local, source: info.source })), + ) + .find(local => local.local === name); + if (imported === undefined || imported.imported === null) { + return "its declaration was not found"; + } + + const file = resolveSpecifier(plan, project, parsed.path, imported.source); + + return file === null + ? `"${imported.source}" does not resolve to a file in this plugin` + : { exportName: imported.imported, file }; +}; + const entryOf = ( plan: GenerationPlan, project: GeneratorProject, diff --git a/packages/vitnode/scripts/cli/inspect/api-metadata.ts b/packages/vitnode/scripts/cli/inspect/api-metadata.ts new file mode 100644 index 0000000000..5cd9477383 --- /dev/null +++ b/packages/vitnode/scripts/cli/inspect/api-metadata.ts @@ -0,0 +1,261 @@ +import { existsSync } from "node:fs"; +import { join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; + +import type { NestedRegistration } from "../../../src/api/lib/nested-registrations"; +import type { RouteStaffPermissionRef } from "../../../src/api/lib/route-metadata"; +import type { LoadConfiguredPluginIds } from "../plugins/discover"; +import type { ModuleImporter } from "../plugins/validate"; + +import { findNestedRegistrations } from "../../../src/api/lib/nested-registrations"; +import { routeStaffPermissions } from "../../../src/api/lib/route-metadata"; +import { matchesPlugin } from "../commands/plugin-validate"; +import { errorMessage } from "../errors"; +import { discoverPlugins, isPluginPackage } from "../plugins/discover"; +import { findDefinition } from "../plugins/validate"; +import { findPackageRoot, readPackageJson } from "../project/packages"; +import { toDisplayPath } from "../ui/format"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const nativeImport: ModuleImporter = async file => + import(pathToFileURL(file).href) as Promise; + +export interface InspectedRoute { + admin?: RouteStaffPermissionRef; + method: string; + moderator?: RouteStaffPermissionRef; + /** `admin/notes` */ + modulePath: string; + /** Mount path inside the plugin: `/admin/notes/{id}`. */ + path: string; +} + +export interface InspectedJob { + kind: "cron" | "queue"; + module: string; + name: string; + schedule?: string; +} + +export interface InspectedListener { + event: string; + kind: "event" | "websocket"; + module: string; +} + +export interface PluginApiMetadata { + /** Cron jobs, events, queue tasks and WebSockets on nested modules, which never run. */ + ignored: NestedRegistration[]; + jobs: InspectedJob[]; + listeners: InspectedListener[]; + /** `permissionStaff` after content types and containers added theirs. */ + permissionStaff: { + admin: Record; + moderator: Record; + }; + routes: InspectedRoute[]; +} + +export type PluginApiLoad = + | { id: string; metadata: PluginApiMetadata; root: string; status: "loaded" } + | { + id: string; + reason: string; + root: null | string; + status: "error" | "skipped"; + }; + +interface ModuleLike { + cronJobs?: unknown[]; + events?: unknown[]; + modules?: ModuleLike[]; + name: string; + queueTasks?: unknown[]; + routes?: unknown[]; + webSockets?: unknown[]; +} + +const permissionNames = (entries: unknown): string[] => + Array.isArray(entries) + ? entries.flatMap(entry => + typeof entry === "string" + ? [entry] + : isRecord(entry) && typeof entry.permission === "string" + ? [entry.permission] + : [], + ) + : []; + +const permissionMap = (value: unknown): Record => + isRecord(value) + ? Object.fromEntries( + Object.entries(value).map(([module, entries]) => [ + module, + permissionNames(entries), + ]), + ) + : {}; + +const stringField = (value: unknown, key: string): string => + isRecord(value) && typeof value[key] === "string" ? value[key] : "?"; + +/** + * Reads what a built API definition registers - routes, staff permissions, + * cron jobs, queue tasks, event listeners, WebSockets - from its objects. + * Nothing is executed beyond building the definition: no request is sent, no + * scheduler starts, no database is touched. + */ +export const describeApiDefinition = ( + definition: Record, +): PluginApiMetadata => { + const modules = ( + Array.isArray(definition.modules) ? definition.modules : [] + ) as ModuleLike[]; + const routes: InspectedRoute[] = []; + + const walk = (module: ModuleLike, prefix: string[]) => { + const path = [...prefix, module.name]; + for (const route of module.routes ?? []) { + const config = + isRecord(route) && isRecord(route.route) ? route.route : {}; + const routePath = typeof config.path === "string" ? config.path : "?"; + const permissions = routeStaffPermissions(route); + routes.push({ + admin: permissions?.admin, + method: + typeof config.method === "string" ? config.method.toUpperCase() : "?", + moderator: permissions?.moderator, + modulePath: path.join("/"), + path: `/${path.join("/")}${routePath === "/" ? "" : routePath}`, + }); + } + for (const child of module.modules ?? []) walk(child, path); + }; + for (const module of modules) walk(module, []); + + const tagged = (value: unknown) => (Array.isArray(value) ? value : []); + const staff = isRecord(definition.permissionStaff) + ? definition.permissionStaff + : {}; + + return { + ignored: findNestedRegistrations(modules), + jobs: [ + ...tagged(definition.cronJobs).map(job => ({ + kind: "cron" as const, + module: stringField(job, "module"), + name: stringField(job, "name"), + schedule: stringField(job, "schedule"), + })), + ...tagged(definition.queueTasks).map(task => ({ + kind: "queue" as const, + module: stringField(task, "module"), + name: stringField(task, "name"), + })), + ], + listeners: [ + ...tagged(definition.events).map(listener => ({ + event: stringField(listener, "event"), + kind: "event" as const, + module: stringField(listener, "module"), + })), + ...tagged(definition.webSockets).map(socket => ({ + event: stringField(socket, "id"), + kind: "websocket" as const, + module: stringField(socket, "module"), + })), + ], + permissionStaff: { + admin: permissionMap(staff.admin), + moderator: permissionMap(staff.moderator), + }, + routes, + }; +}; + +/** Loads one plugin's built `dist/src/config.api.js` and describes it. */ +export const loadPluginApi = async ( + id: string, + root: null | string, + importModule: ModuleImporter = nativeImport, +): Promise => { + if (root === null) { + return { + id, + reason: "configured but not installed", + root, + status: "error", + }; + } + + const file = join(root, "dist", "src", "config.api.js"); + if (!existsSync(file)) { + return existsSync(join(root, "src", "config.api.ts")) + ? { + id, + reason: `not built: ${toDisplayPath(relative(root, file))} is missing - run vitnode build in the plugin`, + root, + status: "skipped", + } + : { + id, + reason: "has no API (no src/config.api.ts)", + root, + status: "skipped", + }; + } + + try { + const definition = findDefinition(await importModule(file)); + if (definition === null) { + return { + id, + reason: `${toDisplayPath(relative(root, file))} exports no buildApiPlugin() factory`, + root, + status: "error", + }; + } + + return { + id, + metadata: describeApiDefinition(definition), + root, + status: "loaded", + }; + } catch (error) { + return { id, reason: errorMessage(error), root, status: "error" }; + } +}; + +/** + * The plugins a diagnostics command talks about: the plugin it runs in, or + * every plugin the app configures plus the workspace's other plugins - + * narrowed to one when `name` is given. + */ +export const diagnosticTargets = async ( + cwd: string, + name: string | undefined, + loadIds?: LoadConfiguredPluginIds, +): Promise<{ configured: boolean; id: string; root: null | string }[]> => { + const packageRoot = findPackageRoot(cwd); + if ( + name === undefined && + packageRoot !== null && + isPluginPackage(packageRoot) + ) { + return [ + { + configured: true, + id: readPackageJson(packageRoot)?.name ?? packageRoot, + root: packageRoot, + }, + ]; + } + + const { plugins } = await discoverPlugins(cwd, { loadIds }); + return plugins + .filter(plugin => name === undefined || matchesPlugin(plugin, name)) + .map(({ configured, id, root }) => ({ configured, id, root })); +}; diff --git a/packages/vitnode/scripts/cli/inspect/static-checks.ts b/packages/vitnode/scripts/cli/inspect/static-checks.ts new file mode 100644 index 0000000000..084c280b41 --- /dev/null +++ b/packages/vitnode/scripts/cli/inspect/static-checks.ts @@ -0,0 +1,347 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, posix, relative } from "node:path"; + +import type { ModuleSource } from "../generate/api-tree"; +import type { PluginCheck } from "../plugins/validate"; + +import { errorMessage } from "../errors"; +import { + readModuleContainer, + readParsed, + readRootContainer, + resolveBinding, +} from "../generate/api-tree"; +import { + arrayValue, + getProperty, + isExported, + listImports, + parseSource, + unwrap, +} from "../generate/ast"; +import { GenerationPlan } from "../generate/plan"; +import { API_CONFIG } from "../generate/project"; +import { workspacePackageDirs } from "../plugins/workspace"; +import { plural } from "../ui/format"; + +const MODULES_DIR = "src/api/modules"; + +const sourceFiles = (root: string, dir: string): string[] => { + const absolute = join(root, dir); + if (!existsSync(absolute)) return []; + + return readdirSync(absolute).flatMap(entry => { + const path = posix.join(dir, entry); + if (statSync(join(root, path)).isDirectory()) + return sourceFiles(root, path); + + return /\.tsx?$/.test(entry) && + !/\.(test|spec)\.tsx?$/.test(entry) && + !entry.endsWith(".d.ts") + ? [path] + : []; + }); +}; + +/** Exported `const x = ({...})` declarations in a file. */ +const builderExports = ( + plan: GenerationPlan, + file: string, + builder: "buildModule" | "buildRoute", +): string[] => { + const parsed = readParsed(plan, file); + + return parsed.program.body.flatMap(statement => { + if (statement.type !== "ExportNamedDeclaration") return []; + if (statement.declaration?.type !== "VariableDeclaration") return []; + + return statement.declaration.declarations.flatMap(declarator => { + if (declarator.id.type !== "Identifier" || !declarator.init) return []; + const init = unwrap(declarator.init); + + return init.type === "CallExpression" && + init.callee.type === "Identifier" && + init.callee.name === builder + ? [declarator.id.name] + : []; + }); + }); +}; + +/** + * Modules and routes declared under `src/api/modules` that no registration + * reaches - written, but never mounted. Read from source with the same + * resolver `vitnode generate` uses; nothing is executed. + */ +export const registrationCheck = (root: string): PluginCheck => { + const name = "API registration"; + if (!existsSync(join(root, API_CONFIG))) { + return { + details: [], + name, + status: "skipped", + summary: "no src/config.api.ts", + }; + } + + const plan = new GenerationPlan(root); + const tsconfig = join(root, "tsconfig.json"); + const project = { + alias: + existsSync(tsconfig) && + /["']@\/\*["']\s*:/.test(readFileSync(tsconfig, "utf8")), + display: ".", + id: "", + root, + }; + const reachedModules = new Set(); + const reachedRoutes = new Set(); + const unresolved: string[] = []; + + try { + const visit = (module: ModuleSource) => { + reachedModules.add(`${module.file}#${module.exportName}`); + + const routes = arrayValue(getProperty(module.options, "routes")); + for (const element of routes?.elements ?? []) { + if (element === null || element.type === "SpreadElement") continue; + const value = unwrap(element); + if (value.type !== "Identifier") continue; + const binding = resolveBinding( + plan, + project, + module.parsed, + value.name, + ); + if (typeof binding === "string") + unresolved.push(`${value.name}: ${binding}`); + else reachedRoutes.add(`${binding.file}#${binding.exportName}`); + } + + for (const entry of readModuleContainer(plan, project, module).entries) { + if (entry.kind === "module") visit(entry.source); + if (entry.kind === "unresolved") + unresolved.push(`${entry.label}: ${entry.reason}`); + } + }; + + for (const entry of readRootContainer(plan, project).entries) { + if (entry.kind === "module") visit(entry.source); + if (entry.kind === "unresolved") + unresolved.push(`${entry.label}: ${entry.reason}`); + } + } catch (error) { + return { + details: [errorMessage(error)], + name, + status: "skipped", + summary: "the module tree could not be read from source", + }; + } + + const unregistered: string[] = []; + for (const file of sourceFiles(root, MODULES_DIR)) { + let modules: string[]; + let routes: string[]; + try { + modules = builderExports(plan, file, "buildModule"); + routes = builderExports(plan, file, "buildRoute"); + } catch { + continue; + } + for (const exportName of modules) { + if (!reachedModules.has(`${file}#${exportName}`)) { + unregistered.push( + `Module ${exportName} (${file}) is not registered in any modules array.`, + ); + } + } + for (const exportName of routes) { + if (!reachedRoutes.has(`${file}#${exportName}`)) { + unregistered.push( + `Route ${exportName} (${file}) is not in any registered module's routes.`, + ); + } + } + } + + if (unresolved.length > 0) { + return { + details: [ + ...unregistered, + ...unresolved.map(line => `Not followed: ${line}`), + "Entries that could not be followed may register more than is reported here.", + ], + name, + status: "warning", + }; + } + + return unregistered.length > 0 + ? { details: unregistered, name, status: "warning" } + : { + details: [], + name, + status: "ok", + summary: `${plural(reachedModules.size, "module")}, ${plural(reachedRoutes.size, "route")} reached`, + }; +}; + +/** What the fetcher's type registry needs from the plugin. */ +export const registryCheck = (root: string): PluginCheck => { + const name = "Fetcher registry"; + if (!existsSync(join(root, API_CONFIG))) { + return { + details: [], + name, + status: "skipped", + summary: "no src/config.api.ts", + }; + } + + const problems: string[] = []; + const warnings: string[] = []; + try { + const parsed = parseSource( + API_CONFIG, + readFileSync(join(root, API_CONFIG), "utf8"), + ); + if ( + !isExported(parsed.program, "VitNodeApiPlugin") && + !/export\s+type\s+VitNodeApiPlugin\b/.test(parsed.source) + ) { + problems.push( + `${API_CONFIG} does not export type VitNodeApiPlugin = ApiPluginContract<...>; apps cannot type fetcher calls to this plugin.`, + ); + } + } catch (error) { + problems.push(errorMessage(error)); + } + + if (!existsSync(join(root, "types", "api-registry.gen.d.ts"))) { + warnings.push( + "types/api-registry.gen.d.ts is missing - run vitnode build or vitnode dev in the plugin so its own pages can call its API.", + ); + } else { + const tsconfig = join(root, "tsconfig.json"); + if ( + existsSync(tsconfig) && + !/["']types["']/.test(readFileSync(tsconfig, "utf8")) + ) { + warnings.push( + 'tsconfig.json does not include "types", so types/api-registry.gen.d.ts is not loaded.', + ); + } + } + + if (problems.length > 0) + return { details: [...problems, ...warnings], name, status: "error" }; + if (warnings.length > 0) + return { details: warnings, name, status: "warning" }; + + return { details: [], name, status: "ok" }; +}; + +/** The `vitnode.api.config.ts` files in the workspace, and the specifiers they import. */ +const apiConfigImports = ( + workspaceRoot: null | string, + appRoot: null | string, +): Map => { + const roots = new Set(); + if (appRoot !== null) roots.add(appRoot); + if (workspaceRoot !== null) + for (const dir of workspacePackageDirs(workspaceRoot)) roots.add(dir); + + const found = new Map(); + for (const dir of roots) { + const file = join(dir, "src", "vitnode.api.config.ts"); + if (!existsSync(file)) continue; + try { + const parsed = parseSource(file, readFileSync(file, "utf8")); + found.set( + file, + listImports(parsed.program).map(info => info.source), + ); + } catch { + // An unreadable config is reported by the app's own build. + } + } + + return found; +}; + +/** + * Whether the plugin's two halves are registered together: an app that lists + * the plugin in `vitnode.config.ts` should also import its `config.api` in a + * `vitnode.api.config.ts`, and the other way round. + */ +export const appRegistrationCheck = ({ + appRoot, + configured, + cwd, + id, + root, + workspaceRoot, +}: { + appRoot: null | string; + configured: boolean; + cwd: string; + id: string; + root: string; + workspaceRoot: null | string; +}): PluginCheck => { + const name = "App registration"; + if (!existsSync(join(root, API_CONFIG))) { + return { + details: [], + name, + status: "skipped", + summary: "no API to register", + }; + } + + const configs = apiConfigImports(workspaceRoot, appRoot); + if (configs.size === 0) { + return { + details: [], + name, + status: "skipped", + summary: "no vitnode.api.config.ts found in this workspace", + }; + } + + const registeredIn = [...configs].filter(([, sources]) => + sources.includes(`${id}/config.api`), + ); + const where = registeredIn.map(([file]) => relative(cwd, file) || file); + + if (configured && registeredIn.length === 0) { + return { + details: [ + `The app configures ${id} in vitnode.config.ts, but no vitnode.api.config.ts imports "${id}/config.api".`, + "Its pages load, but every call to its API answers 404. Add its API plugin to buildApiConfig({ plugins }).", + ], + name, + status: "error", + }; + } + + if (!configured && registeredIn.length > 0) { + return { + details: [ + `${where.join(", ")} registers the API of ${id}, but the app does not list it in vitnode.config.ts.`, + ], + name, + status: "warning", + }; + } + + return registeredIn.length === 0 + ? { + details: [], + name, + status: "skipped", + summary: "not used by an app in this workspace", + } + : { details: [], name, status: "ok", summary: where.join(", ") }; +}; diff --git a/packages/vitnode/scripts/cli/program.ts b/packages/vitnode/scripts/cli/program.ts index d9996d7565..05e57a2258 100644 --- a/packages/vitnode/scripts/cli/program.ts +++ b/packages/vitnode/scripts/cli/program.ts @@ -123,6 +123,47 @@ export const createProgram = (hooks: ProgramHooks): Command => { registerGenerateCommands(program, run); + outputOptions(program.command("doctor")) + .description("Check plugins for configuration and registration problems") + .argument( + "[name]", + "plugin package name or folder (default: all, or the one you are in)", + ) + .action( + run( + async () => (await import("./commands/doctor")).runDoctorCommand, + ([name]) => ({ name: name as string | undefined }), + ), + ); + + const inspect = program + .command("inspect") + .helpCommand(false) + .description("Show what plugins register, read from their build output"); + const inspectHandlers = async () => import("./commands/inspect"); + for (const [kind, description, handler] of [ + [ + "routes", + "Routes, their paths and declared staff permissions", + "runInspectRoutesCommand", + ], + ["jobs", "Cron jobs and queue tasks", "runInspectJobsCommand"], + ["events", "Event listeners and WebSockets", "runInspectEventsCommand"], + ] as const) { + outputOptions(inspect.command(kind)) + .description(description) + .argument( + "[name]", + "plugin package name or folder (default: all, or the one you are in)", + ) + .action( + run( + async () => (await inspectHandlers())[handler], + ([name]) => ({ name: name as string | undefined }), + ), + ); + } + const db = program .command("db") .description("Generate, apply and inspect database migrations"); diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts index 21524c3975..b823edd370 100644 --- a/packages/vitnode/src/api/lib/module.ts +++ b/packages/vitnode/src/api/lib/module.ts @@ -99,7 +99,8 @@ export interface BuildModuleOptions< * Mind the depth: content types, models, containers, notification types and * search indexers are collected from the whole module tree, but cron jobs, * events, queue tasks and WebSockets only from the modules passed to - * `buildApiPlugin` directly - declared on a nested module, they never run. + * `buildApiPlugin` directly - declared on a nested module, they never run, and + * `buildApiPlugin` throws in development to say so (`vitnode doctor` too). * * @example * ```ts diff --git a/packages/vitnode/src/api/lib/nested-registrations.ts b/packages/vitnode/src/api/lib/nested-registrations.ts new file mode 100644 index 0000000000..b581bcde08 --- /dev/null +++ b/packages/vitnode/src/api/lib/nested-registrations.ts @@ -0,0 +1,96 @@ +/** A registration kind `buildApiPlugin` reads from top-level modules only. */ +export type TopLevelOnlyRegistration = + | "cronJobs" + | "events" + | "queueTasks" + | "webSockets"; + +/** The parts of a built module the check reads. */ +export interface ModuleRegistrationTree { + cronJobs?: readonly unknown[]; + events?: readonly unknown[]; + modules?: readonly ModuleRegistrationTree[]; + name: string; + queueTasks?: readonly unknown[]; + webSockets?: readonly unknown[]; +} + +/** One nested module's registrations that `buildApiPlugin` ignores. */ +export interface NestedRegistration { + /** How many entries of `kind` the module declares. */ + count: number; + kind: TopLevelOnlyRegistration; + /** The nested module's path, e.g. `admin/ai`. */ + modulePath: string; + /** The names (or ids) of the ignored entries. */ + names: string[]; +} + +const KINDS: readonly TopLevelOnlyRegistration[] = [ + "cronJobs", + "events", + "queueTasks", + "webSockets", +]; + +const nameOf = (entry: unknown): string => { + if (typeof entry !== "object" || entry === null) return "?"; + const { event, id, name } = entry as { + event?: unknown; + id?: unknown; + name?: unknown; + }; + const value = name ?? id ?? event; + + return typeof value === "string" ? value : "?"; +}; + +/** + * Cron jobs, event listeners, queue tasks and WebSockets declared on a module + * nested inside another one. `buildApiPlugin` only collects those kinds from + * the modules passed to it directly, so these never run. + * + * Reads module objects only; nothing is executed. Used by `buildApiPlugin`'s + * development check and by `vitnode doctor`. + */ +export const findNestedRegistrations = ( + modules: readonly ModuleRegistrationTree[], +): NestedRegistration[] => { + const found: NestedRegistration[] = []; + + const visit = ( + module: ModuleRegistrationTree, + path: string, + nested: boolean, + ) => { + if (nested) { + for (const kind of KINDS) { + const entries = module[kind]; + if (entries !== undefined && entries.length > 0) { + found.push({ + count: entries.length, + kind, + modulePath: path, + names: entries.map(nameOf), + }); + } + } + } + + for (const child of module.modules ?? []) { + visit(child, `${path}/${child.name}`, true); + } + }; + + for (const module of modules) visit(module, module.name, false); + + return found; +}; + +/** One readable line per finding, for errors and `vitnode doctor`. */ +export const describeNestedRegistration = ({ + kind, + modulePath, + names, +}: NestedRegistration): string => + `${kind} on nested module "${modulePath}" (${names.join(", ")}) never run: move them to the top-level module "${modulePath.split("/")[0] ?? modulePath}".`; diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index cdf98637b2..4670cfa273 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -26,6 +26,7 @@ import { } from "@/content/registry"; import { contentDefinitionOf } from "@/content/server/model"; import { contentReportTarget } from "@/content/server/report-target"; +import { CONFIG } from "@/lib/config"; import { assertPluginManifest } from "@/lib/plugin-manifest"; import type { SearchIndexer } from "../models/search"; @@ -46,6 +47,10 @@ import { collectAiActions } from "./ai/registry"; import { checkPluginId } from "./check-plugin-id"; import { registerEditablePage, validateEditablePages } from "./editable-pages"; import { withModerationPermissions } from "./moderation/registry"; +import { + describeNestedRegistration, + findNestedRegistrations, +} from "./nested-registrations"; import { validateNotificationTypes } from "./notifications/registry"; import { applyModuleTags } from "./openapi-tags"; @@ -126,7 +131,9 @@ export type AnyBuildPluginApiReturn = BuildPluginApiReturn< * @throws When `manifest` is malformed; when `blocks` belongs to another * plugin; when content types, containers, editable pages, AI actions, search * indexers or notification types are invalid or collide. In development, also - * when no installed package's `package.json` has the plugin id as its name. + * when no installed package's `package.json` has the plugin id as its name, and + * when a nested module declares cron jobs, events, queue tasks or WebSockets - + * production ignores those, as it always has. * * @example * ```ts @@ -268,6 +275,20 @@ export function buildApiPlugin< }); }); + // Development only: production keeps ignoring nested registrations, as it + // always has, but a developer should hear that they never run. + if (CONFIG.node_development) { + const nested = findNestedRegistrations(modules); + if (nested.length > 0) { + throw new Error( + [ + `Plugin "${pluginId}" declares background work on nested modules. buildApiPlugin only collects it from top-level modules:`, + ...nested.map(entry => ` - ${describeNestedRegistration(entry)}`), + ].join("\n"), + ); + } + } + const registered: RegisteredContentType[] = validateContentTypes( contentTypes.map(definition => ({ definition, pluginId })), ); diff --git a/packages/vitnode/src/api/lib/route-metadata.ts b/packages/vitnode/src/api/lib/route-metadata.ts new file mode 100644 index 0000000000..73a7ea873e --- /dev/null +++ b/packages/vitnode/src/api/lib/route-metadata.ts @@ -0,0 +1,49 @@ +/** + * Where `buildRoute` records the staff permissions it guards a route with, so + * tooling (`vitnode inspect routes`, `vitnode doctor`) can list them without + * sending a request. A global symbol, because the CLI and a plugin may load + * different copies of `@vitnode/core`. + */ +const ROUTE_STAFF_PERMISSIONS = Symbol.for("vitnode.route.staffPermissions"); + +/** A staff permission as `buildRoute` takes it. */ +export interface RouteStaffPermissionRef { + module: string; + permission: string; + /** Defaults to the route's own plugin. */ + plugin?: string; +} + +/** The staff permissions `buildRoute` was given for a route. */ +export interface RouteStaffPermissions { + admin?: RouteStaffPermissionRef; + moderator?: RouteStaffPermissionRef; +} + +/** Records a built route's declared staff permissions. Called by `buildRoute`. */ +export const setRouteStaffPermissions = ( + route: object, + permissions: RouteStaffPermissions, +): void => { + Object.defineProperty(route, ROUTE_STAFF_PERMISSIONS, { + enumerable: false, + value: permissions, + }); +}; + +/** + * The `adminStaffPermission` and `moderatorStaffPermission` a built route was + * declared with, or `undefined` when it has neither (or was not built by + * `buildRoute`). + * + * Declaration metadata only: checks a handler or custom middleware makes, such + * as `checkStaffPermission` or ownership rules, are not visible here. + */ +export const routeStaffPermissions = ( + route: unknown, +): RouteStaffPermissions | undefined => + typeof route === "object" && route !== null + ? (route as Record)[ + ROUTE_STAFF_PERMISSIONS + ] + : undefined; diff --git a/packages/vitnode/src/api/lib/route.ts b/packages/vitnode/src/api/lib/route.ts index 01bd6548e5..e91a8772ba 100644 --- a/packages/vitnode/src/api/lib/route.ts +++ b/packages/vitnode/src/api/lib/route.ts @@ -11,6 +11,7 @@ import { captchaMiddleware } from "../middlewares/captcha.middleware"; import { pluginMiddleware } from "../middlewares/plugin.middleware"; import { assertStaffPermission } from "./check-staff-permission"; import { pluginTag } from "./openapi-tags"; +import { setRouteStaffPermissions } from "./route-metadata"; /** * Runs after request validation, as `@hono/zod-openapi`'s hook. Return a @@ -200,7 +201,7 @@ export const buildRoute = < middleware.push(route.middleware); } - return { + const built: Route = { route: createRouteHono({ // `route` is spread first on purpose: `tags` and `middleware` already // merge the route's own values, so letting the spread win would drop @@ -213,6 +214,15 @@ export const buildRoute = < ...(hook ? { hook } : {}), pluginId, }; + + if (adminStaffPermission || moderatorStaffPermission) { + setRouteStaffPermissions(built, { + admin: adminStaffPermission, + moderator: moderatorStaffPermission, + }); + } + + return built; }; /** A built route, ready for `buildModule`'s `routes`. */ From 1e69a904e4b8ae07977ef40ab6376d4164c88344 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 17:39:02 +0000 Subject: [PATCH 04/51] =?UTF-8?q?feat(core):=20=F0=9F=94=91=20typed=20staf?= =?UTF-8?q?f=20permission=20references=20with=20defineStaffPermissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `defineStaffPermissions({ admin, moderator })` (browser-safe, from `@vitnode/core/lib/staff-permissions`) returns `config` for `buildApiPlugin({ permissionStaff })` and typed `{ module, permission }` references such as `staffPermissions.admin.notes.canCreate`, which routes and AdminCP navigation already accept. String declarations keep working. `vitnode generate` follows `permissionStaff: X.config` to the defineStaffPermissions call: it validates and declares permissions there and writes routes with the typed reference. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CnAiFZ3bExobTQrGyYnt3P --- apps/web/content/docs/dev/cli/generate.mdx | 2 + .../docs/dev/plugins/api/admin-routes.mdx | 2 + .../docs/dev/working-with-users/meta.json | 1 + .../staff-permission-refs.mdx | 116 ++++++++++++ .../working-with-users/staff-permissions.mdx | 4 + .../scripts/cli/generate/generators.ts | 167 ++++++++++++++++-- .../cli/generate/resource-templates.ts | 12 +- .../vitnode/scripts/cli/generate/resource.ts | 8 + .../vitnode/scripts/cli/generate/templates.ts | 37 +++- packages/vitnode/src/lib/staff-permissions.ts | 163 +++++++++++++++++ 10 files changed, 485 insertions(+), 27 deletions(-) create mode 100644 apps/web/content/docs/dev/working-with-users/staff-permission-refs.mdx create mode 100644 packages/vitnode/src/lib/staff-permissions.ts diff --git a/apps/web/content/docs/dev/cli/generate.mdx b/apps/web/content/docs/dev/cli/generate.mdx index 83d5f2bdd9..2a305204cb 100644 --- a/apps/web/content/docs/dev/cli/generate.mdx +++ b/apps/web/content/docs/dev/cli/generate.mdx @@ -133,6 +133,8 @@ export const adminModule = buildModule({ }); ``` +`permissionStaff` may also be `staffPermissions.config`, where `staffPermissions` is a `defineStaffPermissions({...})` call in the plugin. The generator then reads and declares permissions in that call, and generated routes use typed references such as `staffPermissions.admin.notes.canCreate`. See [Typed permission references](/docs/dev/working-with-users/staff-permission-refs). + Module and route entries must be identifiers that resolve to a `const x = buildModule({...})` or `buildRoute({...})` in the plugin. `buildContentAdminModule(...)` and `buildContentPublicModule(...)` are recognized as the generated `content` module. Anything else - a spread, a variable holding the array, a computed name, a module from another package - stops the command before writing and prints the exact lines to add by hand. A route whose method or path cannot be read is reported as a warning, since the collision check could not cover it. diff --git a/apps/web/content/docs/dev/plugins/api/admin-routes.mdx b/apps/web/content/docs/dev/plugins/api/admin-routes.mdx index 76280a2453..525c4c763e 100644 --- a/apps/web/content/docs/dev/plugins/api/admin-routes.mdx +++ b/apps/web/content/docs/dev/plugins/api/admin-routes.mdx @@ -101,6 +101,8 @@ The last paragraph of that comment matters for every write. `can_edit` lets an a +If the plugin declares its permissions with [`defineStaffPermissions`](/docs/dev/working-with-users/staff-permission-refs), the generator edits that definition instead and the route gets `adminStaffPermission: staffPermissions.admin.notes.canCreate`. + ## Check the result Generate a second admin route. The permission module and the `admin/notes` module now exist, so only the new pieces are added: diff --git a/apps/web/content/docs/dev/working-with-users/meta.json b/apps/web/content/docs/dev/working-with-users/meta.json index 132a97703b..e230718c4b 100644 --- a/apps/web/content/docs/dev/working-with-users/meta.json +++ b/apps/web/content/docs/dev/working-with-users/meta.json @@ -10,6 +10,7 @@ "email-verification", "roles", "staff-permissions", + "staff-permission-refs", "presence", "presence-in-plugins", "..." diff --git a/apps/web/content/docs/dev/working-with-users/staff-permission-refs.mdx b/apps/web/content/docs/dev/working-with-users/staff-permission-refs.mdx new file mode 100644 index 0000000000..d3c2cca220 --- /dev/null +++ b/apps/web/content/docs/dev/working-with-users/staff-permission-refs.mdx @@ -0,0 +1,116 @@ +--- +title: Typed permission references +description: Declare a VitNode plugin's staff permissions once with defineStaffPermissions and reference them from routes and AdminCP navigation, so a typo is a compile error. +icon: KeyRound +--- + +`defineStaffPermissions` turns your plugin's permission declarations into typed references. Routes write `adminStaffPermission: staffPermissions.admin.notes.canCreate` instead of repeating `{ module: "notes", permission: "can_create" }`, and a permission that is not declared does not compile. It is optional: string declarations keep working, and you can switch one plugin at a time. + +## Before you begin + +- A plugin with declared staff permissions, such as `site-notes` from [Permissions](/docs/dev/working-with-users/staff-permissions). + + + + + +### Define the permissions + +Create `src/staff-permissions.ts` and move the declarations from `permissionStaff` into it, unchanged: + +```ts title="plugins/site-notes/src/staff-permissions.ts" +import { defineStaffPermissions } from "@vitnode/core/lib/staff-permissions"; + +export const staffPermissions = defineStaffPermissions({ + admin: { + notes: ["can_view", { permission: "can_create", dependsOn: ["can_view"] }], + }, + moderator: { + notes: ["can_delete"], + }, +}); +``` + +Entries are the same strings and `{ permission, dependsOn, scope }` objects `permissionStaff` takes. The module imports nothing from the server, so the AdminCP navigation can use it too. Keep it out of `config.api.ts`, which pulls in your routes and database code. + + + + + +### Register them + +Pass `config` to `buildApiPlugin`. This excerpt replaces the inline object: + +```ts title="plugins/site-notes/src/config.api.ts" +import { staffPermissions } from "@/staff-permissions"; // [!code ++] + +export const siteNotesApiPlugin = () => + buildApiPlugin({ + manifest, + modules: [helloModule, notesModule, adminModule], + permissionStaff: { admin: { ... }, moderator: { ... } }, // [!code --] + permissionStaff: staffPermissions.config, // [!code ++] + }); +``` + +`config` is exactly the declarations you wrote, so the AdminCP permission editor and its labels do not change. + + + + + +### Reference them + +Each permission key becomes camelCase under its module: `can_create` is `canCreate`. A reference is a plain `{ module, permission }` object, which is what routes and navigation already accept: + +```ts title="plugins/site-notes/src/api/modules/admin/notes/create.route.ts" +import { staffPermissions } from "@/staff-permissions"; + +export const createNoteRoute = buildRoute({ + adminStaffPermission: staffPermissions.admin.notes.canCreate, + // ... +}); +``` + +```tsx title="plugins/site-notes/src/admin/nav.tsx" +import { staffPermissions } from "@/staff-permissions"; + +// ... + { + id: "notes", + href: "/admin/site-notes", + icon: , + permission: staffPermissions.admin.notes.canCreate, + }, +``` + +For moderator routes use `moderatorStaffPermission: staffPermissions.moderator.notes.canDelete`. + + + + + +## Check the result + +Misspell a reference, for example `staffPermissions.admin.notes.canCreat`. TypeScript reports `Property 'canCreat' does not exist`, before anything runs. Fix it, build the plugin and run `pnpm vitnode plugin validate`: the **AdminCP navigation** check passes, because the navigation names a declared permission. + +References name a permission; they do not choose the staff type. TypeScript accepts a moderator reference in `adminStaffPermission` just as it accepts any `{ module, permission }` object, so take admin routes' references from `staffPermissions.admin`. [`vitnode doctor`](/docs/dev/cli/doctor) reports a route whose permission is not declared for its type. + +## With the generator + +When `permissionStaff` is `staffPermissions.config`, `vitnode g route ... --permission notes.can_create` checks the permission against `src/staff-permissions.ts`, adds it there with `--declare-permission`, and writes the route with `adminStaffPermission: staffPermissions.admin.notes.canCreate`. See [Protect an admin endpoint](/docs/dev/plugins/api/admin-routes). + +## Related + + + + + diff --git a/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx b/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx index 7ad85ebba1..769963d1cb 100644 --- a/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx +++ b/apps/web/content/docs/dev/working-with-users/staff-permissions.mdx @@ -259,6 +259,10 @@ permissionStaff: { Each permission you add needs its own label and its own `adminStaffPermission` on the routes it protects. +## Reference permissions with types + +To stop repeating `{ module: "notes", permission: "can_create" }` in routes and navigation, declare the permissions once with `defineStaffPermissions` and use `staffPermissions.admin.notes.canCreate` instead. See [Typed permission references](/docs/dev/working-with-users/staff-permission-refs). + ## Check a permission in code `adminStaffPermission` and `moderatorStaffPermission` cover a whole route. For a decision inside a handler, such as letting staff edit any note but members only their own, call `checkStaffPermission(c, { type, plugin, module, permission })` from `@vitnode/core/api/lib/check-staff-permission`; it resolves to `true` or `false`. In AdminCP components, `useAdminPermission({ plugin, module, permission })` from `@vitnode/core/tanstack/admin` answers the same question for the signed-in administrator, for showing or hiding controls. diff --git a/packages/vitnode/scripts/cli/generate/generators.ts b/packages/vitnode/scripts/cli/generate/generators.ts index 050d3687a1..b1c83271c0 100644 --- a/packages/vitnode/scripts/cli/generate/generators.ts +++ b/packages/vitnode/scripts/cli/generate/generators.ts @@ -1,3 +1,5 @@ +import type { ObjectExpression } from "oxc-parser"; + import { posix } from "node:path"; import type { ModuleContainer, ModuleSource } from "./api-tree"; @@ -14,6 +16,7 @@ import { readModuleSource, readParsed, readRootContainer, + resolveBinding, resolveSpecifier, specifierFor, UnsupportedStructureError, @@ -449,6 +452,10 @@ export const planRoute = ( paramType: options.paramType, path: options.path, permission: options.permission, + permissionSource: + options.permission === undefined + ? undefined + : permissionSource(ctx, file, options.permission), }), endpoint, ); @@ -481,27 +488,141 @@ export type PermissionDeclarations = | { modules: Map; status: "resolved" } | { reason: string; status: "unresolved" }; -/** The admin staff permissions `buildApiPlugin({ permissionStaff })` declares, read statically. */ -export const readAdminPermissions = ( +/** Where a plugin's staff permission declarations live, for reading and editing. */ +export type PermissionStaffLocation = + | { + /** Local name of the `defineStaffPermissions` result in config.api.ts. */ + exportName: string; + file: string; + kind: "defined"; + parsed: ParsedSource; + staff: ObjectExpression; + } + | { + file: string; + /** The `buildApiPlugin` options, where a missing `permissionStaff` goes. */ + holder: ObjectExpression; + kind: "inline"; + parsed: ParsedSource; + staff: null | ObjectExpression; + } + | { kind: "unresolved"; reason: string }; + +/** + * `buildApiPlugin({ permissionStaff: { ... } })`, or + * `permissionStaff: staffPermissions.config` where `staffPermissions` is a + * `defineStaffPermissions({ ... })` call in the plugin. Anything else is + * reported, not guessed. + */ +export const locatePermissionStaff = ( ctx: GenerateContext, -): PermissionDeclarations => { +): PermissionStaffLocation => { const root = readRootContainer(ctx.plan, ctx.project); - const permissionStaff = getProperty(root.options, "permissionStaff"); + const property = getProperty(root.options, "permissionStaff"); - if (permissionStaff === null) { + if (property === null) { return hasSpread(root.options) - ? { reason: "buildApiPlugin spreads other options", status: "unresolved" } - : { modules: new Map(), status: "resolved" }; + ? { kind: "unresolved", reason: "buildApiPlugin spreads other options" } + : { + file: API_CONFIG, + holder: root.options, + kind: "inline", + parsed: root.parsed, + staff: null, + }; } - const staff = objectValue(permissionStaff); - if (staff === null) { + const value = unwrap(property.value); + if (value.type === "ObjectExpression") { return { - reason: "permissionStaff is not an object literal", - status: "unresolved", + file: API_CONFIG, + holder: root.options, + kind: "inline", + parsed: root.parsed, + staff: value, }; } + if ( + value.type === "MemberExpression" && + !value.computed && + value.object.type === "Identifier" && + value.property.type === "Identifier" && + value.property.name === "config" + ) { + const binding = resolveBinding( + ctx.plan, + ctx.project, + root.parsed, + value.object.name, + ); + if (typeof binding === "string") { + return { kind: "unresolved", reason: `${value.object.name}: ${binding}` }; + } + + const parsed = readParsed(ctx.plan, binding.file); + const init = findConstInit(parsed.program, binding.exportName); + const call = init === null ? null : unwrap(init); + const staff = + call?.type === "CallExpression" && + call.callee.type === "Identifier" && + call.callee.name === "defineStaffPermissions" + ? objectArgument(call) + : null; + + return staff === null + ? { + kind: "unresolved", + reason: `${binding.exportName} in ${binding.file} is not a defineStaffPermissions({...}) call`, + } + : { + exportName: binding.exportName, + file: binding.file, + kind: "defined", + parsed, + staff, + }; + } + + return { + kind: "unresolved", + reason: "permissionStaff is not an object literal", + }; +}; + +/** How a generated route refers to a permission: a typed reference when the plugin defines them. */ +export const permissionSource = ( + ctx: GenerateContext, + fromFile: string, + ref: PermissionRef, +): undefined | { expression: string; importLine: string } => { + const location = locatePermissionStaff(ctx); + if (location.kind !== "defined") return undefined; + + const member = (key: string) => + /^[A-Za-z_$][\w$]*$/.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`; + const key = ref.permission.replace(/[-_]+([a-z0-9])/g, (_, char: string) => + char.toUpperCase(), + ); + + return { + expression: `${location.exportName}.admin${member(ref.module)}${member(key)}`, + importLine: `import { ${location.exportName} } from ${JSON.stringify(specifierFor(ctx.project, fromFile, location.file, true))};`, + }; +}; + +/** The admin staff permissions `buildApiPlugin({ permissionStaff })` declares, read statically. */ +export const readAdminPermissions = ( + ctx: GenerateContext, +): PermissionDeclarations => { + const location = locatePermissionStaff(ctx); + if (location.kind === "unresolved") { + return { reason: location.reason, status: "unresolved" }; + } + + const { staff } = location; + if (staff === null) return { modules: new Map(), status: "resolved" }; + const admin = getProperty(staff, "admin"); if (admin === null) { return hasSpread(staff) @@ -663,19 +784,27 @@ export const planPermission = ( return; } + const location = locatePermissionStaff(ctx); + const defined = location.kind === "defined"; throw new UserError(`The staff permission ${wanted} is not declared.`, { details: [ - `Nobody can be granted a permission the plugin does not declare in ${API_CONFIG}:`, - ` ${snippet}`, + `Nobody can be granted a permission the plugin does not declare in ${defined ? `${location.file} (defineStaffPermissions)` : API_CONFIG}:`, + ` ${defined ? `admin: { ${ref.module}: [${JSON.stringify(ref.permission)}] }` : snippet}`, ], hint: "Pass --declare-permission to add it (and its labels), or pick a declared one.", }); } - const root = readRootContainer(ctx.plan, ctx.project); - const { parsed } = root; + const location = locatePermissionStaff(ctx); + if (location.kind === "unresolved") { + throw new UnsupportedStructureError( + `Cannot declare ${wanted}: ${location.reason}.`, + [], + ); + } + + const { parsed, staff } = location; const edits: Edit[] = []; - const staff = objectValue(getProperty(root.options, "permissionStaff")); const admin = staff === null ? null : objectValue(getProperty(staff, "admin")); const moduleEntries = @@ -685,7 +814,9 @@ export const planPermission = ( : JSON.stringify(ref.module); if (staff === null) { - edits.push(...addProperty(parsed.source, root.options, snippet)); + if (location.kind === "inline") { + edits.push(...addProperty(parsed.source, location.holder, snippet)); + } } else if (admin === null) { edits.push( ...addProperty( @@ -713,7 +844,7 @@ export const planPermission = ( } ctx.plan.update( - API_CONFIG, + location.file, applyEdits(parsed.source, edits), `declare the admin permission ${wanted}`, ); diff --git a/packages/vitnode/scripts/cli/generate/resource-templates.ts b/packages/vitnode/scripts/cli/generate/resource-templates.ts index f9c4270308..5613353a76 100644 --- a/packages/vitnode/scripts/cli/generate/resource-templates.ts +++ b/packages/vitnode/scripts/cli/generate/resource-templates.ts @@ -1,6 +1,7 @@ -import type { ParamType } from "./templates"; +import type { ParamType, PermissionSource } from "./templates"; import { UserError } from "../errors"; +import { localImports } from "./templates"; /** * A resource field from `--fields`: `title:string`, `pinned:boolean`, @@ -227,6 +228,7 @@ export const resourceRouteTemplate = ({ names, operation, permission, + permissionSource, schemaSpecifier, }: { builders: string; @@ -237,6 +239,8 @@ export const resourceRouteTemplate = ({ names: ResourceNames; operation: ResourceOperation; permission?: { module: string; permission: string }; + /** A typed reference to `permission`, when the plugin defines them. */ + permissionSource?: PermissionSource; schemaSpecifier: string; }): string => { const hasId = @@ -333,7 +337,9 @@ export const resourceRouteTemplate = ({ const guard = permission === undefined ? "" - : ` adminStaffPermission: { module: ${JSON.stringify(permission.module)}, permission: ${JSON.stringify(permission.permission)} },\n`; + : permissionSource === undefined + ? ` adminStaffPermission: { module: ${JSON.stringify(permission.module)}, permission: ${JSON.stringify(permission.permission)} },\n` + : ` adminStaffPermission: ${permissionSource.expression},\n`; const ownership = permission === undefined || operation === "list" || @@ -346,7 +352,7 @@ export const resourceRouteTemplate = ({ * this particular entry is for the service or handler to check.`; return `${usesZ ? 'import { z } from "@hono/zod-openapi";\n' : ""}${usesHttpException ? 'import { HTTPException } from "hono/http-exception";\n' : ""} -import { buildRoute } from ${JSON.stringify(builders)}; +${localImports(builders, permissionSource)} ${schemaImports.length > 0 ? `import { ${schemaImports.join(", ")} } from ${JSON.stringify(schemaSpecifier)};\n` : ""}import { ${names.factory} } from ${JSON.stringify(factorySpecifier)}; diff --git a/packages/vitnode/scripts/cli/generate/resource.ts b/packages/vitnode/scripts/cli/generate/resource.ts index 7da4567847..c98fe9ed9c 100644 --- a/packages/vitnode/scripts/cli/generate/resource.ts +++ b/packages/vitnode/scripts/cli/generate/resource.ts @@ -25,6 +25,7 @@ import { } from "./ast"; import { endpointPath, + permissionSource, planModule, planPermission, readAdminPermissions, @@ -204,6 +205,13 @@ export const planApiResource = ( module: options.permissions.module, permission: operation.permission, }, + permissionSource: + options.permissions === undefined + ? undefined + : permissionSource(ctx, file, { + module: options.permissions.module, + permission: operation.permission, + }), schemaSpecifier: specifierFor(project, file, schema, false), }), endpoint, diff --git a/packages/vitnode/scripts/cli/generate/templates.ts b/packages/vitnode/scripts/cli/generate/templates.ts index 2f4afee1e5..2a8b07cfcf 100644 --- a/packages/vitnode/scripts/cli/generate/templates.ts +++ b/packages/vitnode/scripts/cli/generate/templates.ts @@ -43,10 +43,34 @@ const paramSchema = (type: ParamType) => const paramsObject = (params: readonly string[], type: ParamType) => `z.object({ ${params.map(param => `${param}: ${paramSchema(type)}`).join(", ")} })`; -const permissionLine = (permission?: { module: string; permission: string }) => +/** A typed reference to the permission, when the plugin defines them with `defineStaffPermissions`. */ +export interface PermissionSource { + expression: string; + importLine: string; +} + +const permissionLine = ( + permission?: { module: string; permission: string }, + source?: PermissionSource, +) => permission === undefined ? "" - : ` adminStaffPermission: { module: ${JSON.stringify(permission.module)}, permission: ${JSON.stringify(permission.permission)} },\n`; + : source === undefined + ? ` adminStaffPermission: { module: ${JSON.stringify(permission.module)}, permission: ${JSON.stringify(permission.permission)} },\n` + : ` adminStaffPermission: ${source.expression},\n`; + +/** The builders import, followed by the permission reference's import when there is one. */ +export const localImports = ( + builders: string, + source?: PermissionSource, +): string => { + const buildersLine = `import { buildRoute } from ${JSON.stringify(builders)};`; + if (source === undefined) return buildersLine; + + return source.importLine.includes('"@/') === buildersLine.includes('"@/') + ? [buildersLine, source.importLine].sort().join("\n") + : `${buildersLine}\n\n${source.importLine}`; +}; const forbiddenResponse = (permission?: { module: string; @@ -68,6 +92,7 @@ export interface RouteTemplateOptions { paramType: ParamType; path: string; permission?: { module: string; permission: string }; + permissionSource?: PermissionSource; } /** @@ -111,7 +136,7 @@ const sampleGetTemplate = (options: RouteTemplateOptions): string => { return `import { z } from "@hono/zod-openapi"; -import { buildRoute } from ${JSON.stringify(builders)}; +${localImports(builders, options.permissionSource)} /** * ${endpoint} - a generated starter that answers with sample data. @@ -119,7 +144,7 @@ import { buildRoute } from ${JSON.stringify(builders)}; * Replace the 200 schema and the handler with the real response. */ export const ${identifier} = buildRoute({ -${permissionLine(permission)} route: { +${permissionLine(permission, options.permissionSource)} route: { method: "get", path: ${JSON.stringify(path)}, ${params.length === 0 ? "" : ` request: {\n params: ${paramsObject(params, paramType)},\n },\n`} responses: { @@ -179,7 +204,7 @@ const notImplementedTemplate = (options: RouteTemplateOptions): string => { return `${request.length > 0 ? 'import { z } from "@hono/zod-openapi";\n' : ""}import { HTTPException } from "hono/http-exception"; -import { buildRoute } from ${JSON.stringify(builders)}; +${localImports(builders, options.permissionSource)} /** * ${endpoint} - a generated scaffold that answers 501 until implemented. @@ -188,7 +213,7 @@ import { buildRoute } from ${JSON.stringify(builders)}; * the \`throw\` with the real operation.${ownership} */ export const ${identifier} = buildRoute({ -${permissionLine(permission)} route: { +${permissionLine(permission, options.permissionSource)} route: { method: ${JSON.stringify(method)}, path: ${JSON.stringify(path)}, ${request.length === 0 ? "" : ` request: {\n${request.join("\n")}\n },\n`} responses: { diff --git a/packages/vitnode/src/lib/staff-permissions.ts b/packages/vitnode/src/lib/staff-permissions.ts new file mode 100644 index 0000000000..0a8eaf7a84 --- /dev/null +++ b/packages/vitnode/src/lib/staff-permissions.ts @@ -0,0 +1,163 @@ +/** + * Typed references to a plugin's own staff permissions. + * + * Browser-safe: no server, database or Hono import, so the AdminCP + * navigation can use the same references as the API routes. + */ + +/** One declared permission: its key, or an object with dependencies (and, for moderators, a scope). */ +export type StaffPermissionEntry = + | string + | { + readonly dependsOn?: readonly string[]; + readonly permission: string; + readonly scope?: string; + }; + +/** Permissions grouped by permission module, as `permissionStaff.admin` takes them. */ +export type StaffPermissionModules = Readonly< + Record +>; + +/** A reference to one permission: what `adminStaffPermission` and AdminCP `permission` accept. */ +export interface StaffPermissionRef< + Module extends string = string, + Permission extends string = string, +> { + readonly module: Module; + readonly permission: Permission; +} + +type EntryName = E extends string + ? E + : E extends { readonly permission: infer P extends string } + ? P + : never; + +/** `can_create` → `canCreate`, `view-logs` → `viewLogs`. */ +export type StaffPermissionKey = + S extends `${infer Head}_${infer Tail}` + ? `${Head}${Capitalize>}` + : S extends `${infer Head}-${infer Tail}` + ? `${Head}${Capitalize>}` + : S; + +/** `{ notes: ["can_create"] }` → `{ notes: { canCreate: { module: "notes", permission: "can_create" } } }`. */ +export type StaffPermissionRefs = { + readonly [Module in keyof M & string]: { + readonly [ + Entry in M[Module][number] as StaffPermissionKey> + ]: StaffPermissionRef>; + }; +}; + +const keyOf = (permission: string): string => + permission.replace(/[-_]+([a-z0-9])/g, (_, char: string) => + char.toUpperCase(), + ); + +const nameOf = (entry: StaffPermissionEntry): string => + typeof entry === "string" ? entry : entry.permission; + +const refsOf = (modules: StaffPermissionModules) => + Object.fromEntries( + Object.entries(modules).map(([module, entries]) => [ + module, + Object.fromEntries( + entries.map(entry => { + const permission = nameOf(entry); + + return [keyOf(permission), Object.freeze({ module, permission })]; + }), + ), + ]), + ); + +const toConfig = (modules: StaffPermissionModules) => + Object.fromEntries( + Object.entries(modules).map(([module, entries]) => [ + module, + entries.map(entry => + typeof entry === "string" + ? entry + : { + permission: entry.permission, + ...(entry.dependsOn ? { dependsOn: [...entry.dependsOn] } : {}), + ...(entry.scope ? { scope: entry.scope } : {}), + }, + ), + ]), + ); + +/** What {@link defineStaffPermissions} returns. */ +export interface DefinedStaffPermissions< + Admin extends StaffPermissionModules, + Moderator extends StaffPermissionModules, +> { + /** References to the admin permissions: `admin.notes.canCreate`. */ + readonly admin: StaffPermissionRefs; + /** The same declarations, for `buildApiPlugin({ permissionStaff: permissions.config })`. */ + readonly config: { + admin: Record< + string, + (string | { dependsOn?: string[]; permission: string; scope?: string })[] + >; + moderator: Record< + string, + (string | { dependsOn?: string[]; permission: string; scope?: string })[] + >; + }; + /** References to the moderator permissions: `moderator.notes.canDelete`. */ + readonly moderator: StaffPermissionRefs; +} + +/** + * Declares a plugin's staff permissions once and returns typed references to + * them, so routes and AdminCP navigation name a permission that exists instead + * of repeating `{ module: "notes", permission: "can_create" }` strings. + * + * Pass `config` to `buildApiPlugin({ permissionStaff })`; it is the source of + * truth, exactly as if the object were written there. String-based + * declarations keep working; this is an addition, not a replacement. + * + * Universal and browser-safe. Keep the definition in its own module (e.g. + * `src/staff-permissions.ts`) rather than in `config.api.ts`, so the AdminCP + * navigation can import it without pulling in server code. + * + * @example + * ```ts + * // src/staff-permissions.ts + * import { defineStaffPermissions } from "@vitnode/core/lib/staff-permissions"; + * + * export const staffPermissions = defineStaffPermissions({ + * admin: { + * notes: ["can_view", { permission: "can_edit", dependsOn: ["can_view"] }], + * }, + * }); + * + * // src/config.api.ts + * buildApiPlugin({ manifest, modules, permissionStaff: staffPermissions.config }); + * + * // a route + * buildRoute({ adminStaffPermission: staffPermissions.admin.notes.canEdit, ... }); + * ``` + */ +export const defineStaffPermissions = < + const Admin extends StaffPermissionModules = Record, + const Moderator extends StaffPermissionModules = Record, +>({ + admin, + moderator, +}: { + /** Permissions grantable to AdminCP administrators, by permission module. */ + admin?: Admin; + /** Permissions grantable to moderators, by permission module. */ + moderator?: Moderator; +}): DefinedStaffPermissions => ({ + admin: refsOf(admin ?? {}) as StaffPermissionRefs, + config: { + admin: toConfig(admin ?? {}), + moderator: toConfig(moderator ?? {}), + }, + moderator: refsOf(moderator ?? {}) as StaffPermissionRefs, +}); From c4272036b3ba323bdb916e7b3d1dc6ad04e924a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 17:44:18 +0000 Subject: [PATCH 05/51] =?UTF-8?q?feat(cli):=20=E2=9C=A8=20vitnode=20genera?= =?UTF-8?q?te=20resource=20--kind=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolds an administrative Content Engine content type through the shared planner: the definition (src/content), the model and table export (src/database), the buildContentAdminModule mount in the admin module, the builders binding, the AdminCP sidebar entry and screen, and the labels. The Content Engine still generates the CRUD routes and permissions. No public API, member writes, publication or ownership is enabled, and no migration is written or applied; the command prints the build -> db generate -> db migrate order. Existing nav/admin/content structures are extended when supported; a custom adminContent stops the command with manual steps. Merging into an existing named import now keeps the names sorted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CnAiFZ3bExobTQrGyYnt3P --- .../content/docs/content-engine/generate.mdx | 119 ++++ .../web/content/docs/content-engine/meta.json | 1 + apps/web/content/docs/dev/cli/generate.mdx | 5 +- .../vitnode/scripts/cli/commands/generate.ts | 103 +++- packages/vitnode/scripts/cli/generate/ast.ts | 15 +- .../scripts/cli/generate/content-resource.ts | 572 ++++++++++++++++++ packages/vitnode/scripts/cli/program.ts | 18 +- 7 files changed, 822 insertions(+), 11 deletions(-) create mode 100644 apps/web/content/docs/content-engine/generate.mdx create mode 100644 packages/vitnode/scripts/cli/generate/content-resource.ts diff --git a/apps/web/content/docs/content-engine/generate.mdx b/apps/web/content/docs/content-engine/generate.mdx new file mode 100644 index 0000000000..c9f9d0d7a4 --- /dev/null +++ b/apps/web/content/docs/content-engine/generate.mdx @@ -0,0 +1,119 @@ +--- +title: Generate a content type +description: Scaffold an administrative VitNode Content Engine content type - definition, model, admin module, AdminCP screen and labels - with vitnode generate resource --kind content. +icon: Wand +--- + +`vitnode g resource --kind content` writes the files [First content type](/docs/content-engine/create-a-content-type) walks through by hand: the definition, the model and its table export, the staff API mount, the AdminCP sidebar entry and screen, and the labels. The Content Engine then generates the CRUD routes and permissions, exactly as for a hand-written content type. The generator never writes a migration and never applies one. + +The result is **administrative only**: staff create and edit records in the AdminCP. It has no public API, no member writes, no drafts and no ownership rules, because each of those changes who can read or write the records. Turn them on in the definition when you decide, following the linked guides. + +## Before you begin + +- A plugin made with [Create plugin](/docs/dev/plugins/create), with a terminal in its folder (here `plugins/site-notes`). +- A database, to apply the migration at the end. + + + + + +### Preview the content type + +Name the resource, and list the fields as `name:type` with `text`, `richText`, `boolean`, `int` or `number`. A `?` after a name makes the field optional, and the first required `text` field becomes the title: + +```bash +pnpm vitnode g resource articles --kind content --fields "title:text,body:richText,featured?:boolean" --singular Article --dry-run +``` + +```txt + would create src/content/articles.ts content type sitenotes.articles + would create src/database/articles.ts model and table site_notes_articles + would create src/api/modules/admin/admin.module.ts module "admin"; mount articlesContent in the admin module + would create src/admin/nav.tsx AdminCP sidebar entry + would create src/admin/content.tsx AdminCP screens, re-using the sidebar list + would update src/api/builders.ts bind the Content Engine admin builder + would update src/config.api.ts register adminModule in buildApiPlugin + would update src/locales/en.json AdminCP labels for the content type +``` + +The names are derived, never guessed from meaning: + +| Value | Derived as | Override | +| ----------------- | ----------------------------------------------------- | ---------------- | +| Content type id | Plugin name without dashes, a dot, the resource name | `--content-id` | +| Table | Plugin name in snake_case, `_`, the resource name | `--table` | +| Permission module | The part of the id after the dot: `articles` | - | +| AdminCP path | `/admin/content//` | - | +| Id strategy | `serial`, the Content Engine default | `--id-type uuid` or `bigint` | +| Singular label | The plural, `Articles` | `--singular` | + + + + + +### Generate it + +Run the command without `--dry-run`. The definition it writes: + +```ts title="plugins/site-notes/src/content/articles.ts" +import { defineContentType, field } from "@vitnode/core/content"; + +export const articlesContentType = defineContentType({ + id: "sitenotes.articles", + tableName: "site_notes_articles", + fields: { + title: field.text({ maxLength: 200, required: true }), + body: field.richText({ required: true }), + featured: field.boolean({ defaultValue: false }), + }, + admin: { + path: "site-notes/articles", + titleField: "title", + list: { + columns: ["title", "updatedAt"], + }, + }, +}); +``` + +In an existing plugin, the generator adds to what is there: the model joins the `contentTypes` of an existing `buildContentAdminModule(...)` in the `admin` module, the entry joins `adminNav.contentTypes`, and labels are only added, never overwritten. It stops before writing, with the lines to add by hand, when `src/admin/content.tsx` builds its own `adminContent` instead of re-exporting `adminNav`. + + + + + +### Build and migrate + +Migrations are generated from the plugin's build output, so the order matters: + +```bash +pnpm vitnode build # in plugins/site-notes, or keep pnpm dev running +cd ../../apps/web # the app that owns the database (apps/api in a Monorepo App) +pnpm vitnode db generate # writes the migration for site_notes_articles +pnpm vitnode db migrate # applies it, after asking +``` + +Review the migration before you apply it. In a Turborepo workspace, `pnpm build:plugins` and `pnpm db:migrate` at the root run the same steps. See [Database commands](/docs/dev/cli/database). + + + + + +## Check the result + +Run `pnpm vitnode plugin validate` in the plugin: **AdminCP navigation** and **Database schema** pass. Then open **AdminCP → Site notes → Articles**, choose **Create Article**, and save one. `pnpm vitnode inspect routes` lists the generated staff routes, such as `POST /site-notes/admin/content/articles` guarded by `articles.can_create`, and restricted administrators see the four permissions under **Site notes → Articles**. + +## Related + + + + + diff --git a/apps/web/content/docs/content-engine/meta.json b/apps/web/content/docs/content-engine/meta.json index 9257ce7687..68e4eb5b80 100644 --- a/apps/web/content/docs/content-engine/meta.json +++ b/apps/web/content/docs/content-engine/meta.json @@ -7,6 +7,7 @@ "---Getting started---", "index", "create-a-content-type", + "generate", "---Public content---", "publication", "public-api", diff --git a/apps/web/content/docs/dev/cli/generate.mdx b/apps/web/content/docs/dev/cli/generate.mdx index 2a305204cb..167cf99c82 100644 --- a/apps/web/content/docs/dev/cli/generate.mdx +++ b/apps/web/content/docs/dev/cli/generate.mdx @@ -13,6 +13,7 @@ icon: Wand | `vitnode g route admin/...` | The same, guarded by a staff permission | [Protect an admin endpoint](/docs/dev/plugins/api/admin-routes) | | `vitnode g service ` | `.service.ts`, an empty service factory | [Add a service](/docs/dev/plugins/api/services) | | `vitnode g resource --kind api` | A module, schemas, service stubs and five CRUD routes | [Generate an API resource](/docs/dev/plugins/api/resources) | +| `vitnode g resource --kind content` | An administrative Content Engine content type, wired in | [Generate a content type](/docs/content-engine/generate) | ## Where to run it @@ -74,7 +75,7 @@ Every generate command accepts: | Option | Default | Description | | ------------------------------ | -------- | ------------------------------------------------------------------------------------------------- | -| `--kind ` | - | Required. `api` is the only kind for now; Content Engine resources are defined with `defineContentType` | +| `--kind ` | - | Required. `api`, or `content` (below) | | `--fields ` | Asked, or none | `name:type` pairs, comma-separated: `string`, `number`, `int`, `boolean`; `name?:type` is optional | | `--id-type ` | `string` | `string` or `number` | | `--admin` | - | Generate it in the admin module tree | @@ -84,6 +85,8 @@ Every generate command accepts: The resource's module must be new or have no routes. No table, migration or storage is generated: every operation answers `501` until you implement the service. +`vitnode g resource --kind content` takes `--fields` (required; types `text`, `richText`, `boolean`, `int`, `number`), `--id-type` (`serial`, `uuid` or `bigint`; default `serial`), `--content-id`, `--table` and `--singular`. It generates an administrative content type only - no public API, member writes or publication - and never writes or applies a migration. See [Generate a content type](/docs/content-engine/generate). + ### Route name defaults The route's file name and its URL are separate. These names imply a method and a path; any other name needs `--method` and `--path` (or answers them in an interactive terminal), because `archive.route.ts` is not served at `/archive` unless you say so. diff --git a/packages/vitnode/scripts/cli/commands/generate.ts b/packages/vitnode/scripts/cli/commands/generate.ts index a4c7385a6a..2b2f80c70f 100644 --- a/packages/vitnode/scripts/cli/commands/generate.ts +++ b/packages/vitnode/scripts/cli/commands/generate.ts @@ -8,6 +8,10 @@ import type { Ui } from "../ui/ui"; import { EXIT_CODE, UserError } from "../errors"; import { lookupModule, UnsupportedStructureError } from "../generate/api-tree"; +import { + parseContentFields, + planContentResource, +} from "../generate/content-resource"; import { unifiedDiff } from "../generate/diff"; import { formatPlan } from "../generate/format"; import { @@ -57,6 +61,7 @@ export interface GenerateRouteOptions extends GenerateCommonOptions { export interface GenerateResourceOptions extends GenerateCommonOptions { admin?: boolean; + contentId?: string; declarePermission?: boolean; fields?: string; idType?: string; @@ -64,6 +69,8 @@ export interface GenerateResourceOptions extends GenerateCommonOptions { name?: string; parents?: boolean; permissionModule?: string; + singular?: string; + table?: string; } export interface GenerateServiceOptions extends GenerateCommonOptions { @@ -622,15 +629,16 @@ export const runGenerateResourceCommand = async ( "Name the resource, e.g. vitnode g resource notes --kind api.", ); } + if (options.kind === "content") return runContentResource(context, options); if (options.kind !== "api") { throw new UserError( options.kind === undefined - ? "Pass --kind api." - : `--kind ${options.kind} is not available.`, + ? "Pass --kind api or --kind content." + : `--kind ${options.kind} is not supported.`, { details: [ "--kind api scaffolds a module, schemas, a service and CRUD routes.", - "Content Engine resources (--kind content) are not generated yet; define them with defineContentType.", + "--kind content defines a Content Engine content type with generated staff routes and AdminCP screens.", ], }, ); @@ -731,3 +739,92 @@ export const runGenerateResourceCommand = async ( typesNote(ui); }); }; + +const runContentResource = async ( + context: CommandContext, + options: GenerateResourceOptions, +): Promise => { + const segments = parseLogicalPath(options.name ?? "", "resource name"); + const name = segments[0] ?? ""; + if ( + segments.length !== 1 || + options.admin || + options.permissionModule !== undefined + ) { + throw new UserError( + "A content resource is named by one segment, e.g. articles.", + { + details: [ + "The Content Engine mounts its staff routes under admin/content/ and adds can_view, can_create, can_edit and can_delete itself,", + "so --admin, --permission-module and nested names do not apply.", + ], + }, + ); + } + + const idStrategy = options.idType ?? "serial"; + if ( + idStrategy !== "serial" && + idStrategy !== "uuid" && + idStrategy !== "bigint" + ) { + throw new UserError( + `--id-type for a content resource is serial, uuid or bigint, not "${idStrategy}".`, + ); + } + + const ctx = await start(context, "Generate a content resource", options); + + let fieldsInput = options.fields; + if (fieldsInput === undefined && context.ui.interactive) { + fieldsInput = await context.prompter.text( + "Fields as name:type, comma-separated (text, richText, boolean, int, number; name?: for optional)", + { + validate: value => { + try { + parseContentFields(value); + + return true; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }, + ); + } + if (fieldsInput === undefined) { + throw new UserError("A content resource needs --fields.", { + details: [ + 'For example: --fields "title:text,body:richText". The first required text field is the title.', + ], + }); + } + + const result = planContentResource(ctx, name, { + fields: parseContentFields(fieldsInput), + id: options.contentId, + idStrategy, + singular: options.singular, + tableName: options.table, + }); + + return finish(context, ctx, options, ui => { + ui.note( + `${result.contentTypeId} is administrative only: staff manage it at /admin/content/${result.adminPath}. It has no public API, member writes or publication workflow.`, + ); + ui.note( + `The Content Engine serves its staff routes at ${mountPath(ctx.project, "/admin/content/...")} and guards them with ${result.permissionModule}.can_view, can_create, can_edit and can_delete.`, + ); + ui.line(); + ui.note("Then, in this order - nothing was migrated:"); + ui.line( + ` 1. ${ui.colors.command("vitnode build")} in the plugin (or keep ${ui.colors.command("vitnode dev")} running), so migrations can read the table ${result.tableName}.`, + ); + ui.line( + ` 2. ${ui.colors.command("vitnode db generate")} in the app that owns the database, and review the migration.`, + ); + ui.line( + ` 3. ${ui.colors.command("vitnode db migrate")} there to apply it.`, + ); + }); +}; diff --git a/packages/vitnode/scripts/cli/generate/ast.ts b/packages/vitnode/scripts/cli/generate/ast.ts index c097b4629d..3fc7d8881f 100644 --- a/packages/vitnode/scripts/cli/generate/ast.ts +++ b/packages/vitnode/scripts/cli/generate/ast.ts @@ -440,10 +440,17 @@ export const addNamedImport = ( if (existing !== undefined) { if (existing.locals.some(local => local.local === name)) return []; - const last = - existing.declaration.specifiers[ - existing.declaration.specifiers.length - 1 - ]; + const specifiers = existing.declaration.specifiers; + + // Alphabetical among the named imports, as the repository's lint sorts them. + const next = specifiers.find( + specifier => compareSpecifiers(specifier.local.name, name) > 0, + ); + if (next !== undefined) { + return [{ end: next.start, start: next.start, text: `${name}, ` }]; + } + + const last = specifiers[specifiers.length - 1]; if (last === undefined) return []; const comma = commaAfter(source, last.end); diff --git a/packages/vitnode/scripts/cli/generate/content-resource.ts b/packages/vitnode/scripts/cli/generate/content-resource.ts new file mode 100644 index 0000000000..6b51b368a5 --- /dev/null +++ b/packages/vitnode/scripts/cli/generate/content-resource.ts @@ -0,0 +1,572 @@ +import type { CallExpression, ObjectExpression } from "oxc-parser"; + +import type { Edit, ParsedSource } from "./ast"; +import type { GenerateContext } from "./generators"; + +import { UserError } from "../errors"; +import { + lookupModule, + readParsed, + specifierFor, + UnsupportedStructureError, +} from "./api-tree"; +import { + addNamedImport, + addProperty, + appendToArray, + applyEdits, + arrayValue, + findCalls, + findConstInit, + getProperty, + isExported, + objectArgument, + parseSource, + topLevelNames, + unwrap, +} from "./ast"; +import { ensureBuilders, planModule } from "./generators"; +import { camelCase, humanize } from "./names"; +import { API_BUILDERS } from "./project"; + +/** A field of a generated content type: `title:text`, `body:richText`, `featured?:boolean`. */ +export interface ContentField { + name: string; + optional: boolean; + type: ContentFieldType; +} + +export type ContentFieldType = + | "boolean" + | "int" + | "number" + | "richText" + | "text"; + +const CONTENT_FIELD_TYPES: Record< + ContentFieldType, + (optional: boolean) => string +> = { + boolean: () => "field.boolean({ defaultValue: false })", + int: optional => + `field.number({ integer: true${optional ? ", nullable: true" : ", required: true"} })`, + number: optional => + `field.number({ integer: false${optional ? ", nullable: true" : ", required: true"} })`, + richText: optional => + `field.richText({${optional ? " nullable: true" : " required: true"} })`, + text: optional => + `field.text({ maxLength: 200${optional ? ", nullable: true" : ", required: true"} })`, +}; + +/** Mirrors the Content Engine's own rules (`CONTENT_ID_PATTERN`, `CONTENT_TABLE_NAME_PATTERN`). */ +const CONTENT_ID = /^[a-z0-9]+(?:\.[a-z0-9-]+)+$/; +const TABLE_NAME = /^[a-z][a-z0-9_]*$/; + +export const parseContentFields = (input: string): ContentField[] => { + const fields = input + .split(",") + .map(part => part.trim()) + .filter(part => part !== "") + .map(part => { + const [, name, optional, type] = + /^([^:?\s]+)(\?)?\s*:\s*(\S+)$/.exec(part) ?? []; + if (name === undefined || type === undefined) { + throw new UserError(`"${part}" is not a field definition.`, { + details: [ + "Write fields as name:type, e.g. title:text,body:richText,featured?:boolean.", + ], + }); + } + if ( + !/^[a-z][A-Za-z0-9]*$/.test(name) || + ["createdAt", "id", "updatedAt"].includes(name) + ) { + throw new UserError(`"${name}" is not a supported field name.`, { + details: [ + "Use camelCase. id, createdAt and updatedAt are added by the Content Engine.", + ], + }); + } + if (!(type in CONTENT_FIELD_TYPES)) { + throw new UserError( + `"${type}" is not a supported content field type here.`, + { + hint: `Use one of: ${Object.keys(CONTENT_FIELD_TYPES).join(", ")}. Add other field helpers by hand afterwards.`, + }, + ); + } + + return { + name, + optional: optional === "?", + type: type as ContentFieldType, + }; + }); + + if (!fields.some(field => field.type === "text" && !field.optional)) { + throw new UserError( + "A content type needs a required text field for its title.", + { + details: [ + "The first required text field becomes admin.titleField, which the AdminCP list and pickers show.", + 'For example: --fields "title:text,body:richText".', + ], + }, + ); + } + + const names = fields.map(field => field.name); + const duplicate = names.find((name, index) => names.indexOf(name) !== index); + if (duplicate !== undefined) + throw new UserError(`The field "${duplicate}" is listed twice.`); + + return fields; +}; + +export interface ContentResourceOptions { + fields: readonly ContentField[]; + /** `plugin.entity`; derived from the plugin and resource names when omitted. */ + id?: string; + idStrategy: "bigint" | "serial" | "uuid"; + /** Singular display name, for "Create Article". */ + singular?: string; + tableName?: string; +} + +export interface ContentResourceResult { + adminPath: string; + contentTypeId: string; + permissionModule: string; + tableName: string; +} + +const shortName = (pluginId: string) => + pluginId.includes("/") ? pluginId.slice(pluginId.indexOf("/") + 1) : pluginId; + +const NAV = "src/admin/nav.tsx"; +const CONTENT = "src/admin/content.tsx"; +const LOCALE = "src/locales/en.json"; +const RE_EXPORT = + /^\s*export\s*\{\s*adminNav\s+as\s+adminContent\s*\}\s*from\s*["']\.\/nav["'];?\s*$/; + +const navEntry = (definition: string) => + `{ definition: ${definition}, icon: }`; + +/** Adds the content type to `src/admin/nav.tsx` (and `content.tsx`), creating them when absent. */ +const planAdminNav = ( + ctx: GenerateContext, + definition: string, + definitionFile: string, +): void => { + const { plan, project } = ctx; + const manual = [ + `Import ${definition} and FileTextIcon (lucide-react) in ${NAV}, and add`, + ` ${navEntry(definition)}`, + 'to the contentTypes of its adminNav; src/admin/content.tsx must list it too (export { adminNav as adminContent } from "./nav" does).', + ]; + + const contentSource = plan.read(CONTENT); + const navSource = plan.read(NAV); + + if ( + contentSource !== null && + !RE_EXPORT.test( + contentSource.replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, "").trim(), + ) + ) { + throw new UnsupportedStructureError( + `${CONTENT} builds its own adminContent, so the generator cannot add the screen safely.`, + [ + `Only a missing ${CONTENT} or \`export { adminNav as adminContent } from "./nav"\` is edited automatically.`, + ], + manual, + ); + } + + if (navSource === null) { + plan.create( + NAV, + `import type { AdminNavPluginSource } from "@vitnode/core/lib/plugin"; + +import { FileTextIcon } from "lucide-react"; + +import { ${definition} } from ${JSON.stringify(specifierFor(project, NAV, definitionFile, true))}; +import { manifest } from ${JSON.stringify(specifierFor(project, NAV, "src/manifest.gen.ts", true))}; + +/** The plugin's AdminCP sidebar entries. */ +export const adminNav = { + pluginId: manifest.id, + contentTypes: [${navEntry(definition)}], +} satisfies AdminNavPluginSource; +`, + "AdminCP sidebar entry", + ); + } else { + let parsed: ParsedSource = parseSource(NAV, navSource); + const init = findConstInit(parsed.program, "adminNav"); + const object = init === null ? null : unwrap(init); + if ( + object?.type !== "ObjectExpression" || + object.properties.some(p => p.type === "SpreadElement") + ) { + throw new UnsupportedStructureError( + `${NAV} has no plain \`export const adminNav = { ... }\` object.`, + [], + manual, + ); + } + + let source = navSource; + for (const [specifier, name] of [ + ["lucide-react", "FileTextIcon"], + [specifierFor(project, NAV, definitionFile, true), definition], + ] as const) { + if (topLevelNames(parsed.program).has(name)) { + if (name === "FileTextIcon") continue; + throw new UserError(`${NAV} already declares ${name}.`); + } + source = applyEdits(source, addNamedImport(parsed, specifier, name)); + parsed = parseSource(NAV, source); + } + + const reread = unwrap( + findConstInit(parsed.program, "adminNav") ?? object, + ) as ObjectExpression; + const list = arrayValue(getProperty(reread, "contentTypes")); + const property = getProperty(reread, "contentTypes"); + if (property !== null && list === null) { + throw new UnsupportedStructureError( + `adminNav.contentTypes in ${NAV} is not an array literal.`, + [], + manual, + ); + } + source = applyEdits( + source, + list === null + ? addProperty( + source, + reread, + `contentTypes: [${navEntry(definition)}]`, + "pluginId", + ) + : appendToArray(source, list, navEntry(definition)), + ); + plan.update(NAV, source, `AdminCP sidebar entry for ${definition}`); + } + + if (contentSource === null) { + plan.create( + CONTENT, + `// The AdminCP screens: the same content types as the sidebar. +export { adminNav as adminContent } from "./nav"; +`, + "AdminCP screens, re-using the sidebar list", + ); + } +}; + +/** Exports `buildContentAdminModule` from the plugin's builders, when it does not already. */ +const ensureContentBuilders = (ctx: GenerateContext): void => { + const parsed = readParsed(ctx.plan, API_BUILDERS); + if (isExported(parsed.program, "buildContentAdminModule")) return; + + if ( + !topLevelNames(parsed.program).has("manifest") || + topLevelNames(parsed.program).has("contentBuilders") + ) { + throw new UnsupportedStructureError( + `${API_BUILDERS} does not export buildContentAdminModule, and cannot be extended safely.`, + [], + [ + 'import { bindContentPlugin } from "@vitnode/core/content/server";', + "const contentBuilders = bindContentPlugin(manifest);", + "export const buildContentAdminModule = contentBuilders.buildContentAdminModule;", + ], + ); + } + + let source = applyEdits( + parsed.source, + addNamedImport(parsed, "@vitnode/core/content/server", "bindContentPlugin"), + ); + source = `${source.trimEnd()} + +const contentBuilders = bindContentPlugin(manifest); + +export const buildContentAdminModule = contentBuilders.buildContentAdminModule; +`; + ctx.plan.update( + API_BUILDERS, + source, + "bind the Content Engine admin builder", + ); +}; + +/** Adds the model to `buildContentAdminModule({ contentTypes })` in the admin module, creating either when missing. */ +const planAdminContentModule = ( + ctx: GenerateContext, + model: string, + modelFile: string, +): void => { + const { plan, project } = ctx; + const lookup = lookupModule(plan, project, ["admin"]); + const admin = + lookup.status === "found" + ? lookup.module + : planModule(ctx, ["admin"], { parents: false }); + + let parsed = readParsed(plan, admin.file); + let source = parsed.source; + for (const [specifier, name] of [ + [ + specifierFor(project, admin.file, API_BUILDERS, true), + "buildContentAdminModule", + ], + [specifierFor(project, admin.file, modelFile, true), model], + ] as const) { + if (name === model && topLevelNames(parsed.program).has(name)) { + throw new UserError(`${admin.file} already declares ${name}.`); + } + source = applyEdits(source, addNamedImport(parsed, specifier, name)); + parsed = parseSource(admin.file, source); + } + + const init = findConstInit(parsed.program, admin.exportName); + const call = init === null ? null : unwrap(init); + const options = call?.type === "CallExpression" ? objectArgument(call) : null; + if (options === null) { + throw new UnsupportedStructureError( + `The admin module in ${admin.file} could not be read.`, + [], + ); + } + + const modules = arrayValue(getProperty(options, "modules")); + const existing: CallExpression[] = + modules === null + ? [] + : findCalls(parsed.program, "buildContentAdminModule").filter( + node => node.start >= modules.start && node.end <= modules.end, + ); + + if (existing.length > 1) { + throw new UnsupportedStructureError( + `The admin module in ${admin.file} mounts buildContentAdminModule more than once.`, + [], + [`Add ${model} to the contentTypes of one of them.`], + ); + } + + let edits: Edit[]; + const first = existing[0]; + if (first !== undefined) { + const argument = objectArgument(first); + const contentTypes = + argument === null + ? null + : arrayValue(getProperty(argument, "contentTypes")); + if (contentTypes === null) { + throw new UnsupportedStructureError( + `buildContentAdminModule in ${admin.file} has no contentTypes array literal.`, + [], + [`Add ${model} to its contentTypes.`], + ); + } + edits = appendToArray(source, contentTypes, model); + } else { + const entry = `buildContentAdminModule({ contentTypes: [${model}] })`; + edits = + modules === null + ? addProperty(source, options, `modules: [${entry}]`, "routes") + : appendToArray(source, modules, entry); + } + + plan.update( + admin.file, + applyEdits(source, edits), + `mount ${model} in the admin module`, + ); +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Adds missing keys only; existing translations are never overwritten. */ +const mergeMissing = ( + target: Record, + extra: Record, +) => { + for (const [key, value] of Object.entries(extra)) { + const current = target[key]; + if (current === undefined) target[key] = value; + else if (isRecord(current) && isRecord(value)) mergeMissing(current, value); + } +}; + +const planLabels = ( + ctx: GenerateContext, + labels: Record, +): void => { + const source = ctx.plan.read(LOCALE); + let messages: unknown = null; + try { + messages = source === null ? null : JSON.parse(source); + } catch { + messages = null; + } + + if ( + source === null || + !isRecord(messages) || + `${JSON.stringify(messages, null, 2)}\n` !== source + ) { + ctx.notes.push( + `Add these labels to ${LOCALE} by hand (it is missing or not plain two-space JSON):`, + ); + ctx.notes.push(JSON.stringify(labels, null, 2)); + + return; + } + + const merged = structuredClone(messages); + mergeMissing(merged, labels); + const next = `${JSON.stringify(merged, null, 2)}\n`; + if (next !== source) + ctx.plan.update(LOCALE, next, "AdminCP labels for the content type"); +}; + +/** + * Plans a Content Engine resource that only staff can use: the definition, + * its model and table export, the admin module mount, the AdminCP sidebar and + * screen, and the labels. The Content Engine generates its CRUD routes and + * permissions - nothing is hand-written for them. + * + * No public API, member writes, publication, editorial workflow or ownership + * is enabled: those change who can read or write records and stay explicit + * decisions in the definition. No migration is written or applied. + */ +export const planContentResource = ( + ctx: GenerateContext, + name: string, + options: ContentResourceOptions, +): ContentResourceResult => { + const { plan, project } = ctx; + const plugin = shortName(project.id); + const prefix = plugin.replace(/[^a-z0-9]/g, ""); + const contentTypeId = options.id ?? `${prefix}.${name}`; + const tableName = + options.tableName ?? + `${plugin.replace(/[^a-z0-9]+/g, "_")}_${name.replace(/-/g, "_")}`; + + if (!CONTENT_ID.test(contentTypeId)) { + throw new UserError(`"${contentTypeId}" is not a valid content type id.`, { + details: [ + 'It must look like "plugin.entity": lower-case, dot separated, no dash in the first part.', + ], + hint: "Pass one with --content-id, e.g. --content-id sitenotes.articles.", + }); + } + if (!TABLE_NAME.test(tableName)) { + throw new UserError(`"${tableName}" is not a valid table name.`, { + hint: "Pass one with --table, in snake_case starting with a letter.", + }); + } + + const entity = contentTypeId.slice(contentTypeId.lastIndexOf(".") + 1); + const permissionModule = entity + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + const camel = camelCase(name); + const definition = `${camel}ContentType`; + const model = `${camel}Content`; + const definitionFile = `src/content/${name}.ts`; + const modelFile = `src/database/${name}.ts`; + const adminPath = `${plugin.replace(/[^a-z0-9-]/g, "")}/${name}`; + const title = + options.fields.find(field => field.type === "text" && !field.optional) + ?.name ?? ""; + const plural = humanize(name); + const singular = options.singular ?? plural; + + ensureBuilders(ctx); + + plan.create( + definitionFile, + `import { defineContentType, field } from "@vitnode/core/content"; + +/** + * ${plural}, managed by staff in the AdminCP. + * + * Generated as an administrative content type: no public API, no member + * writes, no publication workflow. Enable those here when you decide who may + * read and write ${plural.toLowerCase()} - each one changes access to the records. + * + * Browser-safe: the server model is \`${model}\` in ${modelFile.replace(/^src\//, "@/").replace(/\.ts$/, "")}. + */ +export const ${definition} = defineContentType({ + id: ${JSON.stringify(contentTypeId)}, + tableName: ${JSON.stringify(tableName)}, +${options.idStrategy === "serial" ? "" : ` idStrategy: ${JSON.stringify(options.idStrategy)},\n`} fields: { +${options.fields.map(field => ` ${field.name}: ${CONTENT_FIELD_TYPES[field.type](field.optional)},`).join("\n")} + }, + admin: { + path: ${JSON.stringify(adminPath)}, + titleField: ${JSON.stringify(title)}, + list: { + columns: [${JSON.stringify(title)}, "updatedAt"], + }, + }, +}); +`, + `content type ${contentTypeId}`, + ); + + plan.create( + modelFile, + `import { createContentModel } from "@vitnode/core/content/server"; + +import { ${definition} } from ${JSON.stringify(specifierFor(project, modelFile, definitionFile, true))}; + +/** The server-side model of {@link ${definition}}. Server-only. */ +export const ${model} = createContentModel(${definition}); + +// Migrations find tables through the exports of the built src/database files. +export const ${tableName} = ${model}.table; +`, + `model and table ${tableName}`, + ); + + ensureContentBuilders(ctx); + planAdminContentModule(ctx, model, modelFile); + planAdminNav(ctx, definition, definitionFile); + + const fieldLabels = Object.fromEntries([ + ...options.fields.map(field => [ + field.name, + humanize(field.name.replace(/([A-Z])/g, " $1").toLowerCase()), + ]), + ["updatedAt", "Updated"], + ]); + planLabels(ctx, { + [project.id]: { + content: { + [entity]: { + desc: `Manage ${plural.toLowerCase()}.`, + fields: fieldLabels, + label: `{count, plural, one {${singular}} other {${plural}}}`, + title: plural, + }, + }, + title: humanize(plugin), + }, + [`${project.id}:${permissionModule}`]: plural, + [`${project.id}:${permissionModule}:can_view`]: `View ${plural.toLowerCase()}`, + [`${project.id}:${permissionModule}:can_create`]: `Create ${plural.toLowerCase()}`, + [`${project.id}:${permissionModule}:can_edit`]: `Edit ${plural.toLowerCase()}`, + [`${project.id}:${permissionModule}:can_delete`]: `Delete ${plural.toLowerCase()}`, + }); + + return { adminPath, contentTypeId, permissionModule, tableName }; +}; diff --git a/packages/vitnode/scripts/cli/program.ts b/packages/vitnode/scripts/cli/program.ts index 05e57a2258..92c4a2d5a0 100644 --- a/packages/vitnode/scripts/cli/program.ts +++ b/packages/vitnode/scripts/cli/program.ts @@ -377,13 +377,25 @@ const registerGenerateCommands = (program: Command, run: Run) => { common(generate.command("resource")) .description("Create a module with schemas, a service and CRUD routes") .argument("", "module path for the resource, e.g. notes") - .option("--kind ", "what to generate: api") + .option("--kind ", "what to generate: api or content") .option( "--fields ", "fields as name:type, e.g. title:string,pinned?:boolean", ) - .option("--id-type ", "schema of the id: string or number", "string") - .option("--admin", "create it in the admin module tree (admin/)") + .option( + "--id-type ", + "api: string or number (default string); content: serial, uuid or bigint (default serial)", + ) + .option("--admin", "api: create it in the admin module tree (admin/)") + .option( + "--content-id ", + "content: the content type id, plugin.entity (default: derived)", + ) + .option( + "--table ", + "content: the table name (default: _)", + ) + .option("--singular