Skip to Content
Documentation

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.

CommandCreatesGuide
vitnode g module <path><name>/<name>.module.ts, registered in config.api.ts or a parentGenerate a module
vitnode g route <module>/<name>routes/<name>.route.ts, added to the module's routesGenerate an endpoint
vitnode g route admin/...The same, guarded by a staff permissionProtect an admin endpoint
vitnode g service <module><module>.service.ts, a service factory with explicit dependenciesAdd a service
vitnode g resource <name> --kind apiA module, schemas, a service and five CRUD routes in routes/Generate an API resource
vitnode g resource <name> --kind contentAn administrative Content Engine content type, wired inGenerate 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 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:

pnpm --filter site-notes exec vitnode g module notes

Which plugin it writes to

You run itTarget
With --project <name or path>The workspace plugin with exactly that package name, or the plugin in that folder
Inside a pluginThat 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:

src/api/builders.ts
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:

OptionDescription
--project <name|path>The plugin to write into, by exact package name or folder
--dry-runPrint the full diff of every planned change and write nothing
--plain, --verboseOutput 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>:

OptionDescription
--adminCreate it in the admin module tree: notes becomes admin/notes
--parentsAlso create missing parent modules instead of asking

g route

vitnode g route <module path>/<route name>:

OptionDefaultDescription
-m, --method <method>From the nameget, post, put, patch or delete
--path <path>From the namePath relative to the module, in OpenAPI syntax: /, /{id}, /{id}/archive
--id-type <type>stringSchema 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:

OptionDefaultDescription
--kind <kind>-Required: api, or content (below)
--fields <fields>Asked, or noneComma-separated name:type pairs with string, number, int or boolean; name?:type is optional
--id-type <type>stringstring or number
--admin-Generate it in the admin module tree
--permission-module <module>Asked; required when nothing can promptAdmin 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:

OptionDefaultDescription
--fields <fields>Asked; required when nothing can promptname:type pairs with text, richText, boolean, int or number. The first required text field is the title
--id-type <type>serialserial, uuid or bigint
--content-id <id>DerivedThe content type id, as plugin.entity
--table <name><plugin>_<name>The table name, in snake_case
--singular <label>The plural labelSingular 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:

NameMethodPath
listGET/
showGET/{id}
createPOST/
updatePATCH/{id}
deleteDELETE/{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

  • GET answers 200 with sample data, { "sample": true, "message": "..." } plus any path parameters, and a response schema that matches it exactly. Replace both.
  • POST, PUT and PATCH validate their path parameters and a JSON body, an empty z.object({}) for you to fill in. DELETE validates only its path parameters. All four answer 501 Not Implemented, so a client never sees a fake success.
  • Admin routes add adminStaffPermission with a typed reference, such as staffPermissions.admin.notes.canCreate, and a 403 response. The permission must be declared in src/staff-permissions.ts, or added with --declare-permission. When the plugin's admin module mounts Content Engine modules and the permission module is not declared, g route only warns, because a content type may declare it. g resource --admin always 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-run resolves, 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 notes route GET /drafts and a notes/drafts route GET / are both GET /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 first and second both list notesModule, its GET /items answers both GET /first/notes/items and GET /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. 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. 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:

src/config.api.ts
buildApiPlugin({
  manifest,
  modules: [notesModule, adminModule],
  permissionStaff: staffPermissions,
});
src/staff-permissions.ts
export const staffPermissions = defineStaffPermissions(manifest, {
  admin: { notes: ["can_create"] },
});
src/api/modules/admin/admin.module.ts
import { adminNotesModule } from "./notes/notes.module";

export const adminModule = buildModule({
  name: "admin",
  routes: [],
  modules: [adminNotesModule],
});
  • modules and routes are array literals, and name is a string literal.
  • Each array entry is an identifier that resolves to a const x = buildModule({...}) or buildRoute({...}) in the plugin, declared in the same file or imported with @/ or a relative path.
  • buildContentAdminModule(...) and buildContentPublicModule(...) are recognized as the generated content module. They take no hand-written routes or child modules.
  • permissionStaff is an identifier for an exported defineStaffPermissions(manifest, {...}) call in another file of the plugin, normally staffPermissions in src/staff-permissions.ts, whose admin entry 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 as staffPermissions.admin.notes.canCreate. An inline object, or a definition inside config.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:

SourceWhat the generator does
The property written twiceEdits 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 nameStops: its value comes from running code
No such property, but a spread or an unknown computed key in the objectStops: 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.ts imports VitNodeApiPlugin from src/config.api.ts, so nothing 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.
  • URLs. A plugin is mounted at /{plugin id} under the app's API base path, /api in the starter apps. notes/list in site-notes answers GET /api/site-notes/notes.
  • Checks. After a build, run vitnode plugin validate to load the plugin the way an app does, and vitnode doctor to find unregistered modules, undeclared permissions and duplicate endpoints.