@tulipes/cli 0.1.1 → 0.2.0-rc.2

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 (211) 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.d.ts +8 -3
  45. package/dist/update.js +111 -38
  46. package/dist/update.js.map +1 -1
  47. package/package.json +17 -16
  48. package/templates/AGENTS.md +68 -31
  49. package/templates/CLAUDE.md +5 -4
  50. package/templates/app/.envs/.env.development +18 -0
  51. package/templates/app/.envs/.env.production +19 -0
  52. package/templates/app/.envs/.env.staging +19 -0
  53. package/templates/app/README.md +327 -0
  54. package/templates/app/app.ts +3 -0
  55. package/templates/app/config/app.config.ts +73 -0
  56. package/templates/app/docs/agent-manifest.json +724 -0
  57. package/templates/app/ecosystem.config.cjs +60 -0
  58. package/templates/app/modules/agent/controllers/agent.controllers.ts +31 -0
  59. package/templates/app/modules/agent/i18n/en.json +5 -0
  60. package/templates/app/modules/agent/i18n/fr.json +5 -0
  61. package/templates/app/modules/agent/module.acl.ts +14 -0
  62. package/templates/app/modules/agent/package.json +21 -0
  63. package/templates/app/modules/agent/routes/agent.routes.ts +29 -0
  64. package/templates/app/modules/agent/schemas/agent.schemas.ts +9 -0
  65. package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +26 -0
  66. package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +44 -0
  67. package/templates/app/modules/auth/controllers/auth.controllers.ts +312 -0
  68. package/templates/app/modules/auth/helpers/access-check.ts +42 -0
  69. package/templates/app/modules/auth/helpers/auth.ts +48 -0
  70. package/templates/app/modules/auth/helpers/documents.ts +57 -0
  71. package/templates/app/modules/auth/helpers/passport.ts +180 -0
  72. package/templates/app/modules/auth/helpers/passwords.ts +58 -0
  73. package/templates/app/modules/auth/helpers/session-cache.ts +67 -0
  74. package/templates/app/modules/auth/helpers/tokens.ts +329 -0
  75. package/templates/app/modules/auth/i18n/en.json +19 -0
  76. package/templates/app/modules/auth/i18n/fr.json +19 -0
  77. package/templates/app/modules/auth/lib/index.ts +12 -0
  78. package/templates/app/modules/auth/models/credential.model.ts +31 -0
  79. package/templates/app/modules/auth/models/revoked-token.model.ts +20 -0
  80. package/templates/app/modules/auth/models/session.model.ts +42 -0
  81. package/templates/app/modules/auth/module.acl.ts +41 -0
  82. package/templates/app/modules/auth/module.config.ts +30 -0
  83. package/templates/app/modules/auth/package.json +61 -0
  84. package/templates/app/modules/auth/routes/auth.routes.ts +94 -0
  85. package/templates/app/modules/auth/schemas/auth.schemas.ts +37 -0
  86. package/templates/app/modules/auth/tests/auth.test.ts +1079 -0
  87. package/templates/app/modules/config/controllers/config.controllers.ts +89 -0
  88. package/templates/app/modules/config/helpers/audit.ts +41 -0
  89. package/templates/app/modules/config/helpers/describe.ts +72 -0
  90. package/templates/app/modules/config/i18n/en.json +6 -0
  91. package/templates/app/modules/config/i18n/fr.json +6 -0
  92. package/templates/app/modules/config/models/config-audit.model.ts +26 -0
  93. package/templates/app/modules/config/module.acl.ts +12 -0
  94. package/templates/app/modules/config/package.json +23 -0
  95. package/templates/app/modules/config/routes/config.routes.ts +37 -0
  96. package/templates/app/modules/config/schemas/config.schemas.ts +13 -0
  97. package/templates/app/modules/config/tests/config.test.ts +126 -0
  98. package/templates/app/modules/core/bootstrap/cache.bootstrap.ts +12 -0
  99. package/templates/app/modules/core/helpers/cache/index.ts +60 -0
  100. package/templates/app/modules/core/helpers/cache/memory-cache.ts +53 -0
  101. package/templates/app/modules/core/helpers/cache/redis-cache.ts +68 -0
  102. package/templates/app/modules/core/helpers/cache/types.ts +37 -0
  103. package/templates/app/modules/core/helpers/lazy.ts +13 -0
  104. package/templates/app/modules/core/helpers/logger/index.ts +115 -0
  105. package/templates/app/modules/core/helpers/mongoose/index.ts +27 -0
  106. package/templates/app/modules/core/helpers/mongoose/plugins/base.plugin.ts +14 -0
  107. package/templates/app/modules/core/helpers/mongoose/plugins/soft-delete.plugin.ts +124 -0
  108. package/templates/app/modules/core/helpers/mongoose/plugins/timestamps.plugin.ts +13 -0
  109. package/templates/app/modules/core/helpers/mongoose/plugins/types.ts +45 -0
  110. package/templates/app/modules/core/helpers/mongoose/register.ts +20 -0
  111. package/templates/app/modules/core/lib/index.ts +34 -0
  112. package/templates/app/modules/core/module.config.ts +24 -0
  113. package/templates/app/modules/core/package.json +80 -0
  114. package/templates/app/modules/core/routes/core.routes.ts +24 -0
  115. package/templates/app/modules/core/tests/core.test.ts +19 -0
  116. package/templates/app/modules/hello/agent/hello.agent.ts +33 -0
  117. package/templates/app/modules/hello/bootstrap/seed-greetings.bootstrap.ts +27 -0
  118. package/templates/app/modules/hello/controllers/greetings.controllers.ts +43 -0
  119. package/templates/app/modules/hello/controllers/hello.controllers.ts +50 -0
  120. package/templates/app/modules/hello/helpers/greeting.ts +8 -0
  121. package/templates/app/modules/hello/i18n/en.json +8 -0
  122. package/templates/app/modules/hello/i18n/fr.json +8 -0
  123. package/templates/app/modules/hello/models/greeting.model.ts +24 -0
  124. package/templates/app/modules/hello/module.acl.ts +16 -0
  125. package/templates/app/modules/hello/module.config.ts +14 -0
  126. package/templates/app/modules/hello/package.json +24 -0
  127. package/templates/app/modules/hello/queues/hello.queues.ts +38 -0
  128. package/templates/app/modules/hello/routes/greetings.routes.ts +25 -0
  129. package/templates/app/modules/hello/routes/hello.routes.ts +44 -0
  130. package/templates/app/modules/hello/sockets/hello.sockets.ts +21 -0
  131. package/templates/app/modules/hello/tests/hello.test.ts +19 -0
  132. package/templates/app/modules/security/bootstrap/rate-limit.bootstrap.ts +8 -0
  133. package/templates/app/modules/security/helpers/hardening.ts +65 -0
  134. package/templates/app/modules/security/helpers/proxy.ts +16 -0
  135. package/templates/app/modules/security/helpers/rate-limit.ts +150 -0
  136. package/templates/app/modules/security/module.config.ts +59 -0
  137. package/templates/app/modules/security/package.json +26 -0
  138. package/templates/app/modules/security/routes/security.routes.ts +42 -0
  139. package/templates/app/modules/security/tests/security.test.ts +174 -0
  140. package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +12 -0
  141. package/templates/app/modules/settings/controllers/settings.controllers.ts +58 -0
  142. package/templates/app/modules/settings/helpers/access-lease.ts +99 -0
  143. package/templates/app/modules/settings/helpers/log-stream.ts +272 -0
  144. package/templates/app/modules/settings/helpers/log-tail.ts +253 -0
  145. package/templates/app/modules/settings/i18n/en.json +3 -0
  146. package/templates/app/modules/settings/i18n/fr.json +3 -0
  147. package/templates/app/modules/settings/module.acl.ts +12 -0
  148. package/templates/app/modules/settings/package.json +21 -0
  149. package/templates/app/modules/settings/routes/settings.routes.ts +33 -0
  150. package/templates/app/modules/settings/schemas/settings.schemas.ts +8 -0
  151. package/templates/app/modules/settings/tests/settings.test.ts +409 -0
  152. package/templates/app/modules/users/controllers/users.controllers.ts +113 -0
  153. package/templates/app/modules/users/i18n/en.json +14 -0
  154. package/templates/app/modules/users/i18n/fr.json +14 -0
  155. package/templates/app/modules/users/models/user.model.ts +18 -0
  156. package/templates/app/modules/users/module.acl.ts +10 -0
  157. package/templates/app/modules/users/module.config.ts +11 -0
  158. package/templates/app/modules/users/package.json +24 -0
  159. package/templates/app/modules/users/queues/users.queues.ts +31 -0
  160. package/templates/app/modules/users/routes/users.routes.ts +60 -0
  161. package/templates/app/modules/users/schemas/users.schemas.ts +30 -0
  162. package/templates/app/modules/users/sockets/users.sockets.ts +18 -0
  163. package/templates/app/nginx/development.conf +44 -0
  164. package/templates/app/nginx/production.conf +75 -0
  165. package/templates/app/nginx/staging.conf +51 -0
  166. package/templates/app/package.json +58 -0
  167. package/templates/app/scripts/count-greetings.ts +29 -0
  168. package/templates/app/scripts/deploy-nginx.ts +143 -0
  169. package/templates/app/scripts/list-users.ts +34 -0
  170. package/templates/app/tests/app.test.ts +28 -0
  171. package/templates/app/tests/support.ts +36 -0
  172. package/templates/app/tsconfig.json +28 -0
  173. package/templates/app/vitest.config.ts +6 -0
  174. package/templates/app/worker.ts +6 -0
  175. package/templates/claude/skills/tulipes-agent-tool/SKILL.md +87 -0
  176. package/templates/claude/skills/tulipes-boot-errors/SKILL.md +11 -5
  177. package/templates/claude/skills/tulipes-config/SKILL.md +74 -0
  178. package/templates/claude/skills/tulipes-endpoint/SKILL.md +38 -25
  179. package/templates/claude/skills/tulipes-env-variable/SKILL.md +54 -26
  180. package/templates/claude/skills/tulipes-model/SKILL.md +11 -4
  181. package/templates/claude/skills/tulipes-module/SKILL.md +11 -10
  182. package/templates/claude/skills/tulipes-queue/SKILL.md +20 -6
  183. package/templates/claude/skills/tulipes-settings/SKILL.md +49 -100
  184. package/templates/claude/skills/tulipes-spec/SKILL.md +10 -12
  185. package/templates/minimal/README.md +21 -0
  186. package/templates/minimal/app.ts +3 -0
  187. package/templates/minimal/config/app.config.ts +16 -0
  188. package/templates/minimal/modules/agent/module.acl.ts +3 -0
  189. package/templates/minimal/modules/agent/package.json +14 -0
  190. package/templates/minimal/modules/agent/routes/agent.routes.ts +20 -0
  191. package/templates/minimal/modules/core/module.acl.ts +4 -0
  192. package/templates/minimal/modules/core/package.json +21 -0
  193. package/templates/minimal/modules/hello/i18n/en.json +3 -0
  194. package/templates/minimal/modules/hello/module.acl.ts +2 -0
  195. package/templates/minimal/modules/hello/package.json +11 -0
  196. package/templates/minimal/modules/hello/routes/health.routes.ts +9 -0
  197. package/templates/minimal/modules/hello/routes/hello.routes.ts +10 -0
  198. package/templates/minimal/modules/security/package.json +15 -0
  199. package/templates/minimal/modules/security/routes/security.routes.ts +8 -0
  200. package/templates/minimal/package.json +38 -0
  201. package/templates/minimal/tests/app.test.ts +52 -0
  202. package/templates/minimal/tsconfig.json +22 -0
  203. package/templates/minimal/vitest.config.ts +5 -0
  204. package/templates/overlays/no-queues/ecosystem.config.cjs +52 -0
  205. package/templates/overlays/no-queues/modules/hello/controllers/greetings.controllers.ts +41 -0
  206. package/templates/overlays/no-queues/modules/users/controllers/users.controllers.ts +107 -0
  207. package/templates/versions.json +11 -0
  208. package/dist/minimal.d.ts +0 -2
  209. package/dist/minimal.js +0 -2200
  210. package/dist/minimal.js.map +0 -1
  211. /package/templates/{browser-auth.md → app/docs/browser-auth.md} +0 -0
