@tulipes/cli 0.1.1 → 0.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/README.md +10 -7
  2. package/dist/agent.d.ts +12 -0
  3. package/dist/agent.js +29 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/app-core.d.ts +8 -6
  6. package/dist/app-core.js +23 -14
  7. package/dist/app-core.js.map +1 -1
  8. package/dist/bin.js +3 -3
  9. package/dist/bin.js.map +1 -1
  10. package/dist/config-check.d.ts +8 -0
  11. package/dist/config-check.js +43 -0
  12. package/dist/config-check.js.map +1 -0
  13. package/dist/config-import.d.ts +20 -0
  14. package/dist/config-import.js +133 -0
  15. package/dist/config-import.js.map +1 -0
  16. package/dist/dev.d.ts +4 -4
  17. package/dist/dev.js +7 -10
  18. package/dist/dev.js.map +1 -1
  19. package/dist/init.d.ts +6 -11
  20. package/dist/init.js +11 -6422
  21. package/dist/init.js.map +1 -1
  22. package/dist/inspection.d.ts +9 -8
  23. package/dist/inspection.js +10 -11
  24. package/dist/inspection.js.map +1 -1
  25. package/dist/main.js +82 -11
  26. package/dist/main.js.map +1 -1
  27. package/dist/migrate.d.ts +30 -0
  28. package/dist/migrate.js +772 -0
  29. package/dist/migrate.js.map +1 -0
  30. package/dist/new-module.js +84 -43
  31. package/dist/new-module.js.map +1 -1
  32. package/dist/routes.d.ts +3 -5
  33. package/dist/routes.js +4 -6
  34. package/dist/routes.js.map +1 -1
  35. package/dist/spec.d.ts +2 -5
  36. package/dist/spec.js +13 -6
  37. package/dist/spec.js.map +1 -1
  38. package/dist/sync.d.ts +3 -11
  39. package/dist/sync.js +81 -20
  40. package/dist/sync.js.map +1 -1
  41. package/dist/template.d.ts +58 -0
  42. package/dist/template.js +244 -0
  43. package/dist/template.js.map +1 -0
  44. package/dist/update.js +4 -4
  45. package/package.json +17 -16
  46. package/templates/AGENTS.md +68 -31
  47. package/templates/CLAUDE.md +5 -4
  48. package/templates/app/.envs/.env.development +18 -0
  49. package/templates/app/.envs/.env.production +19 -0
  50. package/templates/app/.envs/.env.staging +19 -0
  51. package/templates/app/README.md +327 -0
  52. package/templates/app/app.ts +3 -0
  53. package/templates/app/config/app.config.ts +73 -0
  54. package/templates/app/docs/agent-manifest.json +724 -0
  55. package/templates/app/ecosystem.config.cjs +60 -0
  56. package/templates/app/modules/agent/controllers/agent.controllers.ts +31 -0
  57. package/templates/app/modules/agent/i18n/en.json +5 -0
  58. package/templates/app/modules/agent/i18n/fr.json +5 -0
  59. package/templates/app/modules/agent/module.acl.ts +14 -0
  60. package/templates/app/modules/agent/package.json +21 -0
  61. package/templates/app/modules/agent/routes/agent.routes.ts +29 -0
  62. package/templates/app/modules/agent/schemas/agent.schemas.ts +9 -0
  63. package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +26 -0
  64. package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +44 -0
  65. package/templates/app/modules/auth/controllers/auth.controllers.ts +312 -0
  66. package/templates/app/modules/auth/helpers/access-check.ts +42 -0
  67. package/templates/app/modules/auth/helpers/auth.ts +48 -0
  68. package/templates/app/modules/auth/helpers/documents.ts +57 -0
  69. package/templates/app/modules/auth/helpers/passport.ts +180 -0
  70. package/templates/app/modules/auth/helpers/passwords.ts +58 -0
  71. package/templates/app/modules/auth/helpers/session-cache.ts +67 -0
  72. package/templates/app/modules/auth/helpers/tokens.ts +329 -0
  73. package/templates/app/modules/auth/i18n/en.json +19 -0
  74. package/templates/app/modules/auth/i18n/fr.json +19 -0
  75. package/templates/app/modules/auth/lib/index.ts +12 -0
  76. package/templates/app/modules/auth/models/credential.model.ts +31 -0
  77. package/templates/app/modules/auth/models/revoked-token.model.ts +20 -0
  78. package/templates/app/modules/auth/models/session.model.ts +42 -0
  79. package/templates/app/modules/auth/module.acl.ts +41 -0
  80. package/templates/app/modules/auth/module.config.ts +30 -0
  81. package/templates/app/modules/auth/package.json +61 -0
  82. package/templates/app/modules/auth/routes/auth.routes.ts +94 -0
  83. package/templates/app/modules/auth/schemas/auth.schemas.ts +37 -0
  84. package/templates/app/modules/auth/tests/auth.test.ts +1079 -0
  85. package/templates/app/modules/config/controllers/config.controllers.ts +89 -0
  86. package/templates/app/modules/config/helpers/audit.ts +41 -0
  87. package/templates/app/modules/config/helpers/describe.ts +72 -0
  88. package/templates/app/modules/config/i18n/en.json +6 -0
  89. package/templates/app/modules/config/i18n/fr.json +6 -0
  90. package/templates/app/modules/config/models/config-audit.model.ts +26 -0
  91. package/templates/app/modules/config/module.acl.ts +12 -0
  92. package/templates/app/modules/config/package.json +23 -0
  93. package/templates/app/modules/config/routes/config.routes.ts +37 -0
  94. package/templates/app/modules/config/schemas/config.schemas.ts +13 -0
  95. package/templates/app/modules/config/tests/config.test.ts +126 -0
  96. package/templates/app/modules/core/bootstrap/cache.bootstrap.ts +12 -0
  97. package/templates/app/modules/core/helpers/cache/index.ts +60 -0
  98. package/templates/app/modules/core/helpers/cache/memory-cache.ts +53 -0
  99. package/templates/app/modules/core/helpers/cache/redis-cache.ts +68 -0
  100. package/templates/app/modules/core/helpers/cache/types.ts +37 -0
  101. package/templates/app/modules/core/helpers/lazy.ts +13 -0
  102. package/templates/app/modules/core/helpers/logger/index.ts +115 -0
  103. package/templates/app/modules/core/helpers/mongoose/index.ts +27 -0
  104. package/templates/app/modules/core/helpers/mongoose/plugins/base.plugin.ts +14 -0
  105. package/templates/app/modules/core/helpers/mongoose/plugins/soft-delete.plugin.ts +124 -0
  106. package/templates/app/modules/core/helpers/mongoose/plugins/timestamps.plugin.ts +13 -0
  107. package/templates/app/modules/core/helpers/mongoose/plugins/types.ts +45 -0
  108. package/templates/app/modules/core/helpers/mongoose/register.ts +20 -0
  109. package/templates/app/modules/core/lib/index.ts +34 -0
  110. package/templates/app/modules/core/module.config.ts +24 -0
  111. package/templates/app/modules/core/package.json +80 -0
  112. package/templates/app/modules/core/routes/core.routes.ts +24 -0
  113. package/templates/app/modules/core/tests/core.test.ts +19 -0
  114. package/templates/app/modules/hello/agent/hello.agent.ts +33 -0
  115. package/templates/app/modules/hello/bootstrap/seed-greetings.bootstrap.ts +27 -0
  116. package/templates/app/modules/hello/controllers/greetings.controllers.ts +43 -0
  117. package/templates/app/modules/hello/controllers/hello.controllers.ts +50 -0
  118. package/templates/app/modules/hello/helpers/greeting.ts +8 -0
  119. package/templates/app/modules/hello/i18n/en.json +8 -0
  120. package/templates/app/modules/hello/i18n/fr.json +8 -0
  121. package/templates/app/modules/hello/models/greeting.model.ts +24 -0
  122. package/templates/app/modules/hello/module.acl.ts +16 -0
  123. package/templates/app/modules/hello/module.config.ts +14 -0
  124. package/templates/app/modules/hello/package.json +24 -0
  125. package/templates/app/modules/hello/queues/hello.queues.ts +38 -0
  126. package/templates/app/modules/hello/routes/greetings.routes.ts +25 -0
  127. package/templates/app/modules/hello/routes/hello.routes.ts +44 -0
  128. package/templates/app/modules/hello/sockets/hello.sockets.ts +21 -0
  129. package/templates/app/modules/hello/tests/hello.test.ts +19 -0
  130. package/templates/app/modules/security/bootstrap/rate-limit.bootstrap.ts +8 -0
  131. package/templates/app/modules/security/helpers/hardening.ts +65 -0
  132. package/templates/app/modules/security/helpers/proxy.ts +16 -0
  133. package/templates/app/modules/security/helpers/rate-limit.ts +150 -0
  134. package/templates/app/modules/security/module.config.ts +59 -0
  135. package/templates/app/modules/security/package.json +26 -0
  136. package/templates/app/modules/security/routes/security.routes.ts +42 -0
  137. package/templates/app/modules/security/tests/security.test.ts +174 -0
  138. package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +12 -0
  139. package/templates/app/modules/settings/controllers/settings.controllers.ts +58 -0
  140. package/templates/app/modules/settings/helpers/access-lease.ts +99 -0
  141. package/templates/app/modules/settings/helpers/log-stream.ts +272 -0
  142. package/templates/app/modules/settings/helpers/log-tail.ts +253 -0
  143. package/templates/app/modules/settings/i18n/en.json +3 -0
  144. package/templates/app/modules/settings/i18n/fr.json +3 -0
  145. package/templates/app/modules/settings/module.acl.ts +12 -0
  146. package/templates/app/modules/settings/package.json +21 -0
  147. package/templates/app/modules/settings/routes/settings.routes.ts +33 -0
  148. package/templates/app/modules/settings/schemas/settings.schemas.ts +8 -0
  149. package/templates/app/modules/settings/tests/settings.test.ts +409 -0
  150. package/templates/app/modules/users/controllers/users.controllers.ts +113 -0
  151. package/templates/app/modules/users/i18n/en.json +14 -0
  152. package/templates/app/modules/users/i18n/fr.json +14 -0
  153. package/templates/app/modules/users/models/user.model.ts +18 -0
  154. package/templates/app/modules/users/module.acl.ts +10 -0
  155. package/templates/app/modules/users/module.config.ts +11 -0
  156. package/templates/app/modules/users/package.json +24 -0
  157. package/templates/app/modules/users/queues/users.queues.ts +31 -0
  158. package/templates/app/modules/users/routes/users.routes.ts +60 -0
  159. package/templates/app/modules/users/schemas/users.schemas.ts +30 -0
  160. package/templates/app/modules/users/sockets/users.sockets.ts +18 -0
  161. package/templates/app/nginx/development.conf +44 -0
  162. package/templates/app/nginx/production.conf +75 -0
  163. package/templates/app/nginx/staging.conf +51 -0
  164. package/templates/app/package.json +58 -0
  165. package/templates/app/scripts/count-greetings.ts +29 -0
  166. package/templates/app/scripts/deploy-nginx.ts +143 -0
  167. package/templates/app/scripts/list-users.ts +34 -0
  168. package/templates/app/tests/app.test.ts +28 -0
  169. package/templates/app/tests/support.ts +36 -0
  170. package/templates/app/tsconfig.json +28 -0
  171. package/templates/app/vitest.config.ts +6 -0
  172. package/templates/app/worker.ts +6 -0
  173. package/templates/claude/skills/tulipes-agent-tool/SKILL.md +83 -0
  174. package/templates/claude/skills/tulipes-boot-errors/SKILL.md +11 -5
  175. package/templates/claude/skills/tulipes-config/SKILL.md +74 -0
  176. package/templates/claude/skills/tulipes-endpoint/SKILL.md +38 -25
  177. package/templates/claude/skills/tulipes-env-variable/SKILL.md +54 -26
  178. package/templates/claude/skills/tulipes-model/SKILL.md +11 -4
  179. package/templates/claude/skills/tulipes-module/SKILL.md +11 -10
  180. package/templates/claude/skills/tulipes-queue/SKILL.md +20 -6
  181. package/templates/claude/skills/tulipes-settings/SKILL.md +49 -100
  182. package/templates/claude/skills/tulipes-spec/SKILL.md +10 -12
  183. package/templates/minimal/README.md +21 -0
  184. package/templates/minimal/app.ts +3 -0
  185. package/templates/minimal/config/app.config.ts +16 -0
  186. package/templates/minimal/modules/agent/module.acl.ts +3 -0
  187. package/templates/minimal/modules/agent/package.json +14 -0
  188. package/templates/minimal/modules/agent/routes/agent.routes.ts +20 -0
  189. package/templates/minimal/modules/core/module.acl.ts +4 -0
  190. package/templates/minimal/modules/core/package.json +21 -0
  191. package/templates/minimal/modules/hello/i18n/en.json +3 -0
  192. package/templates/minimal/modules/hello/module.acl.ts +2 -0
  193. package/templates/minimal/modules/hello/package.json +11 -0
  194. package/templates/minimal/modules/hello/routes/health.routes.ts +9 -0
  195. package/templates/minimal/modules/hello/routes/hello.routes.ts +10 -0
  196. package/templates/minimal/modules/security/package.json +15 -0
  197. package/templates/minimal/modules/security/routes/security.routes.ts +8 -0
  198. package/templates/minimal/package.json +38 -0
  199. package/templates/minimal/tests/app.test.ts +52 -0
  200. package/templates/minimal/tsconfig.json +22 -0
  201. package/templates/minimal/vitest.config.ts +5 -0
  202. package/templates/overlays/no-queues/ecosystem.config.cjs +52 -0
  203. package/templates/overlays/no-queues/modules/hello/controllers/greetings.controllers.ts +41 -0
  204. package/templates/overlays/no-queues/modules/users/controllers/users.controllers.ts +107 -0
  205. package/templates/versions.json +11 -0
  206. package/dist/minimal.d.ts +0 -2
  207. package/dist/minimal.js +0 -2200
  208. package/dist/minimal.js.map +0 -1
  209. /package/templates/{browser-auth.md → app/docs/browser-auth.md} +0 -0
