@tulipes/cli 0.1.0 → 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 +11 -8
  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 +17 -6420
  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 +87 -46
  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 +162 -0
  47. package/templates/CLAUDE.md +11 -118
  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
@@ -0,0 +1,83 @@
1
+ ---
2
+ name: tulipes-agent-tool
3
+ description: Use when exposing a Tulipes module to AI agents — describing it, choosing which endpoints an agent sees, adding an action that is not an endpoint, or wiring the app to Claude Code through MCP. Covers agent/*.agent.ts, the manifest, the agent door and tulipes-mcp.
4
+ ---
5
+
6
+ # Exposing a module to agents
7
+
8
+ Every endpoint is already a tool: its `rai()` declares the name, the
9
+ description, the input schema (`params`, `query`, `body`) and the
10
+ permission, and the boot derives a tool from it — `hello:list-greetings`
11
+ becomes `hello_list-greetings`, a `GET` is marked read-only. A module with
12
+ no `agent/` folder exposes every route it declares. The folder adds what
13
+ cannot be derived.
14
+
15
+ `modules/hello/agent/hello.agent.ts`:
16
+
17
+ ```ts
18
+ import { action, defineAgent } from "@tulipes/core/agent";
19
+ import { requireModels } from "@tulipes/core/boot";
20
+ import { z } from "zod/v4";
21
+
22
+ export default defineAgent({
23
+ summary: "Greets callers and counts how often each greeting is used.",
24
+ tools: [
25
+ // Derived from the route; the description overrides the RAI's for agents only.
26
+ { route: "hello:list-greetings", description: "The stored greetings, most used first" },
27
+ { route: "hello:use-greeting" },
28
+
29
+ // Declared: not an endpoint. The id is a permission, granted in module.acl.ts.
30
+ action({
31
+ id: "hello:reset-counts",
32
+ description: "Reset the usage counter of every greeting to zero",
33
+ input: z.object({ confirm: z.literal(true) }),
34
+ handler: async (ctx, input, actor) => {
35
+ const { modifiedCount } = await requireModels(ctx).get("Greeting").updateMany({}, { times_used: 0 });
36
+ return { reset: modifiedCount };
37
+ },
38
+ }),
39
+ ],
40
+ });
41
+ ```
42
+
43
+ `pnpm tulipes agent manifest` prints the result from an inspection boot —
44
+ no service or `.env` needed; `--role admin` shows what one role gets.
45
+
46
+ ## Rules
47
+
48
+ - **`tools` omitted exposes every route; given, it is the whole list.**
49
+ `{ route: "*" }` is every route of the module, `{ route: "hello:*" }` a
50
+ namespace, an exact id one route. A route id the module never declared is
51
+ a boot error. One `agent/*.agent.ts` per module.
52
+ - **An endpoint that must stay out of the manifest says `agent: false`** on
53
+ its `rai()` — the agent door's own routes do.
54
+ - **An action's id is a permission like any route's.** The ACL decides on
55
+ it first (401 for a guest, 403 for a role without it), then the `input`
56
+ schema validates (422 with field issues), then the handler runs with the
57
+ runtime context, the parsed input and the actor (`req.auth` plus `role`).
58
+ Grant it in `module.acl.ts`; a wildcard such as `hello:*` already covers it.
59
+ - **Write the description for the agent, not the operator.** It is the only
60
+ text the model sees before choosing the tool. Say what the tool does, what
61
+ it changes and what it returns; keep `readOnly: true` honest.
62
+ - **Route tools are called at their own path**, through the app's middleware
63
+ and auth, never around it. The manifest gives the method and path; the
64
+ input is `{ params, query, body }` as the RAI declares them. A path
65
+ parameter without a `params` schema is still listed as a required string.
66
+ - **Keep handlers small.** An action that grows a second step is a
67
+ controller in disguise: move the logic to `helpers/` and call it from both.
68
+
69
+ ## The door and MCP
70
+
71
+ The app's `agent` module serves the manifest at `GET <prefix>/agent/tools`,
72
+ filtered to the caller's role, and runs actions at
73
+ `POST <prefix>/agent/tools/<name>`. Any client with a bearer token the auth
74
+ module accepts can use it. For Claude Code or Claude Desktop:
75
+
76
+ ```sh
77
+ pnpm add -D @tulipes/mcp
78
+ claude mcp add my-app -- pnpm tulipes-mcp --url http://localhost:3000/api/v1/agent --token "$TOKEN"
79
+ ```
80
+
81
+ `tulipes-mcp` lists the manifest as MCP tools and calls each through the
82
+ door with that token, so the app's own auth and ACL decide — it adds no
83
+ identity of its own and never touches the database.
@@ -30,10 +30,16 @@ problems the report has not reached yet.
30
30
  | `module graph` | `dependsOn "x" — no such module exists` | Typo, or you used the package name. `dependsOn` takes short names (`"users"`). |
31
31
  | `module graph` | `dependency cycle: a → b → a` | Mutual dependency. Move the shared part into the core module or a third module. |
32
32
  | `module graph` | `sys module cannot depend on app module` | A sys module needs something app-tier. Either it is not really sys, or the dependency belongs in `lib/`. |
33
- | `config` | `module.config must default-export a function` | Missing/misspelled `export default`. |
33
+ | `modules` | `x does not belong at the module root` | Only `package.json`, `module.config.ts` and `module.acl.ts` live there; the message names the folder the file belongs in. |
34
+ | `modules` | `tulipes.offline is gone` / `meta.variables.json moved to package.json` | The 0.10 forms. Delete the flag; move the variables under `tulipes.env`. |
35
+ | `routes` | `routes function threw: ctx.models is not available during inspection` | A routes file or a controller constructor touched infrastructure while building. Move the read into the handler, or build the middleware with `lazy()`. |
36
+ | `config` | `module.config must default-export defineConfig({ schema })` | Missing/misspelled `export default`, or the 0.10 factory form: values now live in the settings store. |
37
+ | `config` | `module.config exports "onReady"` | The 0.10 lifecycle exports. Initialization is a `bootstrap/*.bootstrap.ts` file, which may return its cleanup; long-lived work ends on `ctx.signal`. |
38
+ | `config` | `field "x" has no default — every setting needs one` | Give the field a `.default()`; an operator is never asked for a value the process needs to boot. |
39
+ | `settings` | `stored setting "users.maxSessions": …` | The store holds a value the schema no longer accepts. Fix it in the store (or the file the message names). |
34
40
  | `acl` | `allow() on unknown role "x"` | Role never defined. Roles live in the sys `auth` module; define it there and `dependsOn: ["auth"]`. |
35
41
  | `acl` | `already granted … duplicate grant` | Two modules grant the same role+resource. Namespace the resource by module. |
36
- | `providers` | `[x] requires capability "models" … declares no provider for it` | A module has `models/` or `bootstrap/` files (or `requires: ["models"]`) but the app's `package.json` has no `"tulipes": { "providers": { "models": "@tulipes/mongoose" } }`. Add that line; an installed package or a `MONGO_URI` alone selects nothing. |
42
+ | `providers` | `[x] requires capability "models" … declares no provider for it` | A module has `models/` files (or `requires: ["models"]`) but the app's `package.json` has no `"tulipes": { "providers": { "models": "@tulipes/mongoose" } }`. Add that line; an installed package or a `MONGO_URI` alone selects nothing. |
37
43
  | `providers` | `provider "…" cannot be resolved from …` | The declared specifier is not installed in the app. Install it (or fix the path). |
38
44
  | `providers` | `"@tulipes/core/db" no longer contains the Mongoose provider` | The app still declares the pre-extraction specifier. Install `@tulipes/mongoose`, declare it, and import `ModelDef`/`BootstrapFn` from it. |
39
45
  | `providers` | `[x] requires capability "queues" … declares no provider for it` | A module has a `queues/` file (or `requires: ["queues"]`) but the app's `package.json` has no `"tulipes": { "providers": { "queues": "@tulipes/bullmq" } }`. Add that line. |
@@ -59,16 +65,16 @@ runs, check in this order:
59
65
  2. **Directory** — routes in `routes/`, models in `models/`, and so on.
60
66
  Nested subfolders are not scanned.
61
67
  3. **The module is registered** — it needs a `package.json` and a
62
- `yarn install` after creation. The startup banner lists every module it
68
+ `pnpm install` after creation. The startup banner lists every module it
63
69
  found; if yours is missing, the problem is there.
64
70
  4. **`export default`** — every contract file except models exports a
65
71
  function as its default.
66
72
 
67
73
  ## Other symptoms
68
74
 
69
- - **Types are `unknown` or a new variable is not typed** → run `yarn sync`.
75
+ - **Types are `unknown` or a new variable is not typed** → run `pnpm sync`.
70
76
  - **Worker exits immediately** → no processors registered; it refuses to
71
77
  idle. Add a `*.queues.ts` with `process()`.
72
78
  - **Env change has no effect** → `process.env` beats the file. Check for a
73
79
  shell export or container variable of the same name.
74
- - To validate configuration without booting the whole app: `yarn check`.
80
+ - To validate configuration without booting the whole app: `pnpm check`.
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: tulipes-config
3
+ description: Use when adding or changing a setting — a value an operator should be able to edit at runtime — or when working on the config module, the console that describes every module and edits settings. Covers defineConfig, onChange, restart-tagged fields, the console endpoints, versions and the audit.
4
+ ---
5
+
6
+ # Settings and the config module
7
+
8
+ A setting is declared once, in the module's `module.config.ts`, the file's
9
+ only export:
10
+
11
+ ```ts
12
+ import { defineConfig } from "@tulipes/core/config";
13
+ import { z } from "zod/v4";
14
+
15
+ export default defineConfig({
16
+ schema: z.object({
17
+ maxSessions: z.number().int().positive().default(5)
18
+ .describe("Max concurrent sessions per user; the oldest is evicted past it"),
19
+ signupMode: z.enum(["open", "invite", "closed"]).default("open")
20
+ .describe("Who can register"),
21
+ listenPort: z.number().int().default(8080).meta({ restart: true })
22
+ .describe("Applied on the next boot"),
23
+ }),
24
+ /** Optional: rebuild what was built from a value. Runs in every replica after a write lands. */
25
+ onChange(next, ctx) {
26
+ rebuildRateLimits(ctx, next.rateLimits);
27
+ },
28
+ });
29
+ ```
30
+
31
+ `pnpm sync` types it: `ctx.config.users.maxSessions` is a `number` at
32
+ every call site. Read it where it is used, never capture it at boot — the
33
+ object is replaced in place when a write lands.
34
+
35
+ ## Rules
36
+
37
+ - **Every field has a default.** A namespace never written is its
38
+ defaults; the first boot writes them. There is no "required setting",
39
+ because an operator cannot be asked for a value the process needs to boot.
40
+ - **No secrets.** `defineConfig` refuses a field whose metadata says
41
+ `secret`; a secret is an environment variable. → skill `tulipes-env-variable`
42
+ - **Describe every field.** `.describe()` is what the console form, the
43
+ generated types and an agent reading the manifest see.
44
+ - **Validate in the schema, not in `onChange`.** The store persists before
45
+ `onChange` runs, so a refinement that throws there would leave a value
46
+ stored that the module cannot use. `.refine()` keeps it out of the store.
47
+ - **Rebuild in `onChange` what you built from a value.** A middleware
48
+ constructed from a setting at boot does not see the write; either build
49
+ it per request from the live value (helmet, CORS, the body parser do) or
50
+ rebuild it in `onChange` (the rate limiter does, because it holds a Redis
51
+ client).
52
+ - **Tag what cannot be hot-applied** with `.meta({ restart: true })`. The
53
+ write persists, applies on the next boot, and the response carries
54
+ `requires_restart` and `meta.action: "restart_required"`.
55
+
56
+ ## The console: the `config` module
57
+
58
+ | | |
59
+ |---|---|
60
+ | `GET /config` | every module in load order: manifest, declared variables (names, types, sources — a secret's value never), settings with schema, live values and stored version, endpoints with the roles that reach them |
61
+ | `GET /config/:module` | the same for one module |
62
+ | `PATCH /config/:module` | `{ values: { … }, version? }` — a patch over the stored values, validated by the module's schema (422 with the field), refused when `version` is not the stored one (409 `STALE_VERSION`), audited before it lands, applied at once in every replica |
63
+
64
+ `config:read` and `config:describe` are granted to `admin` and `sysadmin`;
65
+ `config:write` to `sysadmin` only. The audit (`config_audit_events`) holds
66
+ the namespace, the field names, the version the caller read, the actor and
67
+ the client IP — never a value — and is acknowledged by a majority before
68
+ the write happens: a change nobody can account for is worse than one that
69
+ did not happen. Variables are read-only here: what boot needs before the
70
+ store answers, and secrets, are not edited at runtime.
71
+
72
+ The same three endpoints are tools in the agent manifest, so an agent with
73
+ `sysadmin` reconfigures the app through the door an operator uses, with
74
+ the same audit. → skill `tulipes-agent-tool`
@@ -37,35 +37,42 @@ the handlers that use them without threading ctx through every signature.
37
37
 
38
38
  ```ts