@@ -1,111 +1,60 @@
1
1
  ---
2
2
  name: tulipes-settings
3
- description: Use when working with the settings module — the sysadmin console that describes every module, edits environment variables and translations, restarts the app, or streams logs. Covers the sysadmin role, where a written variable actually lands, and why some writes cannot take effect.
3
+ description: Use when streaming an app's logs, or when deciding whether a value is a setting or an environment variable and where it lives. Covers the settings module (the log stream), the two stores, and why the old console endpoints are gone.
4
4
  ---
5
5
 
6
- # The settings module
7
-
8
- An infrastructure console: what every module declares, what its variables
9
- resolve to and from where, its translations, and a live view of the logs.
10
-
11
- Everything it reports comes from the **booted context** — `ctx.modules`,
12
- the variable store, the ACL, the RAI registry — so it cannot describe an
13
- app different from the one running.
14
-
15
- ## Access is `sysadmin`, and that is a real boundary
16
-
17
- `settings:*` is granted only to `sysadmin`. This works because the auth
18
- module grants `admin` its namespaces **explicitly** rather than `*`:
19
-
20
- ```ts
21
- acl.allow("admin", "auth:*", "users:*", "hello:*");
22
- acl.allow("sysadmin", "auth:*", "users:*", "hello:*"); // + settings:* from settings
23
- ```
24
-
25
- **A wildcard would reach `settings:*` like any other id**, and "sysadmin
26
- only" would be a label rather than a rule. When you add a feature module,
27
- add its namespace to *both* grants — a new module is invisible to admin
28
- otherwise.
29
-
30
- A fresh app seeds an `admin`, so the console is inert until someone is
31
- deliberately promoted to `sysadmin`.
32
-
33
- ## Endpoints
6
+ # Settings and the settings module
7
+
8
+ Two layers of configuration. **Environment** is what the process needs
9
+ before the settings store can answer — the port, the connection strings,
10
+ the log destinations — plus secrets: declared under `tulipes.env` in a
11
+ module's `package.json`, read with `ctx.Environment.get()`, resolved once
12
+ at boot. **Settings** are everything else: declared with a zod schema in
13
+ `module.config.ts`, stored per module, validated on every write,
14
+ hot-applied, read live at `ctx.config.<module>`. The test is mechanical:
15
+ if boot does not need it before the database answers and it is not a
16
+ secret, it is a setting. → skills `tulipes-env-variable`, `tulipes-config`
17
+
18
+ ## Where settings live
19
+
20
+ The app's root `package.json` selects the store. With
21
+ `"providers": { "settings": "@tulipes/mongoose/settings" }` (the production
22
+ preset) every namespace is one document in the `settings` collection —
23
+ `{ _id: "<module>", values, version, updated_at, updated_by }` — a write is
24
+ refused when its version is stale, the replaced document goes to
25
+ `settings_history`, and other processes pick the change up through a change
26
+ stream on a replica set or by polling otherwise. Without the declaration
27
+ (the minimal preset) the store is `config/settings.<APP_ENV>.json`, watched
28
+ for edits. Neither store validates: the module's schema does, in every
29
+ process, before a value is applied — an invalid document is logged and
30
+ ignored, never applied. `pnpm tulipes config:check` prints what the store
31
+ holds; the `config` module serves and edits it over HTTP.
32
+
33
+ ## The settings module: the log stream
34
+
35
+ What is left of the old console is the one thing it did that the store
36
+ cannot: a live view of the logs.
34
37
 
