@setu-ts/cli
Production builds
Run npm run build then npm start in a generated Node service.
setu build [--dir <member>] [--dry-run] checks wiring and bundles main.ts and an optional
migration runner into dist/ with source maps. Libraries are inlined; npm packages remain external. A
runtime devDependency import fails with the package and importing file named. Workspace roots
refuse: select apps/
Use start:dev for source development and start:debug for debugging. Full-stack uses dev.ts during development and builds its frontend before production. DI graph validation runs at boot: run npm test before building an image. Bun and Deno keep running source; Workers uses Wrangler. Native bun build and deno compile are optional custom packaging choices.
Standalone projects include Dockerfile and .dockerignore. Workspace images use the root context and —build-arg MEMBER=orders. Node installs production dependencies in a separate runtime stage; Bun uses —production and a hoisted-linker bunfig.toml so members resolve shared libraries. Deno runs a declared build task before caching source. See the upgrade guide for existing scripts, ignore files and images.
Projects using Swagger declare swagger-ui-dist through their template’s npm dependencies,
including the Deno import map. Node, Bun and Deno self-host the three /docs assets (Deno start
tasks grant read access); Workers templates select pinned CDN assets. Full-stack projects pass that
choice through the starter’s openapi option.
The setu command-line tool: project scaffolding and plugin-aware code generation for the Setu-TS
framework.
Installation
setu is a Node program (Node 22.23.4+, 24.22.1+ or 26.12.1+), published to npm with a bin. Run
it without an install, install it globally, or start through npm create:
npx @setu-ts/cli new my-app
npm install --global @setu-ts/cli # then: setu new my-app
npm create @setu-ts my-app # the same as setu new my-app
It loads a project’s TypeScript — setu.config.ts for plugin commands, a custom schematic — through
tsx, resolving the project’s own imports from its node_modules. A Deno project sets
nodeModulesDir: "auto" so deno install creates that directory; run it once before
setu <plugin-command>.
Scaffolding a project
setu new my-app --example rest-api downloads a runnable example from the GitHub tag matching this
CLI’s version and creates a standalone Node project. Run cd my-app && npm install, then
npm run smoke (the example’s proof, which exits), then npm start (which serves until stopped).
--dry-run lists paths without fetching; downloads all finish before writing. Examples keep their
own composition and refuse composition flags such as --template and --workspace; only
--runtime node is accepted. See
the example options and
walkthroughs. The template scaffolds below generate their own
project structure.
setu new my-app # Node, minimal (runtime plugin only)
setu new my-app --runtime deno # node (default) | deno | bun | cloudflare-workers — see below
setu new my-app --template rest # rest | microservice | class-based | full-stack
setu new my-app --template rest --style class-based # functional | class-based (rest, microservice)
setu new my-app --template rest --env-file config/.env.local
--runtime chooses the manifest and the start command, not the code. On deno, node and bun
the generated main.ts, setu.config.ts and src/ are byte-identical, and RuntimePlugin detects
the platform at startup, so a project can move between those three by swapping its manifest.
cloudflare-workers differs in its entry and factory. See
Runtime targets for the
steps.
Every project gets a setu.config.ts exporting createApp() — the one place its plugin list lives.
main.ts imports it to start the server, and setu imports it to find plugin commands, so the two
cannot disagree. The factory does not start the application.
The factory returns IKernelApplication (or Promise<IKernelApplication> for a starter), so it
works directly with createTestApp({ app: await createApp() }). Every Deno, Node, and Bun scaffold
includes test/app.test.ts, which starts this composition without a socket, checks /health (/
on the minimal host), and stops it. Workers needs the platform environment and emits no smoke test;
its test task permits an empty suite. For an existing project, setu add testing pins the testing
package in Deno’s import map or npm’s devDependencies.
On Deno, Node, and Bun, config-backed templates emit a gitignored .env and tracked .env.example,
and load the selected path through ConfigPlugin({ envFilePath }). Use --env-file <path> to
select another relative path. Cloudflare Workers use request bindings and therefore emit no dotenv
file or filesystem configuration; --env-file is refused there. Values needed while constructing a
plugin must come from this pre-construction environment source; ConfigPlugin cannot retroactively
configure a plugin already built.
| Template | Plugin set |
|---|---|
| (none) | RuntimePlugin only. |
rest | Runtime, Config, Logger, Validation, HttpSecurity, Health, Metrics, OpenApi + errorHandler(). |
microservice | rest plus Messaging, Queue, Resilience, Telemetry. |
class-based | Alias of rest --style class-based: rest plus decorators and DI, an @Injectable service, and a @Controller with a class-position @Inject list. |
full-stack | A React Router 8 SSR app: the full plugin set via createFullStackAppFromConfig, plus an app/ skeleton. |
--style class-based is accepted on rest and microservice and opts into decorators and
dependency injection together; --template class-based is the byte-identical alias of
--template rest --style class-based.
--template microservice --runtime cloudflare-workers is supported. The messaging and queue plugins
reach brokers over raw sockets, which Workers does not provide, so on that target they are swapped
for CloudflarePlugin, which serves both capabilities from Cloudflare Queues and a Durable Object.
The swap also contributes the queue module export the platform invokes, the reply-inbox class, and
the wrangler.toml stanzas both need. Service discovery is not part of it: the wiring selects the
'static' arm, which contacts no backend.
--template full-stack
The only template that composes through a starter rather than inline wiring — its plugin set is
twenty-two, and a generated file a human is meant to edit should not open with twenty-two imports
they did not choose. It emits an app/ tree with the routes -> features -> services -> models
layering, flatRoutes _app/_auth layout groups, the ~/* alias and the .server.ts
convention, plus the Vite build files.
It deliberately emits no lib/session.server.ts, lib/csrf.server.ts, lib/sse.server.ts,
lib/kv.server.ts or lib/service-logger.server.ts — those are the session, SSE, secrets and
logger capabilities, reached through the service registry the SSR plugin attaches to every request.
The generated createApp() is async, and emits no hello-world route (an exact / handler
outranks the SSR catch-all under the M70g specificity rule, so it would shadow the application’s own
index route).
It also shows the project’s two middleware layers side by side, which a new reader otherwise has no
way to tell apart. setu generate middleware writes kernel middleware, which runs for every
request the application serves — health probes and API routes as well as SSR pages. React Router’s
own route middleware export runs only for the SSR routes it is attached to and reads React
Router’s context. The scaffold ships a worked example of the second:
app/middleware/require-user.server.ts redirects a visitor who is not signed in to /login and
puts the signed-in user on the context for the loader, and /products attaches it. The generated
README.md carries a table comparing the two, and a kernel middleware generated into a project that
installs react-router-plugin points at the route-level alternative in its own JSDoc.
The frontend build runs on npm even when the server runs on Deno. Deno and Workers targets get a
standalone package.json for Vite and React Router; Node and Bun get those dev dependencies merged
into the manifest they already have.
| Runtime | Emits |
|---|---|
node (default) | package.json (@setu-ts/* from npm), tsconfig.json, ESLint and Prettier config, main.ts |
bun | the same npm manifests as node, run with bun |
deno | deno.json (npm:@setu-ts/* imports, nodeModulesDir: "auto", start task), main.ts |
cloudflare-workers | deno.json, package.json, wrangler.toml, src/index.ts exporting fetch — no listen |
No target emits an .npmrc or a JSR registry mapping: every target takes the framework from npm.
Every target also gets a README.md and a .gitignore.
Adopting an existing project
setu adopt --name orders --port 8101
Converts a generated single-service project into a workspace whose first member is that project. It
moves only the entries the CLI emits, so .git, CI configuration and the lockfile stay at the root;
records whether the project’s dependencies declare health and metrics support; moves the configured
dotenv pair when its path is a single segment; and rewrites main.ts to bind the port the workspace
allocated it. A multi-segment envFilePath is left in place and the step to move it by hand is
printed.
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.
Workspace operations
Since 0.11.0, a workspace root builds, tests and deploys every member from one place. The commands
read setu.workspace.json when they run, so a member added later is included without editing
anything.
setu workspace build # one image per member: <member>:<short commit>
setu workspace deploy --dry-run # every check, and the plan, with nothing changed
setu workspace deploy # build, ship, apply k8s/, wait for each rollout
A workspace created with 0.11.0 or later also has root build and deploy scripts (Deno: tasks)
and, on npm and Bun, a root test script. It is the package manager’s own recursion —
npm run test --workspaces --if-present or bun run --filter '*' test — so npm test runs every
member and library that has tests and fails if any fails. A Deno root has no test task: Deno’s
recursion includes the root, so such a task would call itself without end. Run
deno task --recursive test from the root instead. An older root keeps its scripts; run the
commands above, or add the entries yourself.
build runs docker build -f docker/Dockerfile --build-arg MEMBER=<member> from the root for each
member, always passing the optional NPMRC build secret. --tag replaces the default tag (the
short commit; <commit>-dirty-<UTC time> with uncommitted changes, so each deploy of new edits
rolls out; or build-<UTC timestamp> outside git). --registry <prefix> names the images
<prefix>/<member>:<tag>; build itself never pushes. --build-arg KEY=VALUE (repeatable) and
--network <name> are passed to every build — for a registry other than npm, pass
--build-arg NPM_CONFIG_REGISTRY=<url>, and keep its token in NPMRC, never in a build argument.
deploy decides everything before it changes anything:
- Secrets. A member’s variables are the names its committed
.env.exampledeclares, less those the generated manifest already sets (sibling hosts, the transport URL). Their values go ink8s/secrets/<member>/.env, read with the same parser as the app’s.env. Deploy refuses, naming every missing variable, and refuses a values file that git tracks. Each member with values gets a<member>-envSecret, applied withkubectl apply --server-side --force-conflictsfrom stdin (deploy owns that Secret, so it takes over keys an earlier hand-made one set) with base64data:— a client-side apply would store every value in plaintext in an annotation. Generated Deployments read it throughenvFrom; explicitenv:entries still win. Deploy writes each Secret’sresourceVersioninto its member’s pod-template annotation (setu-ts.dev/secret-version), so a changed Secret rolls the member out even when the tag and manifest are unchanged, and a deploy re-run after failing part-way still does. Afterwards deploy reads each member’s pod template back from the cluster and, when it does not carry the Secret’s current version (amembers.yamlfrom before the annotation, a hand-edited one, an earlier deploy that failed part-way), sets it withkubectl patch, which rolls the member out.--force-conflictstakes over keys another writer set in<member>-env; keys only that writer owns are left in place, so remove them by hand (or delete the Secret once) when moving an existing Secret to deploy. Compose follows a symbolic link atapps/<member>/.env, sosetu checkrefuses one. That is the only part of Compose it checks: a branch can also editdocker/compose.yamlto name any host file, so review a branch’sdocker/changes before runningdocker compose upon it. Values files,.env.exampleandk8s/members.yamlare read only as regular files of at most 1 MiB inside the workspace — never through a symbolic link — and a values file is refused when git cannot confirm it is uncommitted (a missinggitincluded; outside a repository there is nothing to commit). kubectl’s message for a failed Secret apply is never printed, because it can quote the values. - Where images go. The context is
--contextor the current one. Akind-<cluster>context loads each image into that cluster; any other context needs--registry, and the images are pushed there. - What is applied. The namespace (
--namespace, default the workspace directory name), the Secrets, thenk8s/members.yamlas it is on disk with${NAMESPACE},${WORKSPACE}and each member’simage:line filled in. A missing generated image line, an unknown placeholder, or amembers.yamlwritten before 0.11.0 withoutenvFromis refused, with the lines to add. - Waiting.
kubectl rollout statusper member,--timeoutin kubectl’s form (300s,5m; default120s). A rollout that does not finish names the member and the commands to inspect it.
Exit codes are the CLI’s own: 1 for a refusal or a failed tool, 2 for a usage error (including
deploy on a non-kind context without --registry). A tool that is not installed is named.
Wiring check
setu check # exit 1 when a seam artifact, barrel or config is not wired; writes nothing
setu check --strict # warnings fail too
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 and never loads or starts it. It reports an artifact its barrel does
not list (or a barrel listing one that is gone), a barrel setu.config.ts does not reference, an
artifact registered both by its barrel and by hand, an artifact or module the scan skips, and a
module nothing activates. Each finding has a stable id (setu.E003); errors exit 1, warnings
print and exit 0 unless --strict. Silence an accepted finding in .setu-ts/check.json, by id or
by id and path. From a workspace root it checks every member. Generated projects carry
@setu-ts/cli as a development dependency and run setu check from their check script. See
the CLI guide.
Agent briefs
setu agents # write or refresh this project's AGENTS.md
setu agents --check # report a missing or stale section and exit 1, writing nothing
setu agents --check --strict # also exit 1 when an installed package's agent card is missing
setu agents --dry-run # print what the real run would do
setu new my-app --agents # scaffold, then write the brief
setu agents writes a framework-owned section between <!-- setu-agents:begin --> and
<!-- setu-agents:end --> in AGENTS.md, regenerated from the project’s installed packages, style,
runtime and scripts; everything outside the markers is never touched. In a workspace it writes the
root file and every member’s apps/<name>/AGENTS.md, run from the root or from a member’s own
directory. The brief points each installed package at its AGENT_CARD.md, which every published
library package ships in its npm tarball, so a normal install delivers it. setu agents --check is
the CI step; it deliberately answers without an install, and --strict (valid only with --check)
adds card presence so a run whose cards were not delivered fails. A brief whose bytes already match
is reported unchanged and is not rewritten; --dry-run prints would create, would update or
unchanged, and both modes refuse an unwritable target before writing anything. After a successful
setu add the CLI prints a reminder to run agents through the project’s pinned CLI invocation
(for example npx @setu-ts/cli@<version> agents) when the project already has a sectioned brief.
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 chosen
for the reason — replacing a link with the directory it points at, fixing a member path that is not
a directory, or running in a workspace you own — so 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.
The full behavior is in the CLI guide.
Generating code
setu generate service user-profile
setu g service user-profile # `g` is an alias, `n` aliases `new`
| Schematic | Emits | Requires |
|---|---|---|
plugin | src/plugins/<name>.plugin.ts | — |
module | src/modules/<name>/ — <name>.controller.ts, service, CLI-owned index.ts, <name>.module.ts (class-based), schemas (with validation-plugin), tests | — |
controller | src/controllers/<name>.controller.ts; with modules, src/modules/<module>/<name>.controller.ts (--module required) | — (functional or class-based) |
service | src/services/<name>.service.ts; with modules, src/modules/<module>/<name>.service.ts (--module required) | — |
route | a function controller: src/controllers/<name>.controller.ts; with modules, src/modules/<module>/<name>.controller.ts (--module required) | — |
schema | src/shared/schemas/<name>.schemas.ts; src/modules/<module>/<name>.schemas.ts with --module | validation-plugin |
sse | src/controllers/<name>.controller.ts; with modules, src/modules/<module>/<name>.controller.ts (--module required); plus a React hook only when react-router-plugin and sdk are both installed | sse-plugin |
ws-route | src/plugins/<name>.plugin.ts; class-based: src/ingress/<name>-ws-route.ingress.ts | websocket-plugin |
middleware | src/middleware/<name>.middleware.ts | — |
job | src/jobs/<name>.job.ts; class-based: src/ingress/<name>-job.ingress.ts; functional + queue wires QueuePlugin({ processors }) | queue-plugin in class-based or queue-owning starter projects |
guard | src/guards/<name>.guard.ts — delegates to requirePermission | auth-plugin |
health-indicator | src/health/<name>.indicator.ts | health-plugin |
metric | src/metrics/<name>.metric.ts | metrics-plugin |
command-handler | src/cqrs/<name>.command-handler.ts; class-based: src/ingress/<name>-command-handler.ingress.ts | cqrs-plugin |
query-handler | src/cqrs/<name>.query-handler.ts; class-based: src/ingress/<name>-query-handler.ingress.ts | cqrs-plugin |
event-handler | src/events/<name>.event-handler.ts; class-based: src/ingress/<name>-event-handler.ingress.ts | events-plugin |
migration | src/migrations/<timestamp>-<name>.ts | database-plugin, common |
The name’s casing does not matter — user-profile, UserProfile, userProfile, and user_profile
all produce identical output.
A decorator barrel is a registration list. When setu.config.ts does not reference its export,
setu generate prints the exact line to add — inline DecoratorPlugin({ <key>: [...<EXPORT>] }),
or the decorators: { <key>: [...<EXPORT>] } arm of the starter that composes the application. A
mention in a comment or a string does not count as wired.
In a generated full-stack configuration, setu add events, setu add cqrs and setu add queue
install the package, create its managed barrel and wire that barrel into the starter’s events,
cqrs or queue arm. The starter already registers those plugins. Queue processors and a selected
--queue redis backend are merged in either add order. Existing properties, spreads, non-literal
arms and unfamiliar callback shapes are preserved; the CLI prints manual guidance when it cannot
safely edit them. 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.
Generating a route, controller or module checks React Router’s resolved page tree with the project’s
installed react-router routes --json. A kernel group that would take precedence over an existing
page is refused, naming the page file. For example, setu g route products conflicts with the
scaffold’s /products page. Use another name, or run setu g module api (with
--adopt-controllers when the project already has controllers) followed by
setu g route products --module api to serve /api/products. If the local route-tree command is
unavailable or takes over 15 seconds, generation continues with a notice naming the paths to check
by hand. This executes the project’s route configuration. setu check remains a file-reading wiring
gate: it does not execute React Router or detect a page added after a kernel route.
Plugin awareness
generate reads the target project’s deno.json imports (falling back to package.json
dependencies + devDependencies) to learn which @setu-ts packages are installed. It never
imports or boots your project. A schematic whose plugin is missing is refused, naming the package to
install, and setu generate --help lists only what is available here.
Options
| Option | Behavior |
|---|---|
--dry-run | Prints would create <path> per file and writes absolutely nothing. |
--dir <path> | Operate on this directory instead of the working directory. |
--runtime <target> | On new, the entry shape and manifest (default node); on generate, passed to the schematic, detected from the project when omitted. |
--example <app> | new only: download an offered example as a standalone Node project at the CLI release tag. Dry-run fetches nothing. Refuses template, workspace and composition overrides. |
--template <name> | new only: choose a scaffold composition. Omitted yields the functional minimal plugin set. |
--env-file <path> | new, generate app: choose the emitted dotenv path for ConfigPlugin-backed non-Workers templates. |
--broker <name> | new and add messaging: the standalone project’s message broker (memory, redis, rabbitmq, nats, kafka, pubsub, service-bus). Rewrites the template’s MessagingPlugin wiring, adds the connection variable to the dotenv pair, and emits docker/compose.yaml starting the broker. Refused wherever it would be a silent no-op: Cloudflare Workers and templates registering no messaging. Full-stack receives starter options through config reads; add preserves existing options, creates only missing Compose files, and leaves dotenv unchanged. |
--queue <name> | new and add queue: the job queue backend (memory, redis, rabbitmq). Same overlay as --broker, for the queue wiring. |
--redis-client <name> | new and add with a Redis arm: ioredis (default) or node-redis. node-redis projects depend on redis and wire each plugin through a generated src/node-redis.ts (nodeRedisClient(url) over fromNodeRedis). A workspace records it as redisClient; members and generate app follow the workspace and refuse the flag. Refused without a Redis arm and on Workers. |
--logger <name> | new, generate app: console or pino for templates with logging. Defaults to pino on Node, Deno and Bun, console on Cloudflare Workers. Adds pino at 10.x only when chosen (the same range in Deno’s import map). Pino on Workers, minimal hosts and workspace roots refuse the flag by name. --dry-run reports the choice. Since 0.11.0. |
--yes, -y | new only: take every default and ask nothing. A no-op when no prompter is present. |
--depends-on <name> | generate app: repeat for prerequisites; root dev waits for their /ready endpoints. |
--module <name> | generate: place a controller, route, service, sse or schema in the named feature module (required for the first four once the project has modules; refused while it has none); add view: place the views controller there. See Controllers and modules. |
--adopt-controllers | generate module, first module only: move the flat controllers and services into it, rewriting imports and keeping URLs. |
--config <path> | Load the app from this module instead of ./setu.config.ts. |
--help, -h | Prints usage. |
--version, -v | Prints the version. |
Every command refuses an option outside its own set — subcommand-aware for generate app,
generate library, workspace ports, workspace build and workspace deploy — with exit 2 and
nothing written, suggesting the nearest known name when one is within a small edit distance
(--templat suggests --template; --base-port suggests --port). A flag kept deliberately
recognized for a named refusal (--di and --depends-on on new, the transport flags and
--runtime on generate app) still reaches that specific message. Plugin-registered commands
accept only --dir and --config: a handler receives positionals only, so no plugin command can
read any other flag.
Exit codes: 0 success, 1 runtime error (plugin missing, file exists, write failed), 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 such as 2fa), and 130 interrupted or cancelled with built-in writes rolled back or
plugin application shutdown attempted. Incomplete recovery is reported; plugin handlers’ external
side effects are not rolled back.
A relative --dir is resolved against the working directory.
Devtool development
The devtool supports Deno and Node projects. setu new --devtool, setu generate app --devtool and
setu devtool enable opt a project into the
local diagnostics connector.
They emit a runtime-neutral main.dev.ts development entry the production graph never imports. Node
adds the non-watching dev script (tsx main.dev.ts) and pins @setu-ts/diagnostics-plugin in
devDependencies; its existing check script already covers the entry. Deno adds dev and widens
check. A workspace member also records a devtoolPort in setu.workspace.json, allocated without
colliding with any member’s application port (setu workspace ports --reallocate moves both
together).
setu new my-app --devtool # Node by default; connector probes from 4919
setu new acme --workspace && cd acme
setu generate app orders --devtool # member; the devtool port is allocated
setu devtool enable billing # a member that already exists
setu devtool enable # a standalone project that already exists
# Use --runtime deno when creating a Deno project or workspace.
After a Node manifest changes, run npm install; the CLI prints this next step and never runs it.
The devtool launcher runs the development entry with npm run dev on Node or deno task dev on
Deno, supplying the session pair; run without it, use start:dev as usual. The entry reads
SETU_DEVTOOL_SESSION_ID and SETU_DEVTOOL_SESSION_KEY through the runtime services and refuses to
start when either is absent or malformed. It never generates, writes or prints a pair. Older
CLI-owned entries remain recognized and are upgraded when enabled again or reallocated.
In a workspace the launcher also names SETU_DEVTOOL_MEMBER. The generated scripts/dev.mjs (Node)
or scripts/dev.ts (Deno) forwards all three values only to that member and blanks them for every
sibling. On Node it selects the member’s dev script and runs start:dev for the others; selected
children stay in the launcher’s process group for shutdown. All three variable names are published
CLI surface. Deno’s root dev task grants environment access to exactly those names.
setu devtool enable merges the member’s own manifest and preserves unrelated keys. On Node it
never writes the root workspace package.json; it refuses mixed framework pins, a missing tsx
devDependency, a different existing dev script, or diagnostics in production dependencies. On Deno
it preserves developer-owned tasks and import pins. Running it twice is a no-op. A stale
scripts/dev.mjs or scripts/dev.ts without the handoff is refused: delete it and re-run to get
the current runner. Bun and Workers, starter-composed templates, and known legacy factories are
refused with their remedies. A legacy factory must widen its signature and pass the composition
through to createApplication before enablement can proceed.
import type { IPlugin } from '@setu-ts/common';
import type { IKernelApplication, KernelDiagnosticsOptions } from '@setu-ts/kernel';
export function createApp(
_env?: Readonly<Record<string, unknown>>,
devtool?: { plugins?: readonly IPlugin[]; diagnostics?: KernelDiagnosticsOptions },
): IKernelApplication {
return createApplication({
plugins: [RuntimePlugin(), ...(devtool?.plugins ?? [])],
...(devtool?.diagnostics !== undefined ? { diagnostics: devtool.diagnostics } : {}),
});
}
Keep the existing plugins in the array. The entry checks plugin registration and kernel diagnostics
after startup, stops the application and exits 1 if the factory discarded the composition. All
framework pins must match this CLI version before enabling; upgrade them together first. Workspace
connectors allocate from devtoolBasePort (recorded on first enable, default basePort + 1000,
capped at 65535). Standalone defaults probe 4919 through 5019 on first enablement; later enablement
retains the entry’s recorded port, including while the connector runs. --devtool-port chooses
explicitly. ports --reallocate updates an untouched CLI entry along with the manifest; an edited
entry refuses the whole operation before writing. Production images exclude the entry and remove the
development connector pin before installing the remaining import map. See the
deployment recipe
for the complete build sequence and JSONC support.
Controllers and modules
Routes are declared in one kind of file: a controller, <name>.controller.ts. It takes one of
two shapes, read from its exports:
- a class controller exports a decorated
<Pascal>Controller(class-based projects); - a function controller exports
register<Pascal>Routes(router, services?), which can also register what a decorator cannot: 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. There is no separate routes file.
A project keeps its controllers and services in one place:
- Flat (every new project):
src/controllers/andsrc/services/, wired through their CLI-ownedindex.tsbarrels. Theresttemplate’s greeting example lives there. - Modular:
src/modules/<name>/, one folder per feature. There is nosrc/controllers/,src/services/or root module;setu.config.tsregisterssrc/modules/index.tsalone.
Subfolders are allowed in both (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 the files’ locations and, in a class-based module,
re-points the declaration’s imports. Two controllers with one base name would bind one symbol, so
they are refused (setu.E016).
The first setu g module <name> switches the project to modules. With only empty barrels in the
flat homes, it removes them and rewrites the registration lines of setu.config.ts. With
controllers or services there, it refuses and lists them; --adopt-controllers moves them into the
new module instead, keeping subfolders and URL paths, and rewrites every relative import into or out
of the moved files. The scaffold’s own greeting example, if untouched, moves in without the flag;
once you edit it or add files beside it, the flag is needed. Every move and edit is planned first
and applied in one transaction: an import it cannot read exactly (a relative require, an
import() of a computed path) refuses the whole switch. A config the CLI cannot edit exactly is
left alone, with the lines to change printed. From then on controller, route, service and
sse require --module <name>, and setu add view does too; a flat project refuses --module.
setu check reports controllers or services left outside modules (setu.E014) and imports in
CLI-written files that no longer resolve (setu.E015).
A module holds <name>.controller.ts (GET /<name>, GET /<name>/:id, POST /<name>,
PATCH /<name>/:id), <name>.service.ts (an in-memory Map the JSDoc says to replace with a
repository), <name>.schemas.ts (only when validation-plugin is installed), a CLI-owned
index.ts exporting register<Pascal>Module(router, services?), and — on socket targets —
<name>.test.ts and <name>.e2e.test.ts. A class-based module adds <name>.module.ts, its
@Module declaration. The CLI-owned src/modules/index.ts exports
registerModules(router, services?), which every modular config calls, and in class-based projects
also MODULES for DecoratorPlugin({ modules }).
--module <name> writes into an existing module. A module-scoped controller mounts at
/<module>/<name>, and a static path such as /invoices/summary outranks GET /invoices/:id, so
an id equal to summary is unreachable through /:id. The module’s index.ts registers its
function controllers. In a class-based module, a generated 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; otherwise the declaration is left alone and the line to add is printed. A
module-scoped service is registered under the token <module>.<name>-service. Every other schematic
is app-wide and refuses the flag.
setu generate schema <name> (requires validation-plugin) writes
src/shared/schemas/<name>.schemas.ts — or src/modules/<module>/<name>.schemas.ts with --module
— and never edits an existing file; a second run is the ordinary overwrite refusal.
setu new emits test/support/server.ts (CLI-owned; boots createApp() on a free loopback port)
and test/e2e/app.e2e.test.ts on every socket target; a Cloudflare Workers scaffold emits neither.
g module adds <name>.test.ts, which drives the module in-process through
createTestApp({ app: await createApp() }) — it boots the whole composition, so a plugin that needs
a live backend needs one in the test too (substitute it with createTestApp’s overrides or
without) — and <name>.e2e.test.ts over real HTTP on socket targets. 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, exactly where g controller and g service write in a new
project.
Realtime starting points
setu generate sse <name> writes an SSE controller under src/controllers/, or into a module with
--module, where it is served at /<module>/sse/<name>. When the project already has both
@setu-ts/react-router-plugin (and therefore React) and @setu-ts/sdk, it also writes an
application-local src/hooks/use-<name>.ts React hook that imports createSseClient. Add the SDK
with setu add sdk before generating the hook. React belongs to the consuming application, never a
published Setu package.
setu generate ws-route <name> writes a WebSocket plugin under src/plugins/. It registers an
exact /ws/<name> route, joins the requested room, and broadcasts to other members.
Generated HTTP artifacts register through registerGeneratedRoutes(app.router, app.services);
regenerated barrels also accept older one-argument route registrars.
In a class-based project, setu generate module users writes a controller, an injectable service,
and src/modules/users/users.module.ts. That application-owned file declares its contents with
@Module({ controllers, providers }); the CLI-managed src/modules/index.ts exports MODULES,
which setu.config.ts passes as DecoratorPlugin({ modules: [...MODULES] }). A root module is also
supported when the application should own activation: pass one @Module({ imports: [...] }) class
directly to DecoratorPlugin.
Projects generated before this release have no <name>.module.ts. The next generation reports each
such directory instead of silently dropping it from MODULES. Its regenerated barrel retains the
deprecated MODULE_CONTROLLERS and MODULE_SERVICES exports, so an existing config continues to
compile and register its modules while it migrates. Add the declaration (or delete and regenerate
that module), then change setu.config.ts to import MODULES and pass modules: [...MODULES] to
DecoratorPlugin; do not retain the deprecated arrays in the new config.
Nothing is ever overwritten. A generate that would clobber any existing file writes none of them — every planned path is checked before the first write, so a multi-file schematic cannot leave a half-written tree.
Plugin commands
A plugin publishes commands with ctx.cli.register(name, handler). setu finds them by loading
your setu.config.ts and starting the application with no port, so nothing binds a socket. It
always stops the application afterwards, including when a handler throws.
setu commands # list what this application's plugins provide
setu db:migrate up 3 # positionals after the name reach the handler
import type { IPlugin, IPluginContext } from '@setu-ts/common';
// Your migration runner, invoked with the first positional (or `latest`).
const migrate = (target: string): Promise<void> => Promise.resolve();
export const MigrationsPlugin: IPlugin = {
name: 'migrations',
version: '1.0.0',
register(ctx: IPluginContext): void {
ctx.cli?.register('db:migrate', async (args) => {
await migrate(args[0] ?? 'latest');
});
},
};
Handlers receive positionals only. setu consumes its own flags, so pass a plugin command’s flags
after --:
setu db:migrate -- --verbose --dry
Built-in verbs (new, generate, commands, help) match first, so a plugin cannot shadow
them — and those paths never import your project. Only an unmatched first positional boots it.
Two plugins registering the same name is refused rather than resolved by load order.
Running a plugin command starts your application, which means every plugin’s init and bootstrap hooks run — a database plugin will connect. That is inherent to reading commands the plugins register at startup.
Custom schematics
Drop a module in .setu-ts/schematics/ and the CLI will load it with a real dynamic import():
// .setu-ts/schematics/readme.ts
import type { DerivedNames, GeneratedFile, SchematicOptions } from '@setu-ts/cli';
export function schematic(
names: DerivedNames,
options: SchematicOptions,
): readonly GeneratedFile[] {
return [{ path: `docs/${names.kebab}.md`, contents: `# ${names.pascal}\n` }];
}
setu g custom readme order-item # writes docs/order-item.md
DerivedNames carries raw, kebab, camel, pascal, and screaming. SchematicOptions
carries the target runtime, the detected plugins set, and now() — an injected clock, so
timestamped output stays deterministic. Schematics do no I/O; the CLI writes what they return, which
is what makes --dry-run exact.
Programmatic use
import { runCli } from '@setu-ts/cli';
import { createNodeRuntimeServices } from '@setu-ts/runtime';
const runtime = createNodeRuntimeServices();
const code = await runCli(['generate', 'service', 'billing'], {
fs: runtime.fs!,
cwd: process.cwd(),
now: () => runtime.now(),
log: console.log,
error: console.error,
});
runCli returns an exit code and never exits the process. The dependency bundle has no default on
purpose: src/main.ts (runMain with nodeProcess) owns the process boundary, so every other path
stays testable.
Interactive scaffolding
When run 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. The broker and
queue questions follow only when the answers already given can actually take them: they are skipped
for a template registering no messaging (the minimal default, rest, class-based) and on
Cloudflare Workers; full-stack offers both, which are the same cases the flags themselves refuse. A
host with logging also asks Logger?, with the runtime’s default first; Workers offers console
only. Every prompted value is expressible as a flag, so prompts are never a second way to configure
a project, and --dry-run stays exact.
Non-interactive by construction in three layers: runCli’s dependency bundle takes prompting as an
OPTIONAL ask member no programmatic caller passes; the installed executable supplies it only when
stdin is a terminal; and the reader answers null once input ends. All three fail closed to the
documented defaults. --yes is the explicit escape hatch.
Not yet supported
- A general
--starterflag.full-stackcomposes through@setu-ts/full-stack-starter, but the other templates emit inline wiring and there is no flag to pick a starter for them. - Flags for plugin commands. Handlers receive positionals only; use
--to forward flags. - Prompting inside
setu generate. A generate runs inside scripts and editor hooks; onlysetu newasks questions.
License
MIT
Exports
@setu-ts/cli
| Export | Kind |
|---|---|
deriveNames | function |
detectPlugins | function |
runCli | function |
PROGRAM_NAME | const |
CliDependencies | interface |
DerivedNames | interface |
GeneratedFile | interface |
PromptChoice | interface |
Prompter | interface |
SchematicOptions | interface |
AppLoader | type |
ModuleLoader | type |
PromptSelection | type |
Schematic | type |
TemplateName | type |
@setu-ts/cli/main
| Export | Kind |
|---|---|
emitInterrupt | function |
nodeProcess | function |
runMain | function |
MainProcess | interface |
MainRuntime | interface |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
Full API
Every export and option is documented in PUBLIC_API.md.
Development source policies and registration guidance
Opted-in Deno and Node scaffolds and setu devtool enable write the CLI-managed
src/devtool/diagnostics.ts approval module. setu add refreshes an existing module, using
artifact names and env-example keys rather than values. Source options activate only when the
factory receives its devtool argument; production keeps disabled defaults. Unknown source shapes
receive manual guidance, including the DEVTOOL_SOURCES import and the sources declaration those
lines read. The module is regenerated, so preserve application-specific policy outside that file.
Full-stack accepts new --broker redis --queue redis, and workspace full-stack members receive
messaging / queue options from their workspace transport. Standalone projects can use
setu add messaging --broker redis or setu add queue --queue redis; members use the workspace
transport and refuse per-member flags. The selected client is installed in runtime dependencies or
Deno imports. An absent generated full-stack option is inserted; missing queue transport properties
can merge with its processors. Existing values and unfamiliar configs receive the exact property to
configure manually. With no transport the starter uses memory. add preserves dotenv files and
existing Compose files, prints the connection variable and local default, and creates broker Compose
files only when the project has none. When docker/compose.yaml exists without the selected
broker’s service, it prints the service block to add.
setu add react-router refuses projects without a full-stack factory or existing ReactRouterPlugin.
Use setu new <name> --template full-stack or setu generate app <name> --template full-stack in a
workspace to create the frontend and build setup.
Adding one of the twelve safe zero-configuration providers wires its emitted plugin-list anchor in
either style. Starter compositions instead name their option arm; configure that arm to avoid a
duplicate registration. 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. Manifest updates preserve existing key order. Deno
frontend projects update imports without adding unused framework dependencies to their frontend
package.json.
On a cloudflare-workers project, setu add cloudflare prints the registration that reads the
platform sink — CloudflarePlugin({ env, ...(waitUntil ? { waitUntil } : {}) }) — because the
generated entry passes waitUntil to createApp as its second parameter (M113d §3.8). The factory
names that parameter _waitUntil while nothing reads it, so the command also tells you to rename
it. A project scaffolded before 0.10.0 has no such parameter, and its entry does not pass one; the
command points at
docs/upgrading.md for both edits.
Styled server-rendered views
setu add view adds hono, hono JSX compiler options, Tailwind 4’s CLI and neutral shadcn tokens,
view files, a function controller serving /ui (src/controllers/views.controller.ts, or
src/modules/<module>/views.controller.ts with --module, which a project with modules requires),
CSS build/watch scripts, and StaticPlugin serving build/assets/app.css. A build or start:dev
script it creates or prefixes runs CSS first (one that already called css is left as it was); run
npm run css:watch, bun run css:watch or deno task css:watch separately while editing. Existing
files, pins and settings are preserved and reported; an unfamiliar config receives manual
registration instructions. --dry-run lists all planned changes and writes nothing. Workers gets
dependencies, JSX and example view files only; CSS there belongs to Wrangler static assets. See
the MVC guide.
The full-stack template includes Tailwind through Vite, copied shadcn Button/Card, a fixed app shell
and a light/dark/system toggle. Run npx shadcn add <component> to add more components.
API reference
CLI (@setu-ts/cli)
Devtool enabling requires all existing @setu-ts/* pins to equal this CLI version (^VERSION). A
generated factory must accept the composition as its second argument and use both spreads:
import type { IPlugin } from '@setu-ts/common';
import type { IKernelApplication, KernelDiagnosticsOptions } from '@setu-ts/kernel';
export function createApp(
_env?: Readonly<Record<string, unknown>>,
devtool?: { plugins?: readonly IPlugin[]; diagnostics?: KernelDiagnosticsOptions },
): IKernelApplication {
return createApplication({
plugins: [RuntimePlugin(), ...(devtool?.plugins ?? [])],
...(devtool?.diagnostics !== undefined ? { diagnostics: devtool.diagnostics } : {}),
});
}
Keep the existing plugin list. The generated entry checks registration and app.diagnostics after
start, stops and exits 1 if the composition was dropped. Workspace connectors allocate from
devtoolBasePort, recorded on first allocation (default basePort + 1000, capped at 65535); the
first explicit --devtool-port records that range start. Reallocation updates CLI-rendered entries
and refuses edited entries before any write. Standalone defaults probe 4919 through 5019 and report
the chosen port; new --devtool also writes it into the project README. Re-enabling an unchanged
standalone entry retains its recorded port without probing again.
@setu-ts/cli ships the setu executable: project scaffolding and plugin-aware code generation. It
is a Node program (Node 22.23.4+, 24.22.1+ or 26.12.1+), published to npm with a bin, so it runs
without an install or installs globally; @setu-ts/create makes npm create @setu-ts the same as
setu new:
npx @setu-ts/cli new my-app
npm create @setu-ts my-app -- --template rest
npm install --global @setu-ts/cli # then: setu new my-app
A scaffolded project depends on the framework packages from npm under their own names, pinned to the
CLI’s own version — on every target, Deno included (npm:@setu-ts/… in its import map). Every
target installs with its own package manager: npm install (Node, Workers), bun install (Bun),
deno install (Deno).
The CLI loads a project’s TypeScript — setu.config.ts for plugin commands, a custom schematic —
through tsx, from the project’s own node_modules. A Deno project sets nodeModulesDir: "auto",
so deno install creates that directory; until it has, plugin commands refuse by name and name the
command that fixes it. A Deno project pinning the framework from JSR (jsr:@setu-ts/…, 0.8 and
earlier) is refused the same way, pointing at the upgrade guide.
@setu-ts/cli/main — the process boundary
setu new --logger console|pino and setu generate app --logger console|pino (since 0.11.0) select
the logger for rest, microservice, class-based, and full-stack, including styled templates.
The default is Pino on Node, Deno and Bun, and console on Cloudflare Workers. Pino adds pino: 10.x
to runtime dependencies (the same range in Deno’s import map). Explicit console adds no Pino
dependency. --logger pino on Workers is refused by name before writing. A minimal host or
workspace root also refuses the flag because it registers no logger. Interactive new asks
Logger? only for a host with logging; --yes takes the runtime default. --dry-run reports the
selected logger and writes nothing.
| Export | Kind | Description |
|---|---|---|
runMain | function | (runtime: MainRuntime, host: MainProcess) => Promise<number> — runs one invocation and returns its exit code; never exits |
nodeProcess | function | (onInterrupt?) => MainProcess — the Node host: process.argv, process.cwd(), console output, one shared readline reader |
emitInterrupt | function | Delivers an interrupt to this process’s SIGINT handlers as an event, never as a real signal; nodeProcess’s default |
MainRuntime | interface | The runtime services runMain reads (fs, the clock, onSignal) |
MainProcess | interface | The host: args, cwd(), isTerminal(), prompt(message, signal?) => Promise<string | null>, log, error, portAvailable, dispose? |
Breaking (0.9.0): MainProcess.prompt is asynchronous and takes the run’s interrupt signal, so
an interrupt ends a pending question rather than waiting for Enter; denoProcess is removed,
replaced by nodeProcess. Both executables set process.exitCode rather than calling
process.exit, so output still draining to a pipe is not cut off.
@setu-ts/create
| Export | Kind | Description |
|---|---|---|
createArgs | function | (argv: readonly string[]) => string[] — the setu new arguments npm create stands for |
Its create executable runs runMain with createArgs(process.argv.slice(2)), so it accepts
exactly what setu new accepts.
Commands
setu build [--dir <path>] [--dry-run] checks CLI wiring and bundles one Node project or workspace
member into dist/main.js and a source map, plus dist/src/migrations/run.js when present. It never
boots the app. Libraries are inlined and npm packages remain external; runtime devDependency imports
fail with the importing file named. Dry-run checks and reports without bundling or writing.
Workspace roots and non-Node targets refuse with exit 2; build failures exit 1. Generated Node build
scripts type-check before invoking this command; production start runs the bundle. Use start:dev and
start:debug for development. See CLI build.
Every socket-target scaffold includes test/app.test.ts: it starts the application’s own
createApp() through createTestApp, injects its smoke-test endpoint, and stops it. Workers needs
the platform environment and emits no smoke test; its test task permits an empty suite.
setu add testing pins @setu-ts/testing in Deno’s import map or npm’s devDependencies.
# Scaffold a project (creates ./my-app)
setu new my-app
setu new my-app --runtime deno # node (default) | deno | bun | cloudflare-workers (manifest + start command; code is portable)
setu new my-app --template rest # rest | microservice | class-based | full-stack
setu new my-app --template microservice --runtime bun
setu new my-app --template rest --style class-based # functional | class-based (rest, microservice)
setu new my-app --template class-based # byte-identical alias of --template rest --style class-based
setu new my-app --template rest --env-file config/.env.local
setu new my-app --template rest --logger console # console | pino; since 0.11.0
# Scaffold a monorepo (creates ./acme, no member yet)
setu new acme --workspace
setu new acme --workspace --port 4100 # base port for its members
setu new acme --workspace --transport redis # http | grpc | memory | redis | rabbitmq | nats | kafka
setu generate app orders --template microservice
setu generate app billing --template microservice
setu generate app reports --template rest --logger console
setu generate app shipping --template microservice --depends-on orders --depends-on billing
setu generate app orders --devtool # + main.dev.ts, dev/check tasks, a devtool port
setu devtool enable orders # same composition, on a member that exists
setu devtool enable # standalone project: no member name
setu workspace ports --reallocate
# Install a framework package into this project
setu add auth # short name
setu add auth-plugin # bare package name
setu add @setu-ts/auth-plugin # full specifier — all three are the same command
setu add cache --dir ./services/orders
setu add cache --dry-run # report the manifest edits, write nothing
# Check that every seam artifact is wired (read-only), or rewrite the managed barrels
setu check # exit 1 on an error finding; standalone or workspace
setu check --strict # warnings fail too
setu sync # rewrite CLI-managed barrels from their directories
setu sync --dry-run # print the barrels it would write
# Write or check this project's AGENTS.md brief
setu agents # standalone, workspace root or member
setu agents --check # exit 1 when a section is missing or stale
setu agents --dry-run # print the paths, write nothing
setu new my-app --agents # scaffold, then write the brief
# Commands this application's plugins provide
setu commands
setu db:migrate up 3 # runs a plugin-registered command
# Generate code
setu generate module orders # functional by default; class output with decorator-plugin
setu generate schema create-user # requires @setu-ts/validation-plugin
setu generate service pricing --module users # place a feature file inside an existing module
setu generate plugin my-plugin
setu generate controller user-profile
setu generate service user-profile
setu generate route users
setu generate middleware rate-limit
setu generate guard admin-only # requires @setu-ts/auth-plugin
setu generate health-indicator external-api # requires @setu-ts/health-plugin
setu generate metric orders-placed # requires @setu-ts/metrics-plugin
setu generate command-handler create-user # requires @setu-ts/cqrs-plugin
setu generate query-handler get-user # requires @setu-ts/cqrs-plugin
setu generate event-handler user-created # requires @setu-ts/events-plugin
setu generate job send-welcome-email
setu generate migration add-users-table # requires @setu-ts/database-plugin and @setu-ts/common
# Custom schematics, loaded from .setu-ts/schematics/<schematic>.ts
setu generate custom my-schematic order-item
# Aliases
setu n my-app
setu g service user-profile
# Print the plan, write nothing
setu g controller user-profile --dry-run
Any casing of the name produces identical output: setu g controller user-profile and
setu g controller UserProfile emit the same file.
Options
| Option | Commands | Behavior |
|---|---|---|
--runtime deno|node|bun|cloudflare-workers | new, generate | On new, selects the entry shape and manifest and defaults to node. On generate, passes SchematicOptions.runtime to the schematic (read by custom schematics) and otherwise detects the target from the existing project’s manifest. An unknown value is a usage error (2) on both. |
--example <app> | new | Downloads a standalone Node example from the CLI release tag on GitHub. Dry-run lists paths without fetching. Refuses composition flags and non-Node runtimes; unknown or excluded examples exit 1 with alternatives. |
--workspace | new | Creates a monorepo root instead of a project: a root manifest whose member globs are apps/* and libs/* (a deno.json workspace array, or a package.json workspaces one under --runtime node|bun), plus a setu.workspace.json, with no member. Both globs are written once, so neither a service nor a library ever rewrites the root. Refuses --template, and --runtime cloudflare-workers — each Worker is its own deploy unit — rather than ignoring them. |
--port <n> | new --workspace, generate app, adopt | On new --workspace, the base port: the first member binds it and each later one takes the next free number above the highest in use. On generate app, the port THIS member binds instead of the allocated one — refused when another member already holds it. Defaults to 3000. A usage error on a standalone new, or when it is not an integer 1–65535. |
--env-file <path> | new, generate app | ConfigPlugin-backed Deno, Node, and Bun templates emit that gitignored dotenv file plus a tracked <path>.example, and use it as ConfigPlugin({ envFilePath, envFileOptional: true }). The file is optional because it is gitignored: the project still starts on a fresh clone, in CI, and in a container, reading configuration from the environment. A template that needs a value names it in both files — full-stack emits SESSION_SECRET, with a development value in the ignored file and an empty one in the committed example. Refused by minimal templates, workspace roots, and Cloudflare Workers, which read request bindings rather than a filesystem. Plugin construction precedes ConfigPlugin registration, so construction-time values must come from this environment source. |
--depends-on <member> | generate app | Repeat for each existing prerequisite. The member records the names in setu.workspace.json; the root dev runner starts prerequisites first and waits for /ready. Duplicate or missing names are usage errors, as is using it anywhere but generate app. |
--module <name> | generate, add view | Place a controller, route, service, sse or schema (or add view’s controller) inside the named feature module. The name must be an admitted module, else the refusal lists them. The first four require it once the project has modules, and a flat project refuses it. The file lands in src/modules/<name>/; a function controller is registered by the module’s CLI-owned index.ts, and a class controller or service is added to the @Module declaration’s controllers or providers array when that is a plain array literal (otherwise the line to add is printed). A module-scoped controller mounts at /<module>/<name>, and a module-scoped service registers under the token <module>.<name>-service. Every other schematic is app-wide and refuses the flag. |
--adopt-controllers | generate module | The first module only: move every file under src/controllers/ and src/services/ into it, keeping subfolders and URL paths, rewriting relative imports and switching setu.config.ts, all or nothing. |
--devtool | new, generate app | Opts a Deno or Node project/member into the local diagnostics connector through a runtime-neutral main.dev.ts entry that the production graph never imports. Node adds a dev script (tsx main.dev.ts) and a diagnostics-plugin devDependency; its existing check script covers the entry. Deno adds dev and widens check. A member records an allocated devtoolPort. Bun, Workers and starter-composed templates are refused by name. The entry reads SETU_DEVTOOL_SESSION_ID and SETU_DEVTOOL_SESSION_KEY from runtime services, refuses invalid pairs, and never generates, writes or prints credentials. The launcher names SETU_DEVTOOL_MEMBER; scripts/dev.mjs (Node) or scripts/dev.ts (Deno) forwards all three only to that member and blanks siblings. Node selects npm run dev for that member, npm run start:dev for others, and keeps selected children in the launcher process group. Stale runners are refused with a delete-and-rerun remedy. All three names are published CLI surface; the Deno root task grants environment access to exactly them. Node enablement writes only the member package.json, refuses conflicting framework pins or a developer-owned dev script, prints npm install, and never runs it. Older CLI-owned entries remain recognized. |
--devtool-port <n> | new, generate app, devtool enable | The IPv4 loopback port the connector binds. Range-checked like --port; refused when it equals any member’s application or devtool port. Absent, it is allocated in a workspace and probes 4919 through 5019 standalone. |
--transport <name> | new --workspace | How the workspace’s services talk to each other: http (default), grpc, memory, redis, rabbitmq, nats, kafka, pubsub, service-bus. Recorded in setu.workspace.json; every member added later inherits it. A usage error on a standalone project, on generate app, or for an unknown value. |
--transport-url <url> | new --workspace | Replaces the baked local fallback for the endpoint-shaped broker transports. A usage error for http, grpc and memory, which have no broker to address, and for pubsub and service-bus, whose connection value is a project id or a secret read from the environment — that refusal names the variable. |
--style functional|class-based | new, generate app | The decorator-and-DI axis, separate from --template: functional (default) installs neither DecoratorPlugin nor DiPlugin; class-based installs both together. Accepted on the styleable templates rest and microservice; refused with no template to apply to, with an unknown value, and on full-stack, which composes through a starter and has no controller or ingress seam. --template class-based is a byte-identical alias of --template rest --style class-based. |
--broker <name> | new (standalone) | The project’s own message broker: memory (default), redis, rabbitmq, nats, kafka, pubsub, service-bus. Rewrites the template’s MessagingPlugin wiring to that arm, adds the arm’s connection variable to the generated dotenv pair, and emits docker/compose.yaml starting the broker so the scaffold can complete app.start(). Refused — each with its own reason, never silently ignored — on Cloudflare Workers (the runtime swap removes both wirings), starter-composed templates (full-stack; a factory-rendered plugin list would drop the rewrite), templates registering no messaging (rest and no template; microservice registers messaging in every style), unknown names, and --workspace/generate app (a workspace’s transport is chosen once with --transport). |
--queue <name> | new (standalone) | The job queue backend: memory (default), redis, rabbitmq. Same overlay as --broker, applied to the QueuePlugin wiring, sharing the connection variable and Compose service when both flags name the same arm. Refused for the brokers the queue does not support (nats, kafka, pubsub, service-bus) and everywhere --broker is refused. |
--redis-client <name> | new, add (with a Redis arm) | M117: ioredis (default, output unchanged) or node-redis. A node-redis project depends on redis instead of ioredis and wires each Redis-backed plugin with client: nodeRedisClient(<url>) from a generated src/node-redis.ts (one client per plugin; the backplane also gets subscriber), keeping setu.config.ts free of third-party imports. --workspace --transport redis --redis-client node-redis records redisClient: 'node-redis' in the workspace manifest (absent = ioredis); generate app and member setu add follow it. Refused (exit 2) without a Redis arm, on Cloudflare Workers, on generate app, inside a workspace member, and for an unknown value; an unknown stored redisClient is refused (exit 1) by name. |
--yes, -y | new | Take every default and ask nothing. A no-op when no prompter is present, never an error. |
--scope <scope> | generate library | The import scope a shared library is named under, without the leading @. Defaults to the workspace directory name. |
--name <member> | adopt | The member name the converted project takes. Defaults to the project directory name. |
--agents | new | Write the project’s AGENTS.md agent brief after scaffolding, through the same writer setu agents uses. On --workspace, the brief is written for the new root. Opt-in: setu new writes no brief without it, and setu add does not run the writer. |
--check | agents | Render every brief the command would write and compare 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. Refused together with --dry-run (exit 2). Card presence is not checked, so it does not depend on whether install has run. |
--dir <path> | new, generate, add, agents | Operate on this directory instead of the working directory. A relative path is resolved against the working directory. |
--dry-run | new, generate, add, agents, workspace ports | Prints would create <path> per file and performs zero writes and zero directory creations. On add, prints would update <manifest> instead, since it edits files rather than creating them. On agents, prints would write <path> per brief. workspace ports prints would update <path> for each managed file instead of writing the workspace manifest. |
--help, -h | both | Prints usage and exits 0. setu generate --help lists only the schematics available here. |
--version, -v | — | Prints the version read from the package’s own deno.json and exits 0. |
Every command refuses an option outside its own set — subcommand-aware for generate app,
generate library, and workspace ports — with exit 2 and nothing written, suggesting the
nearest known name when one is within a small edit distance (--templat suggests --template;
--base-port suggests --port). A flag kept deliberately recognized for a named refusal (--di
and --depends-on on new, the transport flags and --runtime on generate app) still reaches
that specific message. Plugin-registered commands accept only --dir and --config: a
CliCommandHandler receives positionals only, so no plugin command can read any other flag.
setu add <plugin>
Adds a framework package to the project’s manifest, pinned to the version of the CLI that added it —
the same rule setu new follows, so a project’s framework packages stay on one version instead of
drifting per install.
The name resolves three ways, all equivalent: a short name (auth), the bare package name
(auth-plugin), or the full specifier (@setu-ts/auth-plugin). It is an explicit allow-list rather
than “anything under @setu-ts/”, because the range written is the CLI’s OWN version, which is only
correct for packages released as one version with it — so a typo is refused, naming what is
accepted, rather than pinned to a version that does not exist.
Deno projects update only deno.json imports, even when a full-stack frontend has its own
package.json. Node, Bun and Workers update every manifest they carry. Every pin comes from npm: a
Deno import holds npm:@setu-ts/<name>@^<version> and a package.json dependency holds
^<version>. Testing uses npm devDependencies. Existing key order is retained; sorted maps
receive a sorted insertion, otherwise the new framework key follows the existing scope block.
Re-adding the same pin leaves the dependency map unchanged.
The twelve safe zero-configuration providers listed in the CLI guide are registered
above the emitted development-plugin anchor in either generator style. 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. Unrecognized source is preserved. Starter
compositions print their option arm; installing a package does not authorize a duplicate
registration. Existing development diagnostics modules are refreshed with names-only approval maps
and known plugin calls receive development-only options.
It writes the manifest and reports the install command rather than running it. The command
matches the project’s toolchain, detected from its manifests the same way setu generate detects
it:
| Project | Printed command |
|---|---|
| Deno | deno install --min-dep-age 0 |
| Node | npm install |
| Bun | bun install |
| Cloudflare Workers | npm install |
That is deliberate. On the day of a release the pin is younger than Deno’s 24-hour
minimum-dependency-age policy, so the flags need to be visible rather than buried in a failing
subprocess — and it keeps the command free of the run permission. No registry mapping is printed:
every package resolves from the default npm registry.
A workspace root is refused (exit 2, nothing written) — recognised by setu.workspace.json, a
deno.json workspace key, or a package.json workspaces key. Framework packages are pinned in
each member, because setu generate reads the member’s manifest for plugin gating; run
setu add <plugin> --dir <member directory> instead.
Since 0.11.0 (M119f), optional peers supplied by setu add are also recorded in the workspace
root’s package.json dependencies when the target is a listed Node/Bun Setu workspace member. The
same CLIENT_RANGES ranges remain in the member; existing peer entries in either manifest are
preserved. Both edited paths are printed, and the writes share rollback handling. An absent root
manifest keeps member-only behavior; JSONC or invalid/unreadable JSON is refused by the root
filename before any writes. Deno workspaces and standalone projects are unchanged. See
Optional peers in a workspace.
Exit codes follow the table below: 0 on success, 1 when the directory holds no manifest or a
manifest cannot be parsed, 2 for an unknown package name, a missing argument, or a workspace root.
setu agents
Writes or checks the project’s agent brief: AGENTS.md in the project directory. The CLI owns one
section, delimited by <!-- setu-agents:begin --> and <!-- setu-agents:end -->, and regenerates
it from the project’s real state — the installed @setu-ts/* packages, style, runtime, the scripts
its manifest defines, and the schematics available to it. The line after begin reads
“setu agents reads manifests and overwrites these markers’ contents.” Every byte outside the
markers is preserved, so a developer-owned preamble survives a rewrite.
The marker arrangements have one behaviour each: an absent file is created containing only the
section; a file with no markers gets the section at the top, one blank line, then its existing
bytes; exactly one begin followed by exactly one end has the bytes between them replaced; and
any other arrangement (an end before begin, two begins, a begin with no end) exits 1
naming the file and the problem, writing nothing. A path that is not a regular file, or is over 1
MiB, is refused the same way.
--check renders every brief it would write and compares it with the section on disk: exit 0 when
all match, exit 1 naming each file that is missing a section or whose section differs, and nothing
written. Card presence is not part of the check, so a CI checkout can run it before install.
--dir selects the project or workspace, a relative path resolved against the working directory.
--dry-run prints would write <path> per file and is refused together with --check. A real
write prints one line per file: created, updated or unchanged, then the path. Outside a
Setu-TS project the command exits 2; an unreadable manifest or a runtime marker whose metadata
cannot be inspected exits 1 naming the file.
In a workspace it may be run from the root or any member directory: it writes the root AGENTS.md
and every member’s apps/<name>/AGENTS.md, and --check checks all of them. The root brief covers
the workspace commands, the member table and the transport; each member’s brief is its standalone
brief plus a pointer at the root file.
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 asks you to run
setu agents in a workspace you own; 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.
Every published library package ships an AGENT_CARD.md in its npm tarball — beside dist, src,
README.md and LICENSE in the generated files list — so a normal install delivers it to
node_modules/@setu-ts/<pkg>/AGENT_CARD.md, the path the brief names. In a workspace member the
named path is the workspace root’s, ../../node_modules/@setu-ts/<pkg>/AGENT_CARD.md. After
writing, one advisory line per missing card is printed (card not found: <path> — run install)
without changing the exit status; a project with no node_modules under Yarn Plug’n’Play or a Deno
project without nodeModulesDir gets the setting to change instead. cli and create carry no
card: they are run, never imported.
setu new --agents runs the same writer after scaffolding. Nothing else runs it: setu add prints
Run `npx @setu-ts/cli@<version> agents` to update AGENTS.md. (the project’s pinned CLI
invocation for its runtime) after a successful add when the project already has a sectioned brief,
and neither command edits an existing file silently.
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, a command handler threw, or a command name is registered twice. |
2 | Usage error: unknown command or schematic, missing argument, unknown --runtime, an unusable --broker/--queue/--redis-client value, 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.
For plugin commands, an already-aborted CliDependencies.interrupt prevents boot. Aborting during a
handler stops awaiting its result and awaits app.stop() before returning 130. The CLI cannot
cancel arbitrary handler code or undo external side effects; the application’s shutdown hooks must
release its resources. Startup is allowed to settle before checking interruption and shutting down.
Interactive scaffolding
At an interactive terminal, setu new asks for the choices it already accepts as flags — on a
standalone project: runtime, template, broker, queue; on a workspace: runtime and transport.
Questions are asked only for absent flags, only when --yes is absent, and only when a prompter was
supplied at all; the broker and queue questions fire only when the answers already collected make
the flag legal under the same predicate the command refuses on. Every prompted value is expressible
as a flag, so prompts are never a second configuration surface, and --dry-run stays exact.
Non-interactive by construction in three layers, all failing closed to the documented defaults:
CliDependencies.ask is OPTIONAL and no programmatic caller passes it; src/main.ts supplies the
terminal implementation only behind Deno.stdin.isTerminal(); and Deno’s own prompt() returns
null on a non-terminal.
The two exported types:
| Export | Kind | Members |
|---|---|---|
Prompter | interface | Since 0.9.0, select(question, choices): Promise<PromptSelection> — answer, unavailable, or cancelled. |
PromptChoice | interface | { value, label } — the value written into the flag record, and one descriptive line shown above the question. |
PromptSelection | type | Since 0.9.0, { kind: 'answer', value } | { kind: 'unavailable' } | { kind: 'cancelled' }. |
createTerminalPrompter is deliberately NOT exported: its only consumer is the executable entry
point, which imports it directly.
Decorators and DI are optional
AI_GUIDELINES states that decorators are optional, dependency injection is optional, and that
everything has a programmatic API — no feature requires decorators or reflection. The CLI has
two coherent compositions on the --style axis: functional is the default; --style class-based
opts into both decorators and dependency injection. The axis is separate from --template, and
--template class-based is a byte-identical alias of --template rest --style class-based.
| You want | Scaffold with | You get |
|---|---|---|
| Neither | setu new app | The runtime plugin alone. g route, g middleware, g plugin, g service, g job all work and are wired. |
| Functional | setu new app --template rest | Router functions and explicit request context; g module creates a registered route with GET and POST handlers. |
| Class-based | setu new app --template rest --style class-based | DecoratorPlugin + DiPlugin, plus decorated controllers, injectable services, and class module registration. |
--style is accepted on the styleable templates rest and microservice, by setu new and
setu generate app; it is refused with no template to apply to, with an unknown value, and on
full-stack, which composes through a starter and has no controller or ingress seam.
The independent --di flag is no longer supported. It is refused with guidance to use
--style class-based (with --template rest or microservice), which always installs the
decorator and DI pair together, and --template nest is refused with a message naming class-based
as its new name. --template full-stack therefore has no DI opt-in from the CLI; an application
composing the starter directly still has FullStackStarterOptions.di.
The style is read from the target project’s manifest on every setu generate, so it PERSISTS: a
project holding decorator-plugin keeps producing decorated classes — including one that holds it
without di-plugin, which only a project predating --template class-based can — and a project
without it keeps producing functional output. One consequence is worth stating plainly:
setu generate service has no registration site in a functional project. A plain exported
function has none to have — no plugin option takes a list of functions — so nothing wires it for
you. It still emits a managed src/services/index.ts, but that barrel is a convenience re-export
rather than a registration: setu.config.ts does not import it, and you import from it where the
function is needed. In a class-based project the same path holds the APP_SERVICES array of
@Injectable classes, which the scaffolded config does pass to DecoratorPlugin.
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 bareSyntaxError: Invalid or unexpected tokenthere — or the constructor parameter propertysetu generate moduleemits (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX). A generated Node project therefore declarestsxindevDependenciesand develops withtsx watch main.ts, which transforms both; itstsconfig.jsonsets no decorator option, because standard decorators need none. Bun compiles TypeScript outright and Deno and Workers never invoke it, so no other target carries the dependency.
The Workers target carries an npm manifest as well as
deno.json.wranglerbundlessrc/index.tswith esbuild, which does not read a Deno import map, so a scaffolded Workers project emitspackage.json(@setu-ts/*dependencies from npm,wranglerpinned indevDependencies, anddev/deployscripts), andnpm install && npx wrangler devworks as printed. The Deno target does NOT get one: it resolves through the import map, and apackage.jsonswitches Deno to node_modules resolution.
The decorator-free way to serve HTTP is setu generate route. It emits
register<Name>Routes(router: IRouterApi) and is ungated, so it works in a project with no plugins
at all — and it is wired on every host, including the no-template one. g controller and g module
stay gated on @setu-ts/decorator-plugin (they emit @Controller, so an ungated project would get
source whose own import cannot resolve), and their refusal names g route as the alternative:
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.
Monorepo workspaces
A Setu workspace is one repository holding several deployable services, each its own scaffolded
project under apps/. The root is a Deno workspace by default and an npm workspace under
--runtime node|bun; see Node and Bun workspaces for what that changes.
setu new acme --workspace # the root: deno.json + setu.workspace.json
cd acme
setu generate app orders --template microservice # apps/orders, port 3000
setu generate app billing --template microservice # apps/billing, port 3001
deno task dev # dependency-aware startup, gated by /ready
The root manifest declares members by glob — "workspace": ["./apps/*"] in deno.json, or
"workspaces": ["apps/*"] in package.json on the npm arm — so adding one creates a directory and
rewrites nothing. Framework packages are pinned in each MEMBER’s own manifest, never at the root:
setu generate detects installed plugins by reading one directory’s manifest and never walks up, so
root-only pins would make every gated schematic refuse inside a member. Members’ dependencies
resolve independently, so two members may install different plugin sets.
Adding a service registers it with its callers. Every member carries a CLI-owned
src/discovery/services.ts that is 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: Deno.env.get('BILLING_HOST') ?? '127.0.0.1',
port: 3001,
},
],
};
The member’s main.ts binds SERVICE_PORT and — when its template installs
@setu-ts/service-discovery-plugin — its setu.config.ts passes SERVICE_ENDPOINTS to
ServiceDiscoveryPlugin({ provider: 'static', services: SERVICE_ENDPOINTS }); the full-stack
factory passes the same typed option through its starter composition. So the port a member binds and
the port its siblings dial are the same datum, and discovery.resolveUrl('billing') works from any
sibling with no configuration. A member without that plugin is still listed in every other member’s
map; being reachable and consuming the map are separate properties.
That module is managed: the CLI rewrites it and a hand edit is lost. The data it is rendered from
is setu.workspace.json at the root, which records each member’s name, port, and optional
dependsOn list — edit a port there and the next setu generate app rewrites every module from it.
setu workspace ports --reallocate instead assigns currently bindable ports and rewrites all
managed maps and deployment artifacts in one operation.
deno task dev (or the Node/Bun equivalent) reads that dependency metadata at runtime. It starts a
prerequisite first, waits until http://127.0.0.1:<port>/ready succeeds, then starts each
dependent; it names a cycle or readiness timeout and terminates children on failure or shutdown. A
discovery map does not imply a startup dependency, so only services declared with --depends-on are
gated.
Each sibling’s host is overridable —
<MEMBER>_HOST— and the fallback is the local address. Both halves matter:deno task devon one machine needs loopback, and inside a container loopback is the container ITSELF, so a fixed value would have every member dial itself on its sibling’s port. The generated Compose stack and Kubernetes objects set those variables to the service names.It is still only a LOCAL topology. A deployed one comes from a real discovery backend (
provider: 'consul','kubernetes','dns'), not from this file.
Container and Kubernetes artifacts
generate app also writes, and regenerates for the whole workspace on every member:
| Path | What it is |
|---|---|
docker/Dockerfile | One parameterized image for every member: docker build -f docker/Dockerfile --build-arg MEMBER=orders -t orders:dev . The context is the workspace root, and every libs/ member is copied in — a service that imports a shared library cannot otherwise resolve it. |
.dockerignore | At the workspace ROOT, which is where Docker reads it — one under docker/ is read by nothing. Keeps .git and every node_modules out of the context, so the host’s copy is never laid over the one the image installed. |
docker/compose.yaml | Every member plus the transport’s backing service, with each member’s allocated port published and its siblings’ hosts set to their service names. |
k8s/members.yaml | A Deployment and a Service per member. ${NAMESPACE} and ${WORKSPACE} are placeholders — pipe it through envsubst. |
All are managed, for the same reason the discovery modules are: a stack that names two of three
members is worse than none. M39 owns this repository’s OWN deployment objects; these are the
generated project’s.
The images and objects carry M39’s cluster findings rather than defaults — a numeric runAsUser
(runAsNonRoot refuses a named one, and only under Kubernetes), /tmp as the only mount (an
emptyDir over the module cache masks the cache baked in at build time), a pinned namespace, and a
terminationGracePeriodSeconds that is real only because the generated entry installs a SIGTERM
handler. Probes are tcpSocket, because /live and /ready exist only when a member registers
HealthPlugin. No Ingress is generated: which controller, host and issuer are cluster decisions,
and a guessed Ingress routes nothing.
| Refusal | Why |
|---|---|
generate app outside a workspace | No setu.workspace.json in the target directory. Exits 1 naming setu new <name> --workspace. |
| A member name already in use | Exits 1 naming the directory it already has. The managed exemption covers regenerated modules only, never a member’s own source. |
--runtime cloudflare-workers | deno, node and bun all host a workspace; Workers does not, and that is a topology difference rather than a missing profile — each Worker is its own deploy unit with its own wrangler.toml, so several in one repository are several deployments, not members sharing a root manifest and a lockfile. Exits 2 naming setu new <name> --runtime <target> for a standalone project. |
--template full-stack on a broker transport | That template composes its curated plugin set through a starter factory, so a broker’s MessagingPlugin rewrite cannot be applied without silently leaving the starter default in place. The command refuses it rather than scaffolding a member that reaches nobody. grpc is supported: its GrpcPlugin({ basePath: '/grpc' }) contribution is registered after the starter factory returns, and the generated form-CSRF policy exempts that RPC prefix only. http, grpc, and memory are allowed; the root gains nodeModulesDir when the frontend member arrives. |
generate app --port on a taken port | Two members on one port cannot both bind, while every sibling’s map names both — so one name would resolve to the other service. Exits 1 naming the member that holds it. A free port is honoured, and allocation still derives from the highest in use, so a hand-picked port moves the ceiling rather than being reused. |
new --workspace --di | The independent DI switch is retired. Exits 2 directing the caller to --style class-based (with --template rest or microservice). |
A port in setu.workspace.json outside 1–65535 | Refused on read, naming the value and the field. Every port there is written into a member’s entry point AND into every sibling’s map, so one bad number breaks the workspace: app.start() throws Invalid port (out of range), and 0 is worse still — it binds an arbitrary free port, so the member looks healthy while every sibling is refused. Exits 1. |
| A workspace with no port left | basePort and its members reach 65535. Exits 1 rather than allocating 65536. |
Inter-service transport
The workspace decides how its services reach each other, because members can only meet on a bus they
share — a per-member choice would make a workspace whose services silently cannot talk expressible
in one flag. generate app therefore refuses --transport and names the workspace flag.
--transport | What every member gets | Proven by |
|---|---|---|
http (default) | Nothing extra. Members call each other at discovery.resolveUrl(name). | Three members, one calling both peers, asserting 200 and the body. |
grpc | GrpcPlugin({ basePath: '/grpc' }), co-serving Connect/gRPC on the member’s own port alongside HTTP. The full-stack template exempts this protocol prefix from form CSRF, leaving its own form routes protected. | Three members, one calling both peers at /grpc/grpc.health.v1.Health/Check, asserting the decoded SERVING. |
memory | The messaging plugin’s own in-process default, named rather than implied. Messages never leave the process. | — |
redis | MessagingPlugin({ broker: 'redis-streams', url }) | A real Redis: one service publishes, another receives the payload. Swapping to memory makes that test fail. |
rabbitmq / nats / kafka | The matching MessagingPlugin arm. | Type-checked in the generated project against the plugin’s discriminated union; not run in CI, which holds no such broker. |
pubsub | MessagingPlugin({ broker: 'pubsub', projectId }), the project id read from PUBSUB_PROJECT_ID. | Two generated members over the real Pub/Sub emulator: one publishes, the other receives the payload. |
service-bus | MessagingPlugin({ broker: 'service-bus', connectionString }), read from SERVICE_BUS_CONNECTION_STRING. | Two generated members over Microsoft’s emulator, started from the generated compose file and its generated entity config. |
There is no raw-TCP transport, and
--transport tcpsays so rather than quietly handing back HTTP under another name. Every inter-service path here is HTTP over TCP or a broker client over TCP.
Every transport with a connection value reads it from the environment, with the local address as
the baked fallback: Deno.env.get('REDIS_URL') ?? 'redis://127.0.0.1:6379'. One mechanism, because
a literal endpoint is unreachable from inside a container — the Compose stack and the Kubernetes
objects override the variable with the broker’s service name.
--transport-url replaces that fallback for the endpoint-shaped arms. It is refused for
pubsub and service-bus, naming the variable instead: a GCP project id is not a URL, and an Azure
connection string carries a shared-access key, which is not something to write into a file you
commit. Their fallbacks are the vendors’ own local-emulator settings, which is what lets an emulator
workspace run with no configuration at all. Two operational facts they carry, because a developer
cannot guess either: Pub/Sub does not create topics (publish posts to an existing one), and
the Service Bus emulator creates no entities at run time — every topic must be declared in the
generated docker/servicebus-config.json with a messaging-consumers subscription before it
starts.
Serving your own gRPC services needs descriptors, and the toolchain to build them ships.
--transport grpc makes every member a Connect server immediately, which is why the health service
answers with no configuration. A service of your own needs a Protobuf-ES descriptor, which only a
compiler can produce — so each member gets an example proto/, both buf manifests, and:
deno task proto:gen # descriptors land in src/gen/
Nothing needs buf or protoc on your PATH: both the compiler and the codegen plugin run through
Deno’s npm compatibility, and the member’s import map carries @bufbuild/protobuf so the generated
file compiles. Hand the descriptor to grpc.addService(definition, implementation).
Node and Bun workspaces
A workspace targets the toolchain you name, because the framework’s own claim is runtime independence and only the MONOREPO was Deno-only:
setu new acme --workspace --runtime node # or bun, or deno (the default)
The runtime is recorded in setu.workspace.json and every later command reads it back. It is a
WORKSPACE-wide choice, like the transport and for a stronger reason: members share one root manifest
and one lockfile, so generate app --runtime is refused when it disagrees with the workspace — a
Node member inside a Deno workspace is not a member at all. An absent field means deno, so every
workspace created before this keeps its shape.
deno | node | bun | |
|---|---|---|---|
| Root manifest | deno.json workspace | package.json workspaces | same as node |
| Install | deno install | npm install | bun install |
| Run every member | deno task dev | npm run dev | npm run dev |
| Environment read in generated source | Deno.env.get(x) | process.env.X | process.env.X |
| Library test runner | @std/testing | node:test | bun test |
| Image | denoland/deno | node:24-alpine | oven/bun |
Three facts were measured, and each shaped the result. Bun needs no root shape of its own — it
reads npm workspaces — but it installs into each MEMBER’s node_modules as well as the root, so
the generated ignore file and image account for both. No registry mapping is needed: every
member takes the framework from npm, so the root carries no .npmrc (before 0.9.0 one mapped the
@jsr scope, and without it no framework package resolved). And a sibling library resolves by its
package name under npm exactly as it does under Deno, so libraries needed no per-runtime design
beyond their manifest.
Cloudflare Workers is refused as a workspace target, with the reason: 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.
Shared libraries
Code two services both need is a workspace member of its own:
setu generate library shared # libs/shared, importable as @acme/shared
It needs no wiring at all, and that is a property of the workspace rather than a convenience: a
member declaring name and exports is importable by every sibling under exactly that name, with
no import-map entry anywhere. It holds on both arms, which is why libraries need no per-runtime
design — under Deno the workspace resolves the member directly, and under npm the symlinks the
install creates in the root node_modules do the same.
import { shared } from '@acme/shared';
The scope defaults to the workspace directory name and --scope overrides it. Nothing is recorded
in setu.workspace.json: a library has no port and is not a service, so it must never appear in a
discovery map or in the Compose stack, and the directory plus the root’s ./libs/* glob already are
the record. A workspace created before libraries existed has that glob added to its root; anything
else about the root is left alone.
Converting an existing project
cd my-service
setu adopt --port 4000 # my-service becomes a workspace holding apps/my-service
It moves only files this CLI emits, from the renderer’s own list rather than a directory walk.
That is a correctness property: a workspace has one lockfile and one history, both at the top, so
.git, CI configuration, deno.lock and node_modules stay at the root. Anything else you added
there stays too.
Because IFileSystem has no rename, each move is copy → verify → delete, in that order: a
failure part-way leaves the file in both places, which is recoverable and reported, rather than in
neither. The member’s entry is rewritten to bind its allocated port; if it no longer carries the
literal this replaces, the two lines to change are printed instead of guessed at.
| Refusal | Why |
|---|---|
| A directory that is already a workspace | setu.workspace.json exists. Exits 1 naming generate app. |
A directory with no setu.config.ts | Not a Setu project: there is no application factory to become the member’s. Exits 1. |
A --name that cannot form an identifier | Exits 2. The default comes from the directory name, so this is reachable without the flag. |
Plugin gating
setu generate reads the target project’s deno.json imports (falling back to package.json
dependencies + devDependencies) to learn which @setu-ts packages are installed. It never
imports or boots the project. A schematic whose backing plugin is absent is refused with exit code
1, naming the package to install, and setu generate --help marks it unavailable.
Overwrite protection
A generate that would overwrite any existing file writes NOTHING at all — every planned path is checked before the first write, so a multi-file schematic can never leave a half-written tree.
The one exception is a managed file: a path the CLI generated itself and regenerates, declared
by the schematic as GeneratedFile.managed. Managed paths are exempt from the check and rewritten
in place; every other path keeps the refusal above, including within the same command. The exemption
is per file rather than a --force flag, so a mistyped setu g service user can never clobber
hand-written work.
The managed files are the CLI-owned seam barrels — one index.ts per generated family
(src/modules/, src/controllers/, src/services/, src/middleware/, src/plugins/,
src/health/, src/metrics/, src/cqrs/, src/events/) — and each functional module’s own
src/modules/<name>/index.ts, which setu generate module and setu generate … --module <name>
regenerate. The development-only source policy src/devtool/diagnostics.ts is also CLI-managed. No
flag can make an arbitrary path managed.
Generated code is wired
Every artifact below reaches its registration site with no edit to a file you own. A wired
schematic emits its artifact plus its family’s seam barrel; the barrel is regenerated from a scan of
its directory, and the scaffolded setu.config.ts already imports it. Some artifacts have no
registration site, and the reason is recorded rather than left to discovery.
| Schematic | Emitted at | Registration site |
|---|---|---|
module | src/modules/<name>/; the first module switches a flat project to modules (--adopt-controllers moves the flat controllers and services in) | registerModules(app.router, app.services) from src/modules/index.ts, plus DecoratorPlugin({ modules }) via MODULES (class mode) |
controller | src/controllers/<name>.controller.ts; src/modules/<module>/<name>.controller.ts with --module (required once the project has modules) | DecoratorPlugin({ controllers }) via APP_CONTROLLERS (class mode) or registerGeneratedRoutes; in a module, its @Module declaration (class) or its index.ts (function) |
service | src/services/<name>.service.ts; src/modules/<module>/<name>.service.ts with --module (required once the project has modules) | DecoratorPlugin({ services }) via APP_SERVICES, or in a module its @Module({ providers }) — see below |
route | a function controller: src/controllers/<name>.controller.ts; src/modules/<module>/<name>.controller.ts with --module | registerGeneratedRoutes(app.router, app.services) in createApp(), or the module’s index.ts |
schema | src/shared/schemas/<name>.schemas.ts; src/modules/<module>/<name>.schemas.ts with --module | Nothing — the feature that uses it imports it |
sse | src/controllers/<name>.controller.ts; src/modules/<module>/<name>.controller.ts with --module; plus src/hooks/use-<name>.ts ONLY when react-router-plugin and sdk are both installed | As for controller; the hook, when emitted, remains application-local |
ws-route | src/plugins/<name>.plugin.ts; class-based: src/ingress/<name>-ws-route.ingress.ts | ...GENERATED_PLUGINS in createApplication({ plugins }); class-based: DecoratorPlugin({ ingress }) |
middleware | src/middleware/<name>.middleware.ts | app.middleware.add(...) over GENERATED_MIDDLEWARE |
plugin | src/plugins/<name>.plugin.ts | ...GENERATED_PLUGINS in createApplication({ plugins }) |
health-indicator | src/health/<name>.indicator.ts | HealthPlugin({ indicators: [...HEALTH_INDICATORS] }) |
metric | src/metrics/<name>.metric.ts | MetricsPlugin({ customMetrics: [...CUSTOM_METRICS] }) |
command-handler | src/cqrs/<name>.command-handler.ts; class-based: src/ingress/<name>-command-handler.ingress.ts | CqrsPlugin({ commandHandlers: COMMAND_HANDLERS }); class-based: DecoratorPlugin({ ingress }) |
query-handler | src/cqrs/<name>.query-handler.ts; class-based: src/ingress/<name>-query-handler.ingress.ts | CqrsPlugin({ queryHandlers: QUERY_HANDLERS }); class-based: DecoratorPlugin({ ingress }) |
event-handler | src/events/<name>.event-handler.ts; class-based: src/ingress/<name>-event-handler.ingress.ts | EventsPlugin({ handlers: EVENT_HANDLERS }); class-based: DecoratorPlugin({ ingress }) |
guard | src/guards/<name>.guard.ts | None — per route, by design (see below) |
job | 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 | src/migrations/<stamp>-<name>.ts | None — the framework ships no migration runner |
Registration details for artifacts with conditional or per-route wiring:
guardis applied per route, withapp.router.get(path, { handler, middleware: [requireX()] })or@UseGuards(requireX())on a controller or handler.auth-pluginpublishes no guard list, and the only barrel-shaped alternative — the global pipeline — would answer401for/health,/metricsand every public route. The emitted guard delegates torequirePermission('<name>'), enforcing the installed authorization provider for anonymous and insufficient-permission requests. A wiring that must not be applied is not a wiring.jobuses the installed queue. Functional projects withqueue-pluginemit a processor factory and the managedsrc/jobs/index.tsbarrel forQueuePlugin({ processors: [...QUEUE_PROCESSORS] }). Without queue they emit a standalone function. Class-based projects require queue and emit a decorated processor through the ingress seam; without queue the command refuses before writing and namessetu add queue. Each processor polls only its own job name; producers enqueue that name explicitly.migrationhas no consumer. No plugin registers a CLI command, so there is nosetu db:migrateand nothing reads migration files. Apply them from your own script or your ORM’s tooling.serviceis shaped on the detected plugin set. With@setu-ts/decorator-plugininstalled it emits@Injectable({ token: '<name>-service' })plus the barrel entry, so the class resolves fromservices.get('<name>-service')(or the DI container, whenDiPluginis registered). Without that package it emits a plain class and no barrel — the schematic stays ungated, so it keeps working in a project with no plugins at all.
Two artifacts cannot share a name, and generate refuses (exit 1) rather than producing something
broken:
route,controllerandmoduleall mount/<name>. The kernel’s router keys routes by method and path, so a duplicate silently overwrites and one artifact becomes unreachable.serviceandmoduleboth register@Injectable({ token: '<name>-service' }). The decorator plugin keeps the first class registered under a token, so the wrong service would be injected.
The first check runs in every style — functional modules are scanned in the name-conflict path, so a
route beside a module of the same name is refused in a functional project too, where the two files
no longer share a path. The second applies only when decorator-plugin is installed, since that
token collision cannot exist without it. A repeat generate of the same schematic is the ordinary
overwrite refusal instead.
A project scaffolded before a seam existed has no import of its barrel. Each barrel’s header states
the exact lines to add to setu.config.ts; add them once and every later generate is wired. The
rest, microservice and class-based templates emit every applicable seam from scaffold time, so
a new project is wired before anything is generated — and so does the no-template path, for the
three seams that need no plugin (route, middleware, plugin). --template full-stack is
deliberately not a host: its layering is routes → features → services, it composes through a
starter factory, and its createApp has no plugin array to spread into.
Artifacts generated before their family gained a second export are skipped, and reported. A
barrel imports specific symbols from each artifact, and two families gained one in this release:
middleware now exports a <SCREAMING>_MIDDLEWARE_PRIORITY constant and metric a
<SCREAMING>_METRIC declaration. An artifact generated earlier has the right filename and lacks
that export, so listing it would put an unresolvable import in the barrel. The scan admits a file
only when it exports everything the barrel will name, and prints what it left out:
Skipped src/middleware/audit-log.middleware.ts: it does not export
AUDIT_LOG_MIDDLEWARE_PRIORITY, so it cannot be listed in the generated barrel.
Regenerate it to bring it up to date.
Delete and regenerate the artifact to bring it back in. The same rule keeps a hand-written module in
a scanned directory out of the barrel — the flat-family form of the precondition that admits a
src/modules/ directory only when it holds both canonical files. Export detection reads the source
text rather than parsing it (this package has no TypeScript parser), so a declaration
(export const X) and a named re-export (export { y as X }) are recognized while an aliased
default export is not; an undetected export means the artifact is skipped and reported, never that a
broken barrel is written.
Which seams a host carries depends on which plugins it registers: the no-template path carries the
three that need none (src/controllers/, src/middleware/, src/plugins/), the functional
templates carry only the seams their plugins consume, and class-based additionally carries
controller, service and module barrels. microservice additionally carries src/cqrs/ and
src/events/, because it is the only template registering CqrsPlugin and EventsPlugin.
Domain modules
setu generate module <name> is the aggregate schematic, and it is ungated — it runs in every
project shape, including one scaffolded with no template. Which of two file sets it emits is decided
by whether @setu-ts/decorator-plugin is installed.
Functional (the default composition):
src/modules/<name>/<name>.service.ts in-memory `Map` store — replace with a repository
src/modules/<name>/<name>.routes.ts register<Name>Routes — GET /, GET /:id, POST / (201), PATCH /:id
src/modules/<name>/<name>.schemas.ts one Zod value and a `z.infer` type per shape (with `validation-plugin`)
src/modules/<name>/index.ts CLI-owned, regenerated — exports `register<Name>Module(router, services?)` and nothing else
src/modules/<name>/<name>.test.ts in-process contract test (socket targets only)
src/modules/<name>/<name>.e2e.test.ts real-HTTP contract test (socket targets only)
src/modules/index.ts the activation barrel (managed — regenerated)
The module registers through registerModules(app.router, app.services) in setu.config.ts — wired
from scaffold time on every functional host, and printed to add (import line and call) on a project
scaffolded before the call existed — so the module answers GET /<name>, GET /<name>/:id,
POST /<name> and PATCH /<name>/:id with no edit to setu.config.ts. setu generate module
writes nothing under src/controllers/ any more, and a route and a module sharing one name is
refused before anything is written, because functional modules are scanned in the name-conflict
path. A pre-M114 functional module (service and index, no <name>.routes.ts) is neither admitted
nor reported by the module scan: its routes stay wired through the src/controllers/index.ts
barrel.
Class-based (--template class-based, or any project holding decorator-plugin):
src/modules/<name>/<name>.service.ts @Injectable, token '<name>-service'
src/modules/<name>/<name>.controller.ts @Controller('/<name>'), class-level @Inject, @Params(…, Ctx())
src/modules/<name>/<name>.module.ts @Module({ controllers, providers }) — application-owned; never rewritten
src/modules/<name>/<name>.schemas.ts one Zod value and a `z.infer` type per shape (with `validation-plugin`)
src/modules/<name>/<name>.test.ts in-process contract test (socket targets only)
src/modules/<name>/<name>.e2e.test.ts real-HTTP contract test (socket targets only)
src/modules/index.ts the aggregate barrel (managed — regenerated)
The aggregate barrel exports MODULE_CONTROLLERS and MODULE_SERVICES, and the class-based
template scaffolds a setu.config.ts that already imports both and passes them to
DecoratorPlugin:
import { MODULE_CONTROLLERS, MODULE_SERVICES } from './src/modules/index.ts';
DecoratorPlugin({ controllers: [...MODULE_CONTROLLERS], services: [...MODULE_SERVICES] });
So generating a module changes only files the CLI owns. Regenerating over an existing module still refuses on that module’s own files; the barrel is rewritten either way and lists each module once.
A project scaffolded before this schematic existed has no barrel import: in class mode, add the two
lines above to setu.config.ts once; in functional mode, setu g module prints the
registerModules import and call to add. Every later setu g module is then wired automatically.
--template full-stack is deliberately not a host: its layering is routes → features → services
and it has no src/modules/ concept.
--module <name> places a service, route (functional only), controller or schema inside an
admitted module: in functional mode the file lands in src/modules/<name>/ and the module’s
CLI-owned index.ts is regenerated to register it, with a module-scoped route or controller
mounting at /<module>/<name>; in class-based mode the file is written and the
@Module({ providers | controllers }) line to add is printed — the declaration is never edited —
and a module-scoped service registers under the token <module>.<name>-service (for example
billing.invoice-service).
A host template declares @std/testing and @std/expect so the emitted test runs with no further
setup — as a deno.json import on Deno and Cloudflare Workers. Node and Bun declare neither: their
emitted test uses the built-in node:test and bun:test, because @std/testing/bdd calls
Deno.test. A directory is admitted as a module only in the project’s style — class-based: it holds
<name>.controller.ts, <name>.service.ts and <name>.module.ts; functional: it holds
<name>.routes.ts and an index.ts exporting register<Name>Module — so unrelated folders under
src/modules/ (a shared-helpers directory, say) are left out of the barrel rather than breaking it,
and an admitted-shape directory missing its registrar is skipped and reported.
The emitted controller’s handlers take only the arguments their @Params(...) names and return
plain values, which the plugin serializes as JSON. That is a constraint of DecoratorPlugin, not a
style choice: it builds a handler’s argument list from that declaration alone and never passes the
request context positionally, so an undeclared ctx: IRequestContext parameter arrives undefined
and the first ctx.response throws — a 500 on every request. A handler that needs the context (to
set a status code, or to stream) declares Ctx() among its sources, which is how the generated
create answers 201.
The service’s @Injectable token is explicit ('<name>-service') and the controller’s @Inject
names that exact string, because emitDecoratorMetadata is unavailable under Deno, so a parameter’s
type cannot be read. The module works with and without DiPlugin: with a container the service is
constructed through it, without one it resolves from the kernel’s service registry.
Project templates
setu new always emits a setu.config.ts exporting createApp() — one place the project’s plugin
list lives. main.ts imports it to start the server, and setu imports it to discover plugin
commands, so the two can never disagree. The factory deliberately does NOT start the application.
// setu.config.ts (--template rest)
import { createApplication, type IKernelApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { ConfigPlugin } from '@setu-ts/config-plugin';
// … logging, validation, security, health, metrics, OpenAPI, decorators
import { errorHandler } from '@setu-ts/exceptions';
export function createApp(): IKernelApplication {
const app = createApplication({
plugins: [RuntimePlugin(), ConfigPlugin({ envFilePath: '.env', envFileOptional: true })],
});
app.middleware.add(errorHandler({ format: 'rfc9457' }));
app.router.get('/', (ctx) => ctx.response.json({ message: 'Hello, World!' }));
return app;
}
| Template | Plugin set |
|---|---|
| (none) | RuntimePlugin only. |
rest | Runtime, Config, Logger, Validation, HttpSecurity, Health, Metrics, OpenApi + RFC 9457 errorHandler(). |
microservice | rest plus Messaging, Queue, Resilience, Telemetry, ServiceDiscovery ('static' arm), Cqrs, Events. |
class-based | rest plus DecoratorPlugin and DiPlugin, an @Injectable service, and a @Controller. |
full-stack | A React Router 8 SSR app: the full plugin set via createFullStackAppFromConfig, plus an app/ skeleton. |
Three of the four templates emit inline wiring, not imports of the @setu-ts/*-starter
packages, so a scaffolded project owns an explicit, editable plugin list. full-stack is the
exception, with cause: its composition is twenty-two plugins, and a generated file a human is meant
to open and edit should not begin with twenty-two imports they did not choose. A general --starter
flag for the other three is still deferred (see “Not in this release”).
--template full-stack
// setu.config.ts (--template full-stack)
import { createFullStackAppFromConfig } from '@setu-ts/full-stack-starter';
import type { IKernelApplication } from '@setu-ts/kernel';
import { getCsrfToken, getSession } from '@setu-ts/session-plugin';
import { csrfContext, sessionContext } from './app/lib/context-keys.ts';
export async function createApp(): Promise<IKernelApplication> {
return await createFullStackAppFromConfig((config) => ({
reactRouter: {
serverBuildPath: './build/server/index.js',
assetsDir: './build/client/assets',
populateLoadContext: (ctx, context) => {
context.set(sessionContext, getSession(ctx));
context.set(csrfContext, getCsrfToken(ctx));
},
},
session: { secret: config.getOrThrow<string>('SESSION_SECRET'), csrf: {} },
}));
}
The emitted app/ tree is the deliverable: routes → features → services → models, flatRoutes
_app/_auth layout groups each wrapped in their own layout, the ~/* alias, the .server.ts
convention, and one worked feature. What it deliberately does not contain is a
lib/session.server.ts, lib/csrf.server.ts, lib/sse.server.ts, lib/kv.server.ts or
lib/service-logger.server.ts — those are the session, SSE, secrets and logger capabilities,
reached through the registry the SSR plugin attaches to every request.
Session reaches loaders through a key the application declares, never a plugin-to-plugin import:
getSession takes an IRequestContext, which a loader never sees, while populateLoadContext
receives exactly that. RouterContextKey is exported from the SSR plugin so app code can declare
keys without importing react-router on the server.
Notes:
- The generated factory is
async.setuawaits it during command discovery, andmain.tsawaits it too, so nothing else changes. - No hello-world route. An exact
/handler takes precedence over the SSR catch-all under the M70g specificity rule (a wildcard ranks below any route naming its path, in either registration order), so it would shadow the application’s own index route. - Every runtime target is supported. Cloudflare Workers omits
assetsDirandenvFilePath: with no filesystem the asset handler would answer 404 for every asset, and a dotenv path would make ConfigPlugin throw. Static assets come from the platform binding and configuration from the requestenvbinding. - The frontend build runs on npm even when the server runs on Deno — the one documented
exception to the Deno-only toolchain. Deno and Workers targets get a standalone
package.jsonfor Vite and React Router; Node and Bun targets get those dev dependencies merged into thepackage.jsonthey already have. The Denostarttask additionally carries--allow-read, which the SSR plugin needs to import its own server build and read client assets. - React Router is pinned to v8, matching the
react-routerthe SSR plugin imports.
A class-based project (the class-based alias, or rest/microservice with --style class-based)
additionally emits src/services/greeting.service.ts and src/controllers/greeting.controller.ts,
registered through the seam barrels that setu.config.ts passes to DecoratorPlugin; the
controller declares its dependency in the class-position @Inject('greeting-service') list. It
supports every runtime target. On Cloudflare Workers, the microservice template swaps its
socket-bound messaging and queue plugins for the platform implementation.
Plugin-contributed commands
A plugin publishes commands with ctx.cli.register(name, handler); the CLI discovers them by
loading setu.config.ts and starting the application with no port, so registration happens
without binding a socket. The application is always stopped afterwards, including when a handler
throws.
setu commands # list what this application's plugins provide
setu db:migrate up 3 # positionals after the name reach the handler
Handlers receive positionals only. setu consumes its own flags, so pass a plugin command’s flags
after --:
setu db:migrate -- --verbose --dry
Built-in verbs (new, n, generate, g, commands, help) are matched first and always
win, so a plugin cannot shadow them — and those paths never import your project. Only an unmatched
first positional triggers a boot.
Two plugins registering the same command name is an error (1) that runs neither: which
registration wins would otherwise depend on plugin load order.
Generated plugin example
setu g plugin my-plugin
Generates src/plugins/my-plugin.plugin.ts plus the managed src/plugins/index.ts barrel that
registers it. The .plugin.ts suffix is what lets the barrel be regenerated from a directory scan
without admitting a module you hand-wrote in the same folder:
import { createCapabilityToken } from '@setu-ts/common';
import type { IPlugin, IPluginContext } from '@setu-ts/common';
/** Capability token this plugin provides. */
export const MY_PLUGIN = createCapabilityToken('my-plugin');
/** The service registered under {@linkcode MY_PLUGIN}. */
export interface IMyPluginService {
/** Replace with the capability this plugin publishes. */
describe(): string;
}
/**
* Registers the my-plugin capability.
*
* @returns The plugin to pass to `createApplication({ plugins: [...] })`
*/
export function MyPluginPlugin(): IPlugin {
return {
name: 'my-plugin',
version: '0.1.0',
provides: [MY_PLUGIN],
register(ctx: IPluginContext): void {
const service: IMyPluginService = {
describe: () => 'my-plugin',
};
ctx.services.register(MY_PLUGIN, service);
},
};
}
Custom schematics
setu generate custom <schematic> <name> resolves .setu-ts/schematics/<schematic>.ts and loads it
with a real dynamic import(). The module must export a schematic function (or a default export
that is a function):
// .setu-ts/schematics/readme.ts
import type { DerivedNames, GeneratedFile, SchematicOptions } from '@setu-ts/cli';
export function schematic(
names: DerivedNames,
options: SchematicOptions,
): readonly GeneratedFile[] {
return [{ path: `docs/${names.kebab}.md`, contents: `# ${names.pascal}\n` }];
}
DerivedNames carries raw, kebab, camel, pascal, and screaming. SchematicOptions
carries the target runtime, the detected plugins set, now() — an injected clock, so
timestamped output stays deterministic — and modules?, the domain modules already present under
src/modules/. With --module, it also carries targetModule? — the admitted feature module
receiving a service, controller, route or schema — and moduleArtifacts?, the registrars
already present inside that module (the per-module counterpart of artifacts?), which the route
and controller schematics read to regenerate the module’s index.ts. Schematics perform no I/O;
the command layer gathers the project state they need and writes what they return, which is what
makes --dry-run exact.
modules is optional so that a harness written before it existed still compiles; setu generate
always supplies it. targetModule and moduleArtifacts are optional for the same
published-interface reason, and are supplied only when --module names an admitted module.
GeneratedFile.bytes, when present, supplies exact binary contents instead of encoding contents.
CliDependencies.fetch optionally injects the example downloader; it defaults to
globalThis.fetch.
GeneratedFile carries an optional managed flag — see “Overwrite protection”.
Programmatic API
| Export | Kind | Purpose |
|---|---|---|
runCli | function | Runs the CLI and RETURNS an exit code; never calls Deno.exit. |
CliDependencies | type | The boundary bundle runCli requires, including optional prompting, interruption and the verified logical working directory. |
deriveNames | function | Produces the five naming forms every schematic uses. |
DerivedNames | type | The result of deriveNames. |
GeneratedFile | type | { path, contents, managed?, bytes? } — one file a schematic asks to create. |
Schematic | type | (names, options) => readonly GeneratedFile[]. |
SchematicOptions | type | The second parameter of every schematic (runtime/plugins/now/modules?, and with --module the optional targetModule? and moduleArtifacts?). |
PROGRAM_NAME | const | 'setu' — interpolated into every usage string. |
TemplateName | type | The --template value union, for callers building argv programmatically. |
ModuleLoader | type | The seam a custom schematic module is loaded through. |
AppLoader | type | The seam setu.config.ts is loaded through (CliDependencies.loadApp). |
PromptChoice | interface | One value and label offered by a Prompter. |
Prompter | interface | Programmatic interactive-question seam. |
PromptSelection | type | Answer, unavailable input, or user cancellation. |
detectPlugins | function | Reads a project manifest and returns the installed @setu-ts names. |
CliDependencies has no default: src/main.ts owns the process boundary (Deno.args,
Deno.cwd(), console, the real filesystem, and the single Deno.exit), so every other path is
testable without terminating the runner. Its optional logicalCwd member carries the shell’s PWD
only when it is absolute and resolves to the physical working directory; setu agents uses it
solely to refuse a write through a symlinked workspace member, never to authorize one.
Not in this release
- Starter-backed scaffolding.
--templateemits inline wiring. Asetu new --starterpath that scaffolds a project importingcreateRestAppand friends is deferred — the starters themselves shipped in Milestone 36 and can be depended on directly. - Flags for plugin commands.
CliCommandHandlerreceives positionals only; giving handlers a parsed flag record would widen a committedcommoncontract. Forward flags with--instead. - Plugin installation.
setugenerates and dispatches; it does not edit your manifest.