@@ -16,22 +16,32 @@ one contract in depth.
16
16
  ## Preset scope
17
17
 
18
18
  Read the installed modules and package scripts before applying integration examples.
19
- The minimal preset has core, security and hello only. Auth, users, settings,
20
- MongoDB, Redis, queues, sockets, shared logger/cache helpers and PM2 examples
21
- below apply when those modules are installed (the production preset). Minimal
19
+ The minimal preset has core, security, hello and agent only. Auth, users,
20
+ config, settings, MongoDB, Redis, queues, sockets, shared logger/cache helpers
21
+ and PM2 examples below apply when those modules are installed (the production
22
+ preset). Minimal
22
23
  requests use the framework guest identity. Grant permissions to roles the app
23
24
  actually defines. Use the generated Vitest suite for changes; production tests
24
25
  require dedicated TEST_MONGO_URI and TEST_REDIS_URL services.
25
26
 
26
27
  ## Non-negotiable rules
27
28
 
28
- 1. **Never read `process.env` directly.** Every configuration value is
29
- declared in a module's `meta.variables.json` and read with
30
- `ctx.Environment.get("NAME")`. Reading an undeclared name throws on
31
- purpose. → skill `tulipes-env-variable`
29
+ 1. **Never read `process.env` directly, and keep the environment small.**
30
+ A value is an environment variable only if boot needs it before the
31
+ database answers (connection strings, the port, log destination) or it
32
+ is a secret: declared under `tulipes.env` in the module's `package.json`, read with
33
+ `ctx.Environment.get("NAME")`. Everything else is a *setting*: declared
34
+ with a zod schema in that module's `module.config.ts`, stored in the
35
+ settings store (the `settings` collection here; a JSON file under
36
+ `config/` in an app without the Mongo store), edited through the
37
+ console, read live at `ctx.config.<module>`. → skill `tulipes-env-variable`
32
38
  2. **A feature is a module, not a layer.** Everything a feature needs —