35
38
  | | |
36
39
  |---|---|
37
- | `GET /settings/modules` | every module: manifest, variables with full spec + resolved source, config, i18n, routes |
38
- | `GET /settings/variables/:name` | one variable; `?reveal=true` returns a secret's value, audited |
39
- | `PUT /settings/variables/:name` | set a value — validated against the variable's own spec |
40
- | `GET`/`PUT /settings/i18n/:module/:locale` | read or replace a module's translations |
41
- | `POST /settings/reboot` | restart via PM2 so written values take effect |
42
40
  | `GET /settings/logs/stream` | SSE; `?process=backend\|worker&level=warn&tail=200` |
43
41
 
44
- ## Where a written variable actually lands
45
-
46
- **Always `.envs/.env.<APP_ENV>` — never `meta.variables.json`.** That file
47
- is the module's committed contract: rewriting a default there would change
48
- it for every environment and be lost on the next deploy. The env file is
49
- what exists to hold per-deployment values.
50
-
51
- The file is edited **surgically** — one line replaced, comments and
52
- ordering untouched — because these files are half explanation and a
53
- `stringify(parse(file))` round trip would erase all of it.
54
-
55
- ### The case that catches people
56
-
57
- Precedence is `process.env` → env file → default. A variable injected by
58
- the **container** outranks both, so writing the file changes nothing, and
59
- a restart re-reads the same container environment and it wins again.
60
-
61
- When that happens the write still lands in the file *and* sets
62
- `process.env` live, and the response says so:
42
+ It is `sysadmin`-only (`settings:*` is granted to that role alone, and the
43
+ auth module never grants `admin` the `*` wildcard, which is what makes the
44
+ boundary real). A stream outlives the request that opened it, so the
45
+ authority behind it is re-checked while it is open — against the database,
46
+ never the auth cache — and the stream ends on logout, password change,
47
+ session end, role change or deletion, on `ctx.signal` at shutdown, and
48
+ after a bounded duration and byte count. It reads the files the sys core
49
+ module's logger writes (`LOG_DIR`, names from `LOG_FILES` in `@app/core`)
50
+ and refuses when `LOG_FILE` is off.
63
51
 
