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

deno install -g -A --min-dep-age 0 -n setu jsr:@setu-ts/cli@^0.6.0/main

Install it with an explicit binary name (-n setu): Deno’s default inference would name the executable after the package, so you would be typing cli new my-app.

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 node                 # deno | node | bun | cloudflare-workers
setu new my-app --template class-based         # decorators and a DI container
setu new my-app --dry-run                      # print the plan, write nothing

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 a decorated controller and an injected service already written
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.

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. The default is functional: no DecoratorPlugin, no DiPlugin, ctx-first handlers, plain exported functions for services. --template class-based is the opt-in, and it always brings both plugins together.

You wantScaffold withYou get
Neithersetu new appThe runtime plugin alone. g route, g middleware, g plugin, g service, g module and g job all 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 class-basedDecoratorPlugin + DiPlugin, decorated controllers, @Injectable services, and class module barrels.

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. That is also why g controller is refused in a functional project: its emitted source imports @setu-ts/decorator-plugin, so an ungated one could not resolve its own import. The refusal names setu generate route, which registers handlers on the router API and needs no decorators.

The independent --di flag was removed. It is refused with a message pointing at --template class-based; 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
deno (default)main.tsapp.start()deno.jsondeno task start
nodemain.tsapp.start()package.json + .npmrc + tsconfig.jsonnpm start (tsx)
bunmain.tsapp.start()package.json + .npmrc + tsconfig.jsonbun run main.ts
cloudflare-workerssrc/index.ts fetch exportdeno.json + package.json + .npmrc + wrangler.tomlnpx wrangler dev

The Node target runs TypeScript through tsx, not through type stripping. 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 starts with tsx 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 resolves neither jsr: specifiers nor a Deno import map, so the project emits package.json (npm-compat @jsr/… dependencies, wrangler pinned) plus .npmrc, and npm install && npx wrangler dev works as printed. The Deno target does not get one: it resolves through the import map, and a package.json switches Deno to node_modules resolution.

--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
deno task start

--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.

Three refusals are worth knowing, because each names why silence would be a lie: on Cloudflare Workers (the runtime swap has already removed both wirings), on starter-composed templates like full-stack (the factory renders the whole plugin list, so a rewrite would be dropped), and on templates registering no messaging at all (rest, class-based, no template) — where the honest answer is “use --template microservice”. memory is accepted everywhere the flags are and rewrites nothing, because stating the default is not a mistake.

Interactive scaffolding

At an interactive terminal, setu new asks for the choices it already accepts as flags — runtime and template on a standalone project, runtime and transport on a workspace. 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, class-based), the starter-composed full-stack, and Cloudflare Workers are all skipped, so a microservice answer gets four questions and the default gets two. 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.

Eleven of the fourteen schematics land 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
moduledecorator-pluginsrc/modules/DecoratorPlugin({ controllers, services })
controllersrc/controllers/DecoratorPlugin({ controllers }) (class mode), or a register…Routes(router) call
servicesrc/services/DecoratorPlugin({ services }), when installed
routesrc/controllers/A register…Routes(router) call in createApp()
middlewaresrc/middleware/The global middleware pipeline
pluginsrc/plugins/The plugins: [...] array
health-indicatorhealth-pluginsrc/health/HealthPlugin({ indicators })GET /health
metricmetrics-pluginsrc/metrics/MetricsPlugin({ customMetrics })/metrics
command-handlercqrs-pluginsrc/cqrs/CqrsPlugin({ commandHandlers })
query-handlercqrs-pluginsrc/cqrs/CqrsPlugin({ queryHandlers })
event-handlerevents-pluginsrc/events/EventsPlugin({ handlers })
guardauth-pluginsrc/guards/Nothing — attach it per route
jobsrc/jobs/Nothing — transport-agnostic by design
migrationdatabase-pluginsrc/migrations/Nothing — no framework code reads migrations

The last three 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). A job is transport-agnostic: registering it as a queue processor would start a worker loop polling for a name nothing enqueues, and scheduling it needs a cron expression the artifact does not carry. Nothing in the framework reads migration files at all.

A schematic gated on a plugin refuses rather than emitting source whose own import cannot resolve, and names the decorator-free alternative when there is one:

The "controller" schematic requires @setu-ts/decorator-plugin, which is not installed in /path/to/app.
Install it, then run this command again.
Or run `setu generate route user-profile` — it registers handlers on the router API, so it needs no decorators.

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.

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.

Domain modules

setu generate module is the aggregate schematic, and its output follows the project’s style.

setu g module orders

In a functional project it writes a plain service, its test, a module barrel, and a route module the managed routes barrel already registers — so the module serves requests with no edit to setu.config.ts:

src/modules/orders/
├── index.ts                      # the module's own barrel
├── orders.service.ts             # export function listOrders()
└── orders.service.test.ts        # describe/it + expect
src/controllers/
├── index.ts                      # managed: registerGeneratedRoutes(app.router)
└── orders.routes.ts              # registerOrdersRoutes — GET / and POST / (201)

In a class-based project it writes the decorated aggregate and rewrites the module barrel, which the scaffolded setu.config.ts already spreads into DecoratorPlugin:

src/modules/
├── index.ts                      # managed: MODULE_CONTROLLERS, MODULE_SERVICES
└── orders/
    ├── index.ts                  # the module's own barrel
    ├── orders.controller.ts      # @Controller, injecting the service by token
    ├── orders.service.ts         # @Injectable
    └── orders.service.test.ts    # describe/it + expect

The schematic is ungated: it works in every project shape, including one scaffolded with no template at all. Which of the two shapes you get is decided by whether @setu-ts/decorator-plugin is installed.

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 a Deno workspace by default and an npm workspace under --runtime node|bun; 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 node           # npm workspaces instead of a Deno one
cd acme
setu generate app orders --template microservice    # apps/orders
setu generate app billing --template microservice   # apps/billing
acme/
├── deno.json               # "workspace": ["./apps/*"]   (package.json on the npm arm)
├── setu.workspace.json     # members, their ports, the transport, the runtime
└── apps/
    ├── orders/
    └── billing/

The root declares its members by glob, so adding one rewrites no manifest and deno task --recursive 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.

Under --runtime node|bun the root is a package.json carrying "workspaces": ["apps/*"] instead, the members are npm packages, and the install and run-all commands become npm install / bun install and npm run dev. 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 = {
  'billing': [{ host: '127.0.0.1', port: 3001 }],
};

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 is refused only on a broker transport, because that template composes its whole plugin set through a starter factory, so a transport’s contribution would be silently dropped; on http and memory it is allowed, and the root gains nodeModulesDir when such a member arrives.

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.

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).

Next Steps