33
39
  routes, models, config, permissions, jobs — lives in one folder under
34
40
  `modules/`. Never add a top-level `services/` or `middlewares/` folder.
41
+ A module's root holds exactly `package.json`, `module.config.ts` and
42
+ `module.acl.ts`; everything else has a folder (`routes/`, `controllers/`,
43
+ `models/`, `schemas/`, `helpers/`, `bootstrap/`, `queues/`, `sockets/`,
44
+ `i18n/`, `agent/`, `lib/`, `tests/`), and the boot refuses a stray root file.
35
45
  → skill `tulipes-module`
36
46
  3. **Never call `mongoose.model()` yourself.** Models are declarations
37
47
  picked up by the framework and read from `ctx.models`. Persisted and
@@ -39,16 +49,23 @@ require dedicated TEST_MONGO_URI and TEST_REDIS_URL services.
39
49
  camelCase is for TypeScript-only names. → skill `tulipes-model`
40
50
  4. **Global middleware belongs to a sys-tier module**, not to the
41
51
  entrypoint. `app.ts` stays three lines. → skill `tulipes-endpoint`
42
- 5. **Run `yarn sync` after touching any `meta.variables.json` or
43
- `module.config.ts`.** It regenerates `types/config.d.ts` and
44
- `.env.example`; stale generated types are a lie.
52
+ 5. **Run `pnpm sync` after touching a module's `tulipes.env`, a
53
+ settings schema in `module.config.ts`, a model file or a queue file's
54
+ `jobs` export.** It regenerates `types/tulipes.d.ts` and `.env.example`;
55
+ stale generated types are a lie. With it, `ctx.models.get("User")` is
56
+ typed from the model's schema and `queues.add("users.welcome", …)` from
57
+ the queue's job schema, so a wrong name or payload does not compile. `module.config.ts`
58
+ has exactly one export, `defineConfig({ schema })`; initialization and
59
+ cleanup belong to `bootstrap/*.bootstrap.ts`, which may return the
60
+ function that closes what it opened, and long-lived work ends on
61
+ `ctx.signal`.
45
62
  6. **Every response is `res.respond({ data, meta })`** — never `res.json`,