64
- ```jsonc
65
- { "meta": { "action": "restart_required_shadowed" } }
66
- ```
52
+ ## What is gone, and where it went
67
53
 
68
- That makes the change real for **this process only**. Other replicas keep
69
- the old value until the container's environment itself is changed.
70
-
71
- ## Nothing takes effect until a restart
72
-
73
- Variables resolve once at boot, and `config.<module>` is derived from them
74
- by factories that also run once. A write persists; it does not move the
75
- running app. Every write response carries `requires_reboot: true`.
76
-
77
- `POST /settings/reboot` restarts through PM2, ordering the process serving
78
- the request **last** so the caller still gets its response. Under `yarn
79
- dev` there is no PM2, and it answers `503 NOT_SUPERVISED` rather than
80
- exiting a process nothing would restart.
81
-
82
- ## Logs are read from files, not hooked in-process
83
-
84
- The stream tails `LOG_DIR`, which is what lets it show the **worker** —
85
- backend and worker are separate processes writing separate files, and an
86
- in-process stream could only ever show its own. The tailer is
87
- rotation-aware: pino-roll renames and starts a new file, and a naive watch
88
- on one path goes silent the moment it rolls.
89
-
90
- `LOG_FILE=false` means there are no files; the endpoint says so rather
91
- than streaming nothing.
92
-
93
- ## Audit
94
-
95
- Every reveal, write and reboot logs at `warn` with actor, role, IP and
96
- old→new. **A secret's value is never recorded in either direction** — an
97
- audit entry that reconstructs a rotated key defeats rotating it; those
98
- entries carry `redacted: true` and no values.
99
-
100
- `?level=warn` on the log stream shows the audit trail and nothing else.
101
-
102
- ## Rules
103
-
104
- - Never add a `settings:*` grant to another role. If someone needs part of
105
- it, give them `sysadmin`, or split the ids.
106
- - Never widen a write to `meta.variables.json`. It is a contract, not
107
- storage.
108
- - A new feature module needs its namespace in **both** the `admin` and
109
- `sysadmin` grants.
110
- - Treat a `restart_required_shadowed` response as a warning to fix the
111
- deployment's environment, not as a successful change.
54
+ | 0.10 endpoint | 0.11 |
55
+ |---|---|
56
+ | `GET /settings/modules` | `GET /config` — the `config` module |
57
+ | `GET`/`PUT /settings/variables/:name` | gone: no editable secrets; variables are read-only in `GET /config` |
58
+ | `POST /settings/reboot` | gone: nothing needs a restart to take effect; a restart-tagged field says so in the write's response |
59
+ | `GET`/`PUT /settings/i18n/:module/:locale` | gone: translations are code, edited in the repository |
60
+ | the `.env` file editor, PM2 control, the audit of those | gone with them; the console's writes are audited by the `config` module |
@@ -18,7 +18,7 @@ router.post("/users", rai({
18
18
  name: "Create a user",
19
19
  body: z.object({ email: z.email("emailInvalid") }),
20
20
  returns: z.object({ id: z.string(), email: z.string() }),
21
- }), users(value => value.create())); // users is a runtime() binder
21
+ }), users.create());
22
22
 