39
39
  import { Router } from "express";
40
- import { defineRoutes } from "@tulipes/core/http";
40
+ import type { BackendCtx } from "@tulipes/core/boot";
41
41
 
42
- export default defineRoutes(({ app, rai, routes, runtime }) => {
42
+ import { BillingController } from "../controllers/billing.controllers.js";
43
+
44
+ export default function billingRoutes(ctx: BackendCtx): Router {
43
45
  const router = Router();
44
- const billing = runtime(async ctx => {
45
- const { BillingController } = await import("../controllers/billing.controllers.js");
46
- return new BillingController(ctx);
47
- });
48
- router.get("/:id", rai({ id: "billing:read", name: "Read an invoice" }),
49
- billing(value => value.show()));
50
- routes.mount(app, "/invoices", router);
51
- });
46
+ const billing = new BillingController(ctx);
47
+ router.get("/:id", ctx.rai({ id: "billing:read", name: "Read an invoice" }), billing.show());
48
+ return router; // mounted under config.api.prefix
49
+ }
52
50
  ```
53
51
 
54
- Set tulipes.offline: true in the module manifest. runtime() acquires each controller
55
- once at boot, and its binder selects callbacks before listening. Offline inspection
56
- builds real routers without running acquisition functions or callback selectors.
57
- Keep service-dependent imports inside runtime(). Pure callbacks need no binding.
52
+ A routes file is a plain function of the context. A returned router is
53
+ mounted under `config.api.prefix`; `ctx.routes.mount(router, "/invoices")`
54
+ chooses another path, and `ctx.routes.mount(parent, "/tenants/:tenant",
55
+ child)` nests one router in another (`Router({ mergeParams: true })` on the
56
+ child for inherited parameters). A file that only mounts global middleware
57
+ on `ctx.app` returns nothing.
58
58
 
59
- Use router.use for shared middleware, router.param for parameter callbacks, and
60
- router.route for chained method registrations. The same binder supports all
61
- callback kinds, including error handlers. Preserve native registration order.
62
- Parameter callbacks run before endpoint RAI; authorization-sensitive loading
63
- belongs after rai() or behind an appropriate earlier authorization check.
59
+ **Build with the context, use infrastructure in handlers.** `tulipes routes`
60
+ and `tulipes spec` build every router with nothing acquired: the environment
61
+ is declared but unresolved and `ctx.models`, `ctx.queues` and `ctx.sockets`
62
+ are stubs that throw. So a controller keeps `ctx` and reads `ctx.models`
63
+ inside its handlers (as every controller in this app does), and a middleware
64
+ that needs a variable or a client is built on its first request:
65
+
66
+ ```ts
67
+ import { lazy } from "@app/core";
68
+ app.use(lazy(() => express.json({ limit: String(Environment.get("BODY_LIMIT")) })));
69
+ ```
64
70
 
65
- Use routes.mount for nested/shared prefixes, including named parameters. Set
66
- Router({mergeParams:true}) on children needing inherited parameter values.
67
- Existing RoutesFn factories remain supported with --runtime. After changes run
68
- the app tests, tulipes routes --offline and yarn spec; inspect generated diffs.
71
+ Use router.use for shared middleware, router.param for parameter callbacks, and
72
+ router.route for chained method registrations. Preserve native registration
73
+ order. Parameter callbacks run before the endpoint's RAI; authorization-sensitive
74
+ loading belongs after rai() or behind an earlier authorization check. After
75
+ changes run the app tests, `tulipes routes` and `pnpm spec`; inspect generated diffs.
69
76
 
70
77
  ## The RAI is not optional
71
78
 
@@ -86,8 +93,14 @@ This exists because the pipeline authenticates rather than rejects: an
86
93
  undeclared route is not merely undocumented, it is reachable by `guest`.
87
94
  Grant the id in `module.acl.ts` — to `guest` if the endpoint is public.
88
95
 
89
- `tulipes routes --offline` lists every declared endpoint with the roles that reach
90
- it.
96
+ `tulipes routes` lists every declared endpoint with the roles that reach it,
97
+ without a service, a secret or a `.env`.
98
+
99
+ The same declaration is the endpoint's **tool definition**: `tulipes agent
100
+ manifest` lists every route as a tool an agent may call, with the `name`,
101
+ `description` and schemas as written here. Write the description for a model
102
+ choosing between tools; `agent: false` keeps a route out of the manifest.
103
+ → skill `tulipes-agent-tool`
91
104
 
92
105
  ## Responses and errors
93
106
 
@@ -1,44 +1,72 @@
1
1
  ---
2
2
  name: tulipes-env-variable
3
- description: Use when adding or changing any configuration value in a Tulipes app — API keys, connection strings, feature toggles, limits — or when tempted to read process.env. Covers meta.variables.json, the resolution precedence and typed reads.
3
+ description: Use when adding or changing any configuration value in a Tulipes app — API keys, connection strings, feature toggles, limits — or when tempted to read process.env. Covers the split between environment variables (tulipes.env in package.json) and settings (the module.config.ts schema), the resolution precedence and typed reads.
4
4
  ---
5
5
 
6
6
  # Adding a configuration value
7
7
 
8
- **Never read `process.env` directly.** Every value is declared by exactly
9
- one module and read through the typed store. An undeclared read throws, so
10
- a typo can never reach production as `undefined`.
8
+ **First decide which of two things it is.** An *environment variable* is
9
+ what the process needs before the database answers — a connection string,
10
+ the port, where logs go — or a secret. Everything else is a *setting*:
11
+ declared with a default in the module's `module.config.ts` schema, stored
12
+ in the settings store, editable by an operator at runtime without a
13
+ restart, read at `ctx.config.<module>`:
14
+
15
+ ```ts
16
+ // modules/billing/module.config.ts — the file's only export
17
+ import { defineConfig } from "@tulipes/core/config";
18
+ import { z } from "zod/v4";
19
+
20
+ export default defineConfig({
21
+ schema: z.object({
22
+ trialDays: z.number().int().positive().default(14).describe("Free trial length"),
23
+ // a field the process cannot apply without a restart:
24
+ workers: z.number().int().positive().default(2).meta({ restart: true }),
25
+ }),
26
+ });
27
+ ```
28
+
29
+ Every field needs a default, secrets are refused, and `pnpm sync` types
30
+ `ctx.config.billing.trialDays` as `number`. The rest of this guide is the
31
+ environment variable.
32
+
33
+ **Never read `process.env` directly.** Every variable is declared by
34
+ exactly one module and read through the typed store. An undeclared read
35
+ throws, so a typo can never reach production as `undefined`.
11
36
 
12
37
  ## Steps
13
38
 
14
39
  1. Choose the owning module. Infra shared by everything (`PORT`,
15
40
  `MONGO_URI`, `REDIS_URL`) belongs to the sys `core` module; anything
16
41
  feature-specific belongs to that feature's module.
17
- 2. Declare it in that module's `meta.variables.json`:
42
+ 2. Declare it under `tulipes.env` in that module's `package.json`:
18
43
 
19
44
  ```json
