@tulipes/cli 0.2.0-rc.2 → 0.3.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 (114) hide show
  1. package/dist/agent.d.ts +5 -5
  2. package/dist/config-check.js +1 -1
  3. package/dist/env-check.d.ts +5 -3
  4. package/dist/env-check.js +26 -19
  5. package/dist/env-check.js.map +1 -1
  6. package/dist/main.js +83 -19
  7. package/dist/main.js.map +1 -1
  8. package/dist/migrate-0.12-env.d.ts +36 -0
  9. package/dist/migrate-0.12-env.js +250 -0
  10. package/dist/migrate-0.12-env.js.map +1 -0
  11. package/dist/migrate-0.12-scaffold.d.ts +1 -0
  12. package/dist/migrate-0.12-scaffold.js +197 -0
  13. package/dist/migrate-0.12-scaffold.js.map +1 -0
  14. package/dist/migrate-0.12.d.ts +47 -0
  15. package/dist/migrate-0.12.js +210 -0
  16. package/dist/migrate-0.12.js.map +1 -0
  17. package/dist/migrate.d.ts +3 -1
  18. package/dist/migrate.js +44 -5
  19. package/dist/migrate.js.map +1 -1
  20. package/dist/new-module.d.ts +7 -0
  21. package/dist/new-module.js +35 -16
  22. package/dist/new-module.js.map +1 -1
  23. package/dist/roles.d.ts +15 -0
  24. package/dist/roles.js +54 -0
  25. package/dist/roles.js.map +1 -0
  26. package/dist/routes.d.ts +7 -2
  27. package/dist/routes.js +19 -10
  28. package/dist/routes.js.map +1 -1
  29. package/dist/spec.d.ts +2 -0
  30. package/dist/spec.js +13 -11
  31. package/dist/spec.js.map +1 -1
  32. package/dist/sync.js +66 -53
  33. package/dist/sync.js.map +1 -1
  34. package/dist/update.js +1 -1
  35. package/dist/update.js.map +1 -1
  36. package/package.json +4 -4
  37. package/templates/AGENTS.md +41 -29
  38. package/templates/app/.envs/.env.development +2 -1
  39. package/templates/app/.envs/.env.production +2 -2
  40. package/templates/app/.envs/.env.staging +2 -2
  41. package/templates/app/README.md +44 -29
  42. package/templates/app/config/app.config.ts +60 -7
  43. package/templates/app/docs/agent-manifest.json +8 -681
  44. package/templates/app/modules/agent/controllers/{agent.controllers.ts → web.agent.controllers.ts} +4 -5
  45. package/templates/app/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +4 -6
  46. package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +20 -12
  47. package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +4 -2
  48. package/templates/app/modules/auth/controllers/{auth.controllers.ts → web.auth.controllers.ts} +1 -1
  49. package/templates/app/modules/auth/helpers/passport.ts +1 -1
  50. package/templates/app/modules/auth/helpers/session-cache.ts +3 -3
  51. package/templates/app/modules/auth/helpers/tokens.ts +3 -5
  52. package/templates/app/modules/auth/module.acl.ts +31 -9
  53. package/templates/app/modules/auth/module.config.ts +5 -3
  54. package/templates/app/modules/auth/package.json +10 -10
  55. package/templates/app/modules/auth/routes/{auth.routes.ts → web.auth.routes.ts} +15 -10
  56. package/templates/app/modules/auth/tests/auth.test.ts +1 -1
  57. package/templates/app/modules/config/controllers/{config.controllers.ts → bo.config.controllers.ts} +3 -2
  58. package/templates/app/modules/config/helpers/audit.ts +1 -1
  59. package/templates/app/modules/config/helpers/describe.ts +35 -26
  60. package/templates/app/modules/config/module.acl.ts +1 -1
  61. package/templates/app/modules/config/routes/{config.routes.ts → bo.config.routes.ts} +5 -5
  62. package/templates/app/modules/config/tests/config.test.ts +14 -6
  63. package/templates/app/modules/core/helpers/cache/index.ts +8 -7
  64. package/templates/app/modules/core/helpers/lazy.ts +3 -3
  65. package/templates/app/modules/core/helpers/logger/index.ts +24 -16
  66. package/templates/app/modules/core/module.config.ts +5 -3
  67. package/templates/app/modules/core/package.json +46 -19
  68. package/templates/app/modules/core/routes/core.routes.ts +4 -4
  69. package/templates/app/modules/hello/agent/hello.agent.ts +18 -14
  70. package/templates/app/modules/hello/controllers/{hello.controllers.ts → web/hello.controllers.ts} +1 -1
  71. package/templates/app/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +4 -8
  72. package/templates/app/modules/hello/helpers/greetings.ts +14 -0
  73. package/templates/app/modules/hello/module.acl.ts +12 -7
  74. package/templates/app/modules/hello/queues/hello.queues.ts +1 -1
  75. package/templates/app/modules/hello/routes/{greetings.routes.ts → web/hello.greetings.routes.ts} +4 -4
  76. package/templates/app/modules/hello/routes/{hello.routes.ts → web/hello.routes.ts} +14 -8
  77. package/templates/app/modules/security/helpers/rate-limit.ts +3 -2
  78. package/templates/app/modules/security/routes/security.routes.ts +4 -4
  79. package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +2 -2
  80. package/templates/app/modules/settings/controllers/{settings.controllers.ts → bo.settings.controllers.ts} +7 -6
  81. package/templates/app/modules/settings/helpers/log-stream.ts +6 -5
  82. package/templates/app/modules/settings/routes/{settings.routes.ts → bo.settings.routes.ts} +3 -4
  83. package/templates/app/modules/settings/tests/settings.test.ts +21 -25
  84. package/templates/app/modules/users/controllers/{users.controllers.ts → bo.users.controllers.ts} +5 -55
  85. package/templates/{overlays/no-queues/modules/users/controllers/users.controllers.ts → app/modules/users/controllers/web.users.controllers.ts} +4 -42
  86. package/templates/app/modules/users/module.acl.ts +4 -4
  87. package/templates/app/modules/users/queues/users.queues.ts +1 -1
  88. package/templates/app/modules/users/routes/bo.users.routes.ts +39 -0
  89. package/templates/app/modules/users/routes/web.users.routes.ts +45 -0
  90. package/templates/app/nginx/production.conf +1 -1
  91. package/templates/app/scripts/deploy-nginx.ts +3 -2
  92. package/templates/app/tests/support.ts +4 -3
  93. package/templates/claude/skills/tulipes-agent-tool/SKILL.md +49 -34
  94. package/templates/claude/skills/tulipes-boot-errors/SKILL.md +13 -7
  95. package/templates/claude/skills/tulipes-config/SKILL.md +6 -4
  96. package/templates/claude/skills/tulipes-endpoint/SKILL.md +36 -19
  97. package/templates/claude/skills/tulipes-env-variable/SKILL.md +62 -37
  98. package/templates/claude/skills/tulipes-i18n/SKILL.md +20 -1
  99. package/templates/claude/skills/tulipes-module/SKILL.md +10 -5
  100. package/templates/claude/skills/tulipes-permissions/SKILL.md +123 -35
  101. package/templates/claude/skills/tulipes-queue/SKILL.md +3 -2
  102. package/templates/claude/skills/tulipes-settings/SKILL.md +9 -8
  103. package/templates/claude/skills/tulipes-spec/SKILL.md +12 -2
  104. package/templates/minimal/config/app.config.ts +13 -3
  105. package/templates/minimal/modules/agent/routes/{agent.routes.ts → web.agent.routes.ts} +2 -2
  106. package/templates/minimal/modules/core/module.acl.ts +7 -1
  107. package/templates/minimal/modules/core/package.json +4 -4
  108. package/templates/minimal/modules/hello/module.acl.ts +1 -1
  109. package/templates/minimal/modules/hello/routes/{health.routes.ts → web.hello.health.routes.ts} +1 -1
  110. package/templates/minimal/modules/hello/routes/{hello.routes.ts → web.hello.routes.ts} +1 -1
  111. package/templates/overlays/no-queues/modules/hello/controllers/{greetings.controllers.ts → web/hello.greetings.controllers.ts} +1 -1
  112. package/templates/overlays/no-queues/modules/users/controllers/bo.users.controllers.ts +57 -0
  113. package/templates/versions.json +8 -8
  114. package/templates/app/modules/users/routes/users.routes.ts +0 -60
