Edit this page on GitHub

CLI Guide

The setu CLI does two things: it scaffolds projects, and it generates code that is already wired into a registration site. This guide covers both, plus monorepo workspaces.

PUBLIC_API.md is the authoritative reference for flags, options and exit codes. This guide explains the choices behind them.

Install

setu is a Node program on npm. Run it once with npx, or install it globally:

npx @setu-ts/cli new my-app
npm install --global @setu-ts/cli

npm create @setu-ts my-app is the same as setu new my-app. The CLI needs Node 22.23.4+, 24.22.1+ or 26.12.1+ whatever target it scaffolds; a Deno or Bun project is still generated by this one Node program.

Scaffolding a project

setu new my-app                                # minimal: the runtime plugin alone
setu new my-app --template rest                # a REST composition
setu new my-app --runtime deno                 # node | deno | bun | cloudflare-workers (see below)
setu new my-app --template class-based         # decorators and a DI container
setu new my-app --dry-run                      # print the plan, write nothing

Start from an example

setu new my-app --example rest-api
cd my-app && npm install
npm run smoke                                  # the example's own proof; exits when done
npm start                                      # serves until stopped
setu new my-app --example rest-api --dry-run

This downloads the example from GitHub at v<CLI version>, rewrites workspace dependencies to the CLI’s framework range, and writes a standalone Node project. All downloads finish before writing; a failed download leaves the target untouched and names a template alternative. Dry-run lists the manifest’s output paths without fetching anything. Existing file collisions are refused through the same writer used by template scaffolds.

Choose minimal, rest-api, database, di-decorators, cqrs, multi-tenant, static-site, worker-pool, graphql-demo, grpc, microservices or realtime. Unknown names list those choices; excluded names explain the repository dependency and an alternative. full-stack uses --template full-stack; compiled-binary, cloudflare, realtime-clients and plugin-development need repository scripts or source.

Examples keep their own composition: --template, --workspace, --style, --broker, --queue, --redis-client, --devtool, --devtool-port, --logger, --env-file, --transport, --transport-url, --port, --depends-on and --di are refused with --example. --runtime node is accepted; other runtimes are refused. --dir, --agents and --dry-run remain available. See Examples for the learning path and prerequisites.

Templates

TemplatePlugins it registers
(none)RuntimePlugin only
restRuntime, Config, Logger, Validation, HttpSecurity, Health, Metrics, OpenAPI + the errorHandler() middleware
microserviceThe REST set + Messaging, Queue, Resilience, Telemetry, CQRS, Events, ServiceDiscovery
class-basedThe REST set + DecoratorPlugin and DiPlugin, with decorated HTTP and non-HTTP ingress seams plus an injected service
full-stackComposed through @setu-ts/full-stack-starter, plus a React Router 8 framework-mode app skeleton

exceptions ships middleware rather than a plugin, which is why rest registers eight plugins and adds errorHandler() to the pipeline separately. class-based was previously called nest; the old name is refused with a message naming the new one.

Every scaffolded project — templated or not — exports createApp() from setu.config.ts. main.ts imports it to start the server and the CLI imports it to discover plugin-contributed commands, so the plugin list has exactly one home. The factory does not start the app: importing a module that binds a socket would make command discovery bind one too.

Socket targets also receive test/app.test.ts, using the target runtime’s test harness and createTestApp({ app: await createApp() }). It starts the real composition, checks /health (/ on the minimal host), and stops it. The factory returns IKernelApplication, or its promise on a starter. Workers requires the platform environment and emits no smoke test; its test task permits an empty suite. Use setu add testing on an existing project to pin @setu-ts/testing in Deno’s import map or npm’s devDependencies.

Style is its own axis: decorators and DI are one choice, and functional is the default

Decorators are optional and dependency injection is optional — and they are one axis with two complete positions rather than a spectrum. That axis is --style, and it is separate from the --template that supplies the plugin set. The default is functional: no DecoratorPlugin, no DiPlugin, ctx-first handlers, plain exported functions for services. --style class-based is the opt-in, and it always brings both plugins together. --template class-based remains a byte-identical alias of --template rest --style class-based: scaffolding it logs one line naming the canonical spelling, and the output is identical.

You wantScaffold withYou get
Neithersetu new appThe runtime plugin alone. g route, g controller, g middleware, g plugin, g service, g module and g job work.
Functionalsetu new app --template restThe REST plugin set. g module writes a plain service and a registered route with GET and POST handlers.
Class-basedsetu new app --template rest --style class-basedDecoratorPlugin + DiPlugin, decorated controllers and ingress classes, @Injectable services, and class module barrels.

--style is accepted by setu new and setu generate app, on the styleable templates rest and microservice. It is refused with no template to apply to, with an unknown value, and with full-stack, which composes through a starter and has no controller or ingress seam to register decorated classes through.

The choice persists. setu generate reads the target project’s manifest, so a project holding @setu-ts/decorator-plugin gets class output and one without it gets functional output — a later generate cannot silently emit the other style. g controller, for example, writes a decorated @Controller class in a class-based project and a register…Routes(router) function in a functional one; both land in src/controllers/, and neither needs a plugin the project lacks.

The independent --di flag was removed. It is refused with a message pointing at --style class-based (with --template rest or microservice); there is no longer a decorator-only or container-only composition, because those are the incoherent middle of the axis. A project scaffolded with the old flag keeps working — generation reads the packages it actually has.

One consequence worth knowing before you reach for it: DecoratorPlugin.registerService registers a provider on the container when one is present and never touches the kernel registry, so services.get('<name>-service') resolves a decorated service only in a project without DiPlugin. In a class-based project, resolve through the container instead. See Decorators — Injectable Classes.

Runtime targets

--runtimeEntryManifestsStart command
node (default)main.ts → app.start()package.json + tsconfig.json + ESLint + Prettiernpm start (compiled)
denomain.ts → app.start()deno.jsondeno task start
bunmain.ts → app.start()package.json + tsconfig.json + ESLint + Prettierbun run start
cloudflare-workerssrc/index.ts fetch exportdeno.json + package.json + tsconfig.json + wrangler.tomlnpm run dev

