@tatara/cli 0.0.0-stage → 0.1.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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bin.mjs +3001 -0
  3. package/package.json +32 -3
  4. package/templates/features/admin/files/src/apps/admin/app.ts +4 -0
  5. package/templates/features/admin/files/src/apps/admin/server.ts +5 -0
  6. package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +24 -0
  7. package/templates/features/admin/files/src/modules/example/tests/admin.test.ts +20 -0
  8. package/templates/features/auth/files/src/core/auth/middleware.ts +29 -0
  9. package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +36 -0
  10. package/templates/features/auth/files/src/core/auth/tests/me.test.ts +25 -0
  11. package/templates/features/auth-orgs/files+drizzle+mysql/src/core/auth/db/organization.schema.ts +38 -0
  12. package/templates/features/auth-orgs/files+drizzle+postgres/src/core/auth/db/organization.schema.ts +38 -0
  13. package/templates/features/auth-orgs/files+prisma/prisma/models/organization.prisma +41 -0
  14. package/templates/features/authz/files/src/core/authz/cache.ts +31 -0
  15. package/templates/features/authz/files/src/core/authz/catalog.ts +85 -0
  16. package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +21 -0
  17. package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +11 -0
  18. package/templates/features/authz/files/src/core/authz/constants.ts +32 -0
  19. package/templates/features/authz/files/src/core/authz/db/seed.ts +10 -0
  20. package/templates/features/authz/files/src/core/authz/middleware.ts +21 -0
  21. package/templates/features/authz/files/src/core/authz/permissions.ts +31 -0
  22. package/templates/features/authz/files/src/core/authz/resolver.ts +98 -0
  23. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +281 -0
  24. package/templates/features/authz/files/src/core/authz/services/audit.ts +24 -0
  25. package/templates/features/authz/files/src/core/authz/services/grants.ts +29 -0
  26. package/templates/features/authz/files/src/core/authz/services/members.ts +84 -0
  27. package/templates/features/authz/files/src/core/authz/services/roles.ts +73 -0
  28. package/templates/features/authz/files/src/core/authz/services/sync.ts +34 -0
  29. package/templates/features/authz/files/src/core/authz/system-roles.ts +9 -0
  30. package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +146 -0
  31. package/templates/features/authz/files/src/core/authz/tests/error-messages.test.ts +34 -0
  32. package/templates/features/authz/files/src/core/authz/tests/request-body.test.ts +27 -0
  33. package/templates/features/authz/files/src/core/authz/types.ts +87 -0
  34. package/templates/features/authz/files/src/core/authz/validators.ts +99 -0
  35. package/templates/features/authz/files/tests/helpers/authz.ts +30 -0
  36. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +275 -0
  37. package/templates/features/authz/files+drizzle+mysql/src/core/authz/db/authz.schema.ts +90 -0
  38. package/templates/features/authz/files+drizzle+postgres/src/core/authz/db/authz.schema.ts +88 -0
  39. package/templates/features/authz/files+prisma/prisma/models/authz.prisma +75 -0
  40. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +236 -0
  41. package/templates/features/base/files/AGENTS.md +71 -0
  42. package/templates/features/base/files/CLAUDE.md +1 -0
  43. package/templates/features/base/files/Dockerfile +16 -0
  44. package/templates/features/base/files/README.md +31 -0
  45. package/templates/features/base/files/_github/workflows/ci.yml +26 -0
  46. package/templates/features/base/files/biome.template.json +20 -0
  47. package/templates/features/base/files/dockerignore +8 -0
  48. package/templates/features/base/files/gitignore +9 -0
  49. package/templates/features/base/files/src/apps/api/app.ts +3 -0
  50. package/templates/features/base/files/src/apps/api/server.ts +5 -0
  51. package/templates/features/base/files/src/apps/cli/index.ts +6 -0
  52. package/templates/features/base/files/src/core/commands/define.ts +33 -0
  53. package/templates/features/base/files/src/core/commands/runner.ts +54 -0
  54. package/templates/features/base/files/src/core/commands/tests/flags.test.ts +16 -0
  55. package/templates/features/base/files/src/core/config/tests/env.test.ts +40 -0
  56. package/templates/features/base/files/src/core/http/app.ts +57 -0
  57. package/templates/features/base/files/src/core/http/context.ts +18 -0
  58. package/templates/features/base/files/src/core/http/errors.ts +79 -0
  59. package/templates/features/base/files/src/core/http/health.ts +25 -0
  60. package/templates/features/base/files/src/core/http/middleware/error-handler.ts +36 -0
  61. package/templates/features/base/files/src/core/http/middleware/request-context.ts +24 -0
  62. package/templates/features/base/files/src/core/http/middleware/validation.ts +17 -0
  63. package/templates/features/base/files/src/core/http/response.ts +72 -0
  64. package/templates/features/base/files/src/core/http/routes.ts +28 -0
  65. package/templates/features/base/files/src/core/http/server.ts +15 -0
  66. package/templates/features/base/files/src/core/logging/logger.ts +17 -0
  67. package/templates/features/base/files/src/core/process/lifecycle.ts +34 -0
  68. package/templates/features/base/files/src/modules/example/routes/greeting.routes.ts +27 -0
  69. package/templates/features/base/files/src/modules/example/services/greeting.service.ts +3 -0
  70. package/templates/features/base/files/src/modules/example/tests/greeting.test.ts +21 -0
  71. package/templates/features/base/files/src/modules/example/validators/greeting.validators.ts +9 -0
  72. package/templates/features/base/files/tests/helpers/api.ts +54 -0
  73. package/templates/features/base/files/tests/setup.ts +2 -0
  74. package/templates/features/base/files/tsconfig.json +20 -0
  75. package/templates/features/base/files/tsdown.config.ts +15 -0
  76. package/templates/features/base/files/vitest.config.ts +16 -0
  77. package/templates/features/better-auth/files/src/core/auth/auth.ts +26 -0
  78. package/templates/features/better-auth/files/src/core/auth/db/seed.ts +24 -0
  79. package/templates/features/better-auth/files/src/core/auth/delivery.ts +48 -0
  80. package/templates/features/better-auth/files/src/core/auth/openapi.ts +60 -0
  81. package/templates/features/better-auth/files/src/core/auth/principal.ts +40 -0
  82. package/templates/features/better-auth/files/src/core/auth/sms.ts +14 -0
  83. package/templates/features/better-auth/files/src/core/auth/tests/openapi.test.ts +45 -0
  84. package/templates/features/better-auth/files/tests/helpers/auth.ts +40 -0
  85. package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +8 -0
  86. package/templates/features/better-auth/files+auth-orgs/src/core/auth/tests/principal.test.ts +44 -0
  87. package/templates/features/better-auth/files+auth-orgs+authz/src/core/auth/tests/principal-authz.test.ts +40 -0
  88. package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/adapter.ts +15 -0
  89. package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/db/auth.schema.ts +57 -0
  90. package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/adapter.ts +15 -0
  91. package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/db/auth.schema.ts +56 -0
  92. package/templates/features/better-auth/files+prisma/prisma/models/auth.prisma +62 -0
  93. package/templates/features/better-auth/files+prisma+mysql/src/core/auth/adapter.ts +4 -0
  94. package/templates/features/better-auth/files+prisma+postgres/src/core/auth/adapter.ts +4 -0
  95. package/templates/features/db/files/src/core/db/commands/migrate.ts +12 -0
  96. package/templates/features/db/files/src/core/db/commands/reset.ts +19 -0
  97. package/templates/features/db/files/src/core/db/commands/seed.ts +13 -0
  98. package/templates/features/db/files/src/core/db/id.ts +13 -0
  99. package/templates/features/db/files/src/core/db/seed.ts +14 -0
  100. package/templates/features/db/files/src/core/db/tests/reset.test.ts +7 -0
  101. package/templates/features/db/files/tests/global-setup.ts +27 -0
  102. package/templates/features/db/files/tests/helpers/database-url.ts +9 -0
  103. package/templates/features/db/files/tests/helpers/db.ts +7 -0
  104. package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +53 -0
  105. package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +78 -0
  106. package/templates/features/db/files+postgres/src/core/db/admin.ts +50 -0
  107. package/templates/features/drizzle/files+mysql/drizzle.config.ts +12 -0
  108. package/templates/features/drizzle/files+mysql/src/core/db/client.ts +20 -0
  109. package/templates/features/drizzle/files+mysql/src/core/db/migrate.ts +13 -0
  110. package/templates/features/drizzle/files+postgres/drizzle.config.ts +12 -0
  111. package/templates/features/drizzle/files+postgres/src/core/db/client.ts +20 -0
  112. package/templates/features/drizzle/files+postgres/src/core/db/migrate.ts +13 -0
  113. package/templates/features/email/files/src/core/email/commands/send-test.ts +13 -0
  114. package/templates/features/email/files/src/core/email/send.ts +22 -0
  115. package/templates/features/email/files/src/core/email/tests/email.test.ts +8 -0
  116. package/templates/features/i18n/files/src/core/i18n/index.ts +41 -0
  117. package/templates/features/i18n/files/src/core/i18n/tests/i18n.test.ts +21 -0
  118. package/templates/features/i18n/files/src/locales/ar.json +10 -0
  119. package/templates/features/i18n/files/src/locales/en.json +10 -0
  120. package/templates/features/jobs/files/src/apps/cron/index.ts +25 -0
  121. package/templates/features/jobs/files/src/apps/worker/index.ts +24 -0
  122. package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +13 -0
  123. package/templates/features/jobs/files/src/core/jobs/define.ts +37 -0
  124. package/templates/features/jobs/files/src/core/jobs/lock.ts +17 -0
  125. package/templates/features/jobs/files/src/core/jobs/process.ts +19 -0
  126. package/templates/features/jobs/files/src/core/jobs/queue.ts +29 -0
  127. package/templates/features/jobs/files/src/core/jobs/redis.ts +17 -0
  128. package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +11 -0
  129. package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +9 -0
  130. package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +29 -0
  131. package/templates/features/keycloak/files/keycloak/realm.json +83 -0
  132. package/templates/features/keycloak/files/src/core/auth/commands/keycloak-token.ts +32 -0
  133. package/templates/features/keycloak/files/src/core/auth/keycloak.ts +58 -0
  134. package/templates/features/keycloak/files/src/core/auth/principal.ts +27 -0
  135. package/templates/features/keycloak/files/tests/helpers/auth.ts +52 -0
  136. package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +18 -0
  137. package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +10 -0
  138. package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +10 -0
  139. package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +10 -0
  140. package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +13 -0
  141. package/templates/features/prisma/files/prisma/models/.gitkeep +0 -0
  142. package/templates/features/prisma/files/prisma.config.ts +10 -0
  143. package/templates/features/prisma/files/src/core/db/migrate.ts +13 -0
  144. package/templates/features/prisma/files+mysql/prisma/schema.prisma +11 -0
  145. package/templates/features/prisma/files+mysql/src/core/db/client.ts +23 -0
  146. package/templates/features/prisma/files+postgres/prisma/schema.prisma +11 -0
  147. package/templates/features/prisma/files+postgres/src/core/db/client.ts +19 -0
  148. package/templates/features/storage/files/src/core/storage/commands/check.ts +18 -0
  149. package/templates/features/storage/files/src/core/storage/index.ts +8 -0
  150. package/templates/features/storage/files/src/core/storage/local.ts +34 -0
  151. package/templates/features/storage/files/src/core/storage/s3.ts +66 -0
  152. package/templates/features/storage/files/src/core/storage/storage.ts +21 -0
  153. package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +22 -0
  154. package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +11 -0
  155. package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +14 -0
  156. package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +12 -0
  157. package/templates/generators/module/files/src/modules/__moduleKebab__/routes/__moduleKebab__.routes.ts +20 -0
  158. package/templates/generators/module/files/src/modules/__moduleKebab__/services/__moduleKebab__.service.ts +4 -0
  159. package/templates/generators/module/files/src/modules/__moduleKebab__/tests/__moduleKebab__.test.ts +10 -0
  160. package/templates/generators/module/files/src/modules/__moduleKebab__/validators/__moduleKebab__.validators.ts +5 -0
  161. package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +18 -0
  162. package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +127 -0
  163. package/templates/generators/resource/files/src/modules/__moduleKebab__/tests/__nameKebab__.routes.test.ts +59 -0
  164. package/templates/generators/resource/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +30 -0
  165. package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +21 -0
  166. package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +78 -0
  167. package/templates/generators/resource/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  168. package/templates/generators/resource/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  169. package/templates/generators/resource/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
  170. package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +73 -0
  171. package/templates/generators/route/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +29 -0
  172. package/templates/generators/route/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +9 -0
  173. package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +13 -0
  174. package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +11 -0
  175. package/templates/generators/schema/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  176. package/templates/generators/schema/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  177. package/templates/generators/schema/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
  178. package/templates/generators/service/files/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +4 -0
  179. package/templates/generators/service/files/src/modules/__moduleKebab__/tests/__nameKebab__.service.test.ts +8 -0
  180. package/templates/versions.json +34 -0
  181. package/README.md +0 -3