@@ -1,4 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { readdir, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
2
6
  import { fileURLToPath } from "node:url";
3
7
  import { z } from "zod/v4";
4
8
  import { afterAll, beforeAll, expect, test, vi } from "vitest";
@@ -8,7 +12,15 @@ import { useTestApp } from "@tulipes/testing";
8
12
  import { testEnv, testSettings } from "../../../tests/support.ts";
9
13
 
10
14
  const rootDir = fileURLToPath(new URL("../../../", import.meta.url));
11
- const app = useTestApp({ rootDir, env: testEnv(), settings: testSettings() }, { beforeAll, afterAll });
15
+
16
+ /**
17
+ * Where this boot's logger writes and the stream reads: environment values
18
+ * are bound once at boot, so the file logging the stream needs is on for
19
+ * the whole file, into a directory the suite owns and empties per test.
20
+ */
21
+ const logDir = mkdtempSync(join(tmpdir(), "tulipes-log-http-"));
22
+ const app = useTestApp({ rootDir, env: { ...testEnv(), LOG_FILE: "true", LOG_DIR: logDir }, settings: testSettings() }, { beforeAll, afterAll });
23
+ afterAll(() => rm(logDir, { recursive: true, force: true }));
12
24
 
13
25
 
14
26
  const tokens = z.object({
@@ -59,30 +71,16 @@ async function sysadmin() {
59
71
  return user;
60
72
  }
61
73
 
74
+ /** `run` sees the log directory empty: one test's files never serve another's stream. */
62
75
  async function logFiles(run: (dir: string) => Promise<void>) {
63
- const { mkdtemp, rm } = await import("node:fs/promises");
64
- const { tmpdir } = await import("node:os");
65
- const { join } = await import("node:path");
66
- const dir = await mkdtemp(join(tmpdir(), "tulipes-log-http-"));
67
- const env = app.handle.ctx.Environment;
68
-
69
- const previous = {
70
- file: env.get("LOG_FILE"),
71
- dir: env.get("LOG_DIR"),
76
+ const empty = async () => {
77
+ for (const entry of await readdir(logDir)) await rm(join(logDir, entry), { recursive: true, force: true });
72
78
  };
73
-
74
- env.store.set("LOG_FILE", true);
75
-
76
- env.store.set("LOG_DIR", dir);
77
-
79
+ await empty();
78
80
  try {
79
- await run(dir);
81
+ await run(logDir);
80
82
  } finally {
81
- env.store.set("LOG_FILE", previous.file);
82
-
83
- env.store.set("LOG_DIR", previous.dir);
84
-
85
- await rm(dir, { recursive: true, force: true });
83
+ await empty();
86
84
  }
87
85
  }
88
86
 
@@ -378,10 +376,8 @@ test("shutdown drains settings streams and a later boot owns fresh stream admiss
378
376
  });
379
377
 
380
378
  try {
381
- next.env.store.set("LOG_FILE", true);
382
-
383
- next.env.store.set("LOG_DIR", dir);
384
-
379
+ // LOG_FILE and LOG_DIR are still set on the process, so this boot
380
+ // reads the same directory.
385
381
  const address = next.server!.address();
386
382
 
387
383
  if (!address || typeof address === "string")
@@ -4,42 +4,18 @@ import type { Ctx } from "@tulipes/core/boot";
4
4
  import { HttpError } from "@tulipes/core/http";
5
5
 
6
6
  /**
7
- * One class per controller, holding the context. Each method returns a
8
- * handler, so the routes file stays a table of contents.
9
- *
10
- * Every response goes through `res.respond`, which wraps the payload in
11
- * the app's envelope. Failures are thrown rather than responded — the
12
- * framework's terminal handler builds the same envelope from them — so a
13
- * handler only ever writes the happy path.
14
- *
15
- * Thrown messages are i18n KEYS: the handler translates them in this
16
- * module's namespace and derives the error code from the key, so
17
- * "notFound" becomes NOT_FOUND with a translated message.
7
+ * The users back office — the users:bo:* endpoints, granted to admin
8
+ * through its users:* grant. Same shape as the web controller beside it:
9
+ * one class holding the context, one handler per method, failures thrown
10
+ * as i18n keys.
18
11
  */
19
- export class UsersController {
12
+ export class BoUsersController {
20
13
  readonly #ctx: Ctx;
21
14
 
22
15
  constructor(ctx: Ctx) {
23
16
  this.#ctx = ctx;
24
17
  }
25
18
 
26
- list(): RequestHandler {
27
- return async (req: Request, res: Response) => {
28
- // ?page and ?per_page, already clamped by config.pagination.
29
- const { per_page, skip } = req.page;
30
- const User = this.#ctx.models!.get("User");
31
-
32
- const [users, total_items] = await Promise.all([
33
- User.find().select("email role -_id").skip(skip).limit(per_page).lean(),
34
- User.countDocuments(),
35
- ]);
36
-
37
- // Passing total_items is what marks this a page: respond fills in
38
- // page, per_page and total_pages from the request and the count.
39
- res.respond({ data: users, meta: { total_items } });
40
- };
41
- }
42
-
43
19
  /** Soft delete: the row stays in mongo but leaves every ordinary query. */
44
20
  softDelete(): RequestHandler {
45
21
  return async (req: Request, res: Response) => {
@@ -84,30 +60,4 @@ export class UsersController {
84
60
  res.respond({ status: 201, data: { email, welcome_queued: true } });
85
61
  };
86
62
  }
87
-
88
- ping(): RequestHandler {
89
- return (req: Request, res: Response) => {
90
- res.respond({
91
- data: {
92
- module: "users",
93
- locale: req.locale,
94
- max_sessions: this.#ctx.config.users?.maxSessions,
95
- },
96
- });
97
- };
98
- }
99
-
100
- /** Demonstrates HttpError — the framework renders it into the envelope. */
101
- demoMissing(): RequestHandler {
102
- return async () => {
103
- throw new HttpError(404, "notFound", { details: { hint: "demo endpoint" } });
104
- };
105
- }
106
-
107
- /** Demonstrates an unexpected error: anonymous 500 outside development. */
108
- demoBoom(): RequestHandler {
109
- return async () => {
110
- throw new Error("secret internals that must not leak in production");
111
- };
112
- }
113
63
  }
@@ -1,11 +1,12 @@
1
- import { forgetUser } from "@app/auth";
2
1
  import type { Request, RequestHandler, Response } from "express";
3
2
  import type { Ctx } from "@tulipes/core/boot";
4
3
  import { HttpError } from "@tulipes/core/http";
5
4
 
6
5
  /**
7
6
  * One class per controller, holding the context. Each method returns a
8
- * handler, so the routes file stays a table of contents.
7
+ * handler, so the routes file stays a table of contents. The class is
8
+ * named after its file — web.users.controllers.ts, WebUsersController —
9
+ * and serves the users:web:* endpoints; the back office has its own.
9
10
  *
10
11
  * Every response goes through `res.respond`, which wraps the payload in
11
12
  * the app's envelope. Failures are thrown rather than responded — the
@@ -16,7 +17,7 @@ import { HttpError } from "@tulipes/core/http";
16
17
  * module's namespace and derives the error code from the key, so
17
18
  * "notFound" becomes NOT_FOUND with a translated message.
18
19
  */
19
- export class UsersController {
20
+ export class WebUsersController {
20
21
  readonly #ctx: Ctx;
21
22
 
22
23
  constructor(ctx: Ctx) {
@@ -40,45 +41,6 @@ export class UsersController {
40
41
  };
41
42
  }
42
43
 
43
- /** Soft delete: the row stays in mongo but leaves every ordinary query. */
44
- softDelete(): RequestHandler {
45
- return async (req: Request, res: Response) => {
46
- const user = await this.#ctx.models!.get("User").findOne({ email: req.params.email });
47
- if (!user) throw new HttpError(404, "notFound");
48
-
49
- // The account leaves every query, so its sessions resolve to nobody on
50
- // the cold path; the cached identities would not know, so they go too.
51
- await forgetUser(this.#ctx, String(user._id));
52
- await (user as unknown as { softDelete(): Promise<unknown> }).softDelete();
53
- res.respond({ data: { email: req.params.email, soft_deleted: true } });
54
- };
55
- }
56
-
57
- listDeleted(): RequestHandler {
58
- return async (_req: Request, res: Response) => {
59
- const deleted = await this.#ctx.models!
60
- .get("User")
61
- .find({ is_deleted: true })
62
- .setOptions({ withDeleted: true } as Record<string, unknown>)
63
- .select("email role -_id")
64
- .lean();
65
-
66
- res.respond({ data: deleted });
67
- };
68
- }
69
-
70
- create(): RequestHandler {
71
- return async (req: Request, res: Response) => {
72
- const { email } = (req.body ?? {}) as { email?: string };
73
- // `field` is what puts this against the input in the envelope.
74
- if (!email) throw new HttpError(422, "emailRequired", { field: "email" });
75
-
76
- await this.#ctx.models!.get("User").create({ email });
77
-
78
- res.respond({ status: 201, data: { email } });
79
- };
80
- }
81
-
82
44
  ping(): RequestHandler {
83
45
  return (req: Request, res: Response) => {
84
46
  res.respond({
@@ -1,10 +1,10 @@
1
1
  import type { AclBuilder } from "@tulipes/core/acl";
2
2
 
3
3
  export default function usersAcl(acl: AclBuilder): void {
4
- acl.allow("user", "users:read", "users:ping");
4
+ acl.allow("user", "users:web:list", "users:web:ping");
5
5
  // The demo endpoints are open so the error-handling examples stay
6
6
  // curl-able without a token.
7
- acl.allow("guest", "users:demo-missing", "users:demo-boom", "users:ping");
8
- // Everything else — create, delete, read-deleted — stays with admin,
9
- // which already holds "*".
7
+ acl.allow("guest", "users:web:demo:*", "users:web:ping");
8
+ // The back office — users:bo:create, :delete, :list:deleted — stays with
9
+ // admin and sysadmin, whose users:* grant in the auth module covers it.
10
10
  }
@@ -16,7 +16,7 @@ export const jobs = {
16
16
  export default function usersQueues(ctx: Ctx, queues: QueueRegistry): void {
17
17
  // The same logger the backend uses. In the worker process it writes
18
18
  // to app-worker.log, so the two processes never share a file.
19
- const log = moduleLogger(ctx.Environment, "users", ctx.mode);
19
+ const log = moduleLogger(ctx, "users");
20
20
 
21
21
  queues.process("users.welcome", async (job) => {
22
22
  // No request here to negotiate a locale from, so the one the caller
@@ -0,0 +1,39 @@
1
+ import { Router } from "express";
2
+ import type { BackendCtx } from "@tulipes/core/boot";
3
+
4
+ import { BoUsersController } from "../controllers/bo.users.controllers.ts";
5
+ import { createBody, emailParam } from "../schemas/users.schemas.ts";
6
+
7
+ /**
8
+ * The users back office: every id is users:bo:…, so `users:bo:*` grants
9
+ * all of it and `tulipes routes --scope bo` lists it. Mounted under /users
10
+ * like the web routes beside it.
11
+ */
12
+ export default function boUsersRoutes(ctx: BackendCtx): void {
13
+ const { rai, routes } = ctx;
14
+ const router = Router();
15
+ const users = new BoUsersController(ctx);
16
+
17
+ router.post("/", rai({
18
+ id: "users:bo:create",
19
+ status: 201,
20
+ name: "Create a user",
21
+ description: "Adds the account and queues its welcome email",
22
+ body: createBody,
23
+ }), users.create());
24
+
25
+ router.get("/deleted", rai({
26
+ id: "users:bo:list:deleted",
27
+ name: "List soft-deleted users",
28
+ }), users.listDeleted());
29
+
30
+ router.delete("/:email", rai({
31
+ id: "users:bo:delete",
32
+ name: "Soft-delete a user",
33
+ description: "Flags the account; ordinary queries stop returning it",
34
+ params: emailParam,
35
+ }), users.softDelete());
36
+
37
+ // Deliberately outside the API prefix, to show a mount of its own.
38
+ routes.mount(router, "/users");
39
+ }
@@ -0,0 +1,45 @@
1
+ import { Router } from "express";
2
+ import type { BackendCtx } from "@tulipes/core/boot";
3
+ import { z } from "zod/v4";
4
+
5
+ import { WebUsersController } from "../controllers/web.users.controllers.ts";
6
+ import { listQuery, userShape } from "../schemas/users.schemas.ts";
7
+
8
+ /**
9
+ * The users endpoints a signed-in client calls. The back office's sit in
10
+ * bo.users.routes.ts beside this file, under the same mount: one file per
11
+ * scope, named scope-first.
12
+ */
13
+ export default function webUsersRoutes(ctx: BackendCtx): void {
14
+ const { rai, routes } = ctx;
15
+ const router = Router();
16
+ const users = new WebUsersController(ctx);
17
+
18
+ router.get("/", rai({
19
+ id: "users:web:list",
20
+ name: "List users",
21
+ description: "Page through accounts, clamped by the shared bounds",
22
+ // Validated on the way in, and described on the way out: one
23
+ // declaration, so the spec cannot drift from what is enforced.
24
+ query: listQuery,
25
+ returns: z.array(userShape),
26
+ }), users.list());
27
+
28
+ router.get("/ping", rai({
29
+ id: "users:web:ping",
30
+ name: "Users module status",
31
+ }), users.ping());
32
+
33
+ router.get("/missing", rai({
34
+ id: "users:web:demo:missing",
35
+ name: "HttpError demo",
36
+ }), users.demoMissing());
37
+
38
+ router.get("/boom", rai({
39
+ id: "users:web:demo:boom",
40
+ name: "Unhandled error demo",
41
+ }), users.demoBoom());
42
+
43
+ // Deliberately outside the API prefix, to show a mount of its own.
44
+ routes.mount(router, "/users");
45
+ }
@@ -18,7 +18,7 @@ server {
18
18
  root /var/www/html;
19
19
  }
20
20
 
21
- # Keep in step with the BODY_LIMIT variable. If nginx is stricter,
21
+ # Keep in step with the security.bodyLimit setting. If nginx is stricter,
22
22
  # oversized requests die here as an nginx HTML page instead of the
23
23
  # API's JSON 413.
24
24
  client_max_body_size 1m;
@@ -40,9 +40,10 @@ try {
40
40
  }
41
41
 
42
42
  async function deploy(): Promise<void> {
43
- const { appEnv } = handle.env;
43
+ const { appEnv } = handle.ctx;
44
44
  const domain = String(handle.ctx.config.core?.publicDomain ?? "localhost").trim();
45
- const port = Number(handle.env.get("PORT"));
45
+ // What app.config wired from PORT — the same port the backend listens on.
46
+ const port = Number(handle.ctx.config.server?.port ?? 3000);
46
47
 
47
48
  const templatePath = join(rootDir, "nginx", `${appEnv}.conf`);
48
49
  if (!existsSync(templatePath)) {
@@ -2,9 +2,10 @@ import { randomBytes } from "node:crypto";
2
2
 
3
3
  /**
4
4
  * What every module test boots the app with. The services are dedicated:
5
- * the suite writes users, sessions and settings. The variables are the
6
- * eleven the app declares; everything else the request pipeline needs is a
7
- * setting, written after the boot the way an operator writes it.
5
+ * the suite writes users, sessions and settings. The variables are the ones
6
+ * the core and auth modules declare, by their env names; everything else the
7
+ * request pipeline needs is a setting, written after the boot the way an
8
+ * operator writes it.
8
9
  */
9
10
  export function testEnv(): Record<string, string> {
10
11
  if (!process.env.TEST_MONGO_URI || !process.env.TEST_REDIS_URL) {
@@ -1,16 +1,14 @@
1
1
  ---
2
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.
3
+ description: Use when a Tulipes module should offer something to AI agents — declaring an action, describing the module, building an agent inside the app from the other modules' actions, or wiring the app to Claude Code through MCP. Covers agent/*.agent.ts, ctx.agent, the manifest, the agent door and tulipes-mcp.
4
4
  ---
5
5
 
6
- # Exposing a module to agents
6
+ # Offering a module's actions to agents
7
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.
8
+ Routes are the API. What a module offers to agents — and to an agent
9
+ module inside the same app — is a separate, explicit list: its **actions**,
10
+ declared in `agent/<module>.agent.ts`. Nothing is derived from the routes,
11
+ and a module with no `agent/` file offers nothing.
14
12
 
15
13
  The agent surface and `@tulipes/mcp` are **experimental** in 0.11: they may
16
14
  change in any release without a deprecation period. Check `MIGRATING.md`
@@ -23,19 +21,22 @@ import { action, defineAgent } from "@tulipes/core/agent";
23
21
  import { requireModels } from "@tulipes/core/boot";
24
22
  import { z } from "zod/v4";
25
23
 
24
+ import { findGreetings } from "../helpers/greetings.ts";
25
+
26
26
  export default defineAgent({
27
27
  summary: "Greets callers and counts how often each greeting is used.",
28
- tools: [
29
- // Derived from the route; the description overrides the RAI's for agents only.
30
- { route: "hello:list-greetings", description: "The stored greetings, most used first" },
31
- { route: "hello:use-greeting" },
32
-
33
- // Declared: not an endpoint. The id is a permission, granted in module.acl.ts.
28
+ actions: [
34
29
  action({
35
- id: "hello:reset-counts",
30
+ id: "hello:web:greetings",
31
+ description: "The stored greetings, with how often each was used",
32
+ readOnly: true,
33
+ handler: async (ctx) => (await findGreetings(ctx))[0],
34
+ }),
35
+ action({
36
+ id: "hello:bo:greetings:reset",
36
37
  description: "Reset the usage counter of every greeting to zero",
37
38
  input: z.object({ confirm: z.literal(true) }),
38
- handler: async (ctx, input, actor) => {
39
+ handler: async (ctx) => {
39
40
  const { modifiedCount } = await requireModels(ctx).get("Greeting").updateMany({}, { times_used: 0 });
40
41
  return { reset: modifiedCount };
41
42
  },
@@ -49,33 +50,47 @@ no service or `.env` needed; `--role admin` shows what one role gets.
49
50
 
50
51
  ## Rules
51
52
 
52
- - **`tools` omitted exposes every route; given, it is the whole list.**
53
- `{ route: "*" }` is every route of the module, `{ route: "hello:*" }` a
54
- namespace, an exact id one route. A route id the module never declared is
55
- a boot error. One `agent/*.agent.ts` per module.
56
- - **An endpoint that must stay out of the manifest says `agent: false`** on
57
- its `rai()` — the agent door's own routes do.
58
- - **An action's id is a permission like any route's.** The ACL decides on
53
+ - **An action's id is a RAI like any route's**, `module:scope:resource[:extra]`:
54
+ the scope says who it is for (`hello:bo:greetings:reset` is a back-office
55
+ action). The ACL decides on
59
56
  it first (401 for a guest, 403 for a role without it), then the `input`
60
57
  schema validates (422 with field issues), then the handler runs with the
61
58
  runtime context, the parsed input and the actor (`req.auth` plus `role`).
62
- Grant it in `module.acl.ts`; a wildcard such as `hello:*` already covers it.
59
+ Grant it in `module.acl.ts`; a wildcard such as `hello:*` already covers
60
+ it. An action that does what a route does may share the route's id, so
61
+ one grant covers both.
62
+ - **Logic lives in `helpers/`, called from both.** A route controller and
63
+ an action call the same helper; a route never calls an action and an
64
+ action never calls a route. An action that grows a second step is a
65
+ controller in disguise.
63
66
  - **Write the description for the agent, not the operator.** It is the only
64
67
  text the model sees before choosing the tool. Say what the tool does, what
65
68
  it changes and what it returns; keep `readOnly: true` honest.
66
- - **Route tools are called at their own path**, through the app's middleware
67
- and auth, never around it. The manifest gives the method and path; the
68
- input is `{ params, query, body }` as the RAI declares them. A path
69
- parameter without a `params` schema is still listed as a required string.
70
- - **Keep handlers small.** An action that grows a second step is a
71
- controller in disguise: move the logic to `helpers/` and call it from both.
69
+ - **One `agent/*.agent.ts` per module.** Tool names come from the id
70
+ (`hello:bo:greetings:reset` → `hello_bo_greetings_reset`); a name two modules claim
71
+ is a boot error.
72
+
73
+ ## An agent inside the app
74
+
75
+ `ctx.agent` exists in every process — backend, worker, script — once the
76
+ ACL is loaded. An agent module runs a model against the other modules'
77
+ actions without any HTTP:
78
+
79
+ ```ts
80
+ const { tools } = ctx.agent.manifest(actor.role); // name, description, input_schema: a tool definition each
81
+ const result = await ctx.agent.invoke(name, input, actor); // ACL, then schema, then the handler
82
+ ```
83
+
84
+ A long run belongs in a queue job (`queues/`), where `ctx.agent` is the
85
+ same registry.
72
86
 
73
87
  ## The door and MCP
74
88
 
75
- The app's `agent` module serves the manifest at `GET <prefix>/agent/tools`,
76
- filtered to the caller's role, and runs actions at
77
- `POST <prefix>/agent/tools/<name>`. Any client with a bearer token the auth
78
- module accepts can use it. For Claude Code or Claude Desktop:
89
+ For agents outside the process, the app's `agent` module serves the
90
+ manifest at `GET <prefix>/agent/tools`, filtered to the caller's role, and
91
+ runs actions at `POST <prefix>/agent/tools/<name>`. Any client with a
92
+ bearer token the auth module accepts can use it. For Claude Code or Claude
93
+ Desktop:
79
94
 
80
95
  ```sh
81
96
  pnpm add -D @tulipes/mcp
@@ -26,13 +26,16 @@ problems the report has not reached yet.
26
26
  |---|---|---|
27
27
  | `environment` | `required variable "X" has no value` | Declared `required` but nothing supplies it. Add it to `.envs/.env.<APP_ENV>`, or give the spec a `default`. |
28
28
  | `environment` | `"abc" is not a number` / `not one of: …` | Value fails its declared `type`/`enum`. Fix the value or the spec. |
29
- | `environment` | `variable "X" is already declared by module "y"` | Two modules declare the same name. Delete the duplicate; read the other module's variable instead. |
29
+ | `environment` | `variable "X" is already declared by module "y"` | Two modules declare the same `key`. Delete the duplicate; read the other module's config instead. |
30
+ | `environment` | `config.x.name is both a setting and an environment value` | A `tulipes.env` name is also a field of that module's settings schema. Rename one; they share the namespace. |
31
+ | `environment` | `tulipes.env is an object keyed by config name since 0.12` | The 0.11 array form. Run `tulipes migrate 0.12`, which rewrites it and the reads. |
30
32
  | `module graph` | `dependsOn "x" — no such module exists` | Typo, or you used the package name. `dependsOn` takes short names (`"users"`). |
31
33
  | `module graph` | `dependency cycle: a → b → a` | Mutual dependency. Move the shared part into the core module or a third module. |
32
34
  | `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
35
  | `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
+ | `modules` | `tulipes.offline is gone` / `meta.variables.json … moved to package.json` | The 0.10 forms. Delete the flag; run `tulipes migrate 0.11` then `0.12` for the variables. |
37
+ | `routes` | `routes function threw: ctx.models is not available during inspection` / `config.x.name is an environment value, not available during inspection` | A routes file or a controller constructor touched infrastructure or an environment value while building. Move the read into the handler, or build the middleware with `lazy()`. |
38
+ | `config` | `app.config returns "x", which is the config namespace of module "x"` | `config/app.config.ts` seeded a key named after a module; that namespace holds the module's settings and environment values. Rename the key. |
36
39
  | `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
40
  | `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
41
  | `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. |
@@ -43,13 +46,13 @@ problems the report has not reached yet.
43
46
  | `providers` | `provider "…" cannot be resolved from …` | The declared specifier is not installed in the app. Install it (or fix the path). |
44
47
  | `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. |
45
48
  | `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. |
46
- | `providers` | `provider "bullmq" is selected but "REDIS_URL" is not declared/set` | Add the variable to the sys `core` module's meta file and set it. |
49
+ | `providers` | `provider "bullmq" is selected but config.bullmq.redisUrl is not set` | Wire it in `config/app.config.ts` — `bullmq: { redisUrl: env.core.redisUrl }` — from a variable the sys `core` module declares, and set that variable. |
47
50
  | `providers` | `"@tulipes/core/queues" no longer contains the BullMQ provider` | Install `@tulipes/bullmq`, declare it, and import `QueueRegistry`/`QueuesFn` from it. |
48
51
  | `providers` | `[x] requires capability "sockets" … declares no provider for it` | A module has a `sockets/` file (or `requires: ["sockets"]`) but the app's `package.json` has no `"tulipes": { "providers": { "sockets": "@tulipes/socket.io" } }`. Add that line. |
49
52
  | `providers` | `"@tulipes/core/sockets" no longer contains the Socket.IO provider` | Install `@tulipes/socket.io`, declare it, and import `SocketRegistry`/`SocketsFn` from it. |
50
53
  | `providers` | `provider "socket.io" failed to attach to the HTTP server` | The transport could not bind to the server core created; the message carries the driver's reason. |
51
- | `providers` | `provider "mongoose" is selected but "MONGO_URI" is not declared/set` | Add the variable to the sys `core` module's meta file and set it. |
52
- | `providers` | `database connection failed` | Mongo unreachable or `MONGO_URI` wrong. Check the service is running. |
54
+ | `providers` | `provider "mongoose" is selected but config.mongoose.uri is not set` | Wire it in `config/app.config.ts` — `mongoose: { uri: env.core.mongodbUri }` (or the host, user, pass and dbName parts) — from variables the sys `core` module declares, and set them. |
55
+ | `providers` | `database connection failed (config.mongoose.uri)` | Mongo unreachable or the URI wrong. Check the service is running and what `pnpm check` resolved. |
53
56
  | `providers` | `package.json "tulipes" key: providers — Unrecognized key` | Typo in the capability name; only `models` exists. |
54
57
  | `providers` | `queue name … is invalid` | Queue names use `.`, not `:`. |
55
58
  | `routes` | `must default-export a routes function` | Missing `export default` in a `*.routes.ts`. |
@@ -72,7 +75,10 @@ runs, check in this order:
72
75
 
73
76
  ## Other symptoms
74
77
 
75
- - **Types are `unknown` or a new variable is not typed** → run `pnpm sync`.
78
+ - **Types are `unknown` or a new environment value is not typed** → run `pnpm sync`.
79
+ - **`console.log(ctx.config.core)` does not show an environment value** →
80
+ by design; they are non-enumerable so a secret never reaches a log or a
81
+ response. `pnpm check` shows them.
76
82
  - **Worker exits immediately** → no processors registered; it refuses to
77
83
  idle. Add a `*.queues.ts` with `process()`.
78
84
  - **Env change has no effect** → `process.env` beats the file. Check for a
@@ -38,7 +38,9 @@ object is replaced in place when a write lands.
38
38
  defaults; the first boot writes them. There is no "required setting",
39
39
  because an operator cannot be asked for a value the process needs to boot.
40
40
  - **No secrets.** `defineConfig` refuses a field whose metadata says
41
- `secret`; a secret is an environment variable. → skill `tulipes-env-variable`
41
+ `secret`; a secret is an environment value, declared in the module's
42
+ package.json and read beside the settings at `ctx.config.<module>`, never
43
+ listed. → skill `tulipes-env-variable`
42
44
  - **Describe every field.** `.describe()` is what the console form, the
43
45
  generated types and an agent reading the manifest see.
44
46
  - **Validate in the schema, not in `onChange`.** The store persists before
@@ -57,12 +59,12 @@ object is replaced in place when a write lands.
57
59
 
58
60
  | | |
59
61
  |---|---|
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 |
62
+ | `GET /config` | every module in load order: manifest, environment values (config name, variable, type, source — a secret's value never), settings with schema, live values and stored version, endpoints with the roles that reach them |
61
63
  | `GET /config/:module` | the same for one module |
62
64
  | `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
65
 
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
+ `config:bo:read` and `config:bo:describe` are granted to `admin` and `sysadmin`;
67
+ `config:bo:write` to `sysadmin` only. The audit (`config_audit_events`) holds
66
68
  the namespace, the field names, the version the caller read, the actor and
67
69
  the client IP — never a value — and is acknowledged by a majority before
68
70
  the write happens: a change nobody can account for is worse than one that