Every target takes the framework from npm: package.json names @setu-ts/* at ^<version>, and deno.json maps each to npm:@setu-ts/<name>@^<version>. A Deno project sets "nodeModulesDir": "auto", so run deno install before a command that loads the project from setu — the CLI reads the project’s packages from that node_modules, and refuses with that command named when it is missing. An npm-target project is formatted with Prettier and linted with ESLint, and carries check, lint, format and format:check scripts.

--runtime picks the packaging, not the code. For deno, node and bun, the generated main.ts, setu.config.ts and src/ are byte-identical: main.ts reaches no Deno or process API, only createRuntimeServices(), and RuntimePlugin detects the platform when the app starts. Only the manifest and the start command differ, so moving a project between those three is a manifest change:

  • Node → Bun: keep package.json, set its scripts to bun run main.ts and bun test, and replace the tsx and @types/node dev dependencies with @types/bun. A full-stack project builds the frontend before production, keeps start: bun run main.ts, prefixes tests with react-router build && and uses start:dev: bun run dev.ts. Bun workspaces need the hoisted linker setting described in the upgrade guide.
  • Node or Bun → Deno: replace package.json, tsconfig.json, the ESLint and Prettier files and the npm lockfile with the deno.json that setu new --runtime deno emits. A deno.json added beside a kept package.json still serves, but setu generate checks package.json first, so it keeps reading the project as Node and emits the Node node:test harness rather than the Deno one.
  • full-stack → Deno is the exception. Its Deno form keeps a package.json, because the Vite build reads it, so do not delete it: add the deno.json a Deno full-stack scaffold emits, then remove the start and test scripts, the @setu-ts/* dependencies, and the tsx and @types/node dev dependencies from package.json, keeping build. Removing start is what makes setu generate read the project as a Deno one.
  • Deno → Node or Bun: the reverse — remove deno.json and add the manifests the matching target emits.

setu generate reads the target from those manifests on every run, so after a move it emits for the new runtime without a --runtime flag. There is no switching command; the manifest is edited by hand. cloudflare-workers is the exception: its entry is a fetch export rather than main.ts, and its setu.config.ts passes the Worker’s bindings to RuntimePlugin({ env }). Moving to or from Workers rewrites those two files. Your routes, middleware and plugins under src/ stay the same.

Node development runs TypeScript through tsx; production runs the bundle. Node’s built-in support (--experimental-strip-types) erases types without transforming code, so it cannot run a decorator — V8 has not shipped them, so even a TC39 standard decorator is a bare SyntaxError — or the constructor parameter property setu generate module emits (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX). A generated Node project therefore declares tsx in devDependencies and develops with tsx watch main.ts, which transforms both. Its tsconfig.json needs no decorator option. Bun compiles TypeScript outright and Deno and Workers never invoke a runner, so no other target carries the dependency.

The Workers target carries an npm manifest as well as deno.json. wrangler bundles src/index.ts with esbuild, which reads no Deno import map, so the project emits package.json (the @setu-ts/* dependencies, wrangler pinned) and npm install && npm run dev works as printed. The deno.json beside it is what setu generate reads for plugin gating.

--template microservice --runtime cloudflare-workers is supported. The template swaps messaging-plugin and queue-plugin — both need a socket — for @setu-ts/cloudflare-plugin, and emits the queue module handlers the platform invokes (createMessagingHandler, createQueueHandler). See Runtime Deployment — Cloudflare Workers.

Choosing a message broker and job queue

A standalone project can select its own message broker and job queue at scaffold time instead of getting the in-memory defaults and hand-editing setu.config.ts. The accepted values are derived from the same transport registry the workspace --transport flag reads, so the two can never drift:

setu new svc --template microservice --broker redis --queue redis
cd svc
docker compose -f docker/compose.yaml up -d   # start the broker the flags named
npm install && npm run start:dev

--broker accepts every arm with messaging wiring plus memory: memory, redis, rabbitmq, nats, kafka, pubsub, service-bus. --queue accepts the smaller set the queue actually supports: memory, redis, rabbitmq (sqs has no arm a scaffold can configure). The selected arm rewrites the template’s MessagingPlugin/QueuePlugin wiring to read its connection from the environment with a local default as fallback; the variable is added to the gitignored .env with that default and to the tracked .env.example empty. The project also gets docker/compose.yaml starting the broker it named — the messaging plugin connects during registration and does not retry, so nothing else would make the first boot succeed.

Full-stack projects accept both flags: the factory receives messaging: / queue: options and reads the connection through config.get, including the project’s dotenv file. Broker clients are installed as runtime dependencies (Deno import-map entries). Workers still refuses both flags, as do templates registering no messaging at all (rest, no template) — use --template microservice (add --style class-based for decorators). memory is accepted everywhere the flags are and rewrites nothing, because stating the default is not a mistake.

Existing standalone projects can use setu add messaging --broker redis or setu add queue --queue redis. These flags apply only to their named package; Workers and workspace members refuse them. Members instead read the workspace transport. add installs the selected client, writes only an absent inline call or generated full-stack option, and prints the exact configuration when it already exists or cannot safely be edited. An absent docker/compose.yaml is created for a standalone selection; existing Compose and dotenv files are preserved. The command prints the connection variable and local default. Without a selection the full-stack starter uses memory. Changing an existing value is a manual edit.

Choosing the Redis client

Redis arms use ioredis by default. --redis-client node-redis (on setu new with --broker redis, --queue redis or --transport redis, and on setu add with a Redis selection) scaffolds node-redis instead: the project depends on redis rather than ioredis, and a generated src/node-redis.ts exports nodeRedisClient(url), which passes fromNodeRedis from @setu-ts/common a factory that imports redis and calls createClient({ url }) on the first connect, so the telemetry plugin’s redis instrumentation is in place before node-redis loads. Each Redis-backed plugin gets its own client, because each closes its client at shutdown; the realtime backplane gets two. setu.config.ts imports the helper rather than redis itself, so setu check and later setu add runs keep reading the configuration.

setu new svc --template microservice --broker redis --queue redis --redis-client node-redis
setu new acme --workspace --transport redis --redis-client node-redis

A workspace records the choice as redisClient: 'node-redis' in its manifest (no key means ioredis), and generate app and setu add in its members follow it; they refuse their own --redis-client, as they refuse --broker, and an unknown stored value is refused by name. The flag is also refused without a Redis arm and on Cloudflare Workers. The interactive setu new asks for the client after a Redis broker or queue is chosen.

setu add react-router refuses a project without a full-stack factory or existing ReactRouterPlugin: create one with setu new <name> --template full-stack, or in a workspace with setu generate app <name> --template full-stack.

Interactive scaffolding

At an interactive terminal, setu new asks for the choices it already accepts as flags — runtime, template and style on a standalone project, runtime and transport on a workspace. The style question is asked only for the styleable templates (rest and microservice), and the class-based alias is omitted from the template choices because two names for one project would tell a user nothing. Broker and queue are asked only when the answers already given can take them, which is the same test the flags apply: a template registering no messaging (the minimal default, rest) and Cloudflare Workers skip them; full-stack offers both. A host with logging also asks for its logger. It never invents a question whose answer no flag can express, and --dry-run stays exact because prompting only rewrites the flag record before the ordinary pipeline runs.

It fails closed in three layers, so scripts, CI and editors are never blocked: programmatic callers pass no prompter at all; the installed executable supplies one only when stdin is a terminal; and Deno’s own prompt returns null on a non-terminal anyway. --yes (or -y) is the explicit escape hatch: take every default and ask nothing.

Generating code

setu generate service billing
setu g service billing            # alias
setu g service billing --dry-run  # print the plan, write nothing

Any casing of the name produces identical output: setu g controller user-profile and setu g controller UserProfile emit the same file.

There are seventeen built-in schematics. Every one except guard, migration, schema and — in a functional project — service and job lands in a barrel the generated setu.config.ts already imports, so a generated artifact reaches its registration site with no edit to a file you own:

SchematicRequiresEmits intoReaches
module—src/modules/<name>/ (no file in the controllers folder)DecoratorPlugin({ modules }) (class mode), or registerModules(app.router, app.services) from src/modules/index.ts (functional mode)
controller—src/controllers/DecoratorPlugin({ controllers }) (class mode), or a register…Routes(router) call
service—src/services/DecoratorPlugin({ services }), when installed
route—src/controllers/; src/modules/<module>/ with --moduleA register…Routes(router) call in createApp(), or the module’s index.ts (functional mode)
schemavalidation-pluginsrc/shared/schemas/<name>.schemas.ts; src/modules/<module>/ with --moduleNothing — the feature that uses it imports it
middleware—src/middleware/The global middleware pipeline
plugin—src/plugins/The plugins: [...] array
health-indicatorhealth-pluginsrc/health/HealthPlugin({ indicators }) → GET /health
metricmetrics-pluginsrc/metrics/MetricsPlugin({ customMetrics }) → /metrics
command-handlercqrs-pluginsrc/cqrs/<name>.command-handler.ts; class-based: src/ingress/<name>-command-handler.ingress.tsCqrsPlugin({ commandHandlers }), or DecoratorPlugin({ ingress }) in class mode
query-handlercqrs-pluginsrc/cqrs/<name>.query-handler.ts; class-based: src/ingress/<name>-query-handler.ingress.tsCqrsPlugin({ queryHandlers }), or DecoratorPlugin({ ingress }) in class mode
event-handlerevents-pluginsrc/events/<name>.event-handler.ts; class-based: src/ingress/<name>-event-handler.ingress.tsEventsPlugin({ handlers }), or DecoratorPlugin({ ingress }) in class mode
guardauth-pluginsrc/guards/Nothing — attach it per route
jobqueue-plugin (class mode or queue-owning starter)src/jobs/<name>.job.ts; class-based: src/ingress/<name>-job.ingress.tsFunctional + queue: QueuePlugin({ processors: [...QUEUE_PROCESSORS] }); standalone without queue; class-based: DecoratorPlugin({ ingress })
migrationdatabase-plugin, commonsrc/migrations/Nothing — no framework code reads migrations
ws-routewebsocket-pluginsrc/plugins/<name>.plugin.ts; class-based: src/ingress/<name>-ws-route.ingress.tsThe plugins: [...] array, or DecoratorPlugin({ ingress }) in class mode
ssesse-pluginsrc/controllers/A register…Routes(router) call, or DecoratorPlugin({ controllers }) in class mode

In a full-stack project, first run setu add events, setu add cqrs or setu add queue for the corresponding handler generator. The command imports the managed barrel and wires the starter’s events: { handlers: EVENT_HANDLERS }, cqrs: { commandHandlers: COMMAND_HANDLERS, queryHandlers: QUERY_HANDLERS } or queue: { processors: [...QUEUE_PROCESSORS] } arm when the generated callback can be safely edited. Existing queue transport options are retained, and add queue --queue redis merges with the processor arm in either add order. Uncertain edits retain the existing configuration and print guidance. The starter owns these plugins; use its arm instead of registering a second instance. A job in a starter that owns the queue requires setu add queue first; otherwise generation refuses before writing and explains that the starter already runs the queue. Functional jobs outside a queue-owning starter remain ungated.

In projects with react-router-plugin, g route, g controller and g module refuse kernel routes that would shadow an existing page, with its file named. To put an API at /api/products while keeping the /products page, run setu g module api, then setu g route products --module api. The check runs the installed react-router routes --json in the project with a 15-second deadline; if it cannot run, generation continues with a notice and the paths to check manually. setu check does not run React Router, so a page added after the route is not detected.

guard, migration and schema are unwired deliberately, not by omission. A guard answers 401 when ctx.request.user is absent, so registering it globally would 401 /health, /metrics and / — turning a generated file into an outage; its positions are per route (@UseGuards, a route’s middleware list). With QueuePlugin installed, a functional job exports a processor factory through src/jobs/index.ts, consumed by QueuePlugin({ processors: [...QUEUE_PROCESSORS] }). Outside a queue-owning starter, without queue it remains a standalone function. Class-based jobs require queue and use the ingress barrel. Nothing in the framework reads migration files at all. A functional service is a plain exported function, which has no registration site; src/services/index.ts re-exports it for convenience and registers nothing. A schema file has no registration site either: it is the shapes file a feature imports — Controllers and domain modules says where shapes live and what --module does with them.

--module <name> is the feature-scoped form of the five schematics a feature owns — controller, route, service, sse and schema: it places the file inside the named module rather than the top-level folder, and every other schematic refuses the flag because it is app-wide. Once a project has modules the first four require it. A function controller is registered by regenerating the module’s CLI-owned index.ts; in a class-based module a class controller or service is added to the controllers or providers array of <module>.module.ts when that array is a plain literal, and otherwise the line to add is printed. A functional service or a schema is a file for the module’s own code to import and registers nothing. See --module: place a file inside a feature.

A schematic gated on a plugin refuses rather than emitting source whose own import cannot resolve, and names the setu add command that adds it:

The "guard" schematic requires @setu-ts/auth-plugin, which is not installed in /path/to/app.
Run `setu add auth`, then this command again.

For generated projects in either style, setu add registers the safe zero-configuration defaults for cqrs, events, messaging, queue, scheduler, websocket, cache, health, metrics, openapi, sse and realtime-backplane above the emitted development-plugin anchor. In a functional project, events, cqrs, health and metrics are registered with the generated-artifact barrel they consume, for example EventsPlugin({ handlers: EVENT_HANDLERS }), and an empty barrel is created when absent, so a handler a later setu generate writes is live. Class-based projects keep the bare call; their handlers arrive through the ingress barrel. Other shapes receive registration guidance. Starter compositions receive their named option arm and a link to the starter README; configure that arm rather than registering the same plugin twice.

Class-based generate job requires queue; functional projects keep the standalone job recipe. Generated guards use requirePermission from auth-plugin. The permission is evaluated by the authorization service AuthPlugin registers only when given an rbac option, which is why setu add auth prints AuthPlugin({ jwt: { … }, rbac: { roles: {} } }); without it, every authenticated request to a guarded route answers 501 and the handler never runs.

new --devtool, app --devtool and devtool enable write the CLI-managed src/devtool/diagnostics.ts policy. add refreshes it only when it already exists. Recognized plugin calls receive source options only when the factory receives its devtool argument; production defaults remain disabled. Discovery copies artifact names and env-example key names, never values. Empty job/operation/role/permission maps approve no observations. This module is regenerated: application-specific policies belong in application-owned configuration.

Dependency additions preserve existing key order. Deno projects update imports only, including full-stack projects with a frontend package.json; Node, Bun and Workers update both manifests.

Managed barrels

Each family’s barrel is managed: the CLI owns it and rewrites it on every generate, which is the one exemption from the refusal that protects an existing file. Two consequences:

  • A barrel admits a file only when that file actually exports every symbol the barrel will import. An artifact that does not is skipped and reported, never silently unwired.
  • Two artifacts that would claim the same DI token or the same METHOD /path are refused before anything is written, naming the conflict. DecoratorPlugin.registerService is first-wins on a token and the kernel’s route map overwrites on a duplicate, so the second artifact would otherwise be silently unreachable.

A barrel is rewritten only when its family is generated, so a file copied or hand-written into a seam directory stays unregistered until then. setu check reports that, and setu sync rewrites every managed barrel from its directory without generating anything.

Custom schematics

A schematic is a pure (names, options) => GeneratedFile[], which is what makes --dry-run exact rather than a prediction. Author your own under .setu-ts/schematics/<name>.ts and run it with:

setu generate custom my-schematic order-item

SchematicOptions carries the target project’s runtime, its detected plugins, and — for the schematics that render a barrel — the modules and artifacts already present.

Controllers and domain modules

Routes are declared in one kind of file, a controller: <name>.controller.ts. Its exports decide its shape:

  • a class controller exports a decorated <Pascal>Controller (class-based projects);
  • a function controller exports register<Pascal>Routes(router, services?). It also covers what a decorator cannot express: a wildcard, a proxy, or a route table built in a loop.

setu g controller writes the project’s default shape, and setu g route always writes a function controller.

One place for controllers and services

A project keeps its controllers and services in one place, never two:

LayoutControllers and servicessetu.config.ts registers
flat (new projects)src/controllers/, src/services/src/controllers/index.ts (and, class-based, src/services/index.ts)
modularsrc/modules/<name>/, one folder per featuresrc/modules/index.ts: registerModules(app.router, app.services), plus MODULES class-based

There is no root module, and no controllers/ folder is generated inside a module. Subfolders work in both layouts (src/controllers/admin/users.controller.ts, src/modules/billing/controllers/invoices.controller.ts). After moving a file, run setu sync: it rewrites the barrels and module indexes from where the files are, and in a class-based module re-points the declaration’s imports. Two controllers with the same base name would bind one symbol, so they are refused (setu.E016).

The first module

setu g module shipment
setu g module shipment --adopt-controllers

The first setu g module switches the project from flat to modular:

  • Nothing but empty barrels in the flat homes: it removes them and rewrites the registration lines of setu.config.ts.
  • Only the scaffold’s greeting example, untouched: it moves the example into the new module, as --adopt-controllers would. Formatting changes do not count as edits.
  • Any other controllers or services there: it refuses and lists them.
  • --adopt-controllers: it moves every file under src/controllers/ and src/services/ into the new module, keeping subfolders, and rewrites every relative import into or out of the moved files. The adopted controllers keep their URL paths. In a class-based project their classes join the module’s @Module declaration; function controllers join its index.ts.

Every move and edit is planned before anything is written and applied in one transaction. An import it cannot read exactly (a relative require, an import() of a computed path, a file that imports a flat barrel) refuses the whole switch. A config whose registration lines are not the generated ones is left alone, with the lines to change printed. setu check reports controllers or services left outside modules once modules exist (setu.E014), and imports in CLI-written files that no longer resolve (setu.E015).

A generated module

src/modules/orders/
├── index.ts                      # CLI-owned; exports registerOrdersModule(router, services?)
├── orders.controller.ts          # GET /orders, GET /orders/:id, POST /orders (201), PATCH /orders/:id
├── orders.service.ts             # an in-memory Map keyed by id; the JSDoc says to replace it with a repository
├── orders.module.ts              # class-based only: @Module({ controllers, providers })
├── orders.schemas.ts             # only when @setu-ts/validation-plugin is installed
├── orders.test.ts                # in-process contract test
└── orders.e2e.test.ts            # real-HTTP contract test (socket targets only)
src/modules/index.ts              # CLI-owned; registerModules(router, services?), and MODULES class-based

The module’s index.ts is CLI-owned and regenerated by every setu generate … --module orders and by setu sync. It exports register<Pascal>Module(router, services?), which registers the module’s function controllers: every controller in a functional module, and in a class-based module only the function controllers, because the class controllers are listed by the @Module declaration (which it re-exports). The generated service is an in-memory Map that the developer replaces with a repository, and a request for a missing id answers 404.

The managed class-based barrel also exports MODULE_CONTROLLERS and MODULE_SERVICES, deprecated, so a config written before @Module declarations keeps working.

Tests are emitted on the socket targets (Node, Deno, Bun) only; a Cloudflare Workers module carries none, and a Workers project’s test task permits an empty suite.

--module: place a file inside a feature

setu g service pricing --module invoices
setu g route summary --module invoices

--module <name> accepts controller, route, service, sse and schema, and <name> must be a module the project already has. The first four require it once the project has modules, and a flat project refuses it. Every other schematic is app-wide and refuses the flag, naming its top-level folder.

  • The file is written into src/modules/<module>/, and a controller is registered by regenerating the module’s index.ts (a function controller) or by adding it to the module’s declaration (a class controller). A module-scoped controller mounts at /<module>/<name> (an SSE controller at /<module>/sse/<name>); a static path such as GET /invoices/summary outranks GET /invoices/:id, so an id equal to summary is unreachable through /:id.
  • Class-based: a class controller or service is added to the controllers or providers array of <module>.module.ts, as the NestJS CLI does, when that array is a plain literal. Anything else is left unchanged and the line to add is printed. A module-scoped service is registered under the token <module>.<name>-service.

src/shared/ and the one rule

One rule covers the whole layout: a file that belongs to one feature goes in the feature; an app-wide file goes in its top-level folder. The app-wide kinds — middleware, plugin, health-indicator, metric, migration, guard, command-handler, query-handler, event-handler, job, ws-route — stay top-level, which is why they refuse --module.

src/shared/ is the home for code several modules use — helpers, request-scoped dependency access, shared types, and src/shared/schemas/ for shared shapes. It needs no wiring (no barrel lists it) and is not scaffolded as an empty folder; the module scanner leaves a directory that is not an admitted module alone, in both styles.

Where request and response shapes live

ShapeHome
a feature’s request/response shapessrc/modules/<name>/<name>.schemas.ts — one export per shape named by purpose (Create<P>Body, Update<P>Body, List<P>Query, <P>Params, <P>Response, <P>ListResponse), each a Zod value plus a same-named z.infer type; routes import it directly
shapes several modules use (pagination query, :id params, money, page envelopes)src/shared/schemas/
shapes several services share (integration events, cross-service contracts)a workspace library, setu generate library contracts
database rowsstay with the database schema — a row type is never an API shape: response schemas define what may leave the service
a consumer’s view of the APIthe generated SDK client, never the server’s schemas

setu generate schema <name> (which requires validation-plugin) writes src/shared/schemas/<name>.schemas.ts, or src/modules/<module>/<name>.schemas.ts with --module, for a shape a feature needs that the module schematic did not emit. It never edits an existing file: a second run is the ordinary overwrite refusal, and g schema invoices --module invoices is refused because g module invoices already wrote invoices.schemas.ts.

zod is declared by every template that installs validation-plugin, and setu add validation adds it on every runtime. In a project scaffolded earlier that has no zod, g module and g schema still write the files and print the install command for the detected runtime — npm install zod@^4, bun add zod@^4 or deno add npm:zod@^4 — rather than reporting success over an import that cannot resolve.

Generated tests

setu new emits test/support/server.ts (CLI-owned; boots the real createApp() on a free loopback port) and test/e2e/app.e2e.test.ts on every socket target; a Cloudflare Workers scaffold emits neither. setu g module adds <name>.test.ts and <name>.e2e.test.ts beside the module (and re-emits test/support/server.ts, so a project scaffolded earlier gains it).

  • <name>.test.ts (in-process) drives the module through createTestApp({ app: await createApp() }) and inject, asserting a contract the generated code and your code both keep: GET /<name> answers 200 with an items array, and a POST then GET reads the created record back. It boots the whole composition in-process, so a plugin that needs a live backend needs one in the test too; substitute it with createTestApp’s overrides or without.
  • <name>.e2e.test.ts (socket targets only) drives the module over real HTTP through startTestServer(): a valid POST answers 201, the created record reads back, an invalid body answers 400 (only with schemas), and /openapi.json lists the module (only when openapi-plugin is installed).

The tasks split the two: test runs every *.test.ts except *.e2e.test.ts; test:e2e runs only *.e2e.test.ts over real HTTP. The rest template’s sample is src/controllers/greeting.controller.ts and src/services/greeting.service.ts, where g controller and g service write in a new project; the first g module moves it into the module while it is untouched.

Form bodies validate with the same schema

The first-party validation service requires application/json or an application/*+json media type, ignoring case and parameters. An absent header or unsupported type (including text/plain) answers 415 before any body read. Send Content-Type: application/json on JSON requests, including empty body-validated requests. Routes reading ctx.request.json() directly are unchanged; replacement IValidationService implementations own their media-type handling. Cookie-authenticated routes still need csrfMiddleware or csrfFormMiddleware.

validateBody(Schema, { forms: true }) (or @ValidateBody(Schema, { forms: true })) also validates application/x-www-form-urlencoded and multipart/form-data bodies with the same schema it validates JSON with. Off by default: a cross-site HTML <form> reaches a route that accepts forms with no CORS preflight, so pair an opted-in route with form CSRF protection (csrfFormMiddleware from @setu-ts/session-plugin). Without the option a form body answers 415 “Unsupported Media Type”. Generated modules do not opt in. With the option, a server-rendered form post validates against the module’s <P>Body like a JSON client does. The limits to know: a field sent once is a single value and a repeated field becomes an array in wire order, with a name ending [] always an array (kept under the name as written); values are strings or the framework’s FormFile (not a web File, so z.instanceof(File) refuses it), and numbers arrive as strings — use z.coerce; the generated OpenAPI document still describes a validated body as JSON; a _csrf field from the session plugin reaches the schema, where a Zod object strips it by default and forbidNonWhitelisted refuses it; and an over-limit body answers 413 when RuntimePlugin({ maxBodyBytes }) is set. The full behaviour is documented in the @setu-ts/validation-plugin README.

A decorated handler receives only its decorated parameters. The plugin builds the argument list from parameter metadata alone and never passes the request context positionally, so a bare ctx parameter would arrive undefined — a 500 on every request. Declare Ctx() in @Params, the built-in decorator, when a handler needs the context itself; that is how the generated create method sets a real 201. Return a plain value and the plugin serializes it as JSON. See Decorators — The Authenticated Principal.

Monorepos

One repository, many deployable services. The root is an npm workspace by default (and under --runtime bun) and a Deno workspace under --runtime deno; each member is a full project with its own manifest and its own port.

setu new acme --workspace                          # the root, no member yet
setu new acme --workspace --port 4100              # base port for its members
setu new acme --workspace --transport redis        # how members talk to each other
setu new acme --workspace --runtime deno           # a Deno workspace instead of npm workspaces
cd acme
setu generate app orders --template microservice    # apps/orders
setu generate app billing --template microservice   # apps/billing
acme/
├── package.json            # "workspaces": ["apps/*"]   (deno.json on the Deno arm)
├── setu.workspace.json     # members, their ports, the transport, the runtime
├── scripts/dev.mjs         # the root `dev` runner: starts every member (dev.ts on Deno)
├── docker/                 # a Compose stack for the members
├── k8s/                    # members.yaml: a Deployment and Service per member
└── apps/
    ├── orders/
    └── billing/

The root declares its members by glob — "workspaces": ["apps/*"] in package.json, or "workspace": ["./apps/*"] in a Deno root — so adding one rewrites no manifest and npm install (or deno install) reaches every member. Framework pins live in each member’s own manifest rather than at the root, because plugin detection reads one directory’s manifest and never walks up — root-only pins would make every gated schematic refuse inside a member.

The run-all command is npm run dev (bun run dev, or deno task dev on a Deno root). Under --runtime deno the root is a deno.json and the members are Deno projects. Everything else about a workspace is the same. See Node and Bun workspaces.

Ports come from setu.workspace.json and are allocated above the highest one in use, so adding a name that sorts earlier cannot move a running service’s port. --transport picks how members reach each other (http, grpc, memory, redis, rabbitmq, nats, kafka); it is recorded once and every member added later inherits it, because services can only meet on a bus they share.

The discovery map

Every member carries a CLI-owned src/discovery/services.ts, regenerated for all members on each setu generate app:

/** The port this workspace member binds. */
export const SERVICE_PORT = 3000;