46
63
  which ships a raw body outside the envelope. Failures are **thrown**:
47
64
  `throw new HttpError(404, "notFound")`, where the message is an i18n
48
65
  key and the code derives from it. Never hand-roll an error shape.
49
66
  → skill `tulipes-response`
50
67
  7. **Every route declares a RAI**: put `rai({ id: "x:read" })` first in
51
- each endpoint handler chain, including routes inside `defineRoutes()`.
68
+ each endpoint handler chain, including routes on a nested router.
52
69
  The id is also the ACL permission, and core refuses to boot if a route
53
70
  is missing one. Public is `acl.allow("guest", "x:read")`, never a
54
71
  missing check. → skill `tulipes-endpoint`
@@ -68,30 +85,47 @@ require dedicated TEST_MONGO_URI and TEST_REDIS_URL services.
68
85
  the auth module's `Credential` model, which is `select: false`.
69
86
  12. **When auth is installed, a new feature module's namespace goes in BOTH the `admin` and
70
87
  `sysadmin` grants** in `modules/auth/module.acl.ts`. Neither role holds
71
- `*` — a wildcard would reach `settings:*` and make the sysadmin
72
- boundary a label rather than a rule. → skill `tulipes-settings`
88
+ `*` — a wildcard would reach `config:write` and `settings:*` and make
89
+ the sysadmin boundary a label rather than a rule. → skills
90
+ `tulipes-config`, `tulipes-settings`
73
91
  13. **Cross-module imports use the package name** (`@app/users`,
74
92
  `@app/core`), never a relative path that climbs out of the module. The
75
93
  workspace boundary is what keeps modules independent, and a package
76
94
  name makes the dependency visible in package.json — declare it there
77
95
  and in `dependsOn`.
96
+ 14. **Every endpoint is a tool an agent can call; describe it as one.** The
97
+ `rai()` name, description and schemas are what the manifest shows, so a
98
+ bare `id` is a tool with no description. A module's `agent/*.agent.ts`
99
+ adds a summary, curates the routes and declares actions that are not
100
+ endpoints; `agent: false` on a `rai()` hides one. → skill
101
+ `tulipes-agent-tool`
102
+ 15. **A module's tests live in `modules/<name>/tests/` and boot the app
103
+ through `@tulipes/testing`**: `useTestApp({ rootDir }, { beforeAll,
104
+ afterAll })` once per file, `app.get(app.api("/x"))` with the envelope
105
+ decoded, `app.as("admin")` for a signed-in role. Never boot per test, and
106
+ never reach into another module's files from a test.
78
107
 
79
108
  For endpoint changes, read `tulipes-endpoint`: native routers live in
80
- `routes/*.routes.ts`. Use `defineRoutes()` and `runtime()` for offline inspection;
81
- keep shared middleware and param callbacks beside their endpoints.
109
+ `routes/*.routes.ts`, plain `(ctx) => Router` functions: build the router from
110
+ the context, return it for the configured prefix, and read models, settings
111
+ and the environment inside handlers, never while building — `tulipes routes`
112
+ and `tulipes spec` build every router with nothing acquired. Global
113
+ middleware that needs the environment is built on its first request with
114
+ `lazy()` from `@app/core`.
82
115
 
83
116
  ## Where things go
84
117
 
85
118
  | Decision | Location |
86
119
  |---|---|
87
- | Value differs per deployment (URL, secret, toggle) | that module's `meta.variables.json` |
88
- | Policy shared by ≥2 modules (pagination, body limit) | `config/app.config.ts` |
89
- | Config for one feature | that module's `module.config.ts` |
120
+ | Connection string, secret, or anything boot needs before the database answers | `tulipes.env` in that module's `package.json` |
121
+ | Policy shared by ≥2 modules and the same everywhere (pagination, locales) | `config/app.config.ts` |
122
+ | A value an operator may change at runtime (limits, modes, toggles, lifetimes) | a field in that module's `module.config.ts` schema, read at `ctx.config.<module>` |
123
+ | Opening a client, seeding rows, ensuring indexes, and closing them again | that module's `bootstrap/*.bootstrap.ts`, returning the cleanup |
90
124
  | A string a user will read | that module's `i18n/<locale>.json`, reached with `req.t` |
91
125
  | The shape of a request body/query | a zod schema on the route's `rai()` |
92
126
  | Middleware for every request | a sys-tier module's routes file |
93
127
  | Middleware for one feature's routes | that module's own `Router` |
94
- | Helper used by ≥2 modules | the sys core module, exported from its `index.ts` and imported as `@app/core` |
128
+ | Helper used by ≥2 modules | the sys core module, exported from its `lib/index.ts` and imported as `@app/core` |
95
129
  | Helper used by one module | that module's `helpers/` |
96
130
  | One-off task (backfill, export, admin chore) | `scripts/`, booted with `mode: "script"` |
97
131
  | Anything you would `console.log` | `moduleLogger(ctx.Environment, "<module>", ctx.mode)` from `@app/core` |
@@ -99,27 +133,30 @@ keep shared middleware and param callbacks beside their endpoints.
99
133
  ## Boot order (why load order matters)
100
134
 
101
135
  `sys` modules load before `app` modules; within a tier, lower `priority`
102
- first; then `dependsOn` topological order. Config → translations → ACL →
103
- database → queues → routes. If code needs something another module
104
- provides, declare `dependsOn` — do not rely on luck.
136
+ first; then `dependsOn` topological order. Declarations → models →
137
+ settings → translations → ACL → bootstrap → queues → routes. If code needs
138
+ something another module provides, declare `dependsOn` — do not rely on
139
+ luck.
105
140
 
106
141
  ## Commands
107
142
 
108
143
  | Command | Use |
109
144
  |---|---|