23
23
  ```
24
24
 
@@ -30,8 +30,8 @@ response.
30
30
  understands v4 schemas. The classic `from "zod"` import still validates but
31
31
  produces no shape in the document, and `tulipes spec` will say so.
32
32
 
33
- Keep schemas in `modules/<name>/schemas.ts` when a route file starts to
34
- sprawl; both the example's users and auth modules do.
33
+ Keep schemas in `modules/<name>/schemas/<name>.schemas.ts` when a route file
34
+ starts to sprawl; the example's users, auth and config modules do.
35
35
 
36
36
  ## What validation gives you
37
37
 
@@ -70,17 +70,15 @@ rai({ id: "auth:sessions", name: "List sessions", folder: "sessions" })
70
70
  ## Generating
71
71
 
72
72
  ```sh
73
- yarn spec # tulipes spec --offline --openapi docs/openapi.json --postman docs/collection.json
73
+ pnpm spec # tulipes spec --openapi docs/openapi.json --postman docs/collection.json
74
74
  ```
75
75
 
76
- Offline generation builds native router factories wrapped in defineRoutes().
77
- Every module opts in with tulipes.offline: true; no extra declaration file is
78
- required. Keep schema imports pure and acquire services/controllers through
79
- runtime(). Use --runtime for factories requiring live configuration.
80
- --url sets the advertised base URL. Offline defaults to http://localhost:3000
81
- and package.json title/version; runtime uses PORT/PUBLIC_DOMAIN and config.app.
82
- Install custom access checkers inside defineRoutes using routes.setAccessChecker;
83
- offline tooling sees the checker and makes no static role/public-access claim.
76
+ Generation is an inspection boot: every routes file runs with nothing
77
+ acquired, so no service, secret or `.env` is needed, and a routes file that
78
+ touches infrastructure while building fails with its path. `--url` sets the
79
+ advertised base URL (default `http://localhost:3000`). A custom access
80
+ checker installed with `routes.setAccessChecker` is reported as dynamic: the
81
+ document makes no static role or public-access claim for it.
84
82
 
85
83
  The generator lives in `@tulipes/spec`, a dev dependency. Core does not
86
84
  depend on it, so an app that never generates a spec never installs it.