20
45
  {
21
- "variables": [
22
- {
23
- "name": "STRIPE_API_KEY",
24
- "type": "secret",
25
- "group": "billing",
26
- "required": true,
27
- "description": "Server-side Stripe key"
28
- },
29
- {
30
- "name": "BILLING_TRIAL_DAYS",
31
- "type": "number",
32
- "group": "billing",
33
- "description": "Free trial length",
34
- "default": 14
35
- }
36
- ]
46
+ "name": "@app/billing",
47
+ "tulipes": {
48
+ "env": [
49
+ {
50
+ "name": "STRIPE_API_KEY",
51
+ "type": "secret",
52
+ "group": "billing",
53
+ "required": true,
54
+ "description": "Server-side Stripe key"
55
+ },
56
+ {
57
+ "name": "BILLING_TRIAL_DAYS",
58
+ "type": "number",
59
+ "group": "billing",
60
+ "description": "Free trial length",
61
+ "default": 14
62
+ }
63
+ ]
64
+ }
37
65
  }
38
66
  ```
39
67
 
40
68
  3. Add a value to `.envs/.env.development` for anything `required`.
41
- 4. `yarn sync` — regenerates `types/config.d.ts` (so the read is typed)
69
+ 4. `pnpm sync` — regenerates `types/tulipes.d.ts` (so the read is typed)
42
70
  and `.env.example` (so deployments know what to set).
43
71
  5. Read it: `Environment.get("BILLING_TRIAL_DAYS")` → typed `number`.
44
72
 
@@ -65,14 +93,14 @@ values always win, which is why deployments need no file.
65
93
 
66
94
  - Value shared by several modules but the same everywhere (pagination
67
95
  limits, body size) → `config/app.config.ts`, not a variable.
68
- - Value used by one module and never changed per deployment → that
69
- module's `module.config.ts`.
96
+ - Any value an operator may want to change at runtime → a setting in that
97
+ module's `module.config.ts` schema (above), not a variable.
70
98
 
71
99
  ## Gotchas
72
100
 
73
101
  - `Environment.get()` on an undeclared name **throws**. If you added the
74
- entry and still get the error, you did not run `yarn sync`, or you
75
- edited a different module's meta file than the one you are reading from.
102
+ entry and still get the error, you did not run `pnpm sync`, or you
103
+ edited a different module's manifest than the one you are reading from.
76
104
  - `APP_ENV` (not `NODE_ENV`) selects the env file.
77
105
  - A missing `required` variable is not a runtime surprise — the boot
78
106
  aborts and lists every missing variable at once.
@@ -49,8 +49,15 @@ Read it anywhere the context reaches:
49
49
  const Invoice = models!.get("Invoice");
50
50
  ```