110
- | `yarn dev` | run the backend process (regenerates types first) |
111
- | `yarn worker` | run the queue-consumer process |
112
- | `yarn sync` | regenerate `types/config.d.ts` + `.env.example` |
113
- | `yarn check` | validate the environment without booting |
114
- | `yarn typecheck` | typecheck the whole app |
115
- | `yarn tulipes new module <name>` | scaffold a module |
145
+ | `pnpm dev` | run the backend process (regenerates types first) |
146
+ | `pnpm worker` | run the queue-consumer process |
147
+ | `pnpm sync` | regenerate `types/tulipes.d.ts` + `.env.example` |
148
+ | `pnpm tulipes agent manifest [--role r]` | print every tool the app exposes to agents, offline |
149
+ | `pnpm test` | every `tests/` and `modules/*/tests/` file; each boots the app once through `@tulipes/testing` |
150
+ | `pnpm check` | validate the environment without booting |
151
+ | `pnpm typecheck` | typecheck the whole app |
152
+ | `pnpm tulipes new module <name>` | scaffold a module |
116
153
  | `tulipes update` | upgrade the global cli and the framework in every manifest |
117
- | `yarn script scripts/x.ts` | run a one-off script against the real app |
154
+ | `pnpm script scripts/x.ts` | run a one-off script against the real app |
118
155
 
119
156
  ## Verifying your work
120
157
 