/** Every OTHER member of this workspace, by service name. */
export const SERVICE_ENDPOINTS = {
  // The generated file reads the BILLING_HOST environment variable first.
  billing: [{ host: '127.0.0.1', port: 3001 }],
};

Each sibling’s host falls back to 127.0.0.1, which is right only while every member runs on one machine, so it is overridable per service through <MEMBER>_HOST. The generated Compose stack sets those variables to the siblings’ service names, because inside a container loopback is the container itself.

main.ts binds SERVICE_PORT and setu.config.ts hands SERVICE_ENDPOINTS to ServiceDiscoveryPlugin({ provider: 'static', … }), so the port a member binds and the port its siblings dial are one datum rather than two that drift. The self-entry is excluded deliberately: a self-address invites a service to route a request back into its own process.

This is the local development topology. A deployed one comes from a real provider arm — consul, kubernetes or dns — not from this file. See @setu-ts/service-discovery-plugin.

Refusals

generate app outside a workspace names setu new <name> --workspace. A duplicate member names the directory it already has. A member’s runtime is the workspace’s, so generate app --runtime is refused when it disagrees with the root rather than when it is not Deno — members share one manifest and one lockfile, and the root is what installs them. --template full-stack accepts broker transports through its starter’s messaging and queue options. A transport with no queue arm leaves the queue in memory. Deno roots need nodeModulesDir: "auto" for the frontend build; the command plans that setting when absent and refuses a conflicting existing value.