51
51
 
52
- `models.get()` throws on an unregistered name (listing what does exist),
53
- so a typo fails at the call site instead of returning `undefined`.
52
+ Run `pnpm sync` after adding a model: it records the model's name and file
53
+ in `types/tulipes.d.ts`, so `models.get("Invoice")` is typed from the
54
+ schema above (its fields, methods and statics) and a name that no model
55
+ file declares does not compile. The name must be a string literal in the
56
+ file for that; a computed name stays reachable only through
57
+ `models.get<T>(name)`, the caller-side generic, which also serves code that
58
+ builds names at runtime. `models.get()` throws on an unregistered name
59
+ (listing what does exist), so a typo in dynamic code still fails at the
60
+ call site instead of returning `undefined`.
54
61
 
55
62
  ## Requirements
56
63
 
@@ -97,8 +104,8 @@ is all the task wanted.
97
104
 
98
105
  - Global plugins (timestamps, soft delete) only apply to schemas compiled
99
106
  *after* registration, which is why `registerBasePlugins()` is called from
100
- the sys core module's `module.config.ts` — phase 6, before models compile
101
- in phase 8. That covers every mode, scripts included; calling it from
107
+ the sys core module's `module.config.ts` — declaration time (phase 4),
108
+ before models compile in phase 5. That covers every mode, scripts included; calling it from
102
109
  `app.ts` instead would leave scripts compiling models without the