@@ -0,0 +1,21 @@
1
+ # tulipes-example
2
+
3
+ Minimal Tulipes API. Requires Node 24.x and pnpm; no MongoDB, Redis or credentials.
4
+
5
+ ```sh
6
+ pnpm install
7
+ pnpm sync
8
+ pnpm typecheck
9
+ pnpm check
10
+ pnpm test
11
+ pnpm spec
12
+ pnpm dev
13
+ ```
14
+
15
+ Open http://localhost:3000/api/v1/hello or http://localhost:3000/health.
16
+
17
+ Routes declare a RAI and use res.respond. Middleware belongs to sys modules.
18
+ Core still installs optional infrastructure libraries, but this app opens no infrastructure connections.
19
+ The production preset adds auth, users, settings, queues and deployment examples:
20
+
21
+ `tulipes init another-api --preset production`
@@ -0,0 +1,3 @@
1
+ import { boot } from "@tulipes/core/boot";
2
+
3
+ await boot({ rootDir: import.meta.dirname, mode: "backend" });
@@ -0,0 +1,16 @@
1
+ import type { AppConfigFn } from "@tulipes/core/boot";
2
+
3
+ /**
4
+ * App-wide values that belong to no module. `api.prefix` is where a router
5
+ * a routes file returns is mounted; `app.name` titles the startup banner.
6
+ */
7
+ const appConfig = (() => ({
8
+ app: { name: "tulipes-example" },
9
+ api: { prefix: "/api/v1" },
10
+ })) satisfies AppConfigFn;
11
+
12
+ export default appConfig;
13
+
14
+ declare module "@tulipes/core/config" {
15
+ interface GlobalConfig extends Partial<ReturnType<typeof appConfig>> {}
16
+ }
@@ -0,0 +1,3 @@
1
+ import type { AclBuilder } from "@tulipes/core/acl";
2
+ // Open to every caller: the manifest shows only what the role may call.
3
+ export default function permissions(acl: AclBuilder) { acl.allow("guest", "agent:*"); }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@tulipes-example/agent",
3
+ "private": true,
4
+ "type": "module",
5
+ "version": "0.0.0",
6
+ "tulipes": {
7
+ "priority": 900
8
+ },
9
+ "dependencies": {
10
+ "@tulipes/core": "workspace:*",
11
+ "express": "^5",
12
+ "zod": "^3.25"
13
+ }
14
+ }
@@ -0,0 +1,20 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod/v4";
3
+ import type { BackendCtx } from "@tulipes/core/boot";
4
+ import { roleOf } from "@tulipes/core/http";
5
+
6
+ /** The agent door: the manifest a role may call, and the action endpoint. */
7
+ export default function agentRoutes(ctx: BackendCtx): Router {
8
+ const { rai } = ctx;
9
+ const router = Router();
10
+ router.get("/agent/tools", rai({ id: "agent:tools", name: "Tools an agent may call", agent: false }), (req, res) => {
11
+ res.respond({ data: ctx.agent.manifest(roleOf(req)) });
12
+ });
13
+ router.post("/agent/tools/:name", rai({
14
+ id: "agent:invoke", name: "Run a declared action", agent: false,
15
+ params: z.object({ name: z.string() }), body: z.looseObject({}),
16
+ }), async (req, res) => {
17
+ res.respond({ data: await ctx.agent.invoke(String(req.params.name), req.body, { ...req.auth, role: roleOf(req) }, req) });
18
+ });
19
+ return router;
20
+ }
@@ -0,0 +1,4 @@
1
+ import type { AclBuilder } from "@tulipes/core/acl";
2
+ export default function permissions(acl: AclBuilder) {
3
+ acl.defineRole("guest").allow("guest", "core:health");
4
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@tulipes-example/core",
3
+ "private": true,
4
+ "type": "module",
5
+ "version": "0.0.0",
6
+ "tulipes": {
7
+ "tier": "sys",
8
+ "priority": 0,
9
+ "env": [
10
+ {
11
+ "name": "PORT",
12
+ "type": "number",
13
+ "default": 3000,
14
+ "description": "HTTP listener port"
15
+ }
16
+ ]
17
+ },
18
+ "dependencies": {
19
+ "@tulipes/core": "workspace:*"
20
+ }
21
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "greeting": "Hello"
3
+ }
@@ -0,0 +1,2 @@
1
+ import type { AclBuilder } from "@tulipes/core/acl";
2
+ export default function permissions(acl: AclBuilder) { acl.allow("guest", "hello:read"); }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@tulipes-example/hello",
3
+ "private": true,
4
+ "type": "module",
5
+ "version": "0.0.0",
6
+ "dependencies": {
7
+ "@tulipes/core": "workspace:*",
8
+ "express": "^5",
9
+ "zod": "^3.25"
10
+ }
11
+ }
@@ -0,0 +1,9 @@
1
+ import { Router } from "express";
2
+ import type { BackendCtx } from "@tulipes/core/boot";
3
+ export default function healthRoutes({ rai, routes }: BackendCtx): void {
4
+ const router = Router();
5
+ router.get("/health", rai({ id: "core:health", name: "Health" }), (_req, res) => {
6
+ res.respond({ data: { status: "ok" } });
7
+ });
8
+ routes.mount(router, "");
9
+ }
@@ -0,0 +1,10 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod/v4";
3
+ import type { BackendCtx } from "@tulipes/core/boot";
4
+ export default function helloRoutes({ rai }: BackendCtx): Router {
5
+ const router = Router();
6
+ router.get("/hello", rai({ id: "hello:read", name: "Hello", returns: z.object({ message: z.string() }) }), (req, res) => {
7
+ res.respond({ data: { message: req.t("greeting") } });
8
+ });
9
+ return router;
10
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@tulipes-example/security",
3
+ "private": true,
4
+ "type": "module",
5
+ "version": "0.0.0",
6
+ "tulipes": {
7
+ "tier": "sys",
8
+ "priority": 10
9
+ },
10
+ "dependencies": {
11
+ "@tulipes/core": "workspace:*",
12
+ "express": "^5",
13
+ "helmet": "^8"
14
+ }
15
+ }
@@ -0,0 +1,8 @@
1
+ import express from "express";
2
+ import helmet from "helmet";
3
+ import type { BackendCtx } from "@tulipes/core/boot";
4
+ export default function securityRoutes({ app }: BackendCtx): void {
5
+ app.disable("x-powered-by");
6
+ app.use(helmet());
7
+ app.use(express.json({ limit: "100kb" }));
8
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "tulipes-example",
3
+ "private": true,
4
+ "type": "module",
5
+ "packageManager": "pnpm@10.28.0",
6
+ "engines": {
7
+ "node": ">=24.0.0 <25"
8
+ },
9
+ "pnpm": {
10
+ "onlyBuiltDependencies": [
11
+ "esbuild"
12
+ ]
13
+ },
14
+ "scripts": {
15
+ "dev": "tulipes dev",
16
+ "start": "node app.ts",
17
+ "sync": "tulipes sync",
18
+ "check": "tulipes env:check",
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "vitest run",
21
+ "spec": "tulipes spec --openapi docs/openapi.json --postman docs/collection.json"
22
+ },
23
+ "dependencies": {
24
+ "@tulipes/core": "workspace:*",
25
+ "express": "^5",
26
+ "zod": "^3.25"
27
+ },
28
+ "devDependencies": {
29
+ "@tulipes/cli": "workspace:*",
30
+ "@tulipes/spec": "workspace:*",
31
+ "@tulipes/testing": "workspace:*",
32
+ "@types/express": "^5",
33
+ "@types/node": "^24",
34
+ "typescript": "^7",
35
+ "vite": "^8.2.2",
36
+ "vitest": "^5.0.0"
37
+ }
38
+ }
@@ -0,0 +1,52 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ import { afterAll, beforeAll, expect, test } from "vitest";
4
+ import { boot } from "@tulipes/core/boot";
5
+ import { extract, toOpenApi, toPostman } from "@tulipes/spec";
6
+ import { useTestApp } from "@tulipes/testing";
7
+
8
+ /**
9
+ * Boots the app once for this file on a free port, with no services: the
10
+ * minimal preset declares none. Module tests live in modules/<name>/tests/
11
+ * and boot the same way.
12
+ */
13
+ const rootDir = fileURLToPath(new URL("../", import.meta.url));
14
+ const app = useTestApp({ rootDir }, { beforeAll, afterAll });
15
+
16
+ test("hello responds through the declared public endpoint", async () => {
17
+ const response = await app.get<{ message: string }>(app.api("/hello"));
18
+
19
+ expect(response.status).toBe(200);
20
+ expect(response.body).toMatchObject({ success: true, errors: [] });
21
+ expect(response.data?.message).toBeTypeOf("string");
22
+ });
23
+
24
+ test("unknown endpoints return the error envelope", async () => {
25
+ const response = await app.get("/not-a-route");
26
+
27
+ expect(response.status).toBe(404);
28
+ expect(response.body).toMatchObject({ success: false, data: null });
29
+ });
30
+
31
+ test("an inspection boot describes the same routes and schemas as the running app", async () => {
32
+ const inspection = await boot({ rootDir, mode: "backend", inspect: true, handleSignals: false });
33
+
34
+ try {
35
+ const inspected = { ...extract(inspection.ctx), app: { name: "parity" } };
36
+ const runtime = { ...extract(app.ctx), app: { name: "parity" } };
37
+
38
+ expect(await toOpenApi(inspected)).toEqual(await toOpenApi(runtime));
39
+ expect(await toPostman(inspected)).toEqual(await toPostman(runtime));
40
+ } finally {
41
+ await inspection.shutdown();
42
+ }
43
+ });
44
+
45
+ test("health needs no external infrastructure", async () => {
46
+ expect(app.ctx.models).toBeUndefined();
47
+ expect(app.ctx.queues).toBeUndefined();
48
+
49
+ const response = await app.get<{ status: string }>("/health");
50
+
51
+ expect(response.data).toEqual({ status: "ok" });
52
+ });
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "allowImportingTsExtensions": true,
7
+ "rewriteRelativeImportExtensions": true,
8
+ "erasableSyntaxOnly": true,
9
+ "strict": true,
10
+ "noUncheckedIndexedAccess": true,
11
+ "verbatimModuleSyntax": true,
12
+ "skipLibCheck": true,
13
+ "noEmit": true
14
+ },
15
+ "include": [
16
+ "app.ts",
17
+ "modules",
18
+ "types",
19
+ "config",
20
+ "tests"
21
+ ]
22
+ }
@@ -0,0 +1,5 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ // Node strips types natively, so the app's own files import without a loader.
4
+ // Every file boots the app once (@tulipes/testing), so files run one after another.
5
+ export default defineConfig({ test: { include: ["tests/**/*.test.ts", "modules/*/tests/**/*.test.ts"], fileParallelism: false } });
@@ -0,0 +1,52 @@
1
+ // PM2 definitions for both Tulipes processes in all three modes.
2
+ //
3
+ // pnpm pm2:start development
4
+ // pnpm pm2:start:staging staging
5
+ // pnpm pm2:start:production production
6
+ // pnpm pm2:stop | pm2:restart | pm2:delete | pm2:logs | pm2:status
7
+ //
8
+ // CommonJS on purpose: this app is an ESM package, and PM2 reads its
9
+ // config with require().
10
+
11
+ /**
12
+ * Each block repeats both variables rather than relying on PM2 merging
13
+ * env_<name> over env: a half-applied environment (right APP_ENV, wrong
14
+ * PROCESS_MODE) would start the wrong process against the wrong config,
15
+ * and the repetition is cheap insurance against that.
16
+ */
17
+ const modes = (processMode) => ({
18
+ env: { APP_ENV: "development", PROCESS_MODE: processMode },
19
+ env_staging: { APP_ENV: "staging", PROCESS_MODE: processMode },
20
+ env_production: { APP_ENV: "production", PROCESS_MODE: processMode },
21
+ });
22
+
23
+ /** Shared by both apps. */
24
+ const common = {
25
+ cwd: __dirname,
26
+ // Node 24 strips types natively, so PM2 runs the TypeScript entrypoints
27
+ // with node itself: no loader, no build step, nothing to point elsewhere.
28
+ interpreter: "node",
29
+ // Fork, not cluster: Socket.IO needs sticky sessions or the Redis
30
+ // adapter before a second instance is safe, and bootstrap tasks would
31
+ // run once per instance. Scale out only after wiring the adapter.
32
+ exec_mode: "fork",
33
+ instances: 1,
34
+ autorestart: true,
35
+ max_memory_restart: "512M",
36
+ // Graceful shutdown drains queues and closes mongo and redis. PM2's
37
+ // default 1.6s SIGKILL would cut that short and lose in-flight jobs.
38
+ kill_timeout: 10000,
39
+ };
40
+
41
+ module.exports = {
42
+ apps: [
43
+ {
44
+ ...common,
45
+ name: "tulipes-example",
46
+ script: "app.ts",
47
+ error_file: "logs/backend-error.log",
48
+ out_file: "logs/backend-out.log",
49
+ ...modes("backend"),
50
+ },
51
+ ],
52
+ };
@@ -0,0 +1,41 @@
1
+ import type { Request, RequestHandler, Response } from "express";
2
+ import { requireModels, type Ctx } from "@tulipes/core/boot";
3
+ import { HttpError } from "@tulipes/core/http";
4
+
5
+ export class GreetingsController {
6
+ readonly #ctx: Ctx;
7
+
8
+ constructor(ctx: Ctx) {
9
+ this.#ctx = ctx;
10
+ }
11
+
12
+ list(): RequestHandler {
13
+ return async (req: Request, res: Response) => {
14
+ // ?page and ?per_page, already clamped by config.pagination — so
15
+ // every list endpoint in every module paginates the same way.
16
+ const { per_page, skip } = req.page;
17
+ const Greeting = requireModels(this.#ctx).get("Greeting");
18
+
19
+ const [greetings, total_items] = await Promise.all([
20
+ Greeting.find().select("name message times_used -_id")
21
+ .skip(skip).limit(per_page).lean(),
22
+ Greeting.countDocuments(),
23
+ ]);
24
+
25
+ // Passing total_items is what marks this a page: respond fills in
26
+ // page, per_page and total_pages from the request and the count.
27
+ res.respond({ data: greetings, meta: { total_items } });
28
+ };
29
+ }
30
+
31
+ use(): RequestHandler {
32
+ return async (req: Request, res: Response) => {
33
+ const name = String(req.params.name);
34
+ const greeting = await requireModels(this.#ctx).get("Greeting").findOne({ name }).lean();
35
+ if (!greeting) throw new HttpError(404, "noSuchGreeting", { vars: { name } });
36
+ // Without a queues provider the count is updated in the request itself.
37
+ await requireModels(this.#ctx).get("Greeting").updateOne({ name }, { $inc: { times_used: 1 } });
38
+ res.respond({ data: { name, message: greeting.message } });
39
+ };
40
+ }
41
+ }