new --workspace refuses --template rather than ignoring it, and refuses --runtime cloudflare-workers — each Worker is its own deploy unit with its own wrangler.toml, so several in one repository are several deployments rather than members of one.

Agent briefs (setu agents)

A coding agent has no training data for Setu-TS, so setu agents writes the brief it reads first: AGENTS.md in the project directory. The brief is rendered from the project’s real state — the installed @setu-ts/* packages, the style, the runtime and the scripts its manifest defines — so re-run it after setu add or a framework upgrade.

setu agents                      # write or refresh AGENTS.md
setu agents --check              # CI: exit 1 when a section is missing or stale, write nothing
setu agents --check --strict     # CI: also exit 1 when an agent card is not installed
setu agents --dry-run            # print what the real run would do
setu agents --dir ./services/orders

The framework section covers a warning not to guess its APIs from other frameworks, Rule 0 (read the package’s card before using a symbol this file and the project’s code do not show, and treat anything documented in neither as nonexistent), the project’s commands (each production, development and debug entry glossed), the schematics available for its plugin set, numbered rules, the project map, the installed packages with their CAPABILITIES keys and card paths, and the packages it could add but has not.

What it writes

Only the framework section is owned by the CLI. It sits between <!-- setu-agents:begin --> and <!-- setu-agents:end -->, and the line after begin reads “setu agents reads manifests and overwrites these markers’ contents.” Everything outside the markers is never read for content and never modified, so a developer-owned preamble or notes survive a rewrite byte for byte.

The file on diskWhat setu agents does
absentCreates it containing only the section.
no markersInserts the section at the top, then one blank line, then the existing bytes.
one begin, then one endReplaces the bytes between them; everything else is untouched.
any other arrangementExits 1 naming the file and the problem, and writes nothing.

An end before begin, two begins, or a begin with no end is refused rather than guessed at. A path that is not a regular file, or is over 1 MiB, is refused the same way.

Flags and exit codes

  • --check renders every brief it would write and compares it with the section on disk, writing nothing. Exit 0 when every file matches; exit 1 naming each file that is missing a section or whose section differs, including after a framework version change. Card presence is deliberately not part of the check, so the result does not depend on whether install has run — which is what makes setu agents --check a CI step on a checkout before installing. Add --strict to include card presence.
  • --strict is accepted only with --check; on its own it is a usage error (exit 2, --strict requires --check.). It adds card presence to the check: after the freshness comparison every card warning is reported as <AGENTS.md path>: <warning> and the command exits 1.
  • --dir <path> operates on that project or workspace instead of the working directory. A relative path is resolved against the working directory.
  • --dry-run prints what the real run would do — would create <path>, would update <path> or unchanged <path> — and writes nothing. A brief whose bytes already match is reported unchanged and is not rewritten by the real run either. Before writing anything, the real run and the dry run both check the changed briefs for writability: a target the invoking user cannot write refuses the whole run with WOULD REFUSE: <path> is not writable by you (dry run) or <path>: not writable by you; nothing was written, exits 1, and writes nothing. It is refused together with --check (exit 2).

A write prints one line per file — created, updated or unchanged, followed by the path; an unchanged brief is reported and left untouched. The command takes no positionals, and an extra one is a usage error (exit 2). Outside a Setu-TS project it exits 2 with <dir> holds no Setu-TS project manifest; run inside a project or pass --dir.; an unreadable manifest or a runtime marker whose metadata it cannot inspect exits 1 naming the file, because a brief built on a guessed runtime would name the wrong commands. Manifest content must be a regular file at most 1 MiB; the Node executable checks the opened handle and bounds the read. Runtime-marker presence uses metadata only: even a FIFO or device marker counts as present and is never opened for content.

Workspaces

setu add <plugin> --dir apps/<member> in a listed Node or Bun Setu workspace records its optional peers in the member’s package.json dependencies and, when the workspace root has a package.json, in the root’s as well (since 0.11.0). A peer already declared in dependencies, optionalDependencies or a non-optional peerDependencies is left as it is (a peer marked optional in peerDependenciesMeta installs nothing, so it is added to dependencies at its own spec), and one declared only in devDependencies is moved to dependencies with its pin unchanged, because an npm install --omit=dev would drop it; a peer the root’s overrides pins (a string value, or an object’s "." entry) is recorded at the override’s own spec, because npm refuses a direct dependency whose spec differs from its override. A $name reference, or an override that is conditional (a key with a version selector such as ioredis@5, or several rules for one package), is left to you and printed, since either spec can be refused. When a workspace member already pins the peer at a different spec, the root entry is still written at the CLI’s range, so the hoisted framework package gets a version inside its peer range, and a note names the member and its pin, since npm may install a second copy for that member. Every edited path is printed. A root package.json that is JSONC or not valid JSON is refused by name, and a workspace with no root package.json gets the member entry only. Run the install command from the root, where hoisted framework packages resolve their peers. Deno workspaces and standalone projects keep their existing behavior. See Optional peers in a workspace for placement, peer ranges and conflicting pins.

In a workspace, setu agents may be run from the root or from any member directory: it finds the workspace root and writes the root’s AGENTS.md and every member’s apps/<name>/AGENTS.md. --check checks all of them. The root brief covers the workspace commands, a member table (name, port, dependsOn, devtool port), the transport and its URL variable, and tells an agent to read the member’s own AGENTS.md before working in it. Each member’s brief is the standalone brief for that member plus a pointer at the root file for workspace layout, ports and transport.

Writing a workspace on POSIX, from its root or a member, checks every target’s directory chain up to /: each directory must belong to the invoking user or root and must not be a symlink, the same ownership rule as git’s safe.directory. Permission bits are not checked, so a directory you own but others can write is trusted. --check writes nothing and skips the check; --dry-run applies it. A failure exits 1, names the directory and reason, writes nothing, and names a remedy for that reason: replace the link with the directory it points at, or run setu agents from its real path (with the real path in parentheses when it can be resolved) for a symlink, fix the workspace layout so every member path is a directory for a non-directory, and run setu agents in a workspace you own for a directory owned by another user or one whose ownership cannot be checked. It never falls back to standalone. This check is skipped when ownership metadata or the invoking uid is unavailable (including Windows); it does not protect against another process running as the same user. Missing directories pass ownership validation only when their nearest existing ancestor and its chain pass; a missing member still exits 1 with <member>: not a project, writing nothing. Standalone projects are unchecked.

A workspace member reached through a symlink is refused from inside the link too. When the shell’s logical working directory (PWD) is absolute and resolves to the same place as the physical working directory, setu agents walks the logical ancestors; a manifest listing that logical path as a member refuses in every mode, writes nothing, and says:

<logical target> is a symlinked member of the workspace at <root>; Setu will not write through links
inside a workspace. Replace the link with the directory it points at, then run setu agents from <root>.

The logical path is used only to refuse; it never authorizes a write.

Where cards come from

Every published library package ships a hand-written AGENT_CARD.md in its npm tarball, so a normal install delivers one to node_modules/@setu-ts/<pkg>/AGENT_CARD.md. That path is what the brief names for each installed package. In a workspace member it is the workspace root’s copy — ../../node_modules/@setu-ts/<pkg>/AGENT_CARD.md — because every workspace toolchain the CLI generates installs at the root.

After writing, the command checks each card it named and prints one advisory line per missing one: card not found: node_modules/@setu-ts/<pkg>/AGENT_CARD.md — run install (prefixed with ../../ in a workspace member). The exit status is unchanged by a plain run; --check --strict turns each of these lines into a failure prefixed with the brief’s path. When the project has no node_modules directory at all and the manifest asks for Yarn Plug’n’Play or a Deno project without nodeModulesDir, the warning names the setting instead — cards require node_modules — set nodeLinker: node-modules and run install, or cards require node_modules — set "nodeModulesDir": "auto" and run install. Cards are never copied into the project; the fix is always to install.

A CLAUDE.md can include the same brief with @AGENTS.md rather than keeping a second copy.

Scaffolding and setu add

setu new --agents runs the same writer on the new project after scaffolding (on a workspace, on the new root), so a project can start with a brief. It is opt-in: setu new writes no brief by default, and neither setu add nor setu generate app runs the command, because re-running it silently would edit a file the developer may not have staged. After a successful setu add, the CLI prints one reminder when the project already has a sectioned brief:

Run `npx @setu-ts/cli@<version> agents` to update AGENTS.md.

The command named is the project’s pinned CLI invocation for its runtime: on Bun it is bunx @setu-ts/cli@<version>, and on Deno it is deno run -A npm:@setu-ts/cli@<version>.

Wiring check (setu check and setu sync)

Code in a seam directory that nothing registers compiles, lints and never runs. tsc cannot see it, and a test sees it only for the routes it requests. setu check is the read-only gate for that:

setu check                       # this project, or every member from a workspace root
setu check --strict              # warnings fail too
setu check --dir apps/orders
setu sync                        # rewrite every CLI-managed barrel from its directory
setu sync --dry-run              # print the barrels it would write

setu check reads the project’s files and writes nothing. It does not load or start the application, so it needs no database or broker. Wiring the DI container validates at boot (ContainerValidationError) stays where it is, in DiPlugin.

It exits 0 with no errors (warnings print but pass), 1 with any error, and 2 on a usage error. Each finding prints with its id, followed by how to fix it, and a summary line ends the run:

error setu.E003: src/controllers/index.ts has barrel drift: unlisted src/controllers/admin.controller.ts; stale (none).
  Run `setu sync` to rewrite src/controllers/index.ts from src/controllers.
setu check: 1 error(s), 0 warning(s), 0 silenced in /work/shop.

From a workspace root it checks every apps/<member>, prefixing each line with the member’s name, and exits with the worst result. A member that is a symbolic link, or that is reached through a linked apps/ directory, is refused by name and fails the run, so neither command reaches files outside the workspace through it. Inside a project, sync also refuses to write through a link; check reads through one. A project with no seam directories (a full-stack project, or one that has never generated anything) prints No CLI-owned seams in <dir>; nothing to check. and exits 0.

IdSeverityFinding
setu.E001errorAn artifact does not export what its barrel imports, so it is skipped
setu.E002errorAn artifact’s file name is not a kebab-case identifier, so it is skipped
setu.E003errorA barrel does not list an artifact in its directory, or lists one that is not there
setu.E004errorA barrel is not a regular file, is over 1 MiB, or cannot be classified
setu.E005errorAn artifact is registered both by its barrel and by hand in setu.config.ts
setu.E006errorsetu.config.ts does not reference a barrel on disk
setu.E007errorA feature module is missing its activation export or @Module declaration
setu.E008errorA feature module’s directory name is not a kebab-case identifier
setu.E009errorA feature module is not activated in src/modules/index.ts
setu.E010errorsrc/routes/ still holds files; it was merged into src/controllers/, so nothing imports them
setu.E011error.setu-ts/check.json is invalid
setu.E012errorsetu.config.ts exists but cannot be read: a directory, a special file, over 1 MiB, or no permission
setu.E013errorAn artifact in a seam directory cannot be read (a special file, over 1 MiB, or no permission), so its wiring is unknown
setu.W001warningsetu.config.ts uses a construct the check cannot classify, so a barrel’s wiring is unknown
setu.W002warningThere is no setu.config.ts, so nothing can be checked for wiring
setu.W003warningsetu.config.ts calls registerGeneratedRoutes(app.router) without app.services
setu.W004warningA .setu-ts/check.json entry matched no finding

Barrels are compared as sets of names, read from their import and export declarations, so a barrel your formatter rewrote, reordered or re-quoted still passes. Ids are never reused: a retired check keeps its number.

setu sync rewrites the managed barrels — each seam family’s index.ts, src/modules/index.ts, and each functional module’s index.ts — with the same renderer setu generate uses, so a synced barrel is byte-identical to the one the next generate would write. It writes a barrel only when it exists or its family has an artifact, and never touches setu.config.ts, a manifest or an artifact. It cannot fix a finding that is not a barrel (a skipped artifact, an unwired config, an inactive module), so afterwards it prints whatever remains and exits 1 if an error does. A barrel whose family holds an artifact it cannot read (setu.E013) is left as it is rather than rewritten without that artifact’s import.

Run setu check on code you have not reviewed; do not run setu sync or setu generate on it. check executes nothing from the project and its parsers are linear in the size of what they read, so it is safe as a CI step on a pull request from anyone — with one condition. On a pull request you have not reviewed, the project’s own check script, and the CLI version its lockfile resolves, are the author’s to change, and so is its .npmrc, which npm reads from the project it runs in and which can point @setu-ts at a registry the author controls. Run a CLI your CI pins, from a working directory outside the checkout and before the checkout’s dependencies are installed, pass the checkout with --dir, and use --strict so a configuration the check cannot classify fails rather than warns:

cd "$RUNNER_TEMP" && npx --yes @setu-ts/cli@<version> check --strict --dir "$CHECKOUT"

RUNNER_TEMP is any directory outside the checkout; CHECKOUT is the checkout’s absolute path.

sync and generate — including with --dry-run — format what they plan with the project’s own Prettier configuration, and loading that configuration runs the project’s prettier.config.* and any plugin it names.

Silencing a finding

A finding you accept can be silenced in .setu-ts/check.json, beside .setu-ts/schematics/:

{
  "silence": [
    "setu.W001",
    { "id": "setu.E003", "path": "src/controllers/index.ts" }
  ]
}

A bare id silences every instance. The object form silences that id only for one project-relative path: the artifact for E001, E002 and E005, the barrel for E003, E004 and E006, and src/modules/<name> for E009. Silenced findings do not print or fail the run; the summary counts them. If the file is invalid — bad JSON, a key other than silence, an unknown id, a malformed path — the run reports setu.E011 and silences nothing, so a typo cannot switch the gate off. E011 and W004 cannot be silenced. In a workspace, each member reads its own file.

Generated projects run it

A scaffolded project carries the CLI as a pinned development dependency and runs setu check from its check script, after the type check:

Targetcheck
Node, Bun, Workerstsc --noEmit && setu check (@setu-ts/cli in devDependencies)
Node/Bun full-stackreact-router build && tsc --noEmit && setu check
Workers with a buildnpm run build && tsc --noEmit && setu check
Denodeno check main.ts setu.config.ts && deno run -A npm:@setu-ts/cli@^<version> check

Deno full-stack prefixes its check task with deno task build && . The generated start:dev, start:debug and start scripts do not run the wiring gate, so the edit loop is never blocked. The CLI generates no CI workflow; in your own, run npm run check (or deno task check). A project scaffolded before this change gets the script from the upgrade guide.

check and sync are built-in verbs, so they take precedence over a plugin command of the same name.

Production build (setu build)

setu build [--dir <path>] [--dry-run] checks CLI wiring, then bundles a Node service into dist/main.js with source maps. It also emits dist/src/migrations/run.js when the source runner exists. A workspace root refuses with exit 2: use setu build --dir apps/orders. Bun and Deno run source natively; Workers uses Wrangler. The command never boots the application.

Generated Node build runs tsc --noEmit && setu build; full-stack prefixes react-router build. npm start runs node --enable-source-maps dist/main.js after building. start:dev runs tsx watch main.ts, or tsx dev.ts for full-stack. start:debug runs node --inspect --import tsx main.ts (full-stack uses dev.ts). The CLI command itself does not run TypeScript: use the project’s build script for type-checking.

build type-checks DI tokens; graph validation happens at boot, so run npm test before building an image.

Workspace libraries are inlined; npm packages, including optional peers and libraries’ own dependencies, remain external. Runtime imports declared only in the importing project’s or library’s devDependencies (a package also listed under peerDependencies included, since npm ci --omit=dev omits it) fail with the package and importing file named: move them to dependencies. --dry-run checks wiring and prints entries, output paths and externals without bundling or writing. The bundle is checked before dist/ is replaced, so a refused build, or one whose bundle fails, keeps the previous output; a failure while writing the new files (a full disk, for example) can leave dist/ incomplete, and rebuilding restores it. A symlinked dist directory is refused, and every CLI command refuses a manifest that is itself a symbolic link instead of reading through it; that refusal relies on O_NOFOLLOW, so on Windows (which has none) a linked manifest is followed, as the CLI’s writer has always been. Build failures exit 1; unsupported scope/runtime exits 2. See deployment and upgrading for images and scripts.

Plugin-contributed commands

A plugin can register its own verb under CAPABILITIES.CLI_COMMAND. Discovery imports the project’s createApp() and starts it with no port — the kernel skips listen without one, but init and bootstrap hooks do run, so a database plugin connects and teardown is unconditional:

setu commands          # list what this project's plugins provide

Built-in verbs match first and never boot the project. Two plugins claiming one command name is refused rather than resolved by load order.

Exit codes

CodeMeaning
0Success, including --help and --version.
1Runtime error: a gated schematic’s plugin is absent, a target file exists, a write failed, the application failed to load or start, or a handler threw.
2Usage error: unknown command or schematic, missing argument, unknown --runtime, an option the command does not recognize, or a name that cannot form an identifier (empty after normalization, or digit-leading).
130Interrupted or cancelled; built-in writes are rolled back, or plugin application shutdown was attempted. Incomplete recovery is reported.

Rollback covers SIGINT (Ctrl+C) and caught errors only. SIGTERM, SIGKILL (kill -9) and power loss end the command without rollback. Re-running setu adopt converges; an interrupted new or generate can leave the files it wrote, which the next run lists in its overwrite refusal.

SIGINT before a plugin command boots prevents it from running. During a handler, the CLI stops awaiting it and runs the application’s shutdown hooks before exiting. Handlers must release their resources through those hooks; their external side effects are not rolled back. An interruption during application startup is observed when startup settles, so shutdown does not race startup.

Next Steps