103
110
  soft-delete guard.
104
111
  - Mongoose queries are lazy: `await` them, and prefer `.lean()` for reads
@@ -15,18 +15,19 @@ touching anything else.
15
15
  (another users endpoint → `modules/users`).
16
16
  - **New module** when it is a separate feature with its own vocabulary.
17
17
  - **The sys core module** when it is a shared helper with no routes or
18
- models of its own — export it from `modules/core/index.ts` and import it
19
- as `@app/core`. There is no top-level `lib/`: a helper two modules need
18
+ models of its own — export it from `modules/core/lib/index.ts` (what its
19
+ `package.json` "exports" points at) and import it as `@app/core`. There is no top-level `lib/`: a helper two modules need
20
20
  is owned by a module like everything else.
21
21
 
22
22
  ## Steps
23
23
 
24
- 1. Scaffold: `yarn tulipes new module billing`. It creates every
25
- contract — package.json (scope detected from siblings), meta.variables,
26
- config with lifecycle hooks, ACL, helper, a controller class, routes
27
- declaring a RAI, model, bootstrap, queue and socket. Delete the ones this feature does not
28
- need. For offline inspection, keep tulipes.offline: true and wrap route
29
- factories in defineRoutes(); modules without routes need no placeholder file.
24
+ 1. Scaffold: `pnpm tulipes new module billing`. It creates every
25
+ contract — package.json (scope detected from siblings), a settings
26
+ declaration, ACL, helper, a controller class, a plain routes function
27
+ declaring a RAI, model, a bootstrap that may return its cleanup, queue and
28
+ socket. Delete the ones this feature does not need. The module root holds
29
+ only `package.json`, `module.config.ts` and `module.acl.ts`; everything
30
+ else has a folder, and the boot refuses a stray root file.
30
31
  2. Set ordering in `modules/billing/package.json`:
