CLI
Generate commands
Reference for vitnode generate: scaffold plugin API modules, typed routes, admin routes, services and resources with dry runs and collision checks.
vitnode generate (alias vitnode g) writes plugin API code and registers it where VitNode reads it: a new module goes into buildApiPlugin({ modules }) or its parent module's modules, and a new route goes into its module's routes. Nothing is discovered at runtime, so src/config.api.ts still shows every module the plugin serves.
| Command | Creates | Guide |
|---|---|---|
vitnode g module <path> | <name>/<name>.module.ts, registered in config.api.ts or a parent | Generate a module |
vitnode g route <module>/<name> | routes/<name>.route.ts, added to the module's routes | Generate an endpoint |
vitnode g route admin/... | The same, guarded by a staff permission | Protect an admin endpoint |
vitnode g service <module> | <module>.service.ts, a service factory with explicit dependencies | Add a service |
vitnode g resource <name> --kind api | A module, schemas, a service and five CRUD routes in routes/ | Generate an API resource |
vitnode g resource <name> --kind content | An administrative Content Engine content type, wired in | Generate a content type |
Where to run it
Run it inside the plugin, through the plugin's own vitnode:
cd plugins/site-notes
pnpm vitnode g module notesThe workspace root does not depend on @vitnode/core, so pnpm vitnode is not found there. From the root, point pnpm at the plugin:
pnpm --filter site-notes exec vitnode g module notesWhich plugin it writes to
| You run it | Target |
|---|---|
With --project <name or path> | The workspace plugin with exactly that package name, or the plugin in 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 |
When several plugins qualify and there is no terminal to ask (CI, a script), the command lists them and exits with code 2. Pass --project to choose.
Only plugins can be targets. The plugin needs src/config.api.ts and the plugin-bound builders in src/api/builders.ts; create-vitnode-app --plugin writes both. When builders.ts is missing, the generator creates it from src/manifest.gen.ts, with all five bound builders:
import { bindApiPlugin } from "@vitnode/core/api/lib/bind-api-plugin";
import { manifest } from "@/manifest.gen";
const apiBuilders = bindApiPlugin(manifest);
/** The API builders, bound to this plugin's id. */
export const buildModule = apiBuilders.buildModule;
export const buildRoute = apiBuilders.buildRoute;
export const buildQueueTask = apiBuilders.buildQueueTask;
export const defineCacheEntry = apiBuilders.defineCacheEntry;
export const defineEvent = apiBuilders.defineEvent;When src/manifest.gen.ts is missing too, run vitnode build in the plugin once to write it. An existing builders.ts must export at least buildModule and buildRoute.
Options
Every generate command accepts:
| Option | Description |
|---|---|
--project <name|path> | 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 |
In an interactive terminal, the command asks for anything it cannot work out, such as a missing parent module or a route's method. Without one (CI, a script), it stops and names the flag to pass.
g module
vitnode g module <path>:
| Option | Description |
|---|---|
--admin | Create it in the admin module tree: notes becomes admin/notes |
--parents | Also create missing parent modules instead of asking |
g route
vitnode g route <module path>/<route name>:
| Option | Default | Description |
|---|---|---|
-m, --method <method> | From the name | get, post, put, patch or delete |
--path <path> | From the name | Path relative to the module, in OpenAPI syntax: /, /{id}, /{id}/archive |
--id-type <type> | string | Schema of every path parameter: string, or number for positive integers |
--admin | - | Put the route in the admin module tree |
--permission <module.permission> | - | Required for admin routes, such as notes.can_create. An interactive terminal offers the declared ones |
--declare-permission | - | Declare that permission in src/staff-permissions.ts, creating the file when needed, and add its AdminCP labels |
--parents | - | Also create missing modules instead of asking |
--permission and --declare-permission only apply to routes in the admin module tree. On any other route they stop the command.
g service
vitnode g service <module path> takes --admin, with the same meaning as for modules. The module must already exist. It writes createXService = (_deps: XServiceDeps) => ({}) with db as the only dependency and no operations, and changes no route.
g resource --kind api
vitnode g resource <name> --kind api:
| Option | Default | Description |
|---|---|---|
--kind <kind> | - | Required: api, or content (below) |
--fields <fields> | Asked, or none | Comma-separated name:type pairs with string, number, int or boolean; name?:type is optional |
--id-type <type> | string | string or number |
--admin | - | Generate it in the admin module tree |
--permission-module <module> | Asked; required when nothing can prompt | Admin resources only. can_view guards list and show; can_create, can_edit and can_delete guard the rest |
--declare-permission | - | Declare the missing permissions and their labels |
--parents | - | Also create missing parent modules instead of asking |
The resource's module must be new or have no routes yet. The generator creates no table, migration or storage. Every service operation rejects with a <Name>NotImplementedError until you implement it, and each route maps that error to 501 with { "error": "not_implemented", "message": "..." }, declared in its responses.
g resource --kind content
vitnode g resource <name> --kind content takes:
| Option | Default | Description |
|---|---|---|
--fields <fields> | Asked; required when nothing can prompt | name:type pairs with text, richText, boolean, int or number. The first required text field is the title |
--id-type <type> | serial | serial, uuid or bigint |
--content-id <id> | Derived | The content type id, as plugin.entity |
--table <name> | <plugin>_<name> | The table name, in snake_case |
--singular <label> | The plural label | Singular label, such as Article |
The name is a single segment, such as articles; --admin and --permission-module do not apply. The result is administrative only, with no public API, member writes or publication, and the command never writes or applies a migration. See Generate a content type for the files it writes and how it handles permissions.
Route names and paths
Every route gets its own file in the module's routes/ folder. notes/list writes src/api/modules/notes/routes/list.route.ts, and notes.module.ts imports it from ./routes/list.route.
A route's file name and its URL are separate. These five names imply a method and a path:
| Name | Method | Path |
|---|---|---|
list | GET | / |
show | GET | /{id} |
create | POST | / |
update | PATCH | /{id} |
delete | DELETE | /{id} |
Any other name needs --method and --path, or answers to the prompts in an interactive terminal. archive.route.ts is not served at /archive unless you say so.
What a generated route does
GETanswers200with sample data,{ "sample": true, "message": "..." }plus any path parameters, and a response schema that matches it exactly. Replace both.POST,PUTandPATCHvalidate their path parameters and a JSON body, an emptyz.object({})for you to fill in.DELETEvalidates only its path parameters. All four answer501 Not Implemented, so a client never sees a fake success.- Admin routes add
adminStaffPermissionwith a typed reference, such asstaffPermissions.admin.notes.canCreate, and a403response. The permission must be declared insrc/staff-permissions.ts, or added with--declare-permission. When the plugin'sadminmodule mounts Content Engine modules and the permission module is not declared,g routeonly warns, because a content type may declare it.g resource --adminalways requires declared permissions.
A staff permission decides who may call a route. It does not check access to a particular record, so do that in the handler when it matters.
Safety rules
-
Dry run first.
--dry-runresolves, validates and formats everything, then prints the exact diff instead of writing. -
No overwrites. An existing file, an identifier the target file already declares, or a module name already registered stops the command before anything is written. There is no
--force. -
No duplicate endpoints. A new route is checked against every route the plugin's module tree reaches, and against the other routes of the same command, such as a resource's five. Full paths are joined the way Hono mounts modules, so a
notesrouteGET /draftsand anotes/draftsrouteGET /are bothGET /notes/drafts. Parameter names don't count (/{id}and/{noteId}are the same route), but a trailing slash does, because routing is strict. The error names the method and path, the route that already answers it, and where the new one would go:ā GET /notes/drafts already exists. listDraftsRoute in src/api/modules/notes/drafts/routes/list.route.ts (module "notes/drafts") answers GET /notes/drafts. Destination: showDraftsNotesRoute in src/api/modules/notes/routes/show-drafts.route.ts (module "notes", path /drafts). Nothing was written.A module registered under several parents is checked at every mount. If
firstandsecondboth listnotesModule, itsGET /itemsanswers bothGET /first/notes/itemsandGET /second/notes/items, and a new route on either path is refused.The generator cannot check parts of the tree it cannot read: the Content Engine modules under
/admin/content, a module entry it cannot resolve, or a module that mounts itself through its own children. A new route under one of them is still written, with a warning that the check is incomplete. -
Names are kept, not rewritten. Module and route names are lower-case kebab-case, because a module name is its URL segment.
myNotesis 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
VitNodeApiPlugintype stay as they were. New imports go in alphabetical order within their group. -
Formatting follows the project. New and changed files are formatted with the project's own oxfmt and config. A file you edited earlier is formatted only if it was already formatted, so the diff never touches code the generator did not add. Without oxfmt in the project, the summary says the code was not formatted.
-
Writes are checked and rolled back. Every file is re-read just before writing; if one changed in the meantime, nothing is written. If a write fails halfway, the files already written are restored. A crash during the final renames can still leave some files written, which is why the summary lists every file.
Supported source patterns
The generator reads your source and never runs it. It follows these patterns:
buildApiPlugin({
manifest,
modules: [notesModule, adminModule],
permissionStaff: staffPermissions,
});export const staffPermissions = defineStaffPermissions(manifest, {
admin: { notes: ["can_create"] },
});import { adminNotesModule } from "./notes/notes.module";
export const adminModule = buildModule({
name: "admin",
routes: [],
modules: [adminNotesModule],
});modulesandroutesare array literals, andnameis a string literal.- Each array entry is an identifier that resolves to a
const x = buildModule({...})orbuildRoute({...})in the plugin, declared in the same file or imported with@/or a relative path. buildContentAdminModule(...)andbuildContentPublicModule(...)are recognized as the generatedcontentmodule. They take no hand-written routes or child modules.permissionStaffis an identifier for an exporteddefineStaffPermissions(manifest, {...})call in another file of the plugin, normallystaffPermissionsinsrc/staff-permissions.ts, whoseadminentry is an object literal with array literals inside. The generator reads and declares permissions in that call, and generated routes import the export and use typed references such asstaffPermissions.admin.notes.canCreate. An inline object, or a definition insideconfig.api.ts, stops the command with the steps to move it. See Permission references.
Other shapes, such as a spread inside modules, a variable holding the array, or a module from another package, stop the command when it has to search or edit that array. It writes nothing and prints the lines to add by hand. Elsewhere in the tree such entries only limit the duplicate-endpoint check, with a warning. A route whose method or path cannot be read also gets a warning, since the check could not cover it.
Which property the generator edits
An object literal can name the same property twice or merge in a spread, and JavaScript keeps the value that comes last. The generator reads and edits only that effective property. This applies to modules, routes, permissionStaff, its admin entry and each module entry in it, and the contentTypes of buildContentAdminModule(...) and adminNav:
| Source | What the generator does |
|---|---|
| The property written twice | Edits the last one, which is the one that runs |
The property after a spread or an unknown computed key: { ...shared, modules: [...] } | Edits it: nothing after it can replace it |
A string-literal computed key: { ["modules"]: [...] } | Reads it as modules |
A spread of an object literal without the key: { ...{ manifest }, modules: [...] } | Ignores the spread |
A spread or an unknown computed key after the property: { modules: [...], ...shared } | Stops: the spread may replace it |
| A getter, setter or method with that name | Stops: its value comes from running code |
| No such property, but a spread or an unknown computed key in the object | Stops: the spread may supply it, and adding the property would override or be overridden by it |
When it stops, it names the line that decides and what to change by hand:
ā The modules of buildApiPlugin cannot be read or edited safely.
At src/config.api.ts:15: a spread after modules may replace it.
Nothing was written.
To wire it by hand:
Create notes/notes.module.ts next to the parent module (src/api/modules/ for a top-level one):
export const notesModule = buildModule({ name: "notes", routes: [] });
Import notesModule in src/config.api.ts and add it to buildApiPlugin({ modules }).After generating
- Types. The plugin's own pages see new routes at once: its
types/api-registry.gen.d.tsimportsVitNodeApiPluginfromsrc/config.api.ts, so nothing needs regenerating. An app sees them once the plugin's declarations are rebuilt, whichvitnode devdoes on save; otherwise runvitnode buildin the plugin. See How the fetcher knows your routes. - URLs. A plugin is mounted at
/{plugin id}under the app's API base path,/apiin the starter apps.notes/listinsite-notesanswersGET /api/site-notes/notes. - Checks. After a build, run
vitnode plugin validateto load the plugin the way an app does, andvitnode doctorto find unregistered modules, undeclared permissions and duplicate endpoints.