@nage-api/cli
v1.0.0-beta.4
Published
The nage CLI — scaffold and manage a @nage-api workspace, its apps, packages and resources
Readme
@nage-api/cli
The nage command (PLAN.md §10): scaffolding that produces a production-shaped
workspace, and workspace management that keeps it coherent as it grows.
The legacy generator wrote a project and then let it drift: --noSpec was the
habit, wiring a new module was a manual edit in three files, and nothing checked
that the result still made sense. Here every command is a transaction over the
whole workspace, every generator that produces behaviour also produces its test,
and nage doctor reports through the same audit that blocks an insecure
production boot.
Commands
| Command | What it does |
| --------------------------- | --------------------------------------------------------------------------- |
| nage create <name> | Scaffold a workspace seeded with one app |
| nage new app <name> | Add an app — its own preset, port and features |
| nage new package <name> | Add a shared local package and wire its alias |
| nage g resource <name> | Entity, DTOs, service, controller, module, tests and a migration |
| nage g <schematic> <name> | module, service, controller, entity, dto, migration, seed |
| nage add <feature> | Enable auth/cache/queue/storage/realtime/notify/observability |
| nage remove <feature> | Disable one, dependency and all |
| nage remove app <name> | Unregister and archive the directory as <name>.removed |
| nage remove package <n> | Unregister and drop the alias; the directory is left where it is |
| nage list / nage info | What exists; what versions a bug report needs |
| nage doctor | Workspace integrity + the secret audit, exit 1 when blocking |
Flags: --app <name>, --all, --dry-run, --db, --first-app, --preset,
--port, --fields, --route, --package, --no-migration, --no-spec,
--legacy, -h, -v. parseArgs needs the whole option set declared up front,
so every flag parses on every command and a command ignores the ones it does not
read. --app is usually unnecessary for g — the current directory says which
app is meant — and doctor accepts it but does not act on it.
g entity, g dto, g migration and g seed emit no spec of their own: they
produce a declaration, and the tests belong to the service and controller that
use it, which g resource, g service and g controller do emit.
Every command, every flag with its type and default, every file each one writes,
and each doctor check individually: docs/cli-reference.md.
Four ideas
Every command is a plan. A command builds a FileTree and returns it; the
caller commits it. That is what makes --dry-run exact rather than a
best-effort preview, and what lets the whole surface be tested without spawning
a process:
import { commitFileTree, loadWorkspace, planGenerate } from '@nage-api/cli';
const { root, manifest } = await loadWorkspace(process.cwd());
const plan = await planGenerate({
root,
manifest,
schematic: 'resource',
name: 'product',
appName: 'api',
});
const willBeWritten: readonly string[] = plan.tree.paths; // exactly what lands
await commitFileTree(plan.tree, { root, dryRun: false });Writes are atomic. commitFileTree refuses the whole batch before writing
anything, backs up what it replaces, and rolls back on any failure. Adding a
shared package touches four files at once — nage.workspace.json, the alias in
tsconfig.base.json, the packages/* glob in pnpm-workspace.yaml and the
project reference in the root tsconfig.json — and a workspace where three of
the four landed is worse than a command that failed cleanly: the alias resolves
in the editor but not in pnpm install, and nothing says why.
The rollback covers a failed write, not a killed process: there is no signal
handler, so Ctrl-C mid-write leaves what had landed. And remove app archives
the directory after the commit rather than inside it, so an archive that fails
leaves the app unregistered.
Generators are idempotent. Re-running one writes only the files that are
missing (onConflict: 'skip'); edited code is reported as left alone, never
clobbered. The module-wiring edit to app.module.ts is deliberately
conservative: when the file no longer matches the shape the CLI emits, it prints
an instruction rather than mangling it.
The one exception is the migration. Its filename carries a fresh timestamp, so it
can never collide with the previous one, and re-running g resource product
leaves two migrations that both create the products table. Pass
--no-migration when re-running a generator over a resource that already exists.
Sub-generators cannot drift. g service is a filter over the file set
g resource would produce, so the two are byte-identical by construction — a
property the suite asserts rather than a convention to remember.
nage.workspace.json
The manifest is the source of truth, serialised deterministically so a rewrite produces an empty diff:
{
"version": 1,
"name": "shop",
"engine": "postgres",
"frameworkVersion": "^0.1.0",
"apps": [{ "name": "api", "preset": "api", "port": 3000, "features": ["cache"] }],
"packages": [{ "name": "domain", "alias": "@app/domain" }]
}One engine per workspace (§10.2): apps share packages/domain entities and
database/migrations, which only stays coherent with a single engine. The
driver package is chosen at scaffold time — a Postgres workspace never installs
the Mongo driver.
What the generated workspace looks like
nage create shop writes a monorepo that is ready to deploy, not ready to
configure: strict TypeScript with project references, Turborepo, ESLint,
Prettier, a CI workflow, a multi-stage non-root Dockerfile, a .env.example,
and an app whose main.ts validates its environment before binding a port. The
.env.example covers NODE_ENV, CORS_ORIGINS, DATABASE_URL and a
<APP>_PORT line per app; it does not cover PORT, which is the variable the
generated env schema actually reads.
Presets: minimal, api, api-realtime, worker. A worker has no HTTP
surface — no port, no CORS, no health controller — because a preset that stays
half-applicable is how insecure defaults spread. minimal, api and
api-realtime currently emit identical output; the preset is recorded in the
manifest so a later version can act on it.
nage doctor
Two kinds of check, one report and one exit code:
- workspace integrity — duplicate app names or ports, aliases that collide, apps registered but missing from disk (and the reverse), shared packages nothing imports;
- secrets — delegated to
auditSecurityin@nage-api/core, so the rule that fails a weak secret here is the same code that refuses a production boot, rather than two lists that drift.
Anything critical or high exits 1, which is what makes it usable as a CI
gate. --legacy additionally scans sources for the insecure patterns of §23.3.
Secret strength is judged from the workspace's own .env, deliberately not
from process.env: the shell a developer or a runner invokes doctor in is
full of tokens whose names match any secret heuristic, and a report about the
machine rather than the project is a report nobody reads.
What doctor does not yet see is the app's own nage.config.ts. It calls
auditSecurity with a minimal production config, so the config-shaped findings
that audit can produce — a wildcard CORS list, ssl: 'no-verify', throttling
switched off — are not reported by the CLI today. Reaching them means executing
the app's TypeScript config, which the CLI does not do. Until it does,
assertSecureConfig at boot is where those are caught (§12).
Not yet implemented
nage upgrade— codemods across framework versions (§10.1).nage db migrate|seed|rollback— needs a driver resolved at runtime inside a generated workspace. The generatedpackage.jsonshipsdb:migrateanddb:seedscripts that invoke it anyway, sopnpm db:migratefails with"db" is not a nage command, as do the two generated notes that recommend it.--standaloneandnage workspace init(§9.3). The flag parses and is accepted;planCreateignores it, so you get a workspace either way.--force. It parses, but no command reads it — a conflicting file still fails the whole batch, whatever the hint in that error says.doctor --app <name>. It parses and reachesrunDoctor, which never reads it: the report always covers the whole workspace, and an app name that does not exist is not even reported.- Validation of
--db,--presetand--port. All three are cast rather than checked, so--db cassandrawrites that engine into the manifest and--port abcrecords"port": null. --package <name>beyond placing the entity file. The service is pointed at@app/<name>, but the controller, the service spec and the query DTO still import./entities/<name>.entity.js, and the package'sindex.tsis not updated to export the entity — so a resource generated this way does not typecheck until three imports and one export are fixed by hand. The same applies tog module,g controllerandg dtoused on their own: each emits files that import siblings it does not emit.- Dropping the root project reference in
remove package. The manifest entry and the alias go;{ "path": "packages/<name>" }stays intsconfig.jsonand breakstsc -bonce you delete the directory.remove appdoes remove its reference. - The security half of
doctorreading the app's config, as above. - Interactive prompts; every command is flag-driven for now.
- The "generated workspace installs from a registry and passes its own CI"
acceptance test (§24 Phase 6). The suite does build, lint, typecheck and run
the generated workspace's own tests, but against symlinked local packages: it
cannot tell you whether the version ranges in the generated
package.jsonresolve, because@nage-api/*is not published yet.
The deeper guide is docs/packages/cli.md.
