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
| Template | Plugins it registers |
|---|---|
| (none) | RuntimePlugin only |
rest | Runtime, Config, Logger, Validation, HttpSecurity, Health, Metrics, OpenAPI + the errorHandler() middleware |
microservice | The REST set + Messaging, Queue, Resilience, Telemetry, CQRS, Events, ServiceDiscovery |
class-based | The REST set + DecoratorPlugin and DiPlugin, with decorated HTTP and non-HTTP ingress seams plus an injected service |
full-stack | Composed 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 want | Scaffold with | You get |
|---|---|---|
| Neither | setu new app | The runtime plugin alone. g route, g controller, g middleware, g plugin, g service, g module and g job work. |
| Functional | setu new app --template rest | The REST plugin set. g module writes a plain service and a registered route with GET and POST handlers. |
| Class-based | setu new app --template rest --style class-based | DecoratorPlugin + 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
--runtime | Entry | Manifests | Start command |
|---|---|---|---|
node (default) | main.ts → app.start() | package.json + tsconfig.json + ESLint + Prettier | npm start (compiled) |
deno | main.ts → app.start() | deno.json | deno task start |
bun | main.ts → app.start() | package.json + tsconfig.json + ESLint + Prettier | bun run start |
cloudflare-workers | src/index.ts fetch export | deno.json + package.json + tsconfig.json + wrangler.toml | npm 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 tobun run main.tsandbun test, and replace thetsxand@types/nodedev dependencies with@types/bun. Afull-stackproject builds the frontend before production, keepsstart: bun run main.ts, prefixes tests withreact-router build &&and usesstart: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 thedeno.jsonthatsetu new --runtime denoemits. Adeno.jsonadded beside a keptpackage.jsonstill serves, butsetu generatecheckspackage.jsonfirst, so it keeps reading the project as Node and emits the Nodenode:testharness rather than the Deno one. full-stack→ Deno is the exception. Its Deno form keeps apackage.json, because the Vite build reads it, so do not delete it: add thedeno.jsona Deno full-stack scaffold emits, then remove thestartandtestscripts, the@setu-ts/*dependencies, and thetsxand@types/nodedev dependencies frompackage.json, keepingbuild. Removingstartis what makessetu generateread the project as a Deno one.- Deno → Node or Bun: the reverse — remove
deno.jsonand 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:
| Schematic | Requires | Emits into | Reaches |
|---|---|---|---|
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 --module | A register…Routes(router) call in createApp(), or the module’s index.ts (functional mode) |
schema | validation-plugin | src/shared/schemas/<name>.schemas.ts; src/modules/<module>/ with --module | Nothing — the feature that uses it imports it |
middleware | — | src/middleware/ | The global middleware pipeline |
plugin | — | src/plugins/ | The plugins: [...] array |
health-indicator | health-plugin | src/health/ | HealthPlugin({ indicators }) → GET /health |
metric | metrics-plugin | src/metrics/ | MetricsPlugin({ customMetrics }) → /metrics |
command-handler | cqrs-plugin | src/cqrs/<name>.command-handler.ts; class-based: src/ingress/<name>-command-handler.ingress.ts | CqrsPlugin({ commandHandlers }), or DecoratorPlugin({ ingress }) in class mode |
query-handler | cqrs-plugin | src/cqrs/<name>.query-handler.ts; class-based: src/ingress/<name>-query-handler.ingress.ts | CqrsPlugin({ queryHandlers }), or DecoratorPlugin({ ingress }) in class mode |
event-handler | events-plugin | src/events/<name>.event-handler.ts; class-based: src/ingress/<name>-event-handler.ingress.ts | EventsPlugin({ handlers }), or DecoratorPlugin({ ingress }) in class mode |
guard | auth-plugin | src/guards/ | Nothing — attach it per route |
job | queue-plugin (class mode or queue-owning starter) | src/jobs/<name>.job.ts; class-based: src/ingress/<name>-job.ingress.ts | Functional + queue: QueuePlugin({ processors: [...QUEUE_PROCESSORS] }); standalone without queue; class-based: DecoratorPlugin({ ingress }) |
migration | database-plugin, common | src/migrations/ | Nothing — no framework code reads migrations |
ws-route | websocket-plugin | src/plugins/<name>.plugin.ts; class-based: src/ingress/<name>-ws-route.ingress.ts | The plugins: [...] array, or DecoratorPlugin({ ingress }) in class mode |
sse | sse-plugin | src/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 /pathare refused before anything is written, naming the conflict.DecoratorPlugin.registerServiceis 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:
| Layout | Controllers and services | setu.config.ts registers |
|---|---|---|
| flat (new projects) | src/controllers/, src/services/ | src/controllers/index.ts (and, class-based, src/services/index.ts) |
| modular | src/modules/<name>/, one folder per feature | src/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-controllerswould. Formatting changes do not count as edits. - Any other controllers or services there: it refuses and lists them.
--adopt-controllers: it moves every file undersrc/controllers/andsrc/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@Moduledeclaration; function controllers join itsindex.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’sindex.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 asGET /invoices/summaryoutranksGET /invoices/:id, so anidequal tosummaryis unreachable through/:id. - Class-based: a class controller or service is added to the
controllersorprovidersarray 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
| Shape | Home |
|---|---|
| a feature’s request/response shapes | src/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 rows | stay 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 API | the 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 throughcreateTestApp({ app: await createApp() })andinject, asserting a contract the generated code and your code both keep:GET /<name>answers 200 with anitemsarray, and aPOSTthenGETreads 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 withcreateTestApp’soverridesorwithout.<name>.e2e.test.ts(socket targets only) drives the module over real HTTP throughstartTestServer(): a validPOSTanswers 201, the created record reads back, an invalid body answers 400 (only with schemas), and/openapi.jsonlists the module (only whenopenapi-pluginis 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 disk | What setu agents does |
|---|---|
| absent | Creates it containing only the section. |
| no markers | Inserts the section at the top, then one blank line, then the existing bytes. |
one begin, then one end | Replaces the bytes between them; everything else is untouched. |
| any other arrangement | Exits 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
--checkrenders every brief it would write and compares it with the section on disk, writing nothing. Exit0when every file matches; exit1naming 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 whetherinstallhas run — which is what makessetu agents --checka CI step on a checkout before installing. Add--strictto include card presence.--strictis accepted only with--check; on its own it is a usage error (exit2,--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 exits1.--dir <path>operates on that project or workspace instead of the working directory. A relative path is resolved against the working directory.--dry-runprints what the real run would do —would create <path>,would update <path>orunchanged <path>— and writes nothing. A brief whose bytes already match is reportedunchangedand 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 withWOULD REFUSE: <path> is not writable by you(dry run) or<path>: not writable by you; nothing was written, exits1, and writes nothing. It is refused together with--check(exit2).
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.
| Id | Severity | Finding |
|---|---|---|
setu.E001 | error | An artifact does not export what its barrel imports, so it is skipped |
setu.E002 | error | An artifact’s file name is not a kebab-case identifier, so it is skipped |
setu.E003 | error | A barrel does not list an artifact in its directory, or lists one that is not there |
setu.E004 | error | A barrel is not a regular file, is over 1 MiB, or cannot be classified |
setu.E005 | error | An artifact is registered both by its barrel and by hand in setu.config.ts |
setu.E006 | error | setu.config.ts does not reference a barrel on disk |
setu.E007 | error | A feature module is missing its activation export or @Module declaration |
setu.E008 | error | A feature module’s directory name is not a kebab-case identifier |
setu.E009 | error | A feature module is not activated in src/modules/index.ts |
setu.E010 | error | src/routes/ still holds files; it was merged into src/controllers/, so nothing imports them |
setu.E011 | error | .setu-ts/check.json is invalid |
setu.E012 | error | setu.config.ts exists but cannot be read: a directory, a special file, over 1 MiB, or no permission |
setu.E013 | error | An artifact in a seam directory cannot be read (a special file, over 1 MiB, or no permission), so its wiring is unknown |
setu.W001 | warning | setu.config.ts uses a construct the check cannot classify, so a barrel’s wiring is unknown |
setu.W002 | warning | There is no setu.config.ts, so nothing can be checked for wiring |
setu.W003 | warning | setu.config.ts calls registerGeneratedRoutes(app.router) without app.services |
setu.W004 | warning | A .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:
| Target | check |
|---|---|
| Node, Bun, Workers | tsc --noEmit && setu check (@setu-ts/cli in devDependencies) |
| Node/Bun full-stack | react-router build && tsc --noEmit && setu check |
| Workers with a build | npm run build && tsc --noEmit && setu check |
| Deno | deno 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
| Code | Meaning |
|---|---|
0 | Success, including --help and --version. |
1 | Runtime 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. |
2 | Usage 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). |
130 | Interrupted 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
- Getting Started — the framework itself
- Decorators Guide — what the decorated schematics emit
- Plugin Catalog — every plugin a template can register
- Runtime Deployment — shipping what you scaffolded