@sundaysf/cli-v3
v0.0.2
Published
Sundays Framework v3 CLI - scaffolds an Express 5 + Knex + Postgres API and generates entity verticals
Maintainers
Readme
Sundays Framework v3 — @sundaysf/cli-v3
CLI that scaffolds a production-ready REST API (Express 5 · TypeScript · Knex · PostgreSQL · Zod · pino · Jest) and generates complete entity verticals inside it.
Commands
# Create a project ---------------------------------------------------------------
npx @sundaysf/cli-v3 new my-api # new folder ./my-api
npx @sundaysf/cli-v3 new my-api --with-auth # + user/auth tables, register/login/me, JWT
npx @sundaysf/cli-v3 new . # scaffold INTO the current (empty) folder
npx @sundaysf/cli-v3 new . --name my-api # same, choosing the package name
npx @sundaysf/cli-v3 new my-api -y # no prompts, defaults (no auth, port 3005, npm)
# Options for `new`
# --with-auth | --no-auth include / skip auth without asking
# --port <n> HTTP port (default 3005)
# --pm npm|pnpm package manager (auto-detected)
# --no-install skip dependency installation
# --no-git skip git init and the initial commit
# --name <name> project name when using "."
# -y, --yes accept every default, never prompt
# Work inside the project --------------------------------------------------------
docker compose up -d # local PostgreSQL 16
npm run db:migrate # apply migrations
npm run start:dev # http://localhost:3005/api/health
npm test # jest + coverage (needs Postgres)
npm run test:unit # jest without database suites
# Generate an entity (run inside the project) ------------------------------------
sundaysf generate entity product name:string:unique price:decimal isActive:boolean=true
sundaysf g entity order userId:user.id total:decimal 'notes:text?' status:string=pending
sundaysf g entity tag --dry-run # print, write nothing (asks for fields on a TTY)
# Options for `generate entity`
# --fields "<spec>" fields as one string instead of positional args
# --no-tests skip unit + route tests
# --no-migration skip the migration
# --dry-run print everything, write nothing
# --force overwrite existing code files (never migrations)
# After generating
npm run db:migrate && npm run typecheck && npm test
# Global install instead of npx ----------------------------------------------------
npm install -g @sundaysf/cli-v3 # then: sundaysf new ..., sundaysf g entity ...
sundaysf --help | sundaysf new --help | sundaysf generate entity --helpField syntax: name:type[?][:unique][=default] with string text integer decimal boolean date
datetime json uuid <entity>.id. Quote fields containing ? in zsh ('notes:text?').
Full reference: sundaysf generate entity.
- What changed since v2
- Install
sundaysf newsundaysf generate entity- The generated API
- Conventions
- Testing the generated API
- Deploying the generated API
- Developing the CLI
- Troubleshooting
What changed since v2
| | v2 (@sundaysf/cli-v2) | v3 (@sundaysf/cli-v3) |
| ----------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Templates | 6 (--backend, --db-sql, --backend-embedded-db-sql...) | 1: API with Knex embedded, plus an optional auth overlay |
| Commands | --init --<template>, --create-controller | new, generate entity |
| Generators | controller + router stubs; dead manifest.js; scripts/import-manifest.js copied into projects | one generator: migration, interface, DAO, DTOs, controller, router, unit + route tests, barrel export |
| Express | 4 | 5 |
| Validation | hand-written DTO classes, phantom @sundaysf/utils | Zod schemas in the same dto/input/<e>/ layout |
| Data access | every DAO re-implements 6 CRUD methods | BaseDAO<T> with pagination and optional transaction |
| Config | duplicated in knexfile.ts and KnexConnection.ts, process.env everywhere | one knex.config.ts; env validated once with Zod |
| Dev tooling | nodemon + ts-node | tsx for the dev server and the knex CLI |
| Logging | morgan + console.* | pino + pino-http with request ids |
| Security | none | helmet, CORS allowlist, graceful shutdown |
| Tests | none shipped | Jest + supertest suite included, ~97% coverage, CI workflow |
| Local DB | bring your own | docker-compose.yml with PostgreSQL 16 |
| Docs | stale README/CLAUDE.md | README, CLAUDE.md and Claude agents rewritten for the real code |
| Removed | ownLibs/, postman.json, .npmrc in projects, root migrations/, lodash, rimraf, expo lint | |
Install
npm install -g @sundaysf/cli-v3 # global: `sundaysf ...`
npx @sundaysf/cli-v3 new my-api # or one-offRequires Node.js 20+ (the generated project targets Node 22+), npm or pnpm, git, and Docker for the local database (optional).
sundaysf new
sundaysf new <name|.> [options]
. scaffold into the current directory (must be empty; an existing .git is kept)
--name <name> project/package name when using "." (default: the folder name, slugified)
--with-auth include auth (user/auth tables, register/login/me, JWT, bcrypt, middleware, tests)
--no-auth skip auth without asking
--port <port> HTTP port (default 3005)
--pm <npm|pnpm> package manager (auto-detected)
--no-install skip dependency installation
--no-git skip git init and the initial commit
-y, --yes accept defaults, never promptInteractive runs ask for anything not given as a flag. With --yes or without a TTY the defaults
are: no auth, port 3005, install, git.
What it does:
- Validates the name (
^[a-z0-9][a-z0-9-]{0,63}$) and that the target folder is empty. With.the folder is the current directory: only.gitand editor folders may already exist, and the name comes from--nameor the folder name (My API v2→my-api-v2). - Copies the
apitemplate, applies theapi-authoverlay when requested, replaces the__SF_*__tokens (project name, port, database name, CLI version), renames_gitignoreand_package.json, and refuses to finish if any token is left or a.npmrcsneaked in. - Writes
.envfrom.env.example. With auth,.envgets a random 64-charJWT_SECRET(.env.examplekeeps a placeholder). git init -b main(skipped when.gitalready exists),npm install,npm run format, and an initial commit (chore: scaffold <name> with @sundaysf/cli-v3 <version>).- Prints the next steps. A failing install or commit is reported but never deletes the files.
Generated tree (auth files marked *):
my-api/
├── .env .env.example .sundaysrc .gitignore .dockerignore .prettierrc eslint.config.js
├── package.json tsconfig.json tsconfig.spec.json jest.config.js jest.setup.js knexfile.ts
├── docker-compose.yml Dockerfile README.md CLAUDE.md
├── .claude/agents/{sundays-backend-builder,knex-table-implementer}.md
├── .github/workflows/{ci,deploy}.yaml
└── src/
├── server.ts boot, migrations on demand, graceful shutdown
├── app.ts helmet, cors, request id, pino-http, parsers, /api, 404, errors
├── common/{config/env,logger,errors/http.error,validation/parse-dto,utils/*}.ts
├── db/{index,BaseDAO,KnexConnection,knex.config,d.types}.ts
├── db/dao/<entity>/<entity>.dao.ts db/interfaces/<entity>/<entity>.interfaces.ts
├── migrations/ seeds/
├── routes/index.ts (auto-discovery) routes/health/ routes/auth/*
├── controllers/health/ controllers/auth/*
├── dto/input/auth/* services/jwt/* services/password/* middlewares/auth/*
├── middlewares/{error,not-found,request-id}/
└── jobs/ (cron jobs: export run() + schedule(), register schedule() in server.ts)sundaysf generate entity
sundaysf generate entity <name> [fields...] [options] (alias: sundaysf g entity)
--fields "<spec>" fields as one string instead of positional arguments
--no-tests skip the unit and route tests
--no-migration skip the migration
--dry-run print everything, write nothing
--force overwrite existing code files (migrations are never overwritten)Must run inside a project created by v3 (it looks for .sundaysrc with a cli field and the
@sundays markers in src/db/index.ts). Without fields on a TTY it asks for them one by one.
Field syntax
name:type[?][:unique][=default]| Type | Column (knex) | TypeScript | Zod (create) |
| ------------- | -------------------------------------------------------------------------------- | ------------------------- | ----------------------------------- |
| string | string(name, 255) | string | z.string().trim().min(1).max(255) |
| text | text(name) | string | z.string() |
| integer | integer(name) | number | z.number().int() |
| decimal | decimal(name, 15, 2) | number | z.number() |
| boolean | boolean(name) | boolean | z.boolean() |
| date | date(name) | string (YYYY-MM-DD) | z.iso.date() |
| datetime | timestamp(name) | Date \| string | z.coerce.date() |
| json | jsonb(name) | Record<string, unknown> | z.record(z.string(), z.unknown()) |
| uuid | uuid(name) | string | z.uuid() |
| <entity>.id | integer(name).references('id').inTable('<entity>').onDelete('CASCADE').index() | number | z.number().int().positive() |
| Modifier | Column | TypeScript | Zod |
| --------- | ------------------------------------------------ | ------------------- | ------------------------ |
| (none) | .notNullable() | required | required |
| ? | nullable | field?: T \| null | .nullable().optional() |
| :unique | .unique() + getBy<Field>() finder in the DAO | | |
| =value | .defaultTo(value) | field?: T | .default(value) |
id, uuid, createdAt and updatedAt are always added and cannot be declared. Field names
are camelCase; the entity name can be written in any case (productCategory, product-category,
ProductCategory) and is derived into kebab (files, /api/product-category), Pascal
(ProductCategoryDAO, IProductCategory), camel (_productCategoryDAO) and snake
(product_category table).
Files written
src/migrations/<timestamp>_create_<table>.ts
src/db/interfaces/<kebab>/<kebab>.interfaces.ts interface I<Pascal> extends IEntity
src/db/dao/<kebab>/<kebab>.dao.ts class <Pascal>DAO extends BaseDAO<I<Pascal>>
src/dto/input/<kebab>/<kebab>.create.dto.ts <Pascal>CreateSchema + validate<Pascal>Create()
src/dto/input/<kebab>/<kebab>.update.dto.ts <Pascal>UpdateSchema (all optional, no defaults)
src/controllers/<kebab>/<kebab>.controller.ts getAll, getByUuid, create, update, delete
src/routes/<kebab>/<kebab>.router.ts GET /, GET /:uuid, POST /, PUT /:uuid, DELETE /:uuid
src/controllers/<kebab>/__tests__/<kebab>.controller.test.ts unit test, DAO mocked
src/routes/<kebab>/__tests__/<kebab>.routes.test.ts supertest lifecycle against Postgres
src/db/index.ts two export lines added above the markersThe route test is written as describe.skip when the entity has foreign keys: the header comment
explains which parent rows to create in beforeAll before enabling it. Generated files are
formatted with the project's prettier.
Markers
Generators never parse TypeScript; they insert lines above marker comments, skipping lines that already exist. The base project ships these markers, keep them:
| File | Marker |
| --------------------------- | -------------------------------------------------------------------- |
| src/db/index.ts | // @sundays:interfaces, // @sundays:daos |
| src/common/config/env.ts | // @sundays:env-schema |
| .env.example | # @sundays:env |
| .github/workflows/ci.yaml | # @sundays:ci-env |
| README.md | <!-- @sundays:readme-env -->, <!-- @sundays:readme-endpoints --> |
| CLAUDE.md | <!-- @sundays:features --> |
The generated API
Request flow: routes/<x>/<x>.router.ts (auto-mounted at /api/<x>) → controllers/<x> →
dto/input/<x> for validation → db/dao/<x> (BaseDAO) → PostgreSQL. Cross-cutting logic goes in
services/<x>, scheduled work in jobs/.
src/server.tsvalidates the environment, connectsKnexManager, runs migrations whenRUN_MIGRATIONS=true, importsapp.tsand listens.SIGTERM/SIGINTclose the HTTP server and the pool (10 s timeout).src/app.tswireshelmet,cors(fromCORS_ORIGINS), the request id middleware,pino-http(skips/api/health), body parsers,/api, the 404 handler and the error handler. A commented hook shows where to mount raw-body webhooks (Stripe) before the JSON parser.src/common/config/env.tsis a Zod schema;envis typed and a bad deploy fails at boot.src/common/errors/http.error.tsexportsHttpErrorandbadRequest(),unauthorized(),forbidden(),notFound(),conflict(). The error middleware renders them as{ success: false, message, errors? }and hides 500 messages in production.src/common/validation/parse-dto.tsturns a Zod failure intoHttpError(400)with{ field: [messages] }.src/db/BaseDAO.tsgives every DAOcreate,getById,getByUuid,update,deleteandgetAll(page, limit)(returnsIDataPaginator), each with an optionaltrx. Subclasses declareprotected readonly tableand add finders withthis.q(trx).src/db/knex.config.tsis the only knex configuration;knexfile.tsre-exports it for the CLI. Migrations and seeds live insrc/and compile with the app (loadExtensionsfollows the running extension:.tsunder tsx/jest,.jsfromdist/).- Auth overlay adds
user+authtables,UserDAO/AuthDAO,JwtService,PasswordService,authMiddleware/optionalAuthMiddleware(setsreq.auth), the/api/auth/register|login|meendpoints, and their tests.
Conventions
- Envelope:
{ success: true, data }/{ success: false, message, errors? }; lists return{ success, data, page, limit, count, totalCount, totalPages }. - Public identifier
uuid, internal numericid. Routes take/:uuid. - Tables snake_case, columns camelCase, every table has
id,uuid,createdAt,updatedAt. - Classes:
XRouter(public router: Router, handlers bound with.bind()),XController(private _xDAO = new XDAO(),try { } catch (err) { next(err) }),XDAO extends BaseDAO<IX>. - Express 5: no bare
*(use/*splat), optional params{/:id},req.queryis read-only,req.bodyisundefinedwithout a matching parser. - Logging through
req.log/logger, neverconsole.
Testing the generated API
| Command | Needs Postgres | What runs |
| ------------------- | -------------- | ---------------------------------------------------------------------- |
| npm test | yes | everything, with coverage (jest.config.js threshold, 80% by default) |
| npm run test:unit | no | everything except src/routes/** and src/db/** |
| npm run typecheck | no | tsc --noEmit on sources and tests |
docker compose up -d starts PostgreSQL 16 with the credentials in .env.example. jest.setup.js
aborts if SQL_HOST is not local unless ALLOW_REMOTE_DB_TESTS=1. Controller tests mock the
src/db barrel; route tests use supertest against the real app and clean up after themselves.
Deploying the generated API
Dockerfile: two-stagenode:24-alpinebuild,npm ci --omit=devruntime,node dist/server.js..github/workflows/ci.yaml: typecheck, lint, migrate, test and build on every PR/push with a Postgres service..github/workflows/deploy.yaml: manual (workflow_dispatch) build → ECR push → ECS task definition render → service deploy. Needssecrets.AWS_ROLE_ARN(OIDC) andvars.AWS_REGION; ECR/ECS resource names default to the project slug..sundaysrccarriesruntime,install,build,start,portand the generatingcliversion for the Sundays platform.
Developing the CLI
src/
cli.ts commander program
commands/{new,generate-entity}.ts
core/ naming, fields DSL, template engine, overlay, inject, project lookup, exec, prompts
generators/entity/ context + render/* (one renderer per file) + index (writes + barrel)
templates/api/ the base project (tokens: __SF_PROJECT_NAME__, __SF_PROJECT_SLUG__,
__SF_DB_NAME__, __SF_PORT__, __SF_CLI_VERSION__, __SF_YEAR__, __SF_JWT_SECRET__)
templates/api-auth/ overlay: new files + overlay.json (package.json merge + marker injections)
test/unit DSL, naming, engine, overlay, inject
test/snapshot every rendered entity file (update with `npx vitest run -u`)
test/e2e scaffold + typecheck + generate (+ migrate + jest with SUNDAYS_E2E_DB=1)npm install
npm run dev -- new demo --yes # run from source (tsx)
npm run build && npm link # real `sundaysf` binary from dist/cli.js
npm test # unit + snapshot (fast)
SUNDAYS_E2E=1 npm run test:e2e # scaffolds into a temp dir, needs network for npm install
SUNDAYS_E2E=1 SUNDAYS_E2E_DB=1 SQL_HOST=localhost SQL_PORT=5432 SQL_USER=postgres \
SQL_PASSWORD=postgres SQL_DB_NAME=e2e_api npm run test:e2e # + migrate + jest
npm pack --dry-run # check templates/ and _gitignore ship, .npmrc does notTo change the base project edit templates/api directly (it is a real project: copy it somewhere,
npm install, run it). Files npm would strip from a tarball are named _gitignore,
_dockerignore, _package.json and renamed on copy (RENAME_MAP in core/template-engine.ts).
To add a field type, extend FIELD_TYPES and the four mappings in core/fields.ts, then update
the snapshot. To add an overlay, create templates/<name>/ with an overlay.json.
Publishing: npm publish runs prepublishOnly (build + tests). The registry token belongs in
the developer's ~/.npmrc, never in the repo or the templates.
Troubleshooting
Marker "// @sundays:daos" not found— the barrel lost its markers; add the two comment lines back tosrc/db/index.ts(see Markers).ts-jesterrors about TypeScript version — the project pinstypescript ~5.9; TypeScript 7 is not supported by ts-jest yet. Do not bump it.SQL_HOST="..." is not local— the test guard refused a remote database. Point.envat localhost or exportALLOW_REMOTE_DB_TESTS=1on purpose.PathError/ routes not matching after copying Express 4 code — Express 5 changed the path syntax:*→/*splat,/:id?→{/:id}, no regex in strings.Invalid environment configuration— a required variable is missing from.env; the message lists the fields. Add it to.env(and to the schema if it is new).npm run start:devruns code with type errors —tsxdoes not type-check; runnpm run typecheck(CI does).- Port 5432 already in use — another Postgres is running; change the host port in
docker-compose.ymlandSQL_PORTin.env.
License
MIT