package/dist/bin.mjs ADDED
@@ -0,0 +1,3001 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import pc from "picocolors";
4
+ import { spawn } from "node:child_process";
5
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
6
+ import { dirname, join, relative, resolve, sep } from "node:path";
7
+ import { glob } from "tinyglobby";
8
+ import { existsSync, readFileSync, readdirSync, watch } from "node:fs";
9
+ import { x } from "tinyexec";
10
+ import { parseEnv } from "node:util";
11
+ import * as p from "@clack/prompts";
12
+ import { randomBytes } from "node:crypto";
13
+ import { stringify } from "yaml";
14
+ //#region \0rolldown/runtime.js
15
+ var __defProp = Object.defineProperty;
16
+ var __exportAll = (all, no_symbols) => {
17
+ let target = {};
18
+ for (var name in all) __defProp(target, name, {
19
+ get: all[name],
20
+ enumerable: true
21
+ });
22
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
23
+ return target;
24
+ };
25
+ //#endregion
26
+ //#region src/commands/app-cli.ts
27
+ const NODE_FLAGS = ["--env-file-if-exists=.env", "--disable-warning=ExperimentalWarning"];
28
+ /** Runs `src/apps/cli/index.ts <args>` in the app, streaming output. Resolves the exit code. */
29
+ function runAppCli(project, args, env) {
30
+ return new Promise((resolve) => {
31
+ const child = spawn(process.execPath, [
32
+ ...NODE_FLAGS,
33
+ "src/apps/cli/index.ts",
34
+ ...args
35
+ ], {
36
+ cwd: project.root,
37
+ stdio: "inherit",
38
+ env: {
39
+ ...process.env,
40
+ ...env
41
+ }
42
+ });
43
+ child.on("exit", (code) => resolve(code ?? 1));
44
+ child.on("error", () => resolve(1));
45
+ });
46
+ }
47
+ function runBin(project, bin, args) {
48
+ return new Promise((resolve) => {
49
+ const child = spawn(`${project.root}/node_modules/.bin/${bin}`, args, {
50
+ cwd: project.root,
51
+ stdio: "inherit"
52
+ });
53
+ child.on("exit", (code) => resolve(code ?? 1));
54
+ child.on("error", () => resolve(1));
55
+ });
56
+ }
57
+ //#endregion
58
+ //#region src/project/config.ts
59
+ var NotATataraProject = class extends Error {
60
+ constructor(from) {
61
+ super(`No tatara.json found in ${from} or its parents. Run this inside a tatara app.`);
62
+ }
63
+ };
64
+ /** Finds the app root (the nearest folder with tatara.json) starting from `from`. */
65
+ function findProjectRoot(from = process.cwd()) {
66
+ let dir = resolve(from);
67
+ for (;;) {
68
+ if (existsSync(join(dir, "tatara.json"))) return dir;
69
+ const parent = dirname(dir);
70
+ if (parent === dir) throw new NotATataraProject(from);
71
+ dir = parent;
72
+ }
73
+ }
74
+ function loadProject(from = process.cwd()) {
75
+ const root = findProjectRoot(from);
76
+ const config = JSON.parse(readFileSync(join(root, "tatara.json"), "utf8"));
77
+ const features = /* @__PURE__ */ new Set([
78
+ config.orm,
79
+ config.database,
80
+ config.auth,
81
+ ...config.authMethods.map((m) => `auth-${m}`),
82
+ ...config.addons,
83
+ ...config.auth === "none" ? [] : ["auth"],
84
+ ...config.database === "none" ? [] : ["db"]
85
+ ]);
86
+ return {
87
+ root,
88
+ config,
89
+ has: (f) => features.has(f)
90
+ };
91
+ }
92
+ //#endregion
93
+ //#region src/commands/route-mounts.ts
94
+ const ROUTE_FILE = /\/routes\/[^/]+\.routes\.ts$/;
95
+ const DEFINE_ROUTES = /defineRoutes\(\s*\{([^}]*)\}/;
96
+ function lineOf$1(text, index) {
97
+ return text.slice(0, index).split("\n").length;
98
+ }
99
+ /** Route files that mount on an app + basePath already taken by an earlier file (sorted by path). */
100
+ function routeMountCollisions(sources) {
101
+ const owners = /* @__PURE__ */ new Map();
102
+ const violations = [];
103
+ for (const file of [...sources.keys()].sort()) {
104
+ if (!ROUTE_FILE.test(file)) continue;
105
+ const text = sources.get(file) ?? "";
106
+ const call = DEFINE_ROUTES.exec(text);
107
+ const app = call && /\bapp:\s*["']([^"']+)["']/.exec(call[1] ?? "")?.[1];
108
+ const basePath = call && /\bbasePath:\s*["']([^"']+)["']/.exec(call[1] ?? "")?.[1];
109
+ if (!call || !app || !basePath) continue;
110
+ const key = `${app} "${basePath}"`;
111
+ const owner = owners.get(key);
112
+ if (owner) violations.push({
113
+ file,
114
+ line: lineOf$1(text, call.index),
115
+ rule: "duplicate-base-path",
116
+ message: `${key} is already mounted by ${owner}; routers on one base path shadow each other, so give this one its own basePath`
117
+ });
118
+ else owners.set(key, file);
119
+ }
120
+ return violations;
121
+ }
122
+ //#endregion
123
+ //#region src/commands/check.ts
124
+ const IMPORT = /(?:import|export)\s[^"']*?from\s*["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)/g;
125
+ function lineOf(text, index) {
126
+ return text.slice(0, index).split("\n").length;
127
+ }
128
+ /** Layout rules from AGENTS.md. Returns every violation; empty means the layout is clean. */
129
+ async function check(project = loadProject()) {
130
+ const { root } = project;
131
+ const files = await glob(["src/**/*.ts", "tests/**/*.ts"], {
132
+ cwd: root,
133
+ ignore: ["src/generated/**"]
134
+ });
135
+ const violations = [];
136
+ const sources = /* @__PURE__ */ new Map();
137
+ for (const file of files.sort()) {
138
+ const text = await readFile(join(root, file), "utf8");
139
+ sources.set(file, text);
140
+ const moduleName = /^src\/modules\/([^/]+)\//.exec(file)?.[1];
141
+ for (const match of text.matchAll(IMPORT)) {
142
+ const spec = match[1] ?? match[2] ?? "";
143
+ const line = lineOf(text, match.index);
144
+ if (spec.startsWith("../")) violations.push({
145
+ file,
146
+ line,
147
+ rule: "no-parent-imports",
148
+ message: `use a # import instead of "${spec}"`
149
+ });
150
+ if (file.startsWith("src/core/") && spec.startsWith("#modules/")) violations.push({
151
+ file,
152
+ line,
153
+ rule: "core-imports-modules",
154
+ message: `core must not import modules ("${spec}")`
155
+ });
156
+ const target = /^#modules\/([^/]+)\/([^/]+)/.exec(spec);
157
+ if (moduleName && target && target[1] !== moduleName && target[2] !== "services") violations.push({
158
+ file,
159
+ line,
160
+ rule: "cross-module-imports",
161
+ message: `use ${target[1]}'s services, not "${spec}"`
162
+ });
163
+ }
164
+ if (/\/routes\/[^/]+\.routes\.ts$/.test(file)) {
165
+ const wildcard = /\.use\(\s*["']\*["']/.exec(text);
166
+ if (wildcard) violations.push({
167
+ file,
168
+ line: lineOf(text, wildcard.index),
169
+ rule: "no-wildcard-middleware",
170
+ message: "module routers must not call use(\"*\"); attach middleware per route or path"
171
+ });
172
+ }
173
+ if (file.startsWith("src/") && file !== "src/core/config/env.ts" && !file.includes("/tests/") && /\bprocess\.env\b/.test(text)) {
174
+ const index = text.search(/\bprocess\.env\b/);
175
+ violations.push({
176
+ file,
177
+ line: lineOf(text, index),
178
+ rule: "env-access",
179
+ message: "read configuration from #core/config/env, not process.env"
180
+ });
181
+ }
182
+ }
183
+ violations.push(...routeMountCollisions(sources));
184
+ return violations.filter((v) => !ignored(v, sources.get(v.file) ?? ""));
185
+ }
186
+ /** `// tatara-check-ignore <rule>: <reason>` on the line above a violation silences it. */
187
+ function ignored(v, text) {
188
+ const previous = text.split("\n")[v.line - 2] ?? "";
189
+ return new RegExp(`tatara-check-ignore\\s+${v.rule}:\\s*\\S`).test(previous);
190
+ }
191
+ //#endregion
192
+ //#region src/commands/sync.ts
193
+ var sync_exports = /* @__PURE__ */ __exportAll({
194
+ specifier: () => specifier,
195
+ sync: () => sync
196
+ });
197
+ const HEADER = "// Generated by `tatara sync` — do not edit.\n";
198
+ /** src/modules/notes/routes/note.routes.ts → #modules/notes/routes/note.routes */
199
+ function specifier(path) {
200
+ return `#${path.replace(/^src\//, "").replace(/\.ts$/, "")}`;
201
+ }
202
+ function identifier(path, prefix) {
203
+ return `${prefix}_${path.replace(/^src\//, "").replace(/\.ts$/, "").split("/").filter((p) => ![
204
+ "routes",
205
+ "commands",
206
+ "consumers",
207
+ "schedules",
208
+ "permissions",
209
+ "db"
210
+ ].includes(p)).join("_")}`.replace(/[^A-Za-z0-9_]/g, "_").replace(/_+/g, "_");
211
+ }
212
+ async function find(root, patterns) {
213
+ return (await glob(patterns, {
214
+ cwd: root,
215
+ ignore: ["**/*.test.ts", "**/*.d.ts"]
216
+ })).sort((a, b) => {
217
+ const rank = (p) => p.startsWith("src/core/") ? 0 : 1;
218
+ return rank(a) - rank(b) || a.localeCompare(b);
219
+ });
220
+ }
221
+ function listModule(files, options) {
222
+ const imports = files.map((f) => `import ${identifier(f, options.prefix)} from "${specifier(f)}";`);
223
+ const names = files.map((f) => identifier(f, options.prefix));
224
+ return `${HEADER}${options.typeImport}
225
+ ${imports.join("\n")}${imports.length ? "\n" : ""}
226
+ export const ${options.exportName}: ${options.typeName}[] = [${names.join(", ")}];
227
+ `;
228
+ }
229
+ async function writeIfChanged(path, content, written) {
230
+ if ((existsSync(path) ? await readFile(path, "utf8") : void 0) !== content) {
231
+ await writeFile(path, content);
232
+ written.push(path);
233
+ }
234
+ }
235
+ async function sync(project = loadProject()) {
236
+ const { root } = project;
237
+ const out = join(root, "src/generated");
238
+ await mkdir(out, { recursive: true });
239
+ const written = [];
240
+ const dirs = ["src/core/*", "src/modules/*"];
241
+ const routes = await find(root, dirs.map((d) => `${d}/routes/*.routes.ts`));
242
+ await writeIfChanged(join(out, "routes.ts"), listModule(routes, {
243
+ prefix: "routes",
244
+ typeImport: "import type { RouteDefinition } from \"#core/http/routes\";",
245
+ typeName: "RouteDefinition",
246
+ exportName: "routes"
247
+ }), written);
248
+ const commands = await find(root, dirs.map((d) => `${d}/commands/*.ts`));
249
+ await writeIfChanged(join(out, "commands.ts"), listModule(commands, {
250
+ prefix: "command",
251
+ typeImport: "import type { CommandDefinition } from \"#core/commands/define\";",
252
+ typeName: "CommandDefinition",
253
+ exportName: "commands"
254
+ }), written);
255
+ if (project.has("jobs")) {
256
+ const consumers = await find(root, dirs.map((d) => `${d}/consumers/*.ts`));
257
+ await writeIfChanged(join(out, "consumers.ts"), listModule(consumers, {
258
+ prefix: "consumer",
259
+ typeImport: "import type { ConsumerDefinition } from \"#core/jobs/define\";",
260
+ typeName: "ConsumerDefinition",
261
+ exportName: "consumers"
262
+ }), written);
263
+ const schedules = await find(root, dirs.map((d) => `${d}/schedules/*.ts`));
264
+ await writeIfChanged(join(out, "schedules.ts"), listModule(schedules, {
265
+ prefix: "schedule",
266
+ typeImport: "import type { ScheduleDefinition } from \"#core/jobs/define\";",
267
+ typeName: "ScheduleDefinition",
268
+ exportName: "schedules"
269
+ }), written);
270
+ }
271
+ if (project.has("authz")) {
272
+ const permissions = await find(root, ["src/modules/*/permissions/*.permissions.ts"]);
273
+ const imports = permissions.map((f) => `import ${identifier(f, "permissions")} from "${specifier(f)}";`);
274
+ const names = permissions.map((f) => identifier(f, "permissions"));
275
+ await writeIfChanged(join(out, "permissions.ts"), `${HEADER}import { mergeCatalogs, type PermissionKeysOf } from "#core/authz/catalog";
276
+ import corePermissions from "#core/authz/permissions";
277
+ ${imports.join("\n")}${imports.length ? "\n" : ""}
278
+ export const catalog = mergeCatalogs(corePermissions${names.map((n) => `, ${n}`).join("")});
279
+
280
+ export type PermissionKey =
281
+ | PermissionKeysOf<typeof corePermissions>${names.map((n) => `\n\t| PermissionKeysOf<typeof ${n}>`).join("")};
282
+ `, written);
283
+ }
284
+ if (project.config.database !== "none") {
285
+ const seeds = await find(root, dirs.map((d) => `${d}/db/seed.ts`));
286
+ await writeIfChanged(join(out, "seeds.ts"), listModule(seeds, {
287
+ prefix: "seed",
288
+ typeImport: "import type { SeedDefinition } from \"#core/db/seed\";",
289
+ typeName: "SeedDefinition",
290
+ exportName: "seeds"
291
+ }), written);
292
+ }
293
+ if (project.config.orm === "drizzle") {
294
+ const schemas = await find(root, dirs.map((d) => `${d}/db/*.schema.ts`));
295
+ await writeIfChanged(join(out, "schema.ts"), `${HEADER}${schemas.map((f) => `export * from "${specifier(f)}";`).join("\n")}\nexport {};\n`, written);
296
+ }
297
+ if (project.config.orm === "prisma") {
298
+ const bin = join(root, "node_modules/.bin/prisma");
299
+ if (existsSync(bin)) {
300
+ const result = await x(bin, ["generate"], {
301
+ nodeOptions: { cwd: root },
302
+ throwOnError: false
303
+ });
304
+ if (result.exitCode !== 0) throw new Error(`prisma generate failed:\n${result.stdout}${result.stderr}`);
305
+ written.push(join(out, "prisma"));
306
+ }
307
+ }
308
+ return { written };
309
+ }
310
+ //#endregion
311
+ //#region src/commands/db.ts
312
+ var DbError = class extends Error {};
313
+ function requireDb(project) {
314
+ if (project.config.orm === "none") throw new DbError("This app has no database.");
315
+ }
316
+ async function dbGenerate(project, name = "change") {
317
+ requireDb(project);
318
+ await sync(project);
319
+ if (project.config.orm === "drizzle") return runBin(project, "drizzle-kit", [
320
+ "generate",
321
+ "--name",
322
+ name
323
+ ]);
324
+ return runBin(project, "prisma", [
325
+ "migrate",
326
+ "dev",
327
+ "--create-only",
328
+ "--name",
329
+ name
330
+ ]);
331
+ }
332
+ async function dbMigrate(project) {
333
+ requireDb(project);
334
+ await sync(project);
335
+ return runAppCli(project, ["db:migrate"]);
336
+ }
337
+ async function dbSeed(project) {
338
+ requireDb(project);
339
+ await sync(project);
340
+ return runAppCli(project, ["db:seed"]);
341
+ }
342
+ async function dbReset(project) {
343
+ requireDb(project);
344
+ await sync(project);
345
+ const code = await runAppCli(project, ["db:reset"]);
346
+ return code === 0 ? runAppCli(project, ["db:seed"]) : code;
347
+ }
348
+ async function dbStudio(project) {
349
+ requireDb(project);
350
+ console.log(pc.dim("Opening the database studio…"));
351
+ if (project.config.orm === "drizzle") return runBin(project, "drizzle-kit", ["studio"]);
352
+ return runBin(project, "prisma", ["studio"]);
353
+ }
354
+ //#endregion
355
+ //#region src/pm.ts
356
+ function pmCommands(pm) {
357
+ switch (pm) {
358
+ case "pnpm": return {
359
+ install: ["pnpm", "install"],
360
+ run: "pnpm",
361
+ exec: "pnpm exec",
362
+ lockfile: "pnpm-lock.yaml"
363
+ };
364
+ case "npm": return {
365
+ install: ["npm", "install"],
366
+ run: "npm run",
367
+ exec: "npx",
368
+ lockfile: "package-lock.json"
369
+ };
370
+ case "bun": return {
371
+ install: ["bun", "install"],
372
+ run: "bun run",
373
+ exec: "bunx",
374
+ lockfile: "bun.lock"
375
+ };
376
+ }
377
+ }
378
+ //#endregion
379
+ //#region src/commands/verify.ts
380
+ const VERIFY_STEPS = [
381
+ "lint",
382
+ "typecheck",
383
+ "test",
384
+ "build"
385
+ ];
386
+ function runInherit(cmd, args, cwd, env) {
387
+ return new Promise((resolve) => {
388
+ const child = spawn(cmd, args, {
389
+ cwd,
390
+ stdio: "inherit",
391
+ env: {
392
+ ...process.env,
393
+ ...env
394
+ }
395
+ });
396
+ child.on("exit", (code) => resolve(code ?? 1));
397
+ child.on("error", () => resolve(1));
398
+ });
399
+ }
400
+ /** Runs lint, typecheck, test and build in order; stops at the first failure. */
401
+ async function verify(project = loadProject()) {
402
+ const [cmd, ...prefix] = pmCommands(project.config.packageManager).run.split(" ");
403
+ for (const step of VERIFY_STEPS) {
404
+ console.log(pc.bold(pc.cyan(`\n▶ ${step}`)));
405
+ if (await runInherit(cmd ?? "npm", [...prefix, step], project.root) !== 0) {
406
+ console.log(pc.red(`\n✖ verify failed at "${step}"`));
407
+ return false;
408
+ }
409
+ }
410
+ console.log(pc.green("\n✔ verify passed (lint, typecheck, test, build)"));
411
+ return true;
412
+ }
413
+ //#endregion
414
+ //#region src/commands/dev.ts
415
+ function devProcesses(project) {
416
+ const list = [{
417
+ name: "api",
418
+ file: "src/apps/api/server.ts",
419
+ color: pc.cyan
420
+ }];
421
+ if (project.has("admin")) list.push({
422
+ name: "admin",
423
+ file: "src/apps/admin/server.ts",
424
+ color: pc.magenta
425
+ });
426
+ if (project.has("jobs")) {
427
+ list.push({
428
+ name: "worker",
429
+ file: "src/apps/worker/index.ts",
430
+ color: pc.yellow
431
+ });
432
+ list.push({
433
+ name: "cron",
434
+ file: "src/apps/cron/index.ts",
435
+ color: pc.blue
436
+ });
437
+ }
438
+ return list;
439
+ }
440
+ function readEnv(project) {
441
+ const file = join(project.root, ".env");
442
+ return existsSync(file) ? parseEnv(readFileSync(file, "utf8")) : {};
443
+ }
444
+ async function compose(project, args) {
445
+ if (!existsSync(join(project.root, "compose.yaml"))) return 0;
446
+ return runInherit("docker", ["compose", ...args], project.root);
447
+ }
448
+ function prefixed(child, label) {
449
+ for (const stream of [child.stdout, child.stderr]) {
450
+ let buffer = "";
451
+ stream?.on("data", (chunk) => {
452
+ buffer += chunk.toString();
453
+ const lines = buffer.split("\n");
454
+ buffer = lines.pop() ?? "";
455
+ for (const line of lines) process.stdout.write(`${label} ${line}\n`);
456
+ });
457
+ }
458
+ }
459
+ const CONVENTION = /\/(routes|commands|consumers|schedules|permissions|db)\/[^/]+\.ts$/;
460
+ /** Re-runs `tatara sync` when convention files appear or disappear, so --watch picks them up. */
461
+ function watchConventions(project) {
462
+ const watchers = [];
463
+ let timer;
464
+ for (const dir of [
465
+ "src/modules",
466
+ "src/core",
467
+ "prisma/models"
468
+ ]) {
469
+ const full = join(project.root, dir);
470
+ if (!existsSync(full)) continue;
471
+ const watcher = watch(full, { recursive: true }, (_event, file) => {
472
+ if (!file || !(CONVENTION.test(`/${file}`) || file.endsWith(".prisma"))) return;
473
+ clearTimeout(timer);
474
+ timer = setTimeout(() => {
475
+ sync(project).catch((error) => console.error(pc.red(String(error))));
476
+ }, 150);
477
+ });
478
+ watchers.push(watcher);
479
+ }
480
+ return watchers;
481
+ }
482
+ async function dev(project, flags = {}) {
483
+ if (flags.down) return compose(project, ["down"]);
484
+ if (flags.reset && await compose(project, ["down", "--volumes"]) !== 0) return 1;
485
+ if (existsSync(join(project.root, "compose.yaml"))) {
486
+ console.log(pc.bold("▶ starting local services"));
487
+ if (await compose(project, [
488
+ "up",
489
+ "--detach",
490
+ "--wait"
491
+ ]) !== 0) {
492
+ console.error(pc.red("Local services did not become healthy. Is Docker running? Try `tatara doctor`."));
493
+ return 1;
494
+ }
495
+ }
496
+ await sync(project);
497
+ if (project.has("db")) {
498
+ console.log(pc.bold("▶ migrating and seeding"));
499
+ if (await runAppCli(project, ["db:migrate"]) !== 0) return 1;
500
+ if (await runAppCli(project, ["db:seed"]) !== 0) return 1;
501
+ }
502
+ if (flags.prepare) return 0;
503
+ const env = readEnv(project);
504
+ const port = env.PORT ?? "3000";
505
+ const children = devProcesses(project).map((p) => {
506
+ const child = spawn(process.execPath, [
507
+ ...NODE_FLAGS,
508
+ "--watch",
509
+ p.file
510
+ ], {
511
+ cwd: project.root,
512
+ stdio: [
513
+ "inherit",
514
+ "pipe",
515
+ "pipe"
516
+ ],
517
+ env: {
518
+ ...process.env,
519
+ FORCE_COLOR: "1"
520
+ }
521
+ });
522
+ prefixed(child, p.color(`[${p.name}]`.padEnd(8)));
523
+ return child;
524
+ });
525
+ const watchers = watchConventions(project);
526
+ const urls = [`API http://localhost:${port}/api`, `Reference http://localhost:${port}/docs`];
527
+ if (project.has("admin")) urls.push(`Admin API http://localhost:${env.ADMIN_PORT ?? "3001"}/docs`);
528
+ if (project.has("email")) urls.push(`Mailpit http://localhost:${env.MAILPIT_UI_PORT ?? "8025"}`);
529
+ if (project.has("storage")) urls.push(`Storage UI http://localhost:${env.STORAGE_CONSOLE_PORT ?? "9001"}`);
530
+ if (project.has("keycloak")) urls.push(`Keycloak http://localhost:${env.KEYCLOAK_PORT ?? "8080"}/admin (admin/admin)`);
531
+ console.log(`\n${urls.map((u) => ` ${pc.green("➜")} ${u}`).join("\n")}\n`);
532
+ return new Promise((resolve) => {
533
+ const stop = () => {
534
+ for (const watcher of watchers) watcher.close();
535
+ let running = children.filter((c) => c.exitCode === null).length;
536
+ if (running === 0) resolve(0);
537
+ for (const child of children) {
538
+ child.once("exit", () => {
539
+ running -= 1;
540
+ if (running <= 0) resolve(0);
541
+ });
542
+ child.kill("SIGTERM");
543
+ }
544
+ setTimeout(() => resolve(0), 5e3).unref();
545
+ };
546
+ process.once("SIGINT", stop);
547
+ process.once("SIGTERM", stop);
548
+ });
549
+ }
550
+ //#endregion
551
+ //#region src/commands/doctor.ts
552
+ const PACKAGES = {
553
+ drizzle: ["drizzle-orm", "drizzle-kit"],
554
+ prisma: ["@prisma/client", "prisma"],
555
+ "better-auth": ["better-auth"],
556
+ keycloak: ["jose"],
557
+ jobs: ["bullmq", "ioredis"]
558
+ };
559
+ async function doctor(project) {
560
+ const checks = [];
561
+ const add = (c) => checks.push(c);
562
+ add({
563
+ name: "node",
564
+ ok: Number(process.versions.node.split(".")[0]) >= 24,
565
+ detail: `v${process.versions.node}`,
566
+ fix: "Install Node 24 or newer"
567
+ });
568
+ const pm = project.config.packageManager;
569
+ const pmVersion = await x(pm, ["--version"], { throwOnError: false });
570
+ add({
571
+ name: pm,
572
+ ok: pmVersion.exitCode === 0,
573
+ detail: pmVersion.exitCode === 0 ? pmVersion.stdout.trim() : "not found",
574
+ fix: pm === "pnpm" ? "corepack enable" : `Install ${pm}`
575
+ });
576
+ if (existsSync(join(project.root, "compose.yaml"))) {
577
+ const docker = await x("docker", [
578
+ "info",
579
+ "--format",
580
+ "{{.ServerVersion}}"
581
+ ], { throwOnError: false });
582
+ add({
583
+ name: "docker",
584
+ ok: docker.exitCode === 0,
585
+ detail: docker.exitCode === 0 ? docker.stdout.trim() : "daemon not reachable",
586
+ fix: "Start Docker"
587
+ });
588
+ if (docker.exitCode === 0) {
589
+ const lines = (await x("docker", [
590
+ "compose",
591
+ "ps",
592
+ "--format",
593
+ "{{.Service}} {{.Health}}"
594
+ ], {
595
+ nodeOptions: { cwd: project.root },
596
+ throwOnError: false
597
+ })).stdout.trim().split("\n").filter(Boolean);
598
+ const unhealthy = lines.filter((l) => !/ (healthy|)$/.test(l));
599
+ add({
600
+ name: "services",
601
+ ok: lines.length > 0 && unhealthy.length === 0,
602
+ detail: lines.length === 0 ? "not running" : lines.join(", "),
603
+ fix: "Run `dev` to start them"
604
+ });
605
+ }
606
+ }
607
+ const envPath = join(project.root, ".env");
608
+ if (!existsSync(envPath)) add({
609
+ name: ".env",
610
+ ok: false,
611
+ detail: "missing",
612
+ fix: "cp .env.example .env"
613
+ });
614
+ else {
615
+ const env = parseEnv(readFileSync(envPath, "utf8"));
616
+ const example = parseEnv(readFileSync(join(project.root, ".env.example"), "utf8"));
617
+ const missing = Object.keys(example).filter((k) => !(k in env));
618
+ add({
619
+ name: ".env",
620
+ ok: missing.length === 0,
621
+ detail: missing.length === 0 ? "matches .env.example" : `missing ${missing.join(", ")}`,
622
+ fix: "Copy the missing keys from .env.example"
623
+ });
624
+ }
625
+ const pkg = JSON.parse(readFileSync(join(project.root, "package.json"), "utf8"));
626
+ const installed = {
627
+ ...pkg.dependencies,
628
+ ...pkg.devDependencies
629
+ };
630
+ const absent = Object.entries(PACKAGES).flatMap(([feature, pkgs]) => project.has(feature) ? pkgs : []).filter((p) => !(p in installed));
631
+ add({
632
+ name: "tatara.json",
633
+ ok: absent.length === 0,
634
+ detail: absent.length === 0 ? "matches package.json" : `package.json lacks ${absent.join(", ")}`,
635
+ fix: "Reinstall the missing packages"
636
+ });
637
+ const violations = await check(project);
638
+ add({
639
+ name: "layout",
640
+ ok: violations.length === 0,
641
+ detail: violations.length === 0 ? "clean" : `${violations.length} violation(s)`,
642
+ fix: "Run `tatara check` for details"
643
+ });
644
+ return checks;
645
+ }
646
+ function printDoctor(checks) {
647
+ for (const c of checks) {
648
+ const mark = c.ok ? pc.green("✔") : pc.red("✖");
649
+ console.log(`${mark} ${c.name.padEnd(12)} ${c.detail}${c.ok || !c.fix ? "" : pc.dim(` → ${c.fix}`)}`);
650
+ }
651
+ }
652
+ //#endregion
653
+ //#region src/paths.ts
654
+ /** Package root: one level above both src/ (dev) and dist/ (published, bundled). */
655
+ const PACKAGE_ROOT = resolve(import.meta.dirname, "..");
656
+ const TEMPLATES_DIR = resolve(PACKAGE_ROOT, "templates");
657
+ function cliVersion() {
658
+ return JSON.parse(readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf8")).version;
659
+ }
660
+ let versions;
661
+ /** Exact versions used in generated apps (templates/versions.json). */
662
+ function templateVersions() {
663
+ versions ??= JSON.parse(readFileSync(resolve(TEMPLATES_DIR, "versions.json"), "utf8"));
664
+ return versions;
665
+ }
666
+ function versionOf(pkg) {
667
+ if (pkg === "@tatara/cli") return process.env.TATARA_CLI_SPEC ?? `^${cliVersion()}`;
668
+ const version = templateVersions()[pkg];
669
+ if (!version) throw new Error(`templates/versions.json has no version for "${pkg}"`);
670
+ return version;
671
+ }
672
+ const authz = {
673
+ id: "authz",
674
+ kind: "addon",
675
+ label: "Permission system",
676
+ slots: [
677
+ {
678
+ file: "tests/helpers/auth.ts",
679
+ slot: "imports",
680
+ content: "import { applyTestAccess, type TestAccess } from \"#tests/helpers/authz\";"
681
+ },
682
+ {
683
+ file: "tests/helpers/auth.ts",
684
+ slot: "options",
685
+ content: "permissions?: TestAccess[\"permissions\"];\nsystemRoles?: TestAccess[\"systemRoles\"];"
686
+ },
687
+ {
688
+ file: "tests/helpers/auth.ts",
689
+ slot: "options",
690
+ when: ["better-auth"],
691
+ content: "orgId?: string;"
692
+ },
693
+ {
694
+ file: "tests/helpers/auth.ts",
695
+ slot: "as-user",
696
+ content: "await applyTestAccess(user.userId, options);"
697
+ },
698
+ {
699
+ file: "AGENTS.md",
700
+ slot: "sections",
701
+ content: `## Permissions (authz)
702
+
703
+ - Permissions are \`resource:action\` (optionally scoped \`own | team | org\`, where a wider scope
704
+ satisfies a narrower one). Each module declares its resources and the grants built-in roles get
705
+ in \`permissions/<name>.permissions.ts\` (\`definePermissions\`); \`tatara sync\` merges them into a
706
+ typed catalog.
707
+ - Guard routes with \`middleware: [requirePermission("project:read")]\` from \`#core/authz/middleware\`
708
+ and declare \`security: [{ bearerAuth: ["project:read"] }]\`. For scoped actions, call
709
+ \`resolveScope(principal, "doc:read")\` in the service and filter rows by the returned scope.
710
+ - Effective permissions = role grants (assigned roles + built-in roles whose key matches an
711
+ identity-provider role) + grant overlays − deny overlays. Deny always wins.
712
+ - Custom roles and grant overlays can never grant \`role\`, \`member\` or \`audit_log\`.
713
+ - Built-in roles live in \`src/core/authz/system-roles.ts\`. Run \`__PM_EXEC__ tatara run authz:sync-permissions\`
714
+ on every deploy (it is idempotent); \`db:seed\` runs it locally.
715
+ - In tests: \`asUser({ permissions: ["project:read"] })\` or \`asUser({ systemRoles: ["admin"] })\`.
716
+ `
717
+ }
718
+ ]
719
+ };
720
+ const jobs = {
721
+ id: "jobs",
722
+ kind: "addon",
723
+ label: "Jobs + cron (BullMQ)",
724
+ dependencies: [
725
+ "bullmq",
726
+ "ioredis",
727
+ "croner"
728
+ ],
729
+ allowBuilds: ["msgpackr-extract"],
730
+ scripts: {
731
+ "start:worker": "node dist/apps/worker/index.mjs",
732
+ "start:cron": "node dist/apps/cron/index.mjs"
733
+ },
734
+ env: [
735
+ {
736
+ key: "REDIS_PORT",
737
+ schema: "",
738
+ example: "6379",
739
+ composeOnly: true,
740
+ comment: "Host port for local Redis"
741
+ },
742
+ {
743
+ key: "REDIS_URL",
744
+ schema: "z.url()",
745
+ example: "redis://localhost:6379/0"
746
+ },
747
+ {
748
+ key: "JOBS_PREFIX",
749
+ schema: "z.string().default(\"bull\")",
750
+ example: "bull",
751
+ comment: "Redis key prefix for queues"
752
+ },
753
+ {
754
+ key: "WORKER_CONCURRENCY",
755
+ schema: "z.coerce.number().int().positive().default(5)",
756
+ example: "5"
757
+ }
758
+ ],
759
+ compose: { redis: {
760
+ image: "redis:8-alpine",
761
+ ports: ["${REDIS_PORT:-6379}:6379"],
762
+ healthcheck: {
763
+ test: [
764
+ "CMD",
765
+ "redis-cli",
766
+ "ping"
767
+ ],
768
+ interval: "2s",
769
+ timeout: "3s",
770
+ retries: 30
771
+ }
772
+ } },
773
+ slots: [
774
+ {
775
+ file: "tsdown.config.ts",
776
+ slot: "entries",
777
+ content: "\"apps/worker/index\": \"src/apps/worker/index.ts\",\n\"apps/cron/index\": \"src/apps/cron/index.ts\","
778
+ },
779
+ {
780
+ file: "tests/setup.ts",
781
+ slot: "setup",
782
+ content: [
783
+ "",
784
+ "// One Redis database per worker.",
785
+ "if (process.env.REDIS_URL) {",
786
+ " const redisUrl = new URL(process.env.REDIS_URL);",
787
+ " redisUrl.pathname = `/${process.env.VITEST_POOL_ID ?? \"1\"}`;",
788
+ " process.env.REDIS_URL = redisUrl.toString();",
789
+ "}"
790
+ ].join("\n")
791
+ },
792
+ {
793
+ file: "AGENTS.md",
794
+ slot: "sections",
795
+ content: `## Jobs and schedules
796
+
797
+ - Background work: \`enqueue(topic, payload)\` from \`#core/jobs/queue\`; a module handles it in
798
+ \`consumers/<name>.ts\` (\`defineConsumer({ topic, schema, handle })\`). Payloads are validated by the
799
+ consumer's zod schema. Handlers must be idempotent: jobs retry.
800
+ - Recurring work: \`schedules/<name>.ts\` (\`defineSchedule({ name, cron, run })\`). A Redis lock makes
801
+ each tick run once across every cron process.
802
+ - Processes: \`apps/worker\` runs consumers, \`apps/cron\` runs schedules. \`dev\` starts both.
803
+ `
804
+ }
805
+ ]
806
+ };
807
+ const admin = {
808
+ id: "admin",
809
+ kind: "addon",
810
+ label: "Admin API app",
811
+ scripts: { "start:admin": "node dist/apps/admin/server.mjs" },
812
+ env: [{
813
+ key: "ADMIN_PORT",
814
+ schema: "z.coerce.number().int().default(3001)",
815
+ example: "3001"
816
+ }],
817
+ slots: [
818
+ {
819
+ file: "src/core/http/routes.ts",
820
+ slot: "app-names",
821
+ content: "\"admin\","
822
+ },
823
+ {
824
+ file: "tsdown.config.ts",
825
+ slot: "entries",
826
+ content: "\"apps/admin/server\": \"src/apps/admin/server.ts\","
827
+ },
828
+ {
829
+ file: "src/core/http/app.ts",
830
+ slot: "imports",
831
+ content: "import { requireRole } from \"#core/auth/middleware\";"
832
+ },
833
+ {
834
+ file: "src/core/http/app.ts",
835
+ slot: "routes",
836
+ order: 5,
837
+ content: "if (name === \"admin\") app.use(`${API_PREFIX}/*`, requireRole(\"admin\"));"
838
+ },
839
+ {
840
+ file: "tests/helpers/api.ts",
841
+ slot: "imports",
842
+ content: "import { app as adminApp } from \"#apps/admin/app\";"
843
+ },
844
+ {
845
+ file: "tests/helpers/api.ts",
846
+ slot: "exports",
847
+ content: "\n/** Calls the admin app in-process. */\nexport const adminApi = client(adminApp);"
848
+ },
849
+ {
850
+ file: "AGENTS.md",
851
+ slot: "sections",
852
+ content: `## Admin app
853
+
854
+ - \`apps/admin\` is a second HTTP app on \`ADMIN_PORT\`. Routes with \`defineRoutes({ app: "admin", … })\`
855
+ are served only there, and every \`/api\` route on it requires the \`admin\` role.
856
+ - Test it with \`adminApi\` from \`#tests/helpers/api\`.
857
+ `
858
+ }
859
+ ]
860
+ };
861
+ const storage = {
862
+ id: "storage",
863
+ kind: "addon",
864
+ label: "File storage",
865
+ dependencies: ["@aws-sdk/client-s3", "@aws-sdk/s3-request-presigner"],
866
+ env: [
867
+ {
868
+ key: "STORAGE_DRIVER",
869
+ schema: "localDefault(z.enum([\"local\", \"s3\"]), \"local\")",
870
+ example: "s3",
871
+ test: "local"
872
+ },
873
+ {
874
+ key: "STORAGE_LOCAL_DIR",
875
+ schema: "z.string().default(\".storage\")",
876
+ example: ".storage"
877
+ },
878
+ {
879
+ key: "S3_ENDPOINT",
880
+ schema: "z.url().optional()",
881
+ example: "http://localhost:9000",
882
+ comment: "Empty for AWS S3"
883
+ },
884
+ {
885
+ key: "S3_REGION",
886
+ schema: "z.string().default(\"us-east-1\")",
887
+ example: "us-east-1"
888
+ },
889
+ {
890
+ key: "S3_BUCKET",
891
+ schema: "z.string().default(\"__APP_NAME__\")",
892
+ example: "__APP_NAME__"
893
+ },
894
+ {
895
+ key: "S3_ACCESS_KEY_ID",
896
+ schema: "z.string().optional()",
897
+ example: "tatara"
898
+ },
899
+ {
900
+ key: "S3_SECRET_ACCESS_KEY",
901
+ schema: "z.string().optional()",
902
+ example: "tatara-secret"
903
+ },
904
+ {
905
+ key: "S3_FORCE_PATH_STYLE",
906
+ schema: "z.stringbool().default(true)",
907
+ example: "true"
908
+ },
909
+ {
910
+ key: "STORAGE_PORT",
911
+ schema: "",
912
+ example: "9000",
913
+ composeOnly: true
914
+ },
915
+ {
916
+ key: "STORAGE_CONSOLE_PORT",
917
+ schema: "",
918
+ example: "9001",
919
+ composeOnly: true
920
+ }
921
+ ],
922
+ compose: { storage: {
923
+ image: "rustfs/rustfs:1.0.1",
924
+ environment: {
925
+ RUSTFS_ACCESS_KEY: "tatara",
926
+ RUSTFS_SECRET_KEY: "tatara-secret",
927
+ RUSTFS_CONSOLE_ENABLE: "true"
928
+ },
929
+ ports: ["${STORAGE_PORT:-9000}:9000", "${STORAGE_CONSOLE_PORT:-9001}:9001"],
930
+ volumes: ["storage-data:/data"],
931
+ healthcheck: {
932
+ test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9000/health || exit 1"],
933
+ interval: "2s",
934
+ timeout: "3s",
935
+ retries: 30
936
+ }
937
+ } },
938
+ composeVolumes: ["storage-data"],
939
+ slots: [
940
+ {
941
+ file: "Dockerfile",
942
+ slot: "docker-runtime",
943
+ order: 50,
944
+ content: "RUN mkdir -p .storage && chown node:node .storage"
945
+ },
946
+ {
947
+ file: ".gitignore",
948
+ slot: "ignore",
949
+ content: ".storage/"
950
+ },
951
+ {
952
+ file: "AGENTS.md",
953
+ slot: "sections",
954
+ content: `## Storage
955
+
956
+ - Use \`storage\` from \`#core/storage/index\` (\`put\`, \`get\`, \`delete\`, \`url\`). \`STORAGE_DRIVER=s3\` works with
957
+ any S3-compatible store (RustFS locally, AWS S3, R2); \`local\` writes to \`STORAGE_LOCAL_DIR\` (tests).
958
+ - \`tatara run storage:check\` creates the bucket and does a round-trip.
959
+ `
960
+ }
961
+ ]
962
+ };
963
+ const email = {
964
+ id: "email",
965
+ kind: "addon",
966
+ label: "Email",
967
+ dependencies: ["nodemailer"],
968
+ devDependencies: ["@types/nodemailer"],
969
+ env: [
970
+ {
971
+ key: "EMAIL_DRIVER",
972
+ schema: "localDefault(z.enum([\"smtp\", \"console\"]), \"console\")",
973
+ example: "smtp",
974
+ test: "console"
975
+ },
976
+ {
977
+ key: "SMTP_URL",
978
+ schema: "localDefault(z.string(), \"smtp://localhost:1025\")",
979
+ example: "smtp://localhost:1025"
980
+ },
981
+ {
982
+ key: "EMAIL_FROM",
983
+ schema: "z.string().default(\"__APP_NAME__ <no-reply@example.com>\")",
984
+ example: "__APP_NAME__ <no-reply@example.com>"
985
+ },
986
+ {
987
+ key: "MAILPIT_SMTP_PORT",
988
+ schema: "",
989
+ example: "1025",
990
+ composeOnly: true
991
+ },
992
+ {
993
+ key: "MAILPIT_UI_PORT",
994
+ schema: "",
995
+ example: "8025",
996
+ composeOnly: true
997
+ }
998
+ ],
999
+ compose: { mailpit: {
1000
+ image: "axllent/mailpit:v1.31",
1001
+ ports: ["${MAILPIT_SMTP_PORT:-1025}:1025", "${MAILPIT_UI_PORT:-8025}:8025"],
1002
+ healthcheck: {
1003
+ test: [
1004
+ "CMD",
1005
+ "/mailpit",
1006
+ "readyz"
1007
+ ],
1008
+ interval: "2s",
1009
+ timeout: "3s",
1010
+ retries: 30
1011
+ }
1012
+ } },
1013
+ slots: [{
1014
+ file: "AGENTS.md",
1015
+ slot: "sections",
1016
+ content: `## Email
1017
+
1018
+ - \`sendEmail({ to, subject, text, html? })\` from \`#core/email/send\`. Locally it goes to Mailpit
1019
+ (UI on \`MAILPIT_UI_PORT\`); tests use the console driver.
1020
+ `
1021
+ }]
1022
+ };
1023
+ const i18n = {
1024
+ id: "i18n",
1025
+ kind: "addon",
1026
+ label: "i18n",
1027
+ env: [{
1028
+ key: "DEFAULT_LANGUAGE",
1029
+ schema: "z.enum([\"en\", \"ar\"]).default(\"en\")",
1030
+ example: "en"
1031
+ }],
1032
+ slots: [
1033
+ {
1034
+ file: "src/core/http/app.ts",
1035
+ slot: "imports",
1036
+ content: "import { detectLanguage } from \"#core/i18n/index\";"
1037
+ },
1038
+ {
1039
+ file: "src/core/http/app.ts",
1040
+ slot: "middleware",
1041
+ order: 10,
1042
+ content: "app.use(detectLanguage);"
1043
+ },
1044
+ {
1045
+ file: "src/core/http/middleware/error-handler.ts",
1046
+ slot: "imports",
1047
+ content: "import { t } from \"#core/i18n/index\";"
1048
+ },
1049
+ {
1050
+ file: "src/core/http/middleware/error-handler.ts",
1051
+ slot: "error-message",
1052
+ content: "if (err.translatable) message = t(c.get(\"language\"), `errors.${err.code}`, message);"
1053
+ },
1054
+ {
1055
+ file: "AGENTS.md",
1056
+ slot: "sections",
1057
+ content: `## i18n
1058
+
1059
+ - Language comes from \`?lang\`, the \`lang\` cookie, then \`Accept-Language\` (\`c.var.language\`).
1060
+ - Strings live in \`src/locales/<lang>.json\`; use \`t(language, "errors.NOT_FOUND")\`. Error responses are
1061
+ translated by code (\`errors.<CODE>\`), so add a key when you add an error code.
1062
+ `
1063
+ }
1064
+ ]
1065
+ };
1066
+ /** Provider-neutral auth: the principal contract, guards and GET /api/me. */
1067
+ const auth = {
1068
+ id: "auth",
1069
+ kind: "auth",
1070
+ label: "Auth (shared)",
1071
+ slots: [
1072
+ {
1073
+ file: "src/core/http/app.ts",
1074
+ slot: "imports",
1075
+ content: "import { attachPrincipal } from \"#core/auth/principal\";"
1076
+ },
1077
+ {
1078
+ file: "src/core/http/app.ts",
1079
+ slot: "middleware",
1080
+ order: 50,
1081
+ content: "app.use(attachPrincipal);"
1082
+ },
1083
+ {
1084
+ file: "src/core/http/app.ts",
1085
+ slot: "routes",
1086
+ order: 0,
1087
+ content: [
1088
+ "app.openAPIRegistry.registerComponent(\"securitySchemes\", \"bearerAuth\", {",
1089
+ " type: \"http\",",
1090
+ " scheme: \"bearer\",",
1091
+ "});"
1092
+ ].join("\n")
1093
+ },
1094
+ {
1095
+ file: "AGENTS.md",
1096
+ slot: "sections",
1097
+ content: `## Auth
1098
+
1099
+ - \`c.var.principal\` is set for every request with valid credentials: \`{ userId, orgId, roles, kind }\`.
1100
+ - Protect routes with \`middleware: [requireAuth()]\` or \`requireRole("admin")\` from
1101
+ \`#core/auth/middleware\`, and declare \`security: [{ bearerAuth: [] }]\` so the route is
1102
+ documented as protected. Read the caller with \`currentPrincipal(c)\`.
1103
+ - In tests, \`asUser()\` from \`#tests/helpers/auth\` returns headers for a fresh authenticated user.
1104
+ `
1105
+ }
1106
+ ]
1107
+ };
1108
+ const BETTER_AUTH_AGENTS = `### better-auth
1109
+
1110
+ - Endpoints are served at \`/api/auth/*\` and listed in \`/openapi.json\` and \`/docs\` under the Auth tag
1111
+ (schemas prefixed \`Auth\`). better-auth's own reference is also at \`/api/auth/reference\`.
1112
+ - Sessions work as cookies (browsers) and as \`Authorization: Bearer <session token>\` (mobile, APIs).
1113
+ - Outside production, every email and SMS is kept in an in-memory outbox:
1114
+ \`GET /api/dev/outbox?to=<address>\` returns the latest messages, including sign-in codes.
1115
+ - To send real SMS, implement \`SmsSender\` in \`src/core/auth/sms.ts\`.
1116
+ `;
1117
+ const drizzlePg = ["drizzle", "postgres"];
1118
+ const drizzleMy = ["drizzle", "mysql"];
1119
+ const betterAuth = {
1120
+ id: "better-auth",
1121
+ kind: "auth",
1122
+ label: "better-auth",
1123
+ dependencies: ["better-auth"],
1124
+ env: [
1125
+ {
1126
+ key: "BETTER_AUTH_SECRET",
1127
+ schema: "z.string().min(32)",
1128
+ example: "replace-with-a-long-random-secret-at-least-32-chars",
1129
+ secret: true
1130
+ },
1131
+ {
1132
+ key: "DEV_OUTBOX",
1133
+ schema: "z.stringbool().default(false)",
1134
+ example: "false",
1135
+ dev: "true",
1136
+ test: "true",
1137
+ comment: "Keep emails/SMS (with sign-in codes) readable at /api/dev/outbox. Development only."
1138
+ },
1139
+ {
1140
+ key: "SEED_ADMIN_EMAIL",
1141
+ schema: "z.email().default(\"admin@example.com\")",
1142
+ example: "admin@example.com",
1143
+ comment: "Development admin created by `db:seed` (never in production)"
1144
+ }
1145
+ ],
1146
+ slots: [
1147
+ {
1148
+ file: "src/core/http/app.ts",
1149
+ slot: "imports",
1150
+ content: "import { auth } from \"#core/auth/auth\";"
1151
+ },
1152
+ {
1153
+ file: "src/core/http/app.ts",
1154
+ slot: "imports",
1155
+ content: "import { outboxEnabled, readOutbox } from \"#core/auth/delivery\";"
1156
+ },
1157
+ {
1158
+ file: "src/core/http/app.ts",
1159
+ slot: "imports",
1160
+ content: "import { mergeAuthOpenApi } from \"#core/auth/openapi\";"
1161
+ },
1162
+ {
1163
+ file: "src/core/http/app.ts",
1164
+ slot: "openapi",
1165
+ content: "if (name === \"api\") await mergeAuthOpenApi(document, `${API_PREFIX}/auth`);"
1166
+ },
1167
+ {
1168
+ file: "src/core/http/app.ts",
1169
+ slot: "routes",
1170
+ order: 1,
1171
+ content: [
1172
+ "if (name === \"api\") {",
1173
+ " app.on([\"GET\", \"POST\"], `${API_PREFIX}/auth/*`, (c) => auth.handler(c.req.raw));",
1174
+ " if (outboxEnabled()) {",
1175
+ " app.get(`${API_PREFIX}/dev/outbox`, (c) => c.json({ messages: readOutbox(c.req.query(\"to\")) }));",
1176
+ " }",
1177
+ "}"
1178
+ ].join("\n")
1179
+ },
1180
+ {
1181
+ file: "src/core/auth/delivery.ts",
1182
+ slot: "email-delivery",
1183
+ unless: ["email"],
1184
+ content: "logger.info({ to: message.to, subject: message.subject }, \"email not sent: no email provider configured\");"
1185
+ },
1186
+ {
1187
+ file: "src/core/auth/delivery.ts",
1188
+ slot: "imports",
1189
+ unless: ["email"],
1190
+ content: "import { logger } from \"#core/logging/logger\";"
1191
+ },
1192
+ {
1193
+ file: "src/core/auth/delivery.ts",
1194
+ slot: "imports",
1195
+ when: ["email"],
1196
+ content: "import { sendEmail } from \"#core/email/send\";"
1197
+ },
1198
+ {
1199
+ file: "src/core/auth/delivery.ts",
1200
+ slot: "email-delivery",
1201
+ when: ["email"],
1202
+ content: "await sendEmail({ to: message.to, subject: message.subject, text: message.text });"
1203
+ },
1204
+ {
1205
+ file: "AGENTS.md",
1206
+ slot: "sections",
1207
+ content: BETTER_AUTH_AGENTS
1208
+ }
1209
+ ]
1210
+ };
1211
+ const authEmailOtp = {
1212
+ id: "auth-email-otp",
1213
+ kind: "auth-method",
1214
+ label: "Email OTP",
1215
+ slots: [
1216
+ {
1217
+ file: "src/core/auth/auth.ts",
1218
+ slot: "imports",
1219
+ content: "import { deliverEmail } from \"#core/auth/delivery\";"
1220
+ },
1221
+ {
1222
+ file: "src/core/auth/auth.ts",
1223
+ slot: "imports",
1224
+ content: "import { emailOTP } from \"better-auth/plugins\";"
1225
+ },
1226
+ {
1227
+ file: "src/core/auth/auth.ts",
1228
+ slot: "plugins",
1229
+ content: [
1230
+ "emailOTP({",
1231
+ " async sendVerificationOTP({ email, otp, type }) {",
1232
+ " await deliverEmail({",
1233
+ " to: email,",
1234
+ " subject: \"Your sign-in code\",",
1235
+ " text: `Your ${type} code is ${otp}. It expires in 5 minutes.`,",
1236
+ " code: otp,",
1237
+ " });",
1238
+ " },",
1239
+ "}),"
1240
+ ].join("\n")
1241
+ }
1242
+ ]
1243
+ };
1244
+ const phoneFields = [
1245
+ {
1246
+ file: "src/core/auth/db/auth.schema.ts",
1247
+ slot: "user-fields",
1248
+ when: drizzlePg,
1249
+ content: "phoneNumber: text().unique(),\nphoneNumberVerified: boolean(),"
1250
+ },
1251
+ {
1252
+ file: "src/core/auth/db/auth.schema.ts",
1253
+ slot: "user-fields",
1254
+ when: drizzleMy,
1255
+ content: "phoneNumber: varchar({ length: 32 }).unique(),\nphoneNumberVerified: boolean(),"
1256
+ },
1257
+ {
1258
+ file: "prisma/models/auth.prisma",
1259
+ slot: "user-fields",
1260
+ content: ["phoneNumber String? @unique @map(\"phone_number\") @db.VarChar(32)", "phoneNumberVerified Boolean? @map(\"phone_number_verified\")"].join("\n")
1261
+ }
1262
+ ];
1263
+ const authPhoneOtp = {
1264
+ id: "auth-phone-otp",
1265
+ kind: "auth-method",
1266
+ label: "Phone OTP",
1267
+ slots: [
1268
+ {
1269
+ file: "src/core/auth/auth.ts",
1270
+ slot: "imports",
1271
+ content: "import { deliverSms } from \"#core/auth/delivery\";"
1272
+ },
1273
+ {
1274
+ file: "src/core/auth/auth.ts",
1275
+ slot: "imports",
1276
+ content: "import { phoneNumber } from \"better-auth/plugins\";"
1277
+ },
1278
+ {
1279
+ file: "src/core/auth/auth.ts",
1280
+ slot: "plugins",
1281
+ content: [
1282
+ "phoneNumber({",
1283
+ " async sendOTP({ phoneNumber: to, code }) {",
1284
+ " await deliverSms({ to, text: `Your sign-in code is ${code}`, code });",
1285
+ " },",
1286
+ " signUpOnVerification: {",
1287
+ " getTempEmail: (phone) => `${phone.replace(/\\D/g, \"\")}@phone.invalid`,",
1288
+ " getTempName: (phone) => phone,",
1289
+ " },",
1290
+ "}),"
1291
+ ].join("\n")
1292
+ },
1293
+ ...phoneFields
1294
+ ]
1295
+ };
1296
+ const authPassword = {
1297
+ id: "auth-password",
1298
+ kind: "auth-method",
1299
+ label: "Email + password",
1300
+ env: [{
1301
+ key: "SEED_ADMIN_PASSWORD",
1302
+ schema: "z.string().min(10).optional()",
1303
+ example: "",
1304
+ secret: true,
1305
+ comment: "Password for the development admin (generated by tatara new)"
1306
+ }],
1307
+ slots: [
1308
+ {
1309
+ file: "src/core/auth/auth.ts",
1310
+ slot: "imports",
1311
+ content: "import { deliverEmail } from \"#core/auth/delivery\";"
1312
+ },
1313
+ {
1314
+ file: "src/core/auth/auth.ts",
1315
+ slot: "options",
1316
+ content: [
1317
+ "emailAndPassword: {",
1318
+ " enabled: true,",
1319
+ " minPasswordLength: 10,",
1320
+ " async sendResetPassword({ user, url }) {",
1321
+ " await deliverEmail({ to: user.email, subject: \"Reset your password\", text: `Reset your password: ${url}` });",
1322
+ " },",
1323
+ "},"
1324
+ ].join("\n")
1325
+ },
1326
+ {
1327
+ file: "src/core/auth/db/seed.ts",
1328
+ slot: "seed-after-user",
1329
+ content: [
1330
+ "if (env.SEED_ADMIN_PASSWORD) {",
1331
+ " await ctx.internalAdapter.linkAccount({",
1332
+ " userId: user.id,",
1333
+ " providerId: \"credential\",",
1334
+ " accountId: user.id,",
1335
+ " password: await ctx.password.hash(env.SEED_ADMIN_PASSWORD),",
1336
+ " });",
1337
+ "}"
1338
+ ].join("\n")
1339
+ }
1340
+ ]
1341
+ };
1342
+ const adminFields = [
1343
+ {
1344
+ file: "src/core/auth/db/auth.schema.ts",
1345
+ slot: "user-fields",
1346
+ when: drizzlePg,
1347
+ content: "role: text(),\nbanned: boolean().default(false),\nbanReason: text(),\nbanExpires: timestamp({ withTimezone: true, mode: \"date\" }),"
1348
+ },
1349
+ {
1350
+ file: "src/core/auth/db/auth.schema.ts",
1351
+ slot: "user-fields",
1352
+ when: drizzleMy,
1353
+ content: "role: varchar({ length: 255 }),\nbanned: boolean().default(false),\nbanReason: text(),\nbanExpires: timestamp({ fsp: 3, mode: \"date\" }),"
1354
+ },
1355
+ {
1356
+ file: "src/core/auth/db/auth.schema.ts",
1357
+ slot: "session-fields",
1358
+ when: drizzlePg,
1359
+ content: "activeOrganizationId: text(),\nimpersonatedBy: text(),"
1360
+ },
1361
+ {
1362
+ file: "src/core/auth/db/auth.schema.ts",
1363
+ slot: "session-fields",
1364
+ when: drizzleMy,
1365
+ content: "activeOrganizationId: varchar({ length: 36 }),\nimpersonatedBy: varchar({ length: 36 }),"
1366
+ },
1367
+ {
1368
+ file: "prisma/models/auth.prisma",
1369
+ slot: "user-fields",
1370
+ content: [
1371
+ "role String? @db.VarChar(255)",
1372
+ "banned Boolean? @default(false)",
1373
+ "banReason String? @map(\"ban_reason\") @db.Text",
1374
+ "banExpires DateTime? @map(\"ban_expires\")"
1375
+ ].join("\n")
1376
+ },
1377
+ {
1378
+ file: "prisma/models/auth.prisma",
1379
+ slot: "user-relations",
1380
+ content: "members Member[]\ninvitations Invitation[]"
1381
+ },
1382
+ {
1383
+ file: "prisma/models/auth.prisma",
1384
+ slot: "session-fields",
1385
+ content: ["activeOrganizationId String? @map(\"active_organization_id\") @db.VarChar(36)", "impersonatedBy String? @map(\"impersonated_by\") @db.VarChar(36)"].join("\n")
1386
+ },
1387
+ {
1388
+ file: "src/core/auth/adapter.ts",
1389
+ slot: "imports",
1390
+ when: ["drizzle"],
1391
+ content: "import * as orgSchema from \"#core/auth/db/organization.schema\";"
1392
+ },
1393
+ {
1394
+ file: "src/core/auth/adapter.ts",
1395
+ slot: "adapter-schema",
1396
+ when: ["drizzle"],
1397
+ content: "organization: orgSchema.organizations,\nmember: orgSchema.members,\ninvitation: orgSchema.invitations,"
1398
+ }
1399
+ ];
1400
+ const authOrgs = {
1401
+ id: "auth-orgs",
1402
+ kind: "auth-method",
1403
+ label: "Organizations + roles",
1404
+ slots: [
1405
+ {
1406
+ file: "src/core/auth/auth.ts",
1407
+ slot: "imports",
1408
+ content: "import { admin, organization } from \"better-auth/plugins\";"
1409
+ },
1410
+ {
1411
+ file: "src/core/auth/auth.ts",
1412
+ slot: "plugins",
1413
+ unless: ["authz"],
1414
+ content: "organization(),\nadmin(),",
1415
+ order: 900
1416
+ },
1417
+ {
1418
+ file: "src/core/auth/auth.ts",
1419
+ slot: "plugins",
1420
+ when: ["authz"],
1421
+ content: [
1422
+ "organization({",
1423
+ " organizationHooks: {",
1424
+ " // authz rows outlive better-auth's member row; without this, re-adding restores old roles",
1425
+ " afterRemoveMember: async ({ member }) => {",
1426
+ " await removeMemberAccess(member.organizationId, member.userId);",
1427
+ " },",
1428
+ " },",
1429
+ "}),",
1430
+ "admin(),"
1431
+ ].join("\n"),
1432
+ order: 900
1433
+ },
1434
+ {
1435
+ file: "src/core/auth/auth.ts",
1436
+ slot: "imports",
1437
+ when: ["authz"],
1438
+ content: "import { removeMemberAccess } from \"#core/authz/services/members\";"
1439
+ },
1440
+ {
1441
+ file: "tests/helpers/auth.ts",
1442
+ slot: "user-fields",
1443
+ content: "...(options.roles ? { role: options.roles.join(\",\") } : {}),"
1444
+ },
1445
+ {
1446
+ file: "src/core/auth/db/seed.ts",
1447
+ slot: "seed-user-fields",
1448
+ content: "role: \"admin\","
1449
+ },
1450
+ ...adminFields
1451
+ ]
1452
+ };
1453
+ const keycloak = {
1454
+ id: "keycloak",
1455
+ kind: "auth",
1456
+ label: "Keycloak",
1457
+ dependencies: ["jose"],
1458
+ env: [
1459
+ {
1460
+ key: "KEYCLOAK_PORT",
1461
+ schema: "",
1462
+ example: "8080",
1463
+ composeOnly: true,
1464
+ comment: "Host port for the local Keycloak container"
1465
+ },
1466
+ {
1467
+ key: "KEYCLOAK_URL",
1468
+ schema: "localDefault(z.url(), \"http://localhost:8080\")",
1469
+ example: "http://localhost:8080"
1470
+ },
1471
+ {
1472
+ key: "KEYCLOAK_REALM",
1473
+ schema: "z.string().default(\"__APP_NAME__\")",
1474
+ example: "__APP_NAME__"
1475
+ },
1476
+ {
1477
+ key: "KEYCLOAK_AUDIENCE",
1478
+ schema: "z.string().default(\"__APP_NAME__-api\")",
1479
+ example: "__APP_NAME__-api",
1480
+ comment: "Tokens must carry this audience"
1481
+ }
1482
+ ],
1483
+ compose: { keycloak: {
1484
+ image: "quay.io/keycloak/keycloak:26.8.1",
1485
+ command: ["start-dev", "--import-realm"],
1486
+ environment: {
1487
+ KC_BOOTSTRAP_ADMIN_USERNAME: "admin",
1488
+ KC_BOOTSTRAP_ADMIN_PASSWORD: "admin",
1489
+ KC_HEALTH_ENABLED: "true",
1490
+ KC_HOSTNAME: "http://localhost:${KEYCLOAK_PORT:-8080}"
1491
+ },
1492
+ ports: ["${KEYCLOAK_PORT:-8080}:8080"],
1493
+ volumes: ["./keycloak:/opt/keycloak/data/import:ro"],
1494
+ healthcheck: {
1495
+ test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q '200 OK' <&3"],
1496
+ interval: "5s",
1497
+ timeout: "5s",
1498
+ retries: 60,
1499
+ start_period: "10s"
1500
+ }
1501
+ } },
1502
+ slots: [
1503
+ {
1504
+ file: "src/core/auth/users.ts",
1505
+ slot: "upsert",
1506
+ when: ["drizzle", "postgres"],
1507
+ content: "await db\n .insert(users)\n .values(values)\n .onConflictDoUpdate({ target: users.id, set: { email: values.email, name: values.name, updatedAt: new Date() } });"
1508
+ },
1509
+ {
1510
+ file: "src/core/auth/users.ts",
1511
+ slot: "upsert",
1512
+ when: ["drizzle", "mysql"],
1513
+ content: "await db\n .insert(users)\n .values(values)\n .onDuplicateKeyUpdate({ set: { email: values.email, name: values.name } });"
1514
+ },
1515
+ {
1516
+ file: "AGENTS.md",
1517
+ slot: "sections",
1518
+ content: `### Keycloak
1519
+
1520
+ - The API is a resource server: it verifies Keycloak access tokens (signature, issuer,
1521
+ audience \`__APP_NAME__-api\`, expiry). Login happens in the frontend via OIDC (PKCE) against
1522
+ the \`__APP_NAME__-web\` client.
1523
+ - Roles come from the token's \`roles\` claim (realm roles) and client roles for the API.
1524
+ - Local dev realm: \`keycloak/realm.json\` (users alice/alice with admin, bob/bob).
1525
+ \`__PM_EXEC__ tatara run keycloak:token --user alice\` prints a token for curl.
1526
+ - Tests sign tokens with a local key; see \`#tests/helpers/auth\`.
1527
+ `
1528
+ }
1529
+ ]
1530
+ };
1531
+ //#endregion
1532
+ //#region src/features/base.ts
1533
+ const base = {
1534
+ id: "base",
1535
+ kind: "base",
1536
+ label: "Hono API",
1537
+ dependencies: [
1538
+ "hono",
1539
+ "@hono/node-server",
1540
+ "@hono/zod-openapi",
1541
+ "@scalar/hono-api-reference",
1542
+ "zod",
1543
+ "@t3-oss/env-core",
1544
+ "pino"
1545
+ ],
1546
+ devDependencies: [
1547
+ "@tatara/cli",
1548
+ "typescript",
1549
+ "@types/node",
1550
+ "vitest",
1551
+ "tsdown",
1552
+ "@biomejs/biome",
1553
+ "pino-pretty"
1554
+ ],
1555
+ scripts: {
1556
+ dev: "tatara dev",
1557
+ "dev:down": "tatara dev --down",
1558
+ "dev:reset": "tatara dev --reset",
1559
+ build: "tatara sync && tsdown",
1560
+ start: "node dist/apps/api/server.mjs",
1561
+ lint: "biome check . && tatara check",
1562
+ format: "biome check --write .",
1563
+ typecheck: "tatara sync && tsc",
1564
+ test: "tatara sync && vitest run",
1565
+ "test:watch": "tatara sync && vitest",
1566
+ verify: "tatara verify"
1567
+ },
1568
+ env: [
1569
+ {
1570
+ key: "NODE_ENV",
1571
+ schema: "z.enum([\"development\", \"test\", \"production\"])",
1572
+ example: "development",
1573
+ test: "test"
1574
+ },
1575
+ {
1576
+ key: "PORT",
1577
+ schema: "z.coerce.number().int().default(3000)",
1578
+ example: "3000"
1579
+ },
1580
+ {
1581
+ key: "LOG_LEVEL",
1582
+ schema: "z.enum([\"fatal\", \"error\", \"warn\", \"info\", \"debug\", \"trace\", \"silent\"]).default(\"info\")",
1583
+ example: "info",
1584
+ test: "silent"
1585
+ },
1586
+ {
1587
+ key: "APP_URL",
1588
+ schema: "localDefault(z.url(), \"http://localhost:3000\")",
1589
+ example: "http://localhost:3000",
1590
+ comment: "Public URL of the API"
1591
+ },
1592
+ {
1593
+ key: "CORS_ORIGINS",
1594
+ schema: "localDefault(z.string(), \"http://localhost:5173\").transform((v) => v.split(\",\").map((o) => o.trim()).filter(Boolean))",
1595
+ example: "http://localhost:5173",
1596
+ comment: "Comma-separated browser origins allowed to call the API"
1597
+ },
1598
+ {
1599
+ key: "BODY_LIMIT_BYTES",
1600
+ schema: "z.coerce.number().int().positive().default(1_048_576)",
1601
+ example: "1048576"
1602
+ }
1603
+ ],
1604
+ entrypoints: [{
1605
+ name: "api",
1606
+ file: "server.ts",
1607
+ description: "HTTP API"
1608
+ }, {
1609
+ name: "cli",
1610
+ file: "index.ts",
1611
+ description: "App commands"
1612
+ }]
1613
+ };
1614
+ const pnpm = {
1615
+ id: "pm-pnpm",
1616
+ kind: "base",
1617
+ label: "pnpm",
1618
+ slots: [
1619
+ {
1620
+ file: "Dockerfile",
1621
+ slot: "docker-base",
1622
+ content: "RUN corepack enable"
1623
+ },
1624
+ {
1625
+ file: "Dockerfile",
1626
+ slot: "docker-build",
1627
+ content: [
1628
+ "COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./",
1629
+ "RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store --mount=type=cache,id=pnpm-cache,target=/root/.cache/pnpm pnpm install --frozen-lockfile",
1630
+ "COPY . .",
1631
+ "RUN pnpm run build"
1632
+ ].join("\n")
1633
+ },
1634
+ {
1635
+ file: "Dockerfile",
1636
+ slot: "docker-runtime",
1637
+ order: 0,
1638
+ content: [
1639
+ "COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./",
1640
+ "# cache mounts keep the package store out of the image",
1641
+ "RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store --mount=type=cache,id=pnpm-cache,target=/root/.cache/pnpm pnpm install --frozen-lockfile --prod",
1642
+ "COPY --from=build /app/dist ./dist"
1643
+ ].join("\n")
1644
+ },
1645
+ {
1646
+ file: ".github/workflows/ci.yml",
1647
+ slot: "ci-setup",
1648
+ content: [
1649
+ "- uses: pnpm/action-setup@v4",
1650
+ "- uses: actions/setup-node@v5",
1651
+ " with:",
1652
+ " node-version: 24",
1653
+ " cache: pnpm",
1654
+ "- run: pnpm install --frozen-lockfile"
1655
+ ].join("\n")
1656
+ }
1657
+ ]
1658
+ };
1659
+ const npm = {
1660
+ id: "pm-npm",
1661
+ kind: "base",
1662
+ label: "npm",
1663
+ slots: [
1664
+ {
1665
+ file: "Dockerfile",
1666
+ slot: "docker-build",
1667
+ content: [
1668
+ "COPY package.json package-lock.json ./",
1669
+ "RUN --mount=type=cache,target=/root/.npm npm ci",
1670
+ "COPY . .",
1671
+ "RUN npm run build"
1672
+ ].join("\n")
1673
+ },
1674
+ {
1675
+ file: "Dockerfile",
1676
+ slot: "docker-runtime",
1677
+ order: 0,
1678
+ content: [
1679
+ "COPY package.json package-lock.json ./",
1680
+ "RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev",
1681
+ "COPY --from=build /app/dist ./dist"
1682
+ ].join("\n")
1683
+ },
1684
+ {
1685
+ file: ".github/workflows/ci.yml",
1686
+ slot: "ci-setup",
1687
+ content: [
1688
+ "- uses: actions/setup-node@v5",
1689
+ " with:",
1690
+ " node-version: 24",
1691
+ " cache: npm",
1692
+ "- run: npm ci"
1693
+ ].join("\n")
1694
+ }
1695
+ ]
1696
+ };
1697
+ const bun = {
1698
+ id: "pm-bun",
1699
+ kind: "base",
1700
+ label: "bun",
1701
+ slots: [
1702
+ {
1703
+ file: "Dockerfile",
1704
+ slot: "docker-base",
1705
+ content: "RUN npm install --global bun"
1706
+ },
1707
+ {
1708
+ file: "Dockerfile",
1709
+ slot: "docker-build",
1710
+ content: [
1711
+ "COPY package.json bun.lock ./",
1712
+ "RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile",
1713
+ "COPY . .",
1714
+ "RUN bun run build"
1715
+ ].join("\n")
1716
+ },
1717
+ {
1718
+ file: "Dockerfile",
1719
+ slot: "docker-runtime",
1720
+ order: 0,
1721
+ content: [
1722
+ "COPY package.json bun.lock ./",
1723
+ "RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile --production",
1724
+ "COPY --from=build /app/dist ./dist"
1725
+ ].join("\n")
1726
+ },
1727
+ {
1728
+ file: ".github/workflows/ci.yml",
1729
+ slot: "ci-setup",
1730
+ content: [
1731
+ "- uses: oven-sh/setup-bun@v2",
1732
+ "- uses: actions/setup-node@v5",
1733
+ " with:",
1734
+ " node-version: 24",
1735
+ "- run: bun install --frozen-lockfile"
1736
+ ].join("\n")
1737
+ }
1738
+ ]
1739
+ };
1740
+ const db$1 = {
1741
+ id: "db",
1742
+ kind: "database",
1743
+ label: "Database (shared)",
1744
+ scripts: {
1745
+ "db:generate": "tatara db generate",
1746
+ "db:migrate": "tatara db migrate",
1747
+ "db:seed": "tatara db seed",
1748
+ "db:reset": "tatara db reset",
1749
+ "db:studio": "tatara db studio"
1750
+ },
1751
+ env: [{
1752
+ key: "DATABASE_POOL_MAX",
1753
+ schema: "z.coerce.number().int().positive().default(10)",
1754
+ example: "10"
1755
+ }],
1756
+ slots: [
1757
+ {
1758
+ file: "vitest.config.ts",
1759
+ slot: "vitest",
1760
+ content: "globalSetup: [\"tests/global-setup.ts\"],\nmaxWorkers: 4,\nfileParallelism: true,"
1761
+ },
1762
+ {
1763
+ file: "tests/setup.ts",
1764
+ slot: "setup",
1765
+ content: [
1766
+ "import { workerDatabaseUrl } from \"#tests/helpers/database-url\";",
1767
+ "",
1768
+ "// One database per worker, created by tests/global-setup.ts.",
1769
+ "process.env.DATABASE_URL = workerDatabaseUrl(process.env.DATABASE_URL ?? \"\", process.env.VITEST_POOL_ID ?? \"1\");"
1770
+ ].join("\n")
1771
+ },
1772
+ {
1773
+ file: "AGENTS.md",
1774
+ slot: "sections",
1775
+ content: `## Database
1776
+
1777
+ - Tables live in \`src/<core|modules>/<area>/db/<name>.schema.ts\` (Drizzle) or
1778
+ \`prisma/models/<name>.prisma\` (Prisma), one file per table. \`tatara make schema\` creates one.
1779
+ - Services take an \`Executor\` (\`db\` or a transaction) as their first argument when they
1780
+ write, so callers can run several services in one transaction.
1781
+ - Change the schema, then generate a migration: \`__PM_EXEC__ tatara db generate --name <change>\`.
1782
+ Never edit an applied migration; add a new one.
1783
+ - Seeds (\`db/seed.ts\`, \`defineSeed\`) must be idempotent: they run on every \`dev\`.
1784
+ - Integration tests run against a real database (one per worker). Call \`resetDatabase()\`
1785
+ from \`#tests/helpers/db\` in \`beforeEach\`.
1786
+ `
1787
+ }
1788
+ ]
1789
+ };
1790
+ //#endregion
1791
+ //#region src/features/index.ts
1792
+ const FEATURES = Object.fromEntries([
1793
+ base,
1794
+ pnpm,
1795
+ npm,
1796
+ bun,
1797
+ db$1,
1798
+ {
1799
+ id: "postgres",
1800
+ kind: "database",
1801
+ label: "PostgreSQL",
1802
+ dependencies: ["pg"],
1803
+ devDependencies: ["@types/pg"],
1804
+ env: [{
1805
+ key: "DB_PORT",
1806
+ schema: "",
1807
+ example: "5432",
1808
+ composeOnly: true,
1809
+ comment: "Host port for the local database container (keep DATABASE_URL in sync)"
1810
+ }, {
1811
+ key: "DATABASE_URL",
1812
+ schema: "z.url()",
1813
+ example: "postgres://postgres:postgres@localhost:5432/__APP_DB__",
1814
+ test: "postgres://postgres:postgres@localhost:5432/__APP_DB___test"
1815
+ }],
1816
+ compose: { postgres: {
1817
+ image: "postgres:18-alpine",
1818
+ environment: {
1819
+ POSTGRES_USER: "postgres",
1820
+ POSTGRES_PASSWORD: "postgres",
1821
+ POSTGRES_DB: "__APP_DB__"
1822
+ },
1823
+ ports: ["${DB_PORT:-5432}:5432"],
1824
+ volumes: ["postgres-data:/var/lib/postgresql"],
1825
+ healthcheck: {
1826
+ test: ["CMD-SHELL", "pg_isready -U postgres"],
1827
+ interval: "2s",
1828
+ timeout: "3s",
1829
+ retries: 30
1830
+ }
1831
+ } },
1832
+ composeVolumes: ["postgres-data"]
1833
+ },
1834
+ {
1835
+ id: "mysql",
1836
+ kind: "database",
1837
+ label: "MySQL",
1838
+ variants: [{
1839
+ when: ["drizzle"],
1840
+ dependencies: ["mysql2"]
1841
+ }, {
1842
+ when: ["prisma"],
1843
+ dependencies: ["mariadb"]
1844
+ }],
1845
+ env: [{
1846
+ key: "DB_PORT",
1847
+ schema: "",
1848
+ example: "3306",
1849
+ composeOnly: true,
1850
+ comment: "Host port for the local database container (keep DATABASE_URL in sync)"
1851
+ }, {
1852
+ key: "DATABASE_URL",
1853
+ schema: "z.url()",
1854
+ example: "mysql://root:root@localhost:3306/__APP_DB__",
1855
+ test: "mysql://root:root@localhost:3306/__APP_DB___test"
1856
+ }],
1857
+ compose: { mysql: {
1858
+ image: "mysql:9.7",
1859
+ environment: {
1860
+ MYSQL_ROOT_PASSWORD: "root",
1861
+ MYSQL_DATABASE: "__APP_DB__"
1862
+ },
1863
+ ports: ["${DB_PORT:-3306}:3306"],
1864
+ volumes: ["mysql-data:/var/lib/mysql"],
1865
+ healthcheck: {
1866
+ test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"],
1867
+ interval: "2s",
1868
+ timeout: "3s",
1869
+ retries: 60
1870
+ }
1871
+ } },
1872
+ composeVolumes: ["mysql-data"]
1873
+ },
1874
+ {
1875
+ id: "drizzle",
1876
+ kind: "orm",
1877
+ label: "Drizzle",
1878
+ dependencies: ["drizzle-orm"],
1879
+ devDependencies: ["drizzle-kit"],
1880
+ allowBuilds: ["esbuild"],
1881
+ slots: [{
1882
+ file: "Dockerfile",
1883
+ slot: "docker-runtime",
1884
+ order: 10,
1885
+ content: "COPY --from=build /app/drizzle ./drizzle"
1886
+ }]
1887
+ },
1888
+ {
1889
+ id: "prisma",
1890
+ kind: "orm",
1891
+ label: "Prisma",
1892
+ dependencies: ["@prisma/client", "prisma"],
1893
+ allowBuilds: [
1894
+ "prisma",
1895
+ "@prisma/engines",
1896
+ "esbuild"
1897
+ ],
1898
+ variants: [{
1899
+ when: ["postgres"],
1900
+ dependencies: ["@prisma/adapter-pg"]
1901
+ }, {
1902
+ when: ["mysql"],
1903
+ dependencies: ["@prisma/adapter-mariadb"]
1904
+ }],
1905
+ slots: [{
1906
+ file: "Dockerfile",
1907
+ slot: "docker-runtime",
1908
+ order: 10,
1909
+ content: "COPY --from=build /app/prisma ./prisma\nCOPY --from=build /app/prisma.config.ts ./"
1910
+ }]
1911
+ },
1912
+ betterAuth,
1913
+ authEmailOtp,
1914
+ authPhoneOtp,
1915
+ authPassword,
1916
+ authOrgs,
1917
+ keycloak,
1918
+ auth,
1919
+ authz,
1920
+ jobs,
1921
+ admin,
1922
+ storage,
1923
+ email,
1924
+ i18n
1925
+ ].map((f) => [f.id, f]));
1926
+ //#endregion
1927
+ //#region src/engine/resolve.ts
1928
+ var OptionsError = class extends Error {};
1929
+ /** Normalizes and validates options; throws OptionsError with a user-facing message. */
1930
+ function validateOptions(input) {
1931
+ const options = {
1932
+ ...input,
1933
+ authMethods: [...new Set(input.authMethods)],
1934
+ addons: [...new Set(input.addons)]
1935
+ };
1936
+ if (!/^[a-z][a-z0-9-]*$/.test(options.name)) throw new OptionsError(`App name "${options.name}" must be lowercase letters, digits and dashes, starting with a letter.`);
1937
+ if (options.orm === "none" !== (options.database === "none")) throw new OptionsError("Choose both an ORM and a database, or neither.");
1938
+ const hasDb = options.database !== "none";
1939
+ if (options.auth !== "none" && !hasDb) throw new OptionsError(`Auth (${options.auth}) needs a database.`);
1940
+ if (options.auth === "better-auth") {
1941
+ if (options.authMethods.filter((m) => m !== "orgs").length === 0) throw new OptionsError("better-auth needs at least one sign-in method: email-otp, phone-otp or password.");
1942
+ } else if (options.authMethods.length > 0) throw new OptionsError("Auth methods only apply to better-auth.");
1943
+ for (const addon of options.addons) {
1944
+ if ((addon === "authz" || addon === "admin") && options.auth === "none") throw new OptionsError(`The ${addon} add-on needs an auth provider.`);
1945
+ if (addon === "authz" && !hasDb) throw new OptionsError("The authz add-on needs a database.");
1946
+ if (addon === "admin" && options.auth === "better-auth" && !options.authMethods.includes("orgs")) throw new OptionsError("The admin add-on with better-auth needs the \"orgs\" method (it provides user roles).");
1947
+ }
1948
+ return options;
1949
+ }
1950
+ /** Feature ids selected by the options, in application order. */
1951
+ function selectedFeatureIds(options) {
1952
+ const ids = ["base", `pm-${options.packageManager}`];
1953
+ if (options.database !== "none") ids.push("db", options.database);
1954
+ if (options.orm !== "none") ids.push(options.orm);
1955
+ if (options.auth !== "none") ids.push(options.auth);
1956
+ if (options.auth === "better-auth") for (const m of options.authMethods) ids.push(`auth-${m}`);
1957
+ if (options.auth !== "none") ids.push("auth");
1958
+ for (const addon of options.addons) ids.push(addon);
1959
+ return ids;
1960
+ }
1961
+ function resolveFeatures(options) {
1962
+ return selectedFeatureIds(options).map((id) => {
1963
+ const feature = FEATURES[id];
1964
+ if (!feature) throw new Error(`Unknown feature "${id}"`);
1965
+ return feature;
1966
+ });
1967
+ }
1968
+ function isApplicable(rule, selected) {
1969
+ if (rule.when && !rule.when.every((id) => selected.has(id))) return false;
1970
+ if (rule.unless?.some((id) => selected.has(id))) return false;
1971
+ return true;
1972
+ }
1973
+ //#endregion
1974
+ //#region src/engine/slots.ts
1975
+ const MARKER = /^(\s*)(?:\/\/|#|<!--)\s*tatara:slot\s+([\w-]+)\s*(?:-->)?\s*$/;
1976
+ /** Replaces every `tatara:slot <name>` marker line with its contributions (or nothing). */
1977
+ function fillSlots(text, fills) {
1978
+ const bySlot = /* @__PURE__ */ new Map();
1979
+ for (const fill of fills) {
1980
+ const list = bySlot.get(fill.slot) ?? [];
1981
+ list.push(fill);
1982
+ bySlot.set(fill.slot, list);
1983
+ }
1984
+ const out = [];
1985
+ for (const line of text.split("\n")) {
1986
+ const match = MARKER.exec(line);
1987
+ if (!match) {
1988
+ out.push(line);
1989
+ continue;
1990
+ }
1991
+ const indent = match[1] ?? "";
1992
+ const contributions = (bySlot.get(match[2] ?? "") ?? []).sort((a, b) => a.order - b.order);
1993
+ const seen = /* @__PURE__ */ new Set();
1994
+ for (const c of contributions) {
1995
+ if (seen.has(c.content)) continue;
1996
+ seen.add(c.content);
1997
+ for (const contentLine of c.content.replace(/\n$/, "").split("\n")) out.push(contentLine.length > 0 ? indent + contentLine : "");
1998
+ }
1999
+ }
2000
+ return out.join("\n");
2001
+ }
2002
+ //#endregion
2003
+ //#region src/engine/generate.ts
2004
+ const RENAMES = {
2005
+ _github: ".github",
2006
+ gitignore: ".gitignore",
2007
+ dockerignore: ".dockerignore",
2008
+ "biome.template.json": "biome.json"
2009
+ };
2010
+ async function walk$1(dir) {
2011
+ const out = [];
2012
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
2013
+ const full = join(dir, entry.name);
2014
+ if (entry.isDirectory()) out.push(...await walk$1(full));
2015
+ else out.push(full);
2016
+ }
2017
+ return out;
2018
+ }
2019
+ function mapPath(rel) {
2020
+ return rel.split(sep).map((segment) => RENAMES[segment] ?? segment).join("/");
2021
+ }
2022
+ /** `files` plus every `files+a+b` folder whose features are all selected. */
2023
+ async function featureFileDirs(feature, selected) {
2024
+ const root = join(TEMPLATES_DIR, "features", feature.templateDir ?? feature.id);
2025
+ if (!existsSync(root)) return [];
2026
+ return (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory() && (e.name === "files" || e.name.startsWith("files+"))).map((e) => e.name).filter((name) => name === "files" || name.split("+").slice(1).every((id) => selected.has(id))).sort((a, b) => a.split("+").length - b.split("+").length || a.localeCompare(b)).map((d) => join(root, d));
2027
+ }
2028
+ async function collectTemplateFiles(features, selected) {
2029
+ const files = /* @__PURE__ */ new Map();
2030
+ for (const feature of features) for (const dir of await featureFileDirs(feature, selected)) for (const file of await walk$1(dir)) files.set(mapPath(relative(dir, file)), await readFile(file, "utf8"));
2031
+ return files;
2032
+ }
2033
+ function applySlots(files, features, selected) {
2034
+ const byFile = /* @__PURE__ */ new Map();
2035
+ let order = 0;
2036
+ for (const feature of features) for (const slot of feature.slots ?? []) {
2037
+ if (!isApplicable(slot, selected)) continue;
2038
+ if (!files.has(slot.file)) continue;
2039
+ const list = byFile.get(slot.file) ?? [];
2040
+ list.push({
2041
+ slot: slot.slot,
2042
+ content: slot.content,
2043
+ order: slot.order ?? 100 + order++
2044
+ });
2045
+ byFile.set(slot.file, list);
2046
+ }
2047
+ for (const [path, text] of files) files.set(path, fillSlots(text, byFile.get(path) ?? []));
2048
+ }
2049
+ function tokens(options) {
2050
+ const pm = pmCommands(options.packageManager);
2051
+ return {
2052
+ __APP_NAME__: options.name,
2053
+ __APP_DB__: options.name.replaceAll("-", "_"),
2054
+ __PM_RUN__: pm.run,
2055
+ __PM_EXEC__: options.packageManager === "pnpm" ? "pnpm" : pm.exec,
2056
+ __BIOME_VERSION__: versionOf("@biomejs/biome")
2057
+ };
2058
+ }
2059
+ function applyTokens(text, values) {
2060
+ let out = text;
2061
+ for (const [token, value] of Object.entries(values)) out = out.replaceAll(token, value);
2062
+ return out;
2063
+ }
2064
+ function envValue(v, kind) {
2065
+ if (kind === "example") return v.example;
2066
+ if (v.secret && kind === "dev") return randomBytes(32).toString("base64url");
2067
+ if (kind === "test") return v.test ?? (v.secret ? "test-secret-test-secret-test-secret-00" : v.dev ?? v.example);
2068
+ return v.dev ?? v.example;
2069
+ }
2070
+ function envFile(vars, kind) {
2071
+ const lines = [];
2072
+ for (const v of vars) {
2073
+ if (v.comment) lines.push(`# ${v.comment}`);
2074
+ lines.push(`${v.key}=${envValue(v, kind)}`);
2075
+ }
2076
+ return `${lines.join("\n")}\n`;
2077
+ }
2078
+ function envModule(vars) {
2079
+ return `import { createEnv } from "@t3-oss/env-core";
2080
+ import { z } from "zod";
2081
+
2082
+ const production = process.env.NODE_ENV === "production";
2083
+
2084
+ /** A default for local development; production must set the value explicitly. */
2085
+ function localDefault<T extends z.ZodType>(schema: T, value: z.input<T>): T {
2086
+ return production ? schema : (schema.default(value as never) as unknown as T);
2087
+ }
2088
+
2089
+ /** The only place that reads process.env. Add new variables here and to .env.example. */
2090
+ export const env = createEnv({
2091
+ server: {
2092
+ ${vars.map((v) => `\t\t${v.key}: ${v.schema},`).join("\n")}
2093
+ },
2094
+ runtimeEnv: process.env,
2095
+ emptyStringAsUndefined: true,
2096
+ });
2097
+ `;
2098
+ }
2099
+ function dedupeEnv(features, tokenValues) {
2100
+ const seen = /* @__PURE__ */ new Map();
2101
+ for (const f of features) for (const v of f.env ?? []) {
2102
+ const resolved = {
2103
+ ...v,
2104
+ example: applyTokens(v.example, tokenValues),
2105
+ ...v.dev === void 0 ? {} : { dev: applyTokens(v.dev, tokenValues) },
2106
+ ...v.test === void 0 ? {} : { test: applyTokens(v.test, tokenValues) }
2107
+ };
2108
+ seen.set(v.key, resolved);
2109
+ }
2110
+ return [...seen.values()];
2111
+ }
2112
+ function sortObject(input) {
2113
+ return Object.fromEntries(Object.entries(input).sort(([a], [b]) => a.localeCompare(b)));
2114
+ }
2115
+ function packageJson(options, features, pmVersion) {
2116
+ const dependencies = {};
2117
+ const devDependencies = {};
2118
+ let scripts = {};
2119
+ for (const f of features) {
2120
+ for (const d of f.dependencies ?? []) dependencies[d] = versionOf(d);
2121
+ for (const d of f.devDependencies ?? []) devDependencies[d] = versionOf(d);
2122
+ scripts = {
2123
+ ...scripts,
2124
+ ...f.scripts
2125
+ };
2126
+ }
2127
+ for (const d of Object.keys(dependencies)) delete devDependencies[d];
2128
+ const pkg = {
2129
+ name: options.name,
2130
+ version: "0.1.0",
2131
+ private: true,
2132
+ type: "module",
2133
+ engines: { node: ">=24" },
2134
+ ...options.packageManager === "pnpm" && pmVersion ? { packageManager: `pnpm@${pmVersion}` } : {},
2135
+ imports: {
2136
+ "#apps/*": "./src/apps/*.ts",
2137
+ "#core/*": "./src/core/*.ts",
2138
+ "#modules/*": "./src/modules/*.ts",
2139
+ "#generated/*": "./src/generated/*.ts",
2140
+ "#tests/*": "./tests/*.ts",
2141
+ "#locales/*": "./src/locales/*.json"
2142
+ },
2143
+ scripts,
2144
+ dependencies: sortObject(dependencies),
2145
+ devDependencies: sortObject(devDependencies)
2146
+ };
2147
+ return `${JSON.stringify(pkg, null, " ")}\n`;
2148
+ }
2149
+ function composeFile(options, features, tokenValues) {
2150
+ const services = {};
2151
+ const volumes = {};
2152
+ for (const f of features) {
2153
+ Object.assign(services, f.compose ?? {});
2154
+ for (const v of f.composeVolumes ?? []) volumes[v] = null;
2155
+ }
2156
+ if (Object.keys(services).length === 0) return void 0;
2157
+ const doc = {
2158
+ name: `\${COMPOSE_PROJECT_NAME:-${options.name}}`,
2159
+ services,
2160
+ ...Object.keys(volumes).length > 0 ? { volumes } : {}
2161
+ };
2162
+ return applyTokens(stringify(doc, { lineWidth: 0 }).replaceAll(": null", ":"), tokenValues);
2163
+ }
2164
+ function pnpmWorkspace(features) {
2165
+ const builds = [...new Set(features.flatMap((f) => f.allowBuilds ?? []))].sort();
2166
+ const lines = ["# Packages allowed to run install scripts."];
2167
+ if (builds.length === 0) return `${lines.join("\n")}\nallowBuilds: {}\n`;
2168
+ lines.push("allowBuilds:", ...builds.map((b) => ` "${b}": true`));
2169
+ return `${lines.join("\n")}\n`;
2170
+ }
2171
+ /** Folds applicable variants into each feature so later steps see one flat definition. */
2172
+ function effectiveFeatures(options) {
2173
+ const features = resolveFeatures(options);
2174
+ const selected = new Set(features.map((f) => f.id));
2175
+ return features.map((f) => {
2176
+ const variants = (f.variants ?? []).filter((v) => isApplicable(v, selected));
2177
+ if (variants.length === 0) return f;
2178
+ return {
2179
+ ...f,
2180
+ dependencies: [...f.dependencies ?? [], ...variants.flatMap((v) => v.dependencies ?? [])],
2181
+ devDependencies: [...f.devDependencies ?? [], ...variants.flatMap((v) => v.devDependencies ?? [])],
2182
+ scripts: Object.assign({}, f.scripts, ...variants.map((v) => v.scripts ?? {})),
2183
+ allowBuilds: [...f.allowBuilds ?? [], ...variants.flatMap((v) => v.allowBuilds ?? [])],
2184
+ env: [...f.env ?? [], ...variants.flatMap((v) => v.env ?? [])]
2185
+ };
2186
+ });
2187
+ }
2188
+ /** Renders the whole app in memory: template files, slots, tokens and generated config. */
2189
+ async function renderApp(options, generate = {}) {
2190
+ const features = effectiveFeatures(options);
2191
+ const selected = new Set(features.map((f) => f.id));
2192
+ const tokenValues = tokens(options);
2193
+ const files = await collectTemplateFiles(features, selected);
2194
+ const env = dedupeEnv(features, tokenValues);
2195
+ files.set("src/core/config/env.ts", envModule(env.filter((v) => !v.composeOnly)));
2196
+ files.set(".env.example", envFile(env, "example"));
2197
+ files.set(".env.test", envFile(env, "test"));
2198
+ const compose = composeFile(options, features, tokenValues);
2199
+ applySlots(files, compose ? [...features, {
2200
+ id: "_compose",
2201
+ kind: "base",
2202
+ label: "compose",
2203
+ slots: [{
2204
+ file: ".github/workflows/ci.yml",
2205
+ slot: "ci-services",
2206
+ content: "- run: docker compose up -d --wait"
2207
+ }]
2208
+ }] : features, selected);
2209
+ for (const [path, text] of files) files.set(path, applyTokens(text, tokenValues));
2210
+ files.set("package.json", packageJson(options, features, generate.pmVersion));
2211
+ if (compose) files.set("compose.yaml", compose);
2212
+ if (options.packageManager === "pnpm") files.set("pnpm-workspace.yaml", pnpmWorkspace(features));
2213
+ const config = {
2214
+ $schema: "https://raw.githubusercontent.com/mohammed-bageri/tatara/main/schema/tatara.json",
2215
+ version: cliVersion(),
2216
+ name: options.name,
2217
+ orm: options.orm,
2218
+ database: options.database,
2219
+ auth: options.auth,
2220
+ authMethods: options.authMethods,
2221
+ addons: options.addons,
2222
+ packageManager: options.packageManager
2223
+ };
2224
+ files.set("tatara.json", `${JSON.stringify(config, null, " ")}\n`);
2225
+ return files;
2226
+ }
2227
+ async function writeFiles(targetDir, files, extra = {}) {
2228
+ for (const [path, content] of files) {
2229
+ const full = join(targetDir, path);
2230
+ await mkdir(dirname(full), { recursive: true });
2231
+ await writeFile(full, content);
2232
+ }
2233
+ if (extra.env !== void 0) await writeFile(join(targetDir, ".env"), extra.env);
2234
+ }
2235
+ function devEnvFromExample(options) {
2236
+ return envFile(dedupeEnv(effectiveFeatures(options), tokens(options)), "dev");
2237
+ }
2238
+ //#endregion
2239
+ //#region src/engine/names.ts
2240
+ function words(input) {
2241
+ return input.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[^A-Za-z0-9]+/g, " ").trim().toLowerCase().split(/\s+/).filter(Boolean);
2242
+ }
2243
+ const IRREGULAR = {
2244
+ person: "people",
2245
+ child: "children",
2246
+ man: "men",
2247
+ woman: "women",
2248
+ mouse: "mice",
2249
+ goose: "geese",
2250
+ tooth: "teeth",
2251
+ foot: "feet"
2252
+ };
2253
+ const UNCOUNTABLE = /* @__PURE__ */ new Set([
2254
+ "data",
2255
+ "information",
2256
+ "equipment",
2257
+ "news",
2258
+ "series",
2259
+ "species",
2260
+ "media"
2261
+ ]);
2262
+ function pluralize(word) {
2263
+ if (UNCOUNTABLE.has(word)) return word;
2264
+ const irregular = IRREGULAR[word];
2265
+ if (irregular) return irregular;
2266
+ if (/(s|x|z|ch|sh)$/.test(word)) return `${word}es`;
2267
+ if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`;
2268
+ if (/(?:[^f])fe$/.test(word)) return `${word.slice(0, -2)}ves`;
2269
+ if (/[^f]f$/.test(word)) return `${word.slice(0, -1)}ves`;
2270
+ return `${word}s`;
2271
+ }
2272
+ const cap = (w) => w.charAt(0).toUpperCase() + w.slice(1);
2273
+ function fromWords(parts) {
2274
+ return {
2275
+ kebab: parts.join("-"),
2276
+ pascal: parts.map(cap).join(""),
2277
+ camel: parts.map((w, i) => i === 0 ? w : cap(w)).join(""),
2278
+ snake: parts.join("_")
2279
+ };
2280
+ }
2281
+ function names(input) {
2282
+ const parts = words(input);
2283
+ if (parts.length === 0) throw new Error(`"${input}" is not a valid name`);
2284
+ if (!/^[a-z]/.test(parts[0] ?? "")) throw new Error(`"${input}" must start with a letter`);
2285
+ const singular = fromWords(parts);
2286
+ const plural = fromWords([...parts.slice(0, -1), pluralize(parts.at(-1) ?? "")]);
2287
+ return {
2288
+ ...singular,
2289
+ pluralKebab: plural.kebab,
2290
+ pluralPascal: plural.pascal,
2291
+ pluralCamel: plural.camel,
2292
+ pluralSnake: plural.snake,
2293
+ title: cap(parts.join(" "))
2294
+ };
2295
+ }
2296
+ /** Replaces `__name__`-style tokens (see Names keys) in a string. */
2297
+ function applyNames(text, prefix, n) {
2298
+ return text.replaceAll(`__${prefix}PluralKebab__`, n.pluralKebab).replaceAll(`__${prefix}PluralPascal__`, n.pluralPascal).replaceAll(`__${prefix}PluralCamel__`, n.pluralCamel).replaceAll(`__${prefix}PluralSnake__`, n.pluralSnake).replaceAll(`__${prefix}Kebab__`, n.kebab).replaceAll(`__${prefix}Pascal__`, n.pascal).replaceAll(`__${prefix}Camel__`, n.camel).replaceAll(`__${prefix}Snake__`, n.snake).replaceAll(`__${prefix}Title__`, n.title);
2299
+ }
2300
+ //#endregion
2301
+ //#region src/generators/index.ts
2302
+ const GENERATORS = [
2303
+ "module",
2304
+ "resource",
2305
+ "route",
2306
+ "service",
2307
+ "schema",
2308
+ "command",
2309
+ "consumer",
2310
+ "schedule",
2311
+ "permission"
2312
+ ];
2313
+ var GeneratorError = class extends Error {};
2314
+ const REQUIRES = {
2315
+ resource: {
2316
+ feature: "db",
2317
+ hint: "it needs a database"
2318
+ },
2319
+ schema: {
2320
+ feature: "db",
2321
+ hint: "it needs a database"
2322
+ },
2323
+ consumer: {
2324
+ feature: "jobs",
2325
+ hint: "it needs the jobs add-on"
2326
+ },
2327
+ schedule: {
2328
+ feature: "jobs",
2329
+ hint: "it needs the jobs add-on"
2330
+ },
2331
+ permission: {
2332
+ feature: "authz",
2333
+ hint: "it needs the authz add-on"
2334
+ }
2335
+ };
2336
+ const ACTIONS = {
2337
+ list: "read",
2338
+ read: "read",
2339
+ create: "create",
2340
+ update: "update",
2341
+ delete: "delete"
2342
+ };
2343
+ function guardSlots(project, n) {
2344
+ const fills = [];
2345
+ const add = (slot, content) => fills.push({
2346
+ slot,
2347
+ content,
2348
+ order: fills.length
2349
+ });
2350
+ if (project.has("authz")) {
2351
+ add("imports", "import { requirePermission } from \"#core/authz/middleware\";");
2352
+ for (const [route, action] of Object.entries(ACTIONS)) add(`guard-${route}`, `middleware: [requirePermission("${n.kebab}:${action}")] as const,\nsecurity: [{ bearerAuth: ["${n.kebab}:${action}"] }],`);
2353
+ add("auth-errors", "...errorResponses(401, 403),");
2354
+ } else if (project.has("auth")) {
2355
+ add("imports", "import { requireAuth } from \"#core/auth/middleware\";");
2356
+ for (const route of Object.keys(ACTIONS)) add(`guard-${route}`, "middleware: [requireAuth()] as const,\nsecurity: [{ bearerAuth: [] }],");
2357
+ add("auth-errors", "...errorResponses(401),");
2358
+ }
2359
+ return fills;
2360
+ }
2361
+ function testSlots(project, n) {
2362
+ if (!project.has("auth")) return [];
2363
+ const fills = [{
2364
+ slot: "test-imports",
2365
+ content: "import { asUser } from \"#tests/helpers/auth\";",
2366
+ order: 0
2367
+ }, {
2368
+ slot: "test-cases",
2369
+ order: 1,
2370
+ content: [
2371
+ "it(\"requires authentication\", async () => {",
2372
+ " const res = await api.get(path);",
2373
+ " expect(res.status).toBe(401);",
2374
+ "});"
2375
+ ].join("\n")
2376
+ }];
2377
+ if (project.has("authz")) {
2378
+ const permissions = [
2379
+ "read",
2380
+ "create",
2381
+ "update",
2382
+ "delete"
2383
+ ].map((a) => `"${n.kebab}:${a}"`);
2384
+ fills.push({
2385
+ slot: "test-auth",
2386
+ order: 2,
2387
+ content: `Object.assign(headers, await asUser({ permissions: [${permissions.join(", ")}] }));`
2388
+ }, {
2389
+ slot: "test-cases",
2390
+ order: 3,
2391
+ content: [
2392
+ "",
2393
+ "it(\"requires permission\", async () => {",
2394
+ " const res = await api.get(path, { headers: await asUser({ permissions: [] }) });",
2395
+ " expect(res.status).toBe(403);",
2396
+ "});"
2397
+ ].join("\n")
2398
+ });
2399
+ } else fills.push({
2400
+ slot: "test-auth",
2401
+ order: 2,
2402
+ content: "Object.assign(headers, await asUser());"
2403
+ });
2404
+ return fills;
2405
+ }
2406
+ async function walk(dir) {
2407
+ const out = [];
2408
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
2409
+ const full = join(dir, entry.name);
2410
+ if (entry.isDirectory()) out.push(...await walk(full));
2411
+ else out.push(full);
2412
+ }
2413
+ return out;
2414
+ }
2415
+ function selectedSet(project) {
2416
+ const c = project.config;
2417
+ const set = /* @__PURE__ */ new Set([
2418
+ c.orm,
2419
+ c.database,
2420
+ c.auth,
2421
+ ...c.addons,
2422
+ ...c.authMethods.map((m) => `auth-${m}`)
2423
+ ]);
2424
+ if (c.database !== "none") set.add("db");
2425
+ if (c.auth !== "none") set.add("auth");
2426
+ return set;
2427
+ }
2428
+ async function generate(project, request) {
2429
+ const requirement = REQUIRES[request.kind];
2430
+ if (requirement && !project.has(requirement.feature)) throw new GeneratorError(`Cannot make a ${request.kind}: ${requirement.hint}.`);
2431
+ let n;
2432
+ let m;
2433
+ try {
2434
+ n = names(request.name);
2435
+ m = names(request.kind === "module" ? request.name : request.module ?? "");
2436
+ } catch (error) {
2437
+ if (request.kind !== "module" && !request.module) throw new GeneratorError(`Pass --module <module> (the module the ${request.kind} belongs to).`);
2438
+ throw new GeneratorError(error.message);
2439
+ }
2440
+ const root = join(TEMPLATES_DIR, "generators", request.kind);
2441
+ const selected = selectedSet(project);
2442
+ const dirs = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory() && (e.name === "files" || e.name.startsWith("files+"))).map((e) => e.name).filter((d) => d === "files" || d.split("+").slice(1).every((id) => selected.has(id))).sort();
2443
+ const fills = [...guardSlots(project, n), ...testSlots(project, n)];
2444
+ const pm = pmCommands(project.config.packageManager);
2445
+ const rendered = /* @__PURE__ */ new Map();
2446
+ for (const dir of dirs) {
2447
+ const base = join(root, dir);
2448
+ for (const file of await walk(base)) {
2449
+ const rel = applyNames(applyNames(relative(base, file), "name", n), "module", m);
2450
+ let text = applyNames(applyNames(await readFile(file, "utf8"), "name", n), "module", m);
2451
+ text = text.replaceAll("__APP_NAME__", project.config.name).replaceAll("__PM_RUN__", pm.run);
2452
+ rendered.set(rel, fillSlots(text, fills));
2453
+ }
2454
+ }
2455
+ const existing = [...rendered.keys()].filter((rel) => existsSync(join(project.root, rel)));
2456
+ if (existing.length > 0) throw new GeneratorError(`Refusing to overwrite existing file(s):\n ${existing.join("\n ")}`);
2457
+ const routeSources = /* @__PURE__ */ new Map();
2458
+ for (const rel of await glob("src/**/routes/*.routes.ts", { cwd: project.root })) routeSources.set(rel, await readFile(join(project.root, rel), "utf8"));
2459
+ const before = routeMountCollisions(routeSources).length;
2460
+ for (const [rel, text] of rendered) routeSources.set(rel, text);
2461
+ const after = routeMountCollisions(routeSources);
2462
+ const collision = after.length > before ? after.at(-1) : void 0;
2463
+ if (collision) throw new GeneratorError(`${collision.file}: ${collision.message}. Pick another --name, or create it and change basePath.`);
2464
+ const files = [...rendered.keys()].sort();
2465
+ if (request.dryRun) return {
2466
+ kind: request.kind,
2467
+ files,
2468
+ dryRun: true
2469
+ };
2470
+ for (const [rel, text] of rendered) {
2471
+ const full = join(project.root, rel);
2472
+ await mkdir(dirname(full), { recursive: true });
2473
+ await writeFile(full, text);
2474
+ }
2475
+ if (!request.skipPostProcess) await postProcess(project, files);
2476
+ return {
2477
+ kind: request.kind,
2478
+ files,
2479
+ dryRun: false
2480
+ };
2481
+ }
2482
+ /** Regenerates discovery output and formats the new files. */
2483
+ async function postProcess(project, files) {
2484
+ const { sync } = await Promise.resolve().then(() => sync_exports);
2485
+ await sync(project);
2486
+ const biome = join(project.root, "node_modules/.bin/biome");
2487
+ const ts = files.filter((f) => f.endsWith(".ts"));
2488
+ if (existsSync(biome) && ts.length > 0) await x(biome, [
2489
+ "check",
2490
+ "--write",
2491
+ ...ts
2492
+ ], {
2493
+ nodeOptions: { cwd: project.root },
2494
+ throwOnError: false
2495
+ });
2496
+ }
2497
+ //#endregion
2498
+ //#region src/commands/post-create.ts
2499
+ async function bin(project, name, args) {
2500
+ const result = await x(join(project.root, "node_modules/.bin", name), args, {
2501
+ nodeOptions: { cwd: project.root },
2502
+ throwOnError: false
2503
+ });
2504
+ if (result.exitCode !== 0) throw new Error(`${name} ${args.join(" ")} failed:\n${result.stdout}\n${result.stderr}`);
2505
+ return result.stdout;
2506
+ }
2507
+ /** Writes the first migration offline, from the schema the chosen features produced. */
2508
+ async function initialMigration(project) {
2509
+ if (project.config.orm === "drizzle") await bin(project, "drizzle-kit", [
2510
+ "generate",
2511
+ "--name",
2512
+ "init"
2513
+ ]);
2514
+ else if (project.config.orm === "prisma") {
2515
+ const dir = join(project.root, "prisma/migrations/00000000000000_init");
2516
+ await mkdir(dir, { recursive: true });
2517
+ await bin(project, "prisma", [
2518
+ "migrate",
2519
+ "diff",
2520
+ "--from-empty",
2521
+ "--to-schema",
2522
+ "prisma",
2523
+ "--script",
2524
+ "--output",
2525
+ join(dir, "migration.sql")
2526
+ ]);
2527
+ const lock = join(project.root, "prisma/migrations/migration_lock.toml");
2528
+ if (!existsSync(lock)) await writeFile(lock, `# Do not edit: managed by Prisma Migrate\nprovider = "${project.config.database === "mysql" ? "mysql" : "postgresql"}"\n`);
2529
+ }
2530
+ }
2531
+ /** Steps `tatara new` runs after writing files ("files") and after installing ("installed"). */
2532
+ async function afterCreate(project, stage) {
2533
+ if (stage === "files" && project.has("db")) await generate(project, {
2534
+ kind: "resource",
2535
+ name: "project",
2536
+ module: "projects",
2537
+ skipPostProcess: true
2538
+ });
2539
+ if (stage === "installed") await initialMigration(project);
2540
+ }
2541
+ //#endregion
2542
+ //#region src/commands/new.ts
2543
+ const ORMS = [
2544
+ "drizzle",
2545
+ "prisma",
2546
+ "none"
2547
+ ];
2548
+ const DATABASES = [
2549
+ "postgres",
2550
+ "mysql",
2551
+ "none"
2552
+ ];
2553
+ const AUTHS = [
2554
+ "better-auth",
2555
+ "keycloak",
2556
+ "none"
2557
+ ];
2558
+ const METHODS = [
2559
+ "email-otp",
2560
+ "phone-otp",
2561
+ "password",
2562
+ "orgs"
2563
+ ];
2564
+ const ADDONS = [
2565
+ "authz",
2566
+ "jobs",
2567
+ "admin",
2568
+ "storage",
2569
+ "email",
2570
+ "i18n"
2571
+ ];
2572
+ const PMS = [
2573
+ "pnpm",
2574
+ "npm",
2575
+ "bun"
2576
+ ];
2577
+ function oneOf(value, allowed, flag) {
2578
+ if (!allowed.includes(value)) throw new OptionsError(`--${flag} must be one of: ${allowed.join(", ")} (got "${value}")`);
2579
+ return value;
2580
+ }
2581
+ function listOf(value, allowed, flag) {
2582
+ if (!value || value === "none") return [];
2583
+ return value.split(",").map((v) => v.trim()).filter(Boolean).map((v) => oneOf(v, allowed, flag));
2584
+ }
2585
+ function detectPackageManager() {
2586
+ const agent = process.env.npm_config_user_agent ?? "";
2587
+ if (agent.startsWith("pnpm")) return "pnpm";
2588
+ if (agent.startsWith("bun")) return "bun";
2589
+ if (agent.startsWith("npm")) return "npm";
2590
+ return "pnpm";
2591
+ }
2592
+ function answer(value) {
2593
+ if (p.isCancel(value)) {
2594
+ p.cancel("Cancelled.");
2595
+ process.exit(1);
2596
+ }
2597
+ return value;
2598
+ }
2599
+ async function prompt(name, flags) {
2600
+ const orm = flags.orm ? oneOf(flags.orm, ORMS, "orm") : answer(await p.select({
2601
+ message: "ORM",
2602
+ options: [
2603
+ {
2604
+ value: "drizzle",
2605
+ label: "Drizzle"
2606
+ },
2607
+ {
2608
+ value: "prisma",
2609
+ label: "Prisma"
2610
+ },
2611
+ {
2612
+ value: "none",
2613
+ label: "No database"
2614
+ }
2615
+ ]
2616
+ }));
2617
+ let database = "none";
2618
+ if (orm !== "none") database = flags.db ? oneOf(flags.db, DATABASES, "db") : answer(await p.select({
2619
+ message: "Database",
2620
+ options: [{
2621
+ value: "postgres",
2622
+ label: "PostgreSQL"
2623
+ }, {
2624
+ value: "mysql",
2625
+ label: "MySQL"
2626
+ }]
2627
+ }));
2628
+ let auth = "none";
2629
+ if (database !== "none") auth = flags.auth ? oneOf(flags.auth, AUTHS, "auth") : answer(await p.select({
2630
+ message: "Auth",
2631
+ options: [
2632
+ {
2633
+ value: "better-auth",
2634
+ label: "better-auth",
2635
+ hint: "sign-in handled by the API"
2636
+ },
2637
+ {
2638
+ value: "keycloak",
2639
+ label: "Keycloak",
2640
+ hint: "API verifies Keycloak tokens"
2641
+ },
2642
+ {
2643
+ value: "none",
2644
+ label: "None"
2645
+ }
2646
+ ]
2647
+ }));
2648
+ let authMethods = [];
2649
+ if (auth === "better-auth") authMethods = flags.authMethods ? listOf(flags.authMethods, METHODS, "auth-methods") : answer(await p.multiselect({
2650
+ message: "Sign-in methods",
2651
+ initialValues: ["email-otp"],
2652
+ options: [
2653
+ {
2654
+ value: "email-otp",
2655
+ label: "Email OTP"
2656
+ },
2657
+ {
2658
+ value: "phone-otp",
2659
+ label: "Phone OTP"
2660
+ },
2661
+ {
2662
+ value: "password",
2663
+ label: "Email + password"
2664
+ },
2665
+ {
2666
+ value: "orgs",
2667
+ label: "Organizations + roles",
2668
+ hint: "organization and admin plugins"
2669
+ }
2670
+ ],
2671
+ required: true
2672
+ }));
2673
+ const labels = {
2674
+ authz: "Permission system",
2675
+ jobs: "Jobs + cron (BullMQ)",
2676
+ admin: "Admin API app",
2677
+ storage: "File storage",
2678
+ email: "Email",
2679
+ i18n: "i18n"
2680
+ };
2681
+ const addons = flags.addons !== void 0 ? listOf(flags.addons, ADDONS, "addons") : answer(await p.multiselect({
2682
+ message: "Add-ons",
2683
+ required: false,
2684
+ options: ADDONS.filter((a) => !((a === "authz" || a === "admin") && auth === "none")).map((value) => ({
2685
+ value,
2686
+ label: labels[value]
2687
+ }))
2688
+ }));
2689
+ const packageManager = flags.pm ? oneOf(flags.pm, PMS, "pm") : answer(await p.select({
2690
+ message: "Package manager",
2691
+ initialValue: detectPackageManager(),
2692
+ options: PMS.map((value) => ({
2693
+ value,
2694
+ label: value
2695
+ }))
2696
+ }));
2697
+ return {
2698
+ name,
2699
+ orm,
2700
+ database,
2701
+ auth,
2702
+ authMethods,
2703
+ addons,
2704
+ packageManager
2705
+ };
2706
+ }
2707
+ function fromFlags(name, flags) {
2708
+ const orm = oneOf(flags.orm ?? "drizzle", ORMS, "orm");
2709
+ const database = oneOf(flags.db ?? (orm === "none" ? "none" : "postgres"), DATABASES, "db");
2710
+ const auth = oneOf(flags.auth ?? (database === "none" ? "none" : "better-auth"), AUTHS, "auth");
2711
+ return {
2712
+ name,
2713
+ orm,
2714
+ database,
2715
+ auth,
2716
+ authMethods: listOf(flags.authMethods ?? (auth === "better-auth" ? "email-otp" : void 0), METHODS, "auth-methods"),
2717
+ addons: listOf(flags.addons, ADDONS, "addons"),
2718
+ packageManager: oneOf(flags.pm ?? detectPackageManager(), PMS, "pm")
2719
+ };
2720
+ }
2721
+ async function pmVersion(pm) {
2722
+ const result = await x(pm, ["--version"], { throwOnError: false });
2723
+ return result.exitCode === 0 ? result.stdout.trim() : void 0;
2724
+ }
2725
+ async function run(cmd, args, cwd, label) {
2726
+ const spinner = Boolean(process.stdout.isTTY) ? p.spinner() : void 0;
2727
+ if (spinner) spinner.start(label);
2728
+ else console.log(`• ${label}`);
2729
+ const result = await x(cmd, args, {
2730
+ nodeOptions: { cwd },
2731
+ throwOnError: false
2732
+ });
2733
+ if (result.exitCode !== 0) {
2734
+ spinner?.stop(pc.red(`${label} failed`));
2735
+ throw new Error(`${cmd} ${args.join(" ")} failed:\n${result.stdout}\n${result.stderr}`);
2736
+ }
2737
+ spinner?.stop(label);
2738
+ }
2739
+ async function newApp(dir, flags) {
2740
+ const interactive = !flags.yes && process.stdout.isTTY;
2741
+ if (interactive) p.intro(pc.bold("tatara"));
2742
+ let target = dir;
2743
+ if (!target) {
2744
+ if (!interactive) throw new OptionsError("Pass the app directory, e.g. `tatara new my-app`.");
2745
+ target = answer(await p.text({
2746
+ message: "App directory",
2747
+ placeholder: "my-app",
2748
+ defaultValue: "my-app"
2749
+ }));
2750
+ }
2751
+ const root = resolve(target);
2752
+ if (existsSync(root) && readdirSync(root).length > 0) throw new OptionsError(`${root} already exists and is not empty.`);
2753
+ const name = root.split(/[\\/]/).at(-1) ?? "app";
2754
+ const options = validateOptions(interactive ? await prompt(name, flags) : fromFlags(name, flags));
2755
+ const pm = pmCommands(options.packageManager);
2756
+ await writeFiles(root, await renderApp(options, { pmVersion: await pmVersion(options.packageManager) }), { env: devEnvFromExample(options) });
2757
+ const project = loadProject(root);
2758
+ await afterCreate(project, "files");
2759
+ if (flags.install !== false) {
2760
+ const [cmd, ...args] = pm.install;
2761
+ await run(cmd ?? "npm", args, root, `Installing dependencies with ${options.packageManager}`);
2762
+ await sync(project);
2763
+ await afterCreate(project, "installed");
2764
+ const [exec, ...execArgs] = pm.exec.split(" ");
2765
+ const format = await x(exec ?? "npx", [
2766
+ ...execArgs,
2767
+ "biome",
2768
+ "check",
2769
+ "--write",
2770
+ "."
2771
+ ], {
2772
+ nodeOptions: { cwd: root },
2773
+ throwOnError: false
2774
+ });
2775
+ if (format.exitCode !== 0) console.warn(pc.yellow(`Formatting reported problems (please report this as a tatara bug):\n${format.stdout}`));
2776
+ }
2777
+ if (flags.git !== false) {
2778
+ if ((await x("git", [
2779
+ "init",
2780
+ "-q",
2781
+ "-b",
2782
+ "main"
2783
+ ], {
2784
+ nodeOptions: { cwd: root },
2785
+ throwOnError: false
2786
+ })).exitCode === 0) {
2787
+ await x("git", ["add", "-A"], {
2788
+ nodeOptions: { cwd: root },
2789
+ throwOnError: false
2790
+ });
2791
+ await x("git", [
2792
+ "commit",
2793
+ "-q",
2794
+ "-m",
2795
+ "Initial commit from tatara"
2796
+ ], {
2797
+ nodeOptions: { cwd: root },
2798
+ throwOnError: false
2799
+ });
2800
+ }
2801
+ }
2802
+ const next = [
2803
+ `cd ${target}`,
2804
+ ...flags.install === false ? [pm.install.join(" ")] : [],
2805
+ `${pm.run} dev`
2806
+ ];
2807
+ if (interactive) p.outro(`Done. Next:\n\n ${next.join("\n ")}`);
2808
+ else console.log(`Created ${root}\nNext:\n ${next.join("\n ")}`);
2809
+ return root;
2810
+ }
2811
+ //#endregion
2812
+ //#region src/commands/routes.ts
2813
+ /** Reads every app's OpenAPI document in-process and lists its routes. */
2814
+ async function listRoutes(project) {
2815
+ await sync(project);
2816
+ const apps = [["api", "#apps/api/app"], ...project.has("admin") ? [["admin", "#apps/admin/app"]] : []];
2817
+ const code = `
2818
+ const out = [];
2819
+ for (const [name, spec] of ${JSON.stringify(apps)}) {
2820
+ const { app } = await import(spec);
2821
+ const doc = app.getOpenAPI31Document({ openapi: "3.1.0", info: { title: name, version: "1" } });
2822
+ for (const [path, item] of Object.entries(doc.paths ?? {})) {
2823
+ for (const [method, op] of Object.entries(item)) {
2824
+ const security = op.security ?? [];
2825
+ const roles = security.flatMap((s) => Object.values(s).flat());
2826
+ const access = security.length === 0 ? "public" : roles.length ? roles.join(", ") : "authenticated";
2827
+ out.push({ app: name, method: method.toUpperCase(), path, summary: op.summary ?? "", access });
2828
+ }
2829
+ }
2830
+ }
2831
+ process.stdout.write(JSON.stringify(out));
2832
+ process.exit(0);
2833
+ `;
2834
+ return new Promise((resolve, reject) => {
2835
+ const child = spawn(process.execPath, [
2836
+ ...NODE_FLAGS,
2837
+ "--input-type=module",
2838
+ "-e",
2839
+ code
2840
+ ], {
2841
+ cwd: project.root,
2842
+ env: {
2843
+ ...process.env,
2844
+ LOG_LEVEL: "silent"
2845
+ }
2846
+ });
2847
+ let stdout = "";
2848
+ let stderr = "";
2849
+ child.stdout.on("data", (d) => {
2850
+ stdout += d.toString();
2851
+ });
2852
+ child.stderr.on("data", (d) => {
2853
+ stderr += d.toString();
2854
+ });
2855
+ child.on("exit", (code) => {
2856
+ if (code !== 0) return reject(/* @__PURE__ */ new Error(`Could not load the apps:\n${stderr}`));
2857
+ resolve(JSON.parse(stdout));
2858
+ });
2859
+ });
2860
+ }
2861
+ function printRoutes(routes) {
2862
+ const rows = routes.map((r) => [
2863
+ r.app,
2864
+ r.method,
2865
+ r.path,
2866
+ r.access,
2867
+ r.summary
2868
+ ]);
2869
+ const widths = [
2870
+ 0,
2871
+ 1,
2872
+ 2,
2873
+ 3
2874
+ ].map((i) => Math.max(...rows.map((r) => (r[i] ?? "").length), 4));
2875
+ for (const r of rows) {
2876
+ const [app = "", method = "", path = "", access = "", summary = ""] = r;
2877
+ console.log(`${pc.dim(app.padEnd(widths[0] ?? 0))} ${pc.bold(method.padEnd(widths[1] ?? 0))} ${path.padEnd(widths[2] ?? 0)} ${(access === "public" ? pc.yellow : pc.green)(access.padEnd(widths[3] ?? 0))} ${pc.dim(summary)}`);
2878
+ }
2879
+ }
2880
+ //#endregion
2881
+ //#region src/commands/tinker.ts
2882
+ /** A REPL with env, logger, db and every module's services loaded. */
2883
+ async function tinker(project) {
2884
+ await sync(project);
2885
+ const imports = (await glob(["src/modules/*/services/*.ts"], {
2886
+ cwd: project.root,
2887
+ ignore: ["**/*.test.ts"]
2888
+ })).map((file) => {
2889
+ const [, moduleName = "", base = ""] = /src\/modules\/([^/]+)\/services\/([^/]+)\.ts$/.exec(file) ?? [];
2890
+ const key = names(`${moduleName} ${base.replace(/\.service$/, "")}`).camel;
2891
+ return `ctx[${JSON.stringify(key)}] = await import(${JSON.stringify(`#${file.slice(4, -3)}`)});`;
2892
+ });
2893
+ const code = `
2894
+ import repl from "node:repl";
2895
+ const ctx = {};
2896
+ ctx.env = (await import("#core/config/env")).env;
2897
+ ctx.logger = (await import("#core/logging/logger")).logger;
2898
+ ${project.has("db") ? "Object.assign(ctx, await import(\"#core/db/client\"));" : ""}
2899
+ ${imports.join("\n")}
2900
+ console.log("Loaded:", Object.keys(ctx).join(", "));
2901
+ const server = repl.start({ prompt: "${project.config.name}> " });
2902
+ Object.assign(server.context, ctx);
2903
+ server.on("exit", () => process.exit(0));
2904
+ `;
2905
+ return new Promise((resolve) => {
2906
+ spawn(process.execPath, [
2907
+ ...NODE_FLAGS,
2908
+ "--input-type=module",
2909
+ "-e",
2910
+ code
2911
+ ], {
2912
+ cwd: project.root,
2913
+ stdio: "inherit"
2914
+ }).on("exit", (c) => resolve(c ?? 0));
2915
+ });
2916
+ }
2917
+ //#endregion
2918
+ //#region src/bin.ts
2919
+ const program = new Command("tatara").description("Create and grow opinionated Hono backends").version(cliVersion());
2920
+ program.command("new").description("Create a new app").argument("[dir]", "directory to create").option("--orm <orm>", "drizzle | prisma | none").option("--db <database>", "postgres | mysql | none").option("--auth <provider>", "better-auth | keycloak | none").option("--auth-methods <list>", "email-otp,phone-otp,password,orgs (better-auth)").option("--addons <list>", "authz,jobs,admin,storage,email,i18n").option("--pm <pm>", "pnpm | npm | bun").option("-y, --yes", "skip prompts and use flags/defaults").option("--no-install", "do not install dependencies").option("--no-git", "do not initialise git").action(async (dir, flags) => {
2921
+ await newApp(dir, flags);
2922
+ });
2923
+ program.command("sync").description("Regenerate src/generated from discovered module files").option("--quiet", "no output").action(async (flags) => {
2924
+ const { written } = await sync();
2925
+ if (!flags.quiet && written.length > 0) console.log(pc.dim(`tatara sync: ${written.length} file(s) updated`));
2926
+ });
2927
+ program.command("check").description("Check layout rules (part of lint)").option("--json", "machine-readable output").action(async (flags) => {
2928
+ const violations = await check();
2929
+ if (flags.json) console.log(JSON.stringify({ violations }, null, 2));
2930
+ else if (violations.length === 0) console.log(pc.green("tatara check: layout OK"));
2931
+ else {
2932
+ for (const v of violations) console.log(`${pc.red("✖")} ${v.file}:${v.line} ${pc.dim(`[${v.rule}]`)} ${v.message}`);
2933
+ console.log(pc.red(`\n${violations.length} layout violation(s)`));
2934
+ }
2935
+ if (violations.length > 0) process.exitCode = 1;
2936
+ });
2937
+ program.command("verify").description("Lint, layout, typecheck, test and build").action(async () => {
2938
+ if (!await verify()) process.exitCode = 1;
2939
+ });
2940
+ program.command("make").description(`Generate code: ${GENERATORS.join(", ")}`).argument("<kind>", GENERATORS.join(" | ")).argument("<name>", "name, e.g. billing-account").option("-m, --module <module>", "module the code belongs to").option("--dry-run", "show the files without writing them").option("--json", "machine-readable output").action(async (kind, name, flags) => {
2941
+ if (!GENERATORS.includes(kind)) throw new GeneratorError(`Unknown generator "${kind}". Use one of: ${GENERATORS.join(", ")}`);
2942
+ const result = await generate(loadProject(), {
2943
+ kind,
2944
+ name,
2945
+ ...flags.module ? { module: flags.module } : {},
2946
+ ...flags.dryRun ? { dryRun: true } : {}
2947
+ });
2948
+ if (flags.json) {
2949
+ console.log(JSON.stringify(result, null, 2));
2950
+ return;
2951
+ }
2952
+ const verb = result.dryRun ? "would create" : "created";
2953
+ for (const file of result.files) console.log(`${pc.green(verb)} ${file}`);
2954
+ });
2955
+ const db = program.command("db").description("Database tasks for the app's ORM");
2956
+ db.command("generate").description("Create a migration from schema changes").option("-n, --name <name>", "migration name", "change").action(async (flags) => {
2957
+ process.exitCode = await dbGenerate(loadProject(), flags.name);
2958
+ });
2959
+ db.command("migrate").description("Apply pending migrations").action(async () => {
2960
+ process.exitCode = await dbMigrate(loadProject());
2961
+ });
2962
+ db.command("seed").description("Run seeds").action(async () => {
2963
+ process.exitCode = await dbSeed(loadProject());
2964
+ });
2965
+ db.command("reset").description("Drop all tables, migrate and seed (not in production)").action(async () => {
2966
+ process.exitCode = await dbReset(loadProject());
2967
+ });
2968
+ db.command("studio").description("Open the ORM's database studio").action(async () => {
2969
+ process.exitCode = await dbStudio(loadProject());
2970
+ });
2971
+ program.command("dev").description("Start local services, migrate, seed and run every process with reload").option("--down", "stop local services").option("--reset", "stop local services and delete their data, then start fresh").option("--prepare", "start services, migrate and seed, then exit").action(async (flags) => {
2972
+ process.exit(await dev(loadProject(), flags));
2973
+ });
2974
+ program.command("run").description("Run an app command (see `tatara run --list`)").argument("[command]", "command name").argument("[args...]", "arguments passed to the command").option("--list", "list commands").allowUnknownOption().helpOption(false).action(async (command, args, flags) => {
2975
+ const project = loadProject();
2976
+ const { sync: runSync } = await Promise.resolve().then(() => sync_exports);
2977
+ await runSync(project);
2978
+ const argv = flags.list || !command ? ["--list"] : [command, ...args];
2979
+ process.exitCode = await runAppCli(project, argv);
2980
+ });
2981
+ program.command("tinker").description("REPL with env, logger, db and module services loaded").action(async () => {
2982
+ process.exitCode = await tinker(loadProject());
2983
+ });
2984
+ program.command("routes").description("List every route and what protects it").option("--json", "machine-readable output").action(async (flags) => {
2985
+ const routes = await listRoutes(loadProject());
2986
+ if (flags.json) console.log(JSON.stringify(routes, null, 2));
2987
+ else printRoutes(routes);
2988
+ });
2989
+ program.command("doctor").description("Check the toolchain, services, env and layout").option("--json", "machine-readable output").action(async (flags) => {
2990
+ const checks = await doctor(loadProject());
2991
+ if (flags.json) console.log(JSON.stringify(checks, null, 2));
2992
+ else printDoctor(checks);
2993
+ if (checks.some((c) => !c.ok)) process.exitCode = 1;
2994
+ });
2995
+ program.parseAsync().catch((error) => {
2996
+ if (error instanceof OptionsError || error instanceof NotATataraProject || error instanceof GeneratorError || error instanceof DbError) console.error(pc.red(error.message));
2997
+ else console.error(error);
2998
+ process.exit(1);
2999
+ });
3000
+ //#endregion
3001
+ export {};