121
- A change is not done until the app boots. Run `yarn typecheck`, then
122
- `yarn dev` and confirm the startup banner lists what you added (modules,
158
+ A change is not done until the app boots. Run `pnpm typecheck`, then
159
+ `pnpm dev` and confirm the startup banner lists what you added (modules,
123
160
  queues, sockets, roles). Boot failures print an aggregate report naming
124
161
  every problem at once — read all of it before fixing anything. → skill
125
162
  `tulipes-boot-errors`
@@ -6,7 +6,8 @@ here; it holds the non-negotiable rules, where each kind of code belongs, the
6
6
  boot order and the commands.
7
7
 
8
8
  This file exists so Claude Code picks that up automatically. The other
9
- Claude-specific piece is `.claude/skills/`: eight task-scoped guides — module,
10
- environment variable, endpoint, model, queue, socket, permissions and reading
11
- boot errors — loaded on demand when a task matches one. AGENTS.md points at
12
- them by name.
9
+ Claude-specific piece is `.claude/skills/`: task-scoped guides — module,
10
+ environment variable, endpoint, response, model, queue, socket, permissions,
11
+ settings (the config module and the log stream), i18n, spec, agent tools and
12
+ reading boot errors — loaded on demand when a task matches one. AGENTS.md
13
+ points at them by name.
@@ -0,0 +1,18 @@
1
+ # Development. NOT committed — .gitignore excludes .envs/, because a
2
+ # file holding working credentials does not belong in a repository.
3
+ # The committed contract is .env.example at the root: copy it here and
4
+ # fill it in. Real deployments inject variables through the container
5
+ # environment, which always wins over this file.
6
+
7
+ PORT=3000
8
+ LOG_LEVEL=debug
9
+
10
+ MONGO_URI=mongodb://127.0.0.1:27017/tulipes-example
11
+ REDIS_URL=redis://127.0.0.1:6379
12
+
13
+ # Auth. Separate keys, so leaking one cannot mint the other.
14
+ JWT_ACCESS_SECRET=dev-only-access-secret-change-me
15
+ JWT_REFRESH_SECRET=dev-only-refresh-secret-change-me
16
+ AUTH_ADMIN_EMAIL=admin@example.com
17
+ AUTH_ADMIN_PASSWORD=dev-admin-password
18
+
@@ -0,0 +1,19 @@
1
+ # Production. NOT committed — .gitignore excludes .envs/. Recreate it
2
+ # on the deployment host from .env.example, or leave it out entirely
3
+ # and inject everything through the container environment.
4
+ #
5
+ # The blanks below are intentional. Real deployments inject MONGO_URI,
6
+ # REDIS_URL and any secret through the container environment, which
7
+ # always wins over this file. Booting without them fails closed with a
8
+ # report naming every missing variable — run `pnpm check` to see it
9
+ # without starting the app.
10
+
11
+ PORT=3000
12
+ LOG_LEVEL=info
13
+
14
+ # MONGO_URI=
15
+ # REDIS_URL=
16
+ # JWT_ACCESS_SECRET=
17
+ # JWT_REFRESH_SECRET=
18
+ # AUTH_ADMIN_EMAIL=
19
+ # AUTH_ADMIN_PASSWORD=
@@ -0,0 +1,19 @@
1
+ # Staging. NOT committed — .gitignore excludes .envs/. Recreate it on
2
+ # the deployment host from .env.example, or leave it out entirely and
3
+ # inject everything through the container environment.
4
+ #
5
+ # The blanks below are intentional. Real deployments inject MONGO_URI,
6
+ # REDIS_URL and any secret through the container environment, which
7
+ # always wins over this file. Booting without them fails closed with a
8
+ # report naming every missing variable — run `pnpm check` to see it
9
+ # without starting the app.
10
+
11
+ PORT=3000
12
+ LOG_LEVEL=info
13
+
14
+ # MONGO_URI=
15
+ # REDIS_URL=
16
+ # JWT_ACCESS_SECRET=
17
+ # JWT_REFRESH_SECRET=
18
+ # AUTH_ADMIN_EMAIL=
19
+ # AUTH_ADMIN_PASSWORD=
@@ -0,0 +1,327 @@
1
+ # tulipes-example
2
+
3
+ A production-preset [Tulipes](https://www.npmjs.com/package/@tulipes/core) application. Requires Node 24.x.
4
+
5
+ ## Run
6
+
7
+ ```sh
8
+ npm install -g pnpm # once per machine
9
+ pnpm install
10
+ pnpm dev # boot the backend process → http://localhost:3000/api/v1/hello
11
+ ```
12
+
13
+ Needs mongo and redis running — see `MONGO_URI` and `REDIS_URL` in
14
+ `.envs/.env.development`. Run `pnpm worker` in a second terminal to
15
+ consume queued jobs.
16
+
17
+ `.envs/` is git-ignored: those files hold working credentials. On a
18
+ fresh clone there is nothing in it, so copy `.env.example` — which is
19
+ committed, and which `pnpm sync` regenerates from every module's
20
+ declared variables — and fill it in:
21
+
22
+ ```sh
23
+ pnpm sync # refresh .env.example and types
24
+ mkdir -p .envs # git does not track empty dirs
25
+ cp .env.example .envs/.env.development
26
+ pnpm check # validate it without booting
27
+ ```
28
+
29
+ ## Verify
30
+
31
+ Run `pnpm sync`, `pnpm typecheck` and `pnpm check`. Tests need dedicated
32
+ services: `TEST_MONGO_URI=mongodb://127.0.0.1:27017/tulipes_tests TEST_REDIS_URL=redis://127.0.0.1:6379/3 pnpm test`.
33
+ Use test-only data: the suite creates users and sessions. `pnpm spec` writes
34
+ OpenAPI/Postman documents offline, without services or runtime secrets.
35
+ Change the shared API prefix in `config/routes.ts`. Runtime middleware stays
36
+ with endpoints in `routes/*.routes.ts`, plain functions of the context.
37
+ Staging/production boot rejects development credentials and equal JWT keys.
38
+ Auth, settings and deployment examples are application policy you own.
39
+
40
+ ## The hello module
41
+
42
+ `modules/hello` is a worked example of every module contract — read it
43
+ before writing your own:
44
+
45
+ | File | Contract |
46
+ |---|---|
47
+ | `package.json` `tulipes.env` | the module's environment contract |
48
+ | `module.config.ts` | `defineConfig({ schema })`: this module's settings |
49
+ | `module.acl.ts` | permission grants on `hello:*` |
50
+ | `routes/*.routes.ts` | native routers, shared middleware and RAI schemas |
51
+ | `controllers/` | the work; constructed from the context while routes build |
52
+ | `helpers/` | pure logic, no framework |
53
+ | `i18n/<locale>.json` | this module's strings — the module is the namespace |
54
+ | `sockets/*.sockets.ts` | a claimed Socket.IO namespace | <!-- feature:sockets -->
55
+ | `models/*.model.ts` | a schema declaration for the model store |
56
+ | `bootstrap/*.bootstrap.ts` | idempotent seeding, before traffic |
57
+ | `queues/*.queues.ts` | a job definition and its processor | <!-- feature:queues -->
58
+
59
+ ## Daily commands
60
+
61
+ | command | does |
62
+ |---|---|
63
+ | `pnpm dev` | regenerate types, run the backend process with `node --watch` |
64
+ | `pnpm worker` | run the queue-consumer process (needs a queue first) | <!-- feature:queues -->
65
+ | `pnpm sync` | regenerate `types/tulipes.d.ts` + `.env.example` |
66
+ | `pnpm check` | validate env against every module contract — CI gate |
67
+ | `pnpm tulipes new module <name>` | scaffold a module |
68
+ | `pnpm tulipes update` | upgrade the framework everywhere it is declared |
69
+
70
+ ## Authentication
71
+
72
+ `modules/auth` is sys tier, so it resolves the caller before any
73
+ feature route runs. A request with no valid token is not rejected — it
74
+ becomes the **guest** caller, which is what makes a public endpoint
75
+ an ordinary ACL grant instead of a second mechanism:
76
+
77
+ ```ts
78
+ acl.allow("guest", "auth:login"); // that is why login is reachable
79
+ ```
80
+
81
+ Guarding is the RAI itself — its `id` IS the permission, so there is
82
+ no second call to forget. 401 means "sign in"; 403 means "signed in,
83
+ still not allowed":
84
+
85
+ ```ts
86
+ router.get(
87
+ `${base}/invoices`,
88
+ rai({ id: "invoices:read", name: "List invoices" }),
89
+ billing.list(),
90
+ );
91
+ ```
92
+
93
+ A route that declares no RAI would be reachable by guest, so core
94
+ scans the router stack after mounting and refuses to boot when it
95
+ finds one. Run `pnpm tulipes routes` to see every endpoint with the
96
+ roles that reach it.
97
+
98
+ Try it — the admin comes from AUTH_ADMIN_EMAIL / AUTH_ADMIN_PASSWORD:
99
+
100
+ ```sh
101
+ curl -X POST localhost:3000/api/v1/auth/login \
102
+ -H 'content-type: application/json' \
103
+ -d '{"email":"admin@example.com","password":"dev-admin-password"}'
104
+
105
+ curl localhost:3000/api/v1/auth/me -H "authorization: Bearer <accessToken>"
106
+ ```
107
+
108
+ Inside a handler, the caller is `req.auth` — resolved before any
109
+ feature route and never undefined inside a declared one:
110
+
111
+ ```ts
112
+ const { isAuthenticated, user, session } = req.auth;
113
+ user?.email // the hydrated user document, null for a guest
114
+ session?.sid // this device's session row, null for a guest
115
+ ```
116
+
117
+ Never re-parse the Authorization header: the pipeline already did. It
118
+ resolves the session and the user on every request — from the app cache
119
+ (Redis, shared by every replica) when a request resolved them recently,
120
+ from the database otherwise — and every path that revokes (logout,
121
+ refresh reuse, password change, a session ended, a deleted account)
122
+ forgets the cached entry first, so a demotion, a ban or a logout takes
123
+ effect on the very next request on any replica. Code that writes a
124
+ user's role calls `forgetUser()` from `@app/auth` for the same reason.
125
+
126
+ Sessions are rows, so a signed-in account can see and end its own
127
+ devices — `GET /auth/sessions`, `DELETE /auth/sessions/:sid`. Ending
128
+ one takes effect on the next request that device makes, even though
129
+ its access token is still signed and unexpired.
130
+
131
+ Passwords never touch the User document: the hash lives in the auth
132
+ module's `Credential` model, `select: false`, so a route that
133
+ serialises a user cannot leak one. Refresh tokens rotate on use;
134
+ logout deletes the refresh row and blacklists the access token's jti
135
+ until it expires; a password change moves a per-user epoch that
136
+ invalidates every token issued before it. Both token collections carry
137
+ TTL indexes.
138
+
139
+ ## The console
140
+
141
+ `modules/config` is the operator's console: `GET /api/v1/config`
142
+ describes every module — manifest, declared variables (names, types and
143
+ sources; a secret's value never), settings with their schema, live values
144
+ and stored version, endpoints with the roles that reach them — and
145
+ `PATCH /api/v1/config/:module` changes a module's settings: validated by
146
+ its schema, refused on a stale version, audited (names, never values)
147
+ before it lands, applied at once in every replica. Reading needs `admin`;
148
+ writing needs `sysadmin`. Settings live in MongoDB (`settings` and
149
+ `settings_history`) because the root `package.json` declares
150
+ `@tulipes/mongoose/settings`; the request pipeline — proxy trust, CORS,
151
+ HSTS, the body limit, the rate limits — is configured from
152
+ `config.security` this way, so none of it is an environment variable.
153
+
154
+ ## Logging
155
+
156
+ The logger lives in the core module and is shared by every other one:
157
+
158
+ ```ts
159
+ import { moduleLogger } from "@app/core";
160
+
161
+ const log = moduleLogger(ctx.Environment, "billing", ctx.mode);
162
+ log.info({ invoiceId }, "invoice sent");
163
+ ```
164
+
165
+ It writes to two destinations at once, because they answer different
166
+ questions: a **coloured, human-readable** stream on the console, and
167
+ **JSON lines** in `logs/` — one object per line, which is what makes
168
+ them greppable with jq or ingestible by a collector. Errors are
169
+ additionally copied to their own file, so the first thing to open when
170
+ something breaks is small.
171
+
172
+ Files carry the process mode — `app-backend.log`, `app-worker.log` —
173
+ because backend and worker run at the same time and two processes
174
+ rotating one file will race.
175
+
176
+ The destinations are environment, because the logger exists before the
177
+ settings store answers: `LOG_LEVEL`, `LOG_CONSOLE`, `LOG_FILE` and
178
+ `LOG_DIR`, declared in `modules/core/package.json`. The file names and
179
+ the rotation (daily or 20m, whichever trips first; fourteen rotated
180
+ files kept) never needed to differ per deployment, so they are the
181
+ `LOG_FILES` constants in `modules/core/helpers/logger/`. A `sysadmin`
182
+ can watch the files live: `GET /api/v1/settings/logs/stream` (SSE).
183
+
184
+ ## Responses
185
+
186
+ Every endpoint answers with one envelope, so a client unwraps once:
187
+
188
+ ```jsonc
189
+ { "success": true, "data": { }, "errors": [], "meta": {} }
190
+ ```
191
+
192
+ `res.respond` builds it. Express is untouched — `res.json` and
193
+ `res.status` behave exactly as they always did:
194
+
195
+ ```ts
196
+ res.respond({ data: user });
197
+ res.respond({ data: user, status: 201 });
198
+ res.respond({ data: items, meta: { total_items } }); // paginated
199
+ ```
200
+
201
+ Failures are **thrown**, never responded — the framework's terminal
202
+ handler builds the same envelope from them, so a handler only writes
203
+ the happy path. The message is an i18n key: it gets translated in the
204
+ module's namespace and the error code is derived from it.
205
+
206
+ ```ts
207
+ throw new HttpError(404, "notFound"); // → NOT_FOUND
208
+ throw new HttpError(422, "emailTaken", { field: "email" });
209
+ ```
210
+
211
+ `req.page` is already parsed from `?page` / `?per_page` and clamped
212
+ by `config.pagination`; passing `total_items` is what marks a
213
+ response as a page and fills in `total_pages`.
214
+
215
+ In development, a route answering without `res.respond` logs a
216
+ warning naming it.
217
+
218
+ ## Browser authentication
219
+
220
+ Read [Browser authentication and cookies](docs/browser-auth.md) before wiring
221
+ up a browser client. The starter uses Bearer headers and JSON refresh tokens;
222
+ CORS settings do not install cookie authentication or CSRF protection.
223
+
224
+ ## Translations
225
+
226
+ Each module owns its strings and IS its own i18n namespace:
227
+ `modules/hello/i18n/en.json`, `fr.json`. Inside an endpoint `req.t`
228
+ is already bound to the module that declared the route — `rai()` binds
229
+ it — so there is nothing to import and no namespace to name:
230
+
231
+ ```ts
232
+ res.json({ message: req.t("created", { email }) });
233
+ throw new HttpError(404, req.t("notFound"));
234
+ ```
235
+
236
+ The locale is `?lang=fr`, else Accept-Language, else
237
+ `config.i18n.defaultLocale` — narrowed to `supportedLocales`:
238
+
239
+ ```sh
240
+ curl 'localhost:3000/api/v1/hello/secret?lang=fr' -H "authorization: Bearer <token>"
241
+ ```
242
+
243
+ Code with no request names both: `ctx.t("users", recipient.locale)`,
244
+ which is how a queue processor translates against the recipient's
245
+ locale rather than the enqueuer's — so put the locale in the job.
246
+
247
+ Only the default locale must be complete. Anything another locale
248
+ lacks falls back to it and is listed in `<locale>.missing.json` beside
249
+ it on the next boot — git-ignored, and never written in production.
250
+
251
+ ## Scripts
252
+
253
+ `scripts/` holds one-off tasks — backfills, exports, admin chores.
254
+ They boot the app in **script mode**, which runs every phase the
255
+ backend runs and then starts no server, so a script gets exactly what
256
+ a route handler gets: config, ACL, models, queue producers.
257
+
258
+ ```sh
259
+ pnpm script scripts/count-greetings.ts
260
+ ```
261
+
262
+ Copy the try/finally from `scripts/count-greetings.ts`: mongo and
263
+ redis hold the event loop open, so a script that forgets
264
+ `handle.shutdown()` prints its output and then hangs.
265
+
266
+ ## Deploying nginx
267
+
268
+ `nginx/{development,staging,production}.conf` are templates with
269
+ `{{DOMAIN}}` and `{{PORT}}` placeholders. The deploy script renders
270
+ the one matching `APP_ENV`, using that same environment's
271
+ the `core.publicDomain` setting and `PORT`, so template, domain and port can never
272
+ disagree:
273
+
274
+ ```sh
275
+ APP_ENV=production pnpm script scripts/deploy-nginx.ts --dry-run
276
+ sudo -E APP_ENV=production pnpm script scripts/deploy-nginx.ts
277
+ ```
278
+
279
+ It writes `sites-available/<domain>`, links `sites-enabled`, runs
280
+ `nginx -t`, then reloads. It refuses when not root, while
281
+ `core.publicDomain` is still `localhost`, or when the conf already exists
282
+ (pass `--force` to overwrite one). Debian/Ubuntu layout.
283
+
284
+ The rendered conf is HTTP-only with the TLS block commented out —
285
+ `nginx -t` fails on certificate paths that do not exist yet, which
286
+ would make the first deploy impossible. Install the certificate,
287
+ uncomment the block, redeploy with `--force`.
288
+
289
+ ## Running under PM2
290
+
291
+ `ecosystem.config.cjs` defines both processes — the backend and the
292
+ queue worker — in all three modes:
293
+
294
+ ```sh
295
+ pnpm pm2:start # development
296
+ pnpm pm2:start:staging
297
+ pnpm pm2:start:production
298
+
299
+ pnpm pm2:status # what is running
300
+ pnpm pm2:logs # tail both processes
301
+ pnpm pm2:restart # reload code
302
+ pnpm pm2:stop # stop, keep them in the process list
303
+ pnpm pm2:delete # remove from the process list
304
+ ```
305
+
306
+ Switching environment requires a restart carrying the flag, because
307
+ PM2 remembers the environment a process was started with:
308
+ `pm2 restart ecosystem.config.cjs --env production`.
309
+
310
+ `.envs/` holds one file per mode and is **git-ignored** — those files
311
+ carry working credentials. What is committed is `.env.example`,
312
+ generated by `pnpm sync`, naming every variable any module declares:
313
+ a clone copies it into `.envs/.env.<mode>` and fills it in. Servers
314
+ usually skip the file entirely and inject variables through the
315
+ container environment, which always wins over it. `pnpm check`
316
+ validates the current environment without booting, so a deploy can
317
+ fail on configuration before it fails on traffic.
318
+
319
+ PM2 is a devDependency so the scripts work straight after install;
320
+ servers usually install it globally instead (`npm i -g pm2`), in
321
+ which case the same scripts still work.
322
+
323
+ Two defaults worth knowing: processes run in **fork** mode with a
324
+ single instance, because Socket.IO needs sticky sessions or the Redis
325
+ adapter before a second instance is safe; and `kill_timeout` is 10s so
326
+ graceful shutdown can drain queues and close mongo and redis before
327
+ PM2 sends SIGKILL.
@@ -0,0 +1,3 @@
1
+ import { boot } from "@tulipes/core/boot";
2
+
3
+ await boot({ rootDir: import.meta.dirname, mode: "backend" });
@@ -0,0 +1,73 @@
1
+ import type { AppConfigFn } from "@tulipes/core/boot";
2
+
3
+ /**
4
+ * ── The app-wide config seed ──────────────────────────────────────────
5
+ *
6
+ * Runs FIRST in boot phase 6, before any module.config.ts factory, and
7
+ * whatever it returns becomes the base of the global config. Modules read
8
+ * it through `ctx.config.<key>`.
9
+ *
10
+ * Add a key here when two or more modules share a value. Config belonging
11
+ * to ONE feature goes in that module's `module.config.ts` instead, where
12
+ * `pnpm sync` types it for you — that is where `users.maxSessions` and
13
+ * `users.signupMode` live. A value that differs per deployment belongs in
14
+ * neither: declare it in a module's `meta.variables.json` and read it with
15
+ * `Environment.get`, the way MONGO_URI, CORS_ORIGINS and BODY_LIMIT do.
16
+ *
17
+ * The framework reads exactly one field of this object: `app.name`, which
18
+ * titles the startup banner. Everything else is yours.
19
+ */
20
+
21
+ // `satisfies` (not a type annotation) so TypeScript keeps the exact shape
22
+ // of this object for the augmentation at the bottom of the file.
23
+ const appConfig = (() => ({
24
+ app: {
25
+ name: "tulipes-example",
26
+ },
27
+
28
+ /**
29
+ * Route prefixing. A router a routes file returns is mounted under this
30
+ * prefix, so versioning the whole API is a one-line change here; a module
31
+ * that wants another path says so with `ctx.routes.mount(router, "/x")`.
32
+ */
33
+ api: {
34
+ prefix: "/api/v1",
35
+ },
36
+
37
+ /**
38
+ * The languages this app speaks. Every module owns its own strings in
39
+ * `modules/<name>/i18n/<locale>.json` — the module is the namespace —
40
+ * and `req.t` inside an endpoint is already bound to the module that
41
+ * declared it. A request picks its locale with `?lang=fr` or an
42
+ * Accept-Language header, narrowed to this list.
43
+ *
44
+ * `defaultLocale` is also the fallback: a key missing from fr.json is
45
+ * served from en.json, and outside production the gap is recorded in
46
+ * `fr.missing.json` next to it.
47
+ */
48
+ i18n: {
49
+ defaultLocale: "en",
50
+ supportedLocales: ["en", "fr"],
51
+ },
52
+
53
+ /**
54
+ * List-endpoint defaults, so every module paginates identically and no
55
+ * caller can ask for ten thousand rows. → modules/users/routes
56
+ */
57
+ pagination: {
58
+ defaultLimit: 20,
59
+ maxLimit: 100,
60
+ },
61
+ })) satisfies AppConfigFn;
62
+
63
+ export default appConfig;
64
+
65
+ /**
66
+ * Types these keys at every call site: `config.pagination!.maxLimit` is a
67
+ * number, not `unknown`. `pnpm sync` does this automatically for
68
+ * module.config.ts factories; the app-level seed types itself here.
69
+ * Partial because the config object is empty until this factory runs.
70
+ */
71
+ declare module "@tulipes/core/config" {
72
+ interface GlobalConfig extends Partial<ReturnType<typeof appConfig>> {}
73
+ }