31
32
 
32
33
  ```json
@@ -37,13 +38,13 @@ touching anything else.
37
38
  }
38
39
  ```
39
40
 
40
- 3. `yarn install` — the module is a workspace, so the package manager must
41
+ 3. `pnpm install` — the module is a workspace, so the package manager must
41
42
  link it before imports resolve.
42
43
  4. Put endpoints and shared middleware in native `routes/*.routes.ts` factories.
43
44
  Add optional files as needed:
44
45
  `models/`, `queues/`, `sockets/`, `bootstrap/`, `module.acl.ts`,
45
46
  `i18n/` — the module is its own i18n namespace. → skill `tulipes-i18n`
46
- 5. `yarn sync && yarn dev` — the banner must list the module.
47
+ 5. `pnpm sync && pnpm dev` — the banner must list the module.
47
48
 
48
49
  ## The manifest key
49
50
 
@@ -14,11 +14,15 @@ consumer. Never duplicate the definition.
14
14
 
15
15
  ```ts
16
16
  import type { Ctx } from "@tulipes/core/boot";
17
- import type { QueueRegistry } from "@tulipes/bullmq";
17
+ import type { JobSchemas, QueueRegistry } from "@tulipes/bullmq";
18
+ import { z } from "zod/v4";
18
19
 
19
- export default function invoiceQueues({ models }: Ctx, queues: QueueRegistry): void {
20
- queues.define("billing.send-invoice");
20
+ // The declaration: defines the queue, validates every add(), types both sides.
21
+ export const jobs = {
22
+ "billing.send-invoice": z.object({ invoiceId: z.string() }),
23
+ } satisfies JobSchemas;
21
24
 
25
+ export default function invoiceQueues({ models }: Ctx, queues: QueueRegistry): void {
22
26
  queues.process("billing.send-invoice", async (job) => {
23
27
  const invoice = await models!.get("Invoice").findById(job.data.invoiceId);
24
28
  // …do the slow thing here…
@@ -33,7 +37,16 @@ Produce from anywhere with the context:
33
37
  await queues!.add("billing.send-invoice", "send", { invoiceId: invoice.id });
34
38
  ```
35
39
 
36
- Run a consumer with `yarn worker`. Without it, jobs pile up in Redis and
40
+ Run `pnpm sync` after adding a queue: it records the `jobs` export in
41
+ `types/tulipes.d.ts`, so the payload above and `job.data` in the processor
42
+ are both typed from the schema, and a queue name no file declares does not
43
+ compile. At runtime `add()` parses the payload with the schema — defaults
44
+ applied, a bad payload refused at the producer rather than in the worker.
45
+ A queue that needs options (`defaultJobOptions`, a prefix) calls
46
+ `queues.define(name, options)` as well; a queue without a schema is defined
47
+ that way alone and takes any payload.
48
+
49
+ Run a consumer with `pnpm worker`. Without it, jobs pile up in Redis and
37
50
  nothing processes them — that is the correct behaviour, not a bug.
38
51
 
39
52
  ## Rules
@@ -42,8 +55,9 @@ nothing processes them — that is the correct behaviour, not a bug.
42
55
  reserves `:` as its Redis key separator; a `:` name crashes the boot.
43
56
  - Names are globally unique, and a queue may have only one processor —
44
57
  duplicates crash the boot naming both modules.
45
- - A processor registered for a queue nobody defined is a boot error; call
46
- `define()` in the same file.
58
+ - A processor registered for a queue nobody defined is a boot error; list
59
+ it in `jobs` or call `define()` in the same file. One schema per queue:
60
+ a second module declaring one is a boot error naming the first.
47
61
  - The app selects its queue provider once, in its root `package.json`:
48
62
  `"tulipes": { "providers": { "queues": "@tulipes/bullmq" } }`. A module with a
49
63
  `queues/` file in an app without that line is a boot refusal before any