@tulipes/cli 0.1.1 → 0.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/README.md +10 -7
  2. package/dist/agent.d.ts +12 -0
  3. package/dist/agent.js +29 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/app-core.d.ts +8 -6
  6. package/dist/app-core.js +23 -14
  7. package/dist/app-core.js.map +1 -1
  8. package/dist/bin.js +3 -3
  9. package/dist/bin.js.map +1 -1
  10. package/dist/config-check.d.ts +8 -0
  11. package/dist/config-check.js +43 -0
  12. package/dist/config-check.js.map +1 -0
  13. package/dist/config-import.d.ts +20 -0
  14. package/dist/config-import.js +133 -0
  15. package/dist/config-import.js.map +1 -0
  16. package/dist/dev.d.ts +4 -4
  17. package/dist/dev.js +7 -10
  18. package/dist/dev.js.map +1 -1
  19. package/dist/init.d.ts +6 -11
  20. package/dist/init.js +11 -6422
  21. package/dist/init.js.map +1 -1
  22. package/dist/inspection.d.ts +9 -8
  23. package/dist/inspection.js +10 -11
  24. package/dist/inspection.js.map +1 -1
  25. package/dist/main.js +82 -11
  26. package/dist/main.js.map +1 -1
  27. package/dist/migrate.d.ts +30 -0
  28. package/dist/migrate.js +772 -0
  29. package/dist/migrate.js.map +1 -0
  30. package/dist/new-module.js +84 -43
  31. package/dist/new-module.js.map +1 -1
  32. package/dist/routes.d.ts +3 -5
  33. package/dist/routes.js +4 -6
  34. package/dist/routes.js.map +1 -1
  35. package/dist/spec.d.ts +2 -5
  36. package/dist/spec.js +13 -6
  37. package/dist/spec.js.map +1 -1
  38. package/dist/sync.d.ts +3 -11
  39. package/dist/sync.js +81 -20
  40. package/dist/sync.js.map +1 -1
  41. package/dist/template.d.ts +58 -0
  42. package/dist/template.js +244 -0
  43. package/dist/template.js.map +1 -0
  44. package/dist/update.js +4 -4
  45. package/package.json +17 -16
  46. package/templates/AGENTS.md +68 -31
  47. package/templates/CLAUDE.md +5 -4
  48. package/templates/app/.envs/.env.development +18 -0
  49. package/templates/app/.envs/.env.production +19 -0
  50. package/templates/app/.envs/.env.staging +19 -0
  51. package/templates/app/README.md +327 -0
  52. package/templates/app/app.ts +3 -0
  53. package/templates/app/config/app.config.ts +73 -0
  54. package/templates/app/docs/agent-manifest.json +724 -0
  55. package/templates/app/ecosystem.config.cjs +60 -0
  56. package/templates/app/modules/agent/controllers/agent.controllers.ts +31 -0
  57. package/templates/app/modules/agent/i18n/en.json +5 -0
  58. package/templates/app/modules/agent/i18n/fr.json +5 -0
  59. package/templates/app/modules/agent/module.acl.ts +14 -0
  60. package/templates/app/modules/agent/package.json +21 -0
  61. package/templates/app/modules/agent/routes/agent.routes.ts +29 -0
  62. package/templates/app/modules/agent/schemas/agent.schemas.ts +9 -0
  63. package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +26 -0
  64. package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +44 -0
  65. package/templates/app/modules/auth/controllers/auth.controllers.ts +312 -0
  66. package/templates/app/modules/auth/helpers/access-check.ts +42 -0
  67. package/templates/app/modules/auth/helpers/auth.ts +48 -0
  68. package/templates/app/modules/auth/helpers/documents.ts +57 -0
  69. package/templates/app/modules/auth/helpers/passport.ts +180 -0
  70. package/templates/app/modules/auth/helpers/passwords.ts +58 -0
  71. package/templates/app/modules/auth/helpers/session-cache.ts +67 -0
  72. package/templates/app/modules/auth/helpers/tokens.ts +329 -0
  73. package/templates/app/modules/auth/i18n/en.json +19 -0
  74. package/templates/app/modules/auth/i18n/fr.json +19 -0
  75. package/templates/app/modules/auth/lib/index.ts +12 -0
  76. package/templates/app/modules/auth/models/credential.model.ts +31 -0
  77. package/templates/app/modules/auth/models/revoked-token.model.ts +20 -0
  78. package/templates/app/modules/auth/models/session.model.ts +42 -0
  79. package/templates/app/modules/auth/module.acl.ts +41 -0
  80. package/templates/app/modules/auth/module.config.ts +30 -0
  81. package/templates/app/modules/auth/package.json +61 -0
  82. package/templates/app/modules/auth/routes/auth.routes.ts +94 -0
  83. package/templates/app/modules/auth/schemas/auth.schemas.ts +37 -0
  84. package/templates/app/modules/auth/tests/auth.test.ts +1079 -0
  85. package/templates/app/modules/config/controllers/config.controllers.ts +89 -0
  86. package/templates/app/modules/config/helpers/audit.ts +41 -0
  87. package/templates/app/modules/config/helpers/describe.ts +72 -0
  88. package/templates/app/modules/config/i18n/en.json +6 -0
  89. package/templates/app/modules/config/i18n/fr.json +6 -0
  90. package/templates/app/modules/config/models/config-audit.model.ts +26 -0
  91. package/templates/app/modules/config/module.acl.ts +12 -0
  92. package/templates/app/modules/config/package.json +23 -0
  93. package/templates/app/modules/config/routes/config.routes.ts +37 -0
  94. package/templates/app/modules/config/schemas/config.schemas.ts +13 -0
  95. package/templates/app/modules/config/tests/config.test.ts +126 -0
  96. package/templates/app/modules/core/bootstrap/cache.bootstrap.ts +12 -0
  97. package/templates/app/modules/core/helpers/cache/index.ts +60 -0
  98. package/templates/app/modules/core/helpers/cache/memory-cache.ts +53 -0
  99. package/templates/app/modules/core/helpers/cache/redis-cache.ts +68 -0
  100. package/templates/app/modules/core/helpers/cache/types.ts +37 -0
  101. package/templates/app/modules/core/helpers/lazy.ts +13 -0
  102. package/templates/app/modules/core/helpers/logger/index.ts +115 -0
  103. package/templates/app/modules/core/helpers/mongoose/index.ts +27 -0
  104. package/templates/app/modules/core/helpers/mongoose/plugins/base.plugin.ts +14 -0
  105. package/templates/app/modules/core/helpers/mongoose/plugins/soft-delete.plugin.ts +124 -0
  106. package/templates/app/modules/core/helpers/mongoose/plugins/timestamps.plugin.ts +13 -0
  107. package/templates/app/modules/core/helpers/mongoose/plugins/types.ts +45 -0
  108. package/templates/app/modules/core/helpers/mongoose/register.ts +20 -0
  109. package/templates/app/modules/core/lib/index.ts +34 -0
  110. package/templates/app/modules/core/module.config.ts +24 -0
  111. package/templates/app/modules/core/package.json +80 -0
  112. package/templates/app/modules/core/routes/core.routes.ts +24 -0
  113. package/templates/app/modules/core/tests/core.test.ts +19 -0
  114. package/templates/app/modules/hello/agent/hello.agent.ts +33 -0
  115. package/templates/app/modules/hello/bootstrap/seed-greetings.bootstrap.ts +27 -0
  116. package/templates/app/modules/hello/controllers/greetings.controllers.ts +43 -0
  117. package/templates/app/modules/hello/controllers/hello.controllers.ts +50 -0
  118. package/templates/app/modules/hello/helpers/greeting.ts +8 -0
  119. package/templates/app/modules/hello/i18n/en.json +8 -0
  120. package/templates/app/modules/hello/i18n/fr.json +8 -0
  121. package/templates/app/modules/hello/models/greeting.model.ts +24 -0
  122. package/templates/app/modules/hello/module.acl.ts +16 -0
  123. package/templates/app/modules/hello/module.config.ts +14 -0
  124. package/templates/app/modules/hello/package.json +24 -0
  125. package/templates/app/modules/hello/queues/hello.queues.ts +38 -0
  126. package/templates/app/modules/hello/routes/greetings.routes.ts +25 -0
  127. package/templates/app/modules/hello/routes/hello.routes.ts +44 -0
  128. package/templates/app/modules/hello/sockets/hello.sockets.ts +21 -0
  129. package/templates/app/modules/hello/tests/hello.test.ts +19 -0
  130. package/templates/app/modules/security/bootstrap/rate-limit.bootstrap.ts +8 -0
  131. package/templates/app/modules/security/helpers/hardening.ts +65 -0
  132. package/templates/app/modules/security/helpers/proxy.ts +16 -0
  133. package/templates/app/modules/security/helpers/rate-limit.ts +150 -0
  134. package/templates/app/modules/security/module.config.ts +59 -0
  135. package/templates/app/modules/security/package.json +26 -0
  136. package/templates/app/modules/security/routes/security.routes.ts +42 -0
  137. package/templates/app/modules/security/tests/security.test.ts +174 -0
  138. package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +12 -0
  139. package/templates/app/modules/settings/controllers/settings.controllers.ts +58 -0
  140. package/templates/app/modules/settings/helpers/access-lease.ts +99 -0
  141. package/templates/app/modules/settings/helpers/log-stream.ts +272 -0
  142. package/templates/app/modules/settings/helpers/log-tail.ts +253 -0
  143. package/templates/app/modules/settings/i18n/en.json +3 -0
  144. package/templates/app/modules/settings/i18n/fr.json +3 -0
  145. package/templates/app/modules/settings/module.acl.ts +12 -0
  146. package/templates/app/modules/settings/package.json +21 -0
  147. package/templates/app/modules/settings/routes/settings.routes.ts +33 -0
  148. package/templates/app/modules/settings/schemas/settings.schemas.ts +8 -0
  149. package/templates/app/modules/settings/tests/settings.test.ts +409 -0
  150. package/templates/app/modules/users/controllers/users.controllers.ts +113 -0
  151. package/templates/app/modules/users/i18n/en.json +14 -0
  152. package/templates/app/modules/users/i18n/fr.json +14 -0
  153. package/templates/app/modules/users/models/user.model.ts +18 -0
  154. package/templates/app/modules/users/module.acl.ts +10 -0
  155. package/templates/app/modules/users/module.config.ts +11 -0
  156. package/templates/app/modules/users/package.json +24 -0
  157. package/templates/app/modules/users/queues/users.queues.ts +31 -0
  158. package/templates/app/modules/users/routes/users.routes.ts +60 -0
  159. package/templates/app/modules/users/schemas/users.schemas.ts +30 -0
  160. package/templates/app/modules/users/sockets/users.sockets.ts +18 -0
  161. package/templates/app/nginx/development.conf +44 -0
  162. package/templates/app/nginx/production.conf +75 -0
  163. package/templates/app/nginx/staging.conf +51 -0
  164. package/templates/app/package.json +58 -0
  165. package/templates/app/scripts/count-greetings.ts +29 -0
  166. package/templates/app/scripts/deploy-nginx.ts +143 -0
  167. package/templates/app/scripts/list-users.ts +34 -0
  168. package/templates/app/tests/app.test.ts +28 -0
  169. package/templates/app/tests/support.ts +36 -0
  170. package/templates/app/tsconfig.json +28 -0
  171. package/templates/app/vitest.config.ts +6 -0
  172. package/templates/app/worker.ts +6 -0
  173. package/templates/claude/skills/tulipes-agent-tool/SKILL.md +83 -0
  174. package/templates/claude/skills/tulipes-boot-errors/SKILL.md +11 -5
  175. package/templates/claude/skills/tulipes-config/SKILL.md +74 -0
  176. package/templates/claude/skills/tulipes-endpoint/SKILL.md +38 -25
  177. package/templates/claude/skills/tulipes-env-variable/SKILL.md +54 -26
  178. package/templates/claude/skills/tulipes-model/SKILL.md +11 -4
  179. package/templates/claude/skills/tulipes-module/SKILL.md +11 -10
  180. package/templates/claude/skills/tulipes-queue/SKILL.md +20 -6
  181. package/templates/claude/skills/tulipes-settings/SKILL.md +49 -100
  182. package/templates/claude/skills/tulipes-spec/SKILL.md +10 -12
  183. package/templates/minimal/README.md +21 -0
  184. package/templates/minimal/app.ts +3 -0
  185. package/templates/minimal/config/app.config.ts +16 -0
  186. package/templates/minimal/modules/agent/module.acl.ts +3 -0
  187. package/templates/minimal/modules/agent/package.json +14 -0
  188. package/templates/minimal/modules/agent/routes/agent.routes.ts +20 -0
  189. package/templates/minimal/modules/core/module.acl.ts +4 -0
  190. package/templates/minimal/modules/core/package.json +21 -0
  191. package/templates/minimal/modules/hello/i18n/en.json +3 -0
  192. package/templates/minimal/modules/hello/module.acl.ts +2 -0
  193. package/templates/minimal/modules/hello/package.json +11 -0
  194. package/templates/minimal/modules/hello/routes/health.routes.ts +9 -0
  195. package/templates/minimal/modules/hello/routes/hello.routes.ts +10 -0
  196. package/templates/minimal/modules/security/package.json +15 -0
  197. package/templates/minimal/modules/security/routes/security.routes.ts +8 -0
  198. package/templates/minimal/package.json +38 -0
  199. package/templates/minimal/tests/app.test.ts +52 -0
  200. package/templates/minimal/tsconfig.json +22 -0
  201. package/templates/minimal/vitest.config.ts +5 -0
  202. package/templates/overlays/no-queues/ecosystem.config.cjs +52 -0
  203. package/templates/overlays/no-queues/modules/hello/controllers/greetings.controllers.ts +41 -0
  204. package/templates/overlays/no-queues/modules/users/controllers/users.controllers.ts +107 -0
  205. package/templates/versions.json +11 -0
  206. package/dist/minimal.d.ts +0 -2
  207. package/dist/minimal.js +0 -2200
  208. package/dist/minimal.js.map +0 -1
  209. /package/templates/{browser-auth.md → app/docs/browser-auth.md} +0 -0
@@ -0,0 +1,772 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, relative } from "node:path";
3
+ const PNPM = "pnpm@10.28.0";
4
+ export async function runMigrate(rootDir, target) {
5
+ if (target !== "0.11") {
6
+ console.error(`unknown migration target "${target}" — this CLI migrates an app to 0.11`);
7
+ process.exitCode = 2;
8
+ return;
9
+ }
10
+ const report = migrate(rootDir);
11
+ for (const line of report.changed)
12
+ console.log(` ${line}`);
13
+ console.log(`\nmigrated ${report.changed.length} thing(s); ${report.todos.length} TODO(s) to review`);
14
+ for (const todo of report.todos)
15
+ console.log(` ! ${todo}`);
16
+ console.log([
17
+ "",
18
+ "next:",
19
+ " pnpm install # the app is a pnpm workspace now",
20
+ " pnpm sync # regenerate types/tulipes.d.ts",
21
+ " pnpm typecheck # the TODOs above are where it may fail",
22
+ " tulipes config:import # carry .envs values that became settings into the store",
23
+ ` review config/migration-0.11.json, then delete it`,
24
+ ].join("\n"));
25
+ }
26
+ export function migrate(rootDir) {
27
+ const report = { changed: [], todos: [], mappings: {} };
28
+ const note = (line) => report.changed.push(line);
29
+ // ── root ────────────────────────────────────────────────────────────────
30
+ migrateRootManifest(rootDir, report);
31
+ migrateTsconfig(rootDir, report);
32
+ migrateVitestConfig(rootDir, report);
33
+ migrateEcosystem(rootDir, report);
34
+ for (const stale of ["types/config.d.ts"]) {
35
+ const file = join(rootDir, stale);
36
+ if (existsSync(file) && readFileSync(file, "utf8").startsWith("// AUTO-GENERATED")) {
37
+ rmSync(file);
38
+ note(`removed ${stale} (tulipes sync writes types/tulipes.d.ts)`);
39
+ }
40
+ }
41
+ // ── modules ─────────────────────────────────────────────────────────────
42
+ const modulesDir = join(rootDir, "modules");
43
+ const modules = existsSync(modulesDir)
44
+ ? readdirSync(modulesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name)
45
+ : [];
46
+ for (const name of modules)
47
+ migrateModule(rootDir, join(modulesDir, name), name, report);
48
+ // ── root files importing what only a module declares (pnpm isolation) ──
49
+ hoistRootImports(rootDir, modules, report);
50
+ // ── every TypeScript file: specifiers, parameter properties, inspection ──
51
+ for (const file of walkTs(rootDir)) {
52
+ const source = readFileSync(file, "utf8");
53
+ let next = rewriteSpecifiers(source);
54
+ next = rewriteParameterProperties(next, relative(rootDir, file), report);
55
+ next = rewriteInspection(next);
56
+ if (next !== source) {
57
+ writeFileSync(file, next);
58
+ note(`rewrote ${relative(rootDir, file)}`);
59
+ }
60
+ }
61
+ const record = join(rootDir, "config", "migration-0.11.json");
62
+ mkdirSync(dirname(record), { recursive: true });
63
+ writeFileSync(record, JSON.stringify({ migrated_at: new Date().toISOString(), settings: report.mappings, todos: report.todos }, null, 2) + "\n");
64
+ note(`wrote config/migration-0.11.json (${Object.keys(report.mappings).length} module(s) with settings imported from variables)`);
65
+ return report;
66
+ }
67
+ // ── root files ────────────────────────────────────────────────────────────
68
+ const BUILTINS = new Set(["assert", "buffer", "child_process", "crypto", "events", "fs", "http", "https", "net", "os", "path", "process", "stream", "timers", "tls", "url", "util", "zlib"]);
69
+ /**
70
+ * yarn hoisted every module's dependencies to the root, so a test or script
71
+ * could import `jsonwebtoken` because the auth module declared it. pnpm
72
+ * installs each package with only its own dependencies: what the root's
73
+ * files import, the root declares — taking the range a module already uses.
74
+ */
75
+ function hoistRootImports(rootDir, modules, report) {
76
+ const file = join(rootDir, "package.json");
77
+ if (!existsSync(file))
78
+ return;
79
+ const pkg = readJson(file);
80
+ const declared = new Set([...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})]);
81
+ const moduleRanges = new Map();
82
+ for (const name of modules) {
83
+ const manifest = join(rootDir, "modules", name, "package.json");
84
+ if (!existsSync(manifest))
85
+ continue;
86
+ const moduleManifest = readJson(manifest);
87
+ for (const [dep, range] of Object.entries({ ...moduleManifest.devDependencies, ...moduleManifest.dependencies })) {
88
+ if (!moduleRanges.has(dep) && !dep.startsWith("@app/"))
89
+ moduleRanges.set(dep, range);
90
+ }
91
+ }
92
+ const rootFiles = walkTs(rootDir).filter((path) => !relative(rootDir, path).startsWith("modules" + "/"));
93
+ const added = [];
94
+ for (const path of rootFiles) {
95
+ for (const match of readFileSync(path, "utf8").matchAll(/(?:from|import)\s*\(?\s*["']([^"'./][^"']*)["']/g)) {
96
+ const specifier = match[1];
97
+ const name = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
98
+ if (name.startsWith("node:") || BUILTINS.has(name) || declared.has(name) || name.startsWith("@app/"))
99
+ continue;
100
+ const range = moduleRanges.get(name);
101
+ if (!range)
102
+ continue;
103
+ (pkg.devDependencies ??= {})[name] = range;
104
+ declared.add(name);
105
+ added.push(name);
106
+ const types = `@types/${name.replace(/^@/, "").replace("/", "__")}`;
107
+ if (!declared.has(types) && moduleRanges.has(types)) {
108
+ pkg.devDependencies[types] = moduleRanges.get(types);
109
+ declared.add(types);
110
+ added.push(types);
111
+ }
112
+ }
113
+ }
114
+ if (added.length > 0) {
115
+ writeJson(file, pkg);
116
+ report.changed.push(`package.json: devDependencies ${added.join(", ")} (imported by root files; pnpm does not hoist a module's dependencies)`);
117
+ }
118
+ }
119
+ function migrateRootManifest(rootDir, report) {
120
+ const file = join(rootDir, "package.json");
121
+ if (!existsSync(file))
122
+ return;
123
+ const pkg = readJson(file);
124
+ let changed = false;
125
+ if (!pkg.packageManager?.startsWith("pnpm@")) {
126
+ pkg.packageManager = PNPM;
127
+ changed = true;
128
+ }
129
+ if (pkg.workspaces) {
130
+ writeFileSync(join(rootDir, "pnpm-workspace.yaml"), "packages:\n" + pkg.workspaces.map((glob) => ` - ${JSON.stringify(glob)}\n`).join(""));
131
+ delete pkg.workspaces;
132
+ changed = true;
133
+ }
134
+ const pnpm = (pkg.pnpm ??= {});
135
+ if (pkg.resolutions) {
136
+ pnpm.overrides = { ...pnpm.overrides, ...Object.fromEntries(Object.entries(pkg.resolutions).map(([key, value]) => [key.replace("pm2/js-yaml", "pm2>js-yaml"), value])) };
137
+ delete pkg.resolutions;
138
+ changed = true;
139
+ }
140
+ const built = new Set(pnpm.onlyBuiltDependencies ?? []);
141
+ built.delete("esbuild");
142
+ if (pkg.tulipes?.providers?.queues)
143
+ built.add("msgpackr-extract");
144
+ const nextBuilt = [...built];
145
+ if (JSON.stringify(nextBuilt) !== JSON.stringify(pnpm.onlyBuiltDependencies ?? [])) {
146
+ pnpm.onlyBuiltDependencies = nextBuilt;
147
+ changed = true;
148
+ }
149
+ if (pkg.scripts) {
150
+ const scripts = pkg.scripts;
151
+ const wanted = {
152
+ start: scripts.start === "tsx app.ts" ? "node app.ts" : undefined,
153
+ worker: scripts.worker === "tsx worker.ts" ? "node worker.ts" : undefined,
154
+ script: scripts.script === "tsx" ? "node" : undefined,
155
+ spec: scripts.spec?.includes("--offline") ? scripts.spec.replace(/\s--offline\b/, "") : undefined,
156
+ };
157
+ for (const [name, value] of Object.entries(wanted)) {
158
+ if (value !== undefined) {
159
+ scripts[name] = value;
160
+ changed = true;
161
+ }
162
+ }
163
+ }
164
+ if (pkg.devDependencies?.tsx) {
165
+ delete pkg.devDependencies.tsx;
166
+ changed = true;
167
+ }
168
+ if (changed) {
169
+ writeJson(file, pkg);
170
+ report.changed.push("package.json: pnpm workspace, node scripts, no tsx");
171
+ }
172
+ for (const yarn of [".yarnrc.yml", "yarn.lock"]) {
173
+ if (existsSync(join(rootDir, yarn))) {
174
+ rmSync(join(rootDir, yarn));
175
+ report.changed.push(`removed ${yarn}`);
176
+ }
177
+ }
178
+ }
179
+ function migrateTsconfig(rootDir, report) {
180
+ const file = join(rootDir, "tsconfig.json");
181
+ if (!existsSync(file))
182
+ return;
183
+ const config = readJson(file);
184
+ const options = (config.compilerOptions ??= {});
185
+ const flags = { allowImportingTsExtensions: true, rewriteRelativeImportExtensions: true, erasableSyntaxOnly: true };
186
+ if (Object.entries(flags).every(([key, value]) => options[key] === value))
187
+ return;
188
+ Object.assign(options, flags);
189
+ writeJson(file, config);
190
+ report.changed.push("tsconfig.json: allowImportingTsExtensions, rewriteRelativeImportExtensions, erasableSyntaxOnly");
191
+ }
192
+ function migrateVitestConfig(rootDir, report) {
193
+ const file = join(rootDir, "vitest.config.ts");
194
+ if (!existsSync(file))
195
+ return;
196
+ const source = readFileSync(file, "utf8");
197
+ const next = source.replace(/,?\s*execArgv:\s*\[\s*"--import",\s*"tsx"\s*\]/, "");
198
+ if (next !== source) {
199
+ writeFileSync(file, next);
200
+ report.changed.push("vitest.config.ts: no tsx loader");
201
+ }
202
+ }
203
+ function migrateEcosystem(rootDir, report) {
204
+ const file = join(rootDir, "ecosystem.config.cjs");
205
+ if (!existsSync(file))
206
+ return;
207
+ const source = readFileSync(file, "utf8");
208
+ const next = source.replace(/interpreter:\s*["']\.\/node_modules\/\.bin\/tsx["']/, 'interpreter: "node"');
209
+ if (next !== source) {
210
+ writeFileSync(file, next);
211
+ report.changed.push("ecosystem.config.cjs: PM2 runs node directly");
212
+ }
213
+ }
214
+ function migrateModule(rootDir, dir, name, report) {
215
+ const label = `modules/${name}`;
216
+ const manifestFile = join(dir, "package.json");
217
+ if (!existsSync(manifestFile))
218
+ return;
219
+ const manifest = readJson(manifestFile);
220
+ let manifestChanged = false;
221
+ // meta.variables.json → package.json tulipes.env
222
+ const meta = join(dir, "meta.variables.json");
223
+ if (existsSync(meta)) {
224
+ const parsed = readJson(meta);
225
+ manifest.tulipes = { ...manifest.tulipes, env: [...(manifest.tulipes?.env ?? []), ...(parsed.variables ?? [])] };
226
+ rmSync(meta);
227
+ manifestChanged = true;
228
+ report.changed.push(`${label}: meta.variables.json → package.json tulipes.env`);
229
+ }
230
+ if (manifest.tulipes && "offline" in manifest.tulipes) {
231
+ delete manifest.tulipes.offline;
232
+ manifestChanged = true;
233
+ report.changed.push(`${label}: tulipes.offline removed (every routes file is inspected)`);
234
+ }
235
+ // index.ts → lib/index.ts
236
+ if (existsSync(join(dir, "index.ts"))) {
237
+ mkdirSync(join(dir, "lib"), { recursive: true });
238
+ const source = readFileSync(join(dir, "index.ts"), "utf8");
239
+ writeFileSync(join(dir, "lib", "index.ts"), deepenRelativeImports(source));
240
+ rmSync(join(dir, "index.ts"));
241
+ manifest.exports = { ".": "./lib/index.ts" };
242
+ manifestChanged = true;
243
+ report.changed.push(`${label}: index.ts → lib/index.ts (package.json exports)`);
244
+ }
245
+ // schemas.ts → schemas/<name>.schemas.ts
246
+ if (existsSync(join(dir, "schemas.ts"))) {
247
+ mkdirSync(join(dir, "schemas"), { recursive: true });
248
+ const source = readFileSync(join(dir, "schemas.ts"), "utf8");
249
+ writeFileSync(join(dir, "schemas", `${name}.schemas.ts`), deepenRelativeImports(source));
250
+ rmSync(join(dir, "schemas.ts"));
251
+ for (const file of walkTs(dir)) {
252
+ const text = readFileSync(file, "utf8");
253
+ const next = text.replace(/(["'])(\.\.?\/)schemas\.(?:js|ts)\1/g, (_match, quote, prefix) => `${quote}${prefix}schemas/${name}.schemas.ts${quote}`);
254
+ if (next !== text)
255
+ writeFileSync(file, next);
256
+ }
257
+ report.changed.push(`${label}: schemas.ts → schemas/${name}.schemas.ts`);
258
+ }
259
+ // module.config.ts: factory + lifecycle → defineConfig + bootstrap
260
+ const configFile = join(dir, "module.config.ts");
261
+ if (existsSync(configFile))
262
+ migrateModuleConfig(rootDir, dir, name, configFile, manifest, report);
263
+ // routes: defineRoutes/runtime → plain functions
264
+ const routesDir = join(dir, "routes");
265
+ if (existsSync(routesDir)) {
266
+ for (const file of readdirSync(routesDir).filter((entry) => entry.endsWith(".ts"))) {
267
+ migrateRoutesFile(join(routesDir, file), dir, name, report);
268
+ }
269
+ }
270
+ // anything else at the root is refused by the boot
271
+ for (const entry of readdirSync(dir)) {
272
+ if (["package.json", "module.config.ts", "module.acl.ts", "node_modules"].includes(entry))
273
+ continue;
274
+ if (statSync(join(dir, entry)).isFile()) {
275
+ report.todos.push(`${label}/${entry}: a module's root holds package.json, module.config.ts and module.acl.ts — move it into a folder (helpers/, lib/, …)`);
276
+ }
277
+ }
278
+ if (manifestChanged)
279
+ writeJson(manifestFile, manifest);
280
+ }
281
+ /** `./helpers/x.ts` → `../helpers/x.ts`: the file moved one folder down. */
282
+ function deepenRelativeImports(source) {
283
+ return rewriteSpecifiers(source).replace(/((?:from|import)\s*\(?\s*['"])(\.\.?\/)/g, (_match, head, prefix) => `${head}${prefix === "./" ? "../" : "../../"}`);
284
+ }
285
+ const CTX_NAMES = ["Environment", "config", "settings", "mode", "modules", "signal", "acl", "models", "queues", "sockets", "app", "routes", "rai", "t", "i18n", "agent"];
286
+ function migrateModuleConfig(rootDir, dir, name, file, manifest, report) {
287
+ const source = readFileSync(file, "utf8");
288
+ if (source.includes("defineConfig("))
289
+ return;
290
+ const label = `modules/${name}/module.config.ts`;
291
+ const factory = /export default (?:async )?function\s*\w*\s*\(([^)]*)\)[^{]*\{\n([\s\S]*?)\n\}/.exec(source);
292
+ const hooks = {};
293
+ for (const hook of ["onReady", "onDrain", "onShutdown"]) {
294
+ const match = new RegExp(`export (?:async )?function ${hook}\\s*\\(([^)]*)\\)[^{]*\\{\\n([\\s\\S]*?)\\n\\}`).exec(source);
295
+ if (match)
296
+ hooks[hook] = { params: match[1], body: match[2] };
297
+ }
298
+ if (!factory && Object.keys(hooks).length === 0) {
299
+ report.todos.push(`${label}: no factory or lifecycle export recognised — rewrite it as defineConfig({ schema }) by hand`);
300
+ return;
301
+ }
302
+ // What the factory returned, field by field.
303
+ const fields = [];
304
+ const prelude = [];
305
+ const variables = manifest.tulipes?.env ?? [];
306
+ const consumed = new Set();
307
+ if (factory) {
308
+ const body = factory[2];
309
+ const returned = /return\s*\{([\s\S]*)\}\s*;?\s*$/.exec(body);
310
+ const before = returned ? body.slice(0, returned.index) : body;
311
+ for (const line of before.split("\n")) {
312
+ if (line.trim() && !line.trim().startsWith("//"))
313
+ prelude.push(line);
314
+ }
315
+ const object = returned?.[1] ?? "";
316
+ for (const entry of splitEntries(object)) {
317
+ const spread = /^\.\.\.config\.(\w+)$/.exec(entry);
318
+ const pair = /^(\w+)\s*:\s*([\s\S]+)$/.exec(entry);
319
+ if (spread) {
320
+ const seed = seedFields(rootDir, spread[1]);
321
+ if (seed)
322
+ fields.push(...seed);
323
+ else
324
+ report.todos.push(`${label}: the factory returned { ...config.${spread[1]} }; declare those fields in the schema with defaults`);
325
+ continue;
326
+ }
327
+ if (!pair) {
328
+ report.todos.push(`${label}: could not read "${entry.trim().slice(0, 60)}" — declare it in the schema by hand`);
329
+ continue;
330
+ }
331
+ const [, key, raw] = pair;
332
+ const expression = raw.trim();
333
+ const read = /^Environment\.get\(["'](\w+)["']\)$/.exec(expression);
334
+ if (read) {
335
+ const variable = variables.find((spec) => spec.name === read[1]);
336
+ if (variable) {
337
+ fields.push(` ${key}: ${zodOf(variable)},`);
338
+ consumed.add(variable.name);
339
+ (report.mappings[name] ??= {})[key] = { variable: variable.name, type: variable.type };
340
+ }
341
+ else {
342
+ fields.push(` ${key}: z.string().default(""), // TODO 0.11: was Environment.get("${read[1]}"), which this module does not declare`);
343
+ report.todos.push(`${label}: ${key} read ${read[1]}, a variable this module does not declare`);
344
+ }
345
+ continue;
346
+ }
347
+ const literal = zodOfLiteral(expression);
348
+ if (literal) {
349
+ fields.push(` ${key}: ${literal},`);
350
+ }
351
+ else {
352
+ fields.push(` ${key}: z.unknown().default(null), // TODO 0.11: was computed: ${expression.replace(/\s+/g, " ").slice(0, 80)}`);
353
+ report.todos.push(`${label}: ${key} was computed at boot (${expression.replace(/\s+/g, " ").slice(0, 60)}); give it a schema and a default`);
354
+ }
355
+ }
356
+ }
357
+ // Variables that only the factory read are settings now.
358
+ for (const variable of consumed) {
359
+ const elsewhere = walkTs(rootDir).some((other) => other !== file && readFileSync(other, "utf8").includes(`"${variable}"`));
360
+ if (elsewhere) {
361
+ report.todos.push(`${label}: ${variable} became a setting but is still read elsewhere — it stays declared in package.json tulipes.env until those reads move to ctx.config.${name}`);
362
+ continue;
363
+ }
364
+ if (manifest.tulipes?.env)
365
+ manifest.tulipes.env = manifest.tulipes.env.filter((spec) => spec.name !== variable);
366
+ }
367
+ if (manifest.tulipes?.env && manifest.tulipes.env.length === 0)
368
+ delete manifest.tulipes.env;
369
+ // Imports: what the schema needs stays; everything the old file imported
370
+ // also travels to the bootstrap file, one folder deeper.
371
+ const imports = source.split("\n").filter((line) => /^import\s/.test(line) || /^import\s*\{/.test(line));
372
+ const keptImports = imports
373
+ .filter((line) => !/from "@tulipes\/core\/boot"/.test(line) || /\b(RuntimeCtx|BootstrapCleanup)\b/.test(line))
374
+ .map((line) => line.replace(/\bConfigCtx,?\s*/g, "").replace(/\{\s*,/, "{").replace(/,\s*\}/, " }"));
375
+ const topLevel = source.split("\n").filter((line) => !/^import\s/.test(line) && !/^export\s/.test(line) && !/^\s/.test(line) && !/^\}/.test(line) && !/^\/\//.test(line) && !/^\/?\*/.test(line) && line.trim() && !line.startsWith("}"));
376
+ const declaration = [
377
+ `import { defineConfig } from "@tulipes/core/config";`,
378
+ `import { z } from "zod/v4";`,
379
+ ...keptImports.filter((line) => !/^import type \{\s*\}/.test(line) && !/from "zod/.test(line)),
380
+ "",
381
+ ...(topLevel.length ? [...topLevel, ""] : []),
382
+ "/**",
383
+ " * Written by `tulipes migrate 0.11` from the 0.10 config factory: every",
384
+ " * field it returned is a setting with a default, stored per deployment",
385
+ " * and edited through the console. Values that came from the environment",
386
+ " * are carried over by `tulipes config:import`.",
387
+ " */",
388
+ "export default defineConfig({",
389
+ " schema: z.object({",
390
+ ...fields,
391
+ " }),",
392
+ "});",
393
+ "",
394
+ ].join("\n");
395
+ writeFileSync(file, declaration);
396
+ report.changed.push(`${label}: factory → defineConfig (${fields.length} field(s))`);
397
+ // Lifecycle bodies → a bootstrap file.
398
+ if (Object.keys(hooks).length > 0 || prelude.length > 0) {
399
+ const names = new Set();
400
+ const aliases = [];
401
+ const collect = (params) => {
402
+ const destructured = /\{([^}]*)\}/.exec(params);
403
+ if (destructured) {
404
+ for (const part of destructured[1].split(",")) {
405
+ const key = part.trim().split(":")[0].trim();
406
+ if (key && CTX_NAMES.includes(key))
407
+ names.add(key);
408
+ }
409
+ }
410
+ else {
411
+ const identifier = params.trim().split(":")[0].trim();
412
+ if (identifier && identifier !== "ctx" && !identifier.startsWith("_"))
413
+ aliases.push(`const ${identifier} = ctx;`);
414
+ }
415
+ };
416
+ if (factory)
417
+ collect(factory[1]);
418
+ for (const hook of Object.values(hooks))
419
+ collect(hook.params);
420
+ const indent = (lines) => lines.map((line) => (line.trim() ? ` ${line}` : line));
421
+ const bootstrapDir = join(dir, "bootstrap");
422
+ mkdirSync(bootstrapDir, { recursive: true });
423
+ const target = join(bootstrapDir, `${name}.lifecycle.bootstrap.ts`);
424
+ const lines = [
425
+ `import type { BootstrapCleanup, RuntimeCtx } from "@tulipes/core/boot";`,
426
+ ...imports.filter((line) => !/from "@tulipes\/core\/boot"/.test(line)).map((line) => deepenRelativeImports(line.replace(/\bConfigCtx,?\s*/g, "").replace(/\{\s*,/, "{").replace(/,\s*\}/, " }"))).filter((line) => !/^import type \{\s*\}/.test(line)),
427
+ "",
428
+ "/**",
429
+ " * Written by `tulipes migrate 0.11` from module.config.ts's lifecycle",
430
+ " * exports. Bootstrap runs in EVERY mode, backend, worker and script,",
431
+ " * after settings, translations and ACL and before routes or queues;",
432
+ " * onReady used to run after the server listened, onShutdown in reverse",
433
+ " * load order after traffic drained — which is when the returned",
434
+ " * cleanup runs now. TODO 0.11: review each block, and guard work a",
435
+ " * one-off script should skip with `if (ctx.mode === \"script\") return;`.",
436
+ " */",
437
+ `export default async function ${camel(name)}Lifecycle(ctx: RuntimeCtx): Promise<BootstrapCleanup | void> {`,
438
+ ...(names.size ? [` const { ${[...names].join(", ")} } = ctx;`] : []),
439
+ ...aliases.map((alias) => ` ${alias}`),
440
+ ...(prelude.length ? [" // TODO 0.11: this ran in the config factory (boot phase 6, before models); it runs at bootstrap now.", ...prelude, ""] : []),
441
+ ...(hooks.onReady ? [" // from onReady:", ...hooks.onReady.body.split("\n"), ""] : []),
442
+ ...(hooks.onDrain ? [" // from onDrain — ctx.signal aborts the moment shutdown begins:", " ctx.signal.addEventListener(\"abort\", () => {", ...indent(hooks.onDrain.body.split("\n")), " }, { once: true });", ""] : []),
443
+ ...(hooks.onShutdown ? [" // from onShutdown — runs after traffic drains, in reverse load order:", " return async () => {", ...indent(hooks.onShutdown.body.split("\n")), " };"] : []),
444
+ "}",
445
+ "",
446
+ ];
447
+ writeFileSync(target, lines.join("\n"));
448
+ report.changed.push(`modules/${name}: lifecycle exports → bootstrap/${name}.lifecycle.bootstrap.ts`);
449
+ report.todos.push(`modules/${name}/bootstrap/${name}.lifecycle.bootstrap.ts: review the moved ${[...(prelude.length ? ["factory statements"] : []), ...Object.keys(hooks)].join(", ")} and the mode guard`);
450
+ }
451
+ }
452
+ /** The `NS: { … }` literal of config/app.config.ts, as schema fields. */
453
+ function seedFields(rootDir, namespace) {
454
+ const file = join(rootDir, "config", "app.config.ts");
455
+ if (!existsSync(file))
456
+ return undefined;
457
+ const source = readFileSync(file, "utf8");
458
+ const block = new RegExp(`\\n\\s*${namespace}:\\s*\\{([\\s\\S]*?)\\n\\s*\\},`).exec(source);
459
+ if (!block)
460
+ return undefined;
461
+ const fields = [];
462
+ for (const entry of splitEntries(block[1])) {
463
+ const pair = /^(\w+)\s*:\s*([\s\S]+)$/.exec(entry);
464
+ const literal = pair && zodOfLiteral(pair[2].trim());
465
+ if (pair && literal)
466
+ fields.push(` ${pair[1]}: ${literal},`);
467
+ else
468
+ return undefined;
469
+ }
470
+ return fields;
471
+ }
472
+ /** Top-level `key: value` entries of an object literal body, comments dropped. */
473
+ function splitEntries(body) {
474
+ const entries = [];
475
+ let depth = 0;
476
+ let current = "";
477
+ let inString;
478
+ let inComment;
479
+ for (let i = 0; i < body.length; i++) {
480
+ const char = body[i];
481
+ const pair = body.slice(i, i + 2);
482
+ if (inComment === "line") {
483
+ if (char === "\n")
484
+ inComment = undefined;
485
+ continue;
486
+ }
487
+ if (inComment === "block") {
488
+ if (pair === "*/") {
489
+ inComment = undefined;
490
+ i++;
491
+ }
492
+ continue;
493
+ }
494
+ if (inString) {
495
+ current += char;
496
+ if (char === "\\") {
497
+ current += body[i + 1] ?? "";
498
+ i++;
499
+ continue;
500
+ }
501
+ if (char === inString)
502
+ inString = undefined;
503
+ continue;
504
+ }
505
+ if (pair === "//") {
506
+ inComment = "line";
507
+ continue;
508
+ }
509
+ if (pair === "/*") {
510
+ inComment = "block";
511
+ i++;
512
+ continue;
513
+ }
514
+ if (char === '"' || char === "'" || char === "`") {
515
+ inString = char;
516
+ current += char;
517
+ continue;
518
+ }
519
+ if ("([{".includes(char))
520
+ depth++;
521
+ if (")]}".includes(char))
522
+ depth--;
523
+ if (char === "," && depth === 0) {
524
+ entries.push(current.trim());
525
+ current = "";
526
+ continue;
527
+ }
528
+ current += char;
529
+ }
530
+ if (current.trim())
531
+ entries.push(current.trim());
532
+ return entries.filter(Boolean);
533
+ }
534
+ function zodOf(spec) {
535
+ const describe = spec.description ? `.describe(${JSON.stringify(spec.description)})` : "";
536
+ const fallback = spec.type === "number" ? 0 : spec.type === "boolean" ? false : spec.type === "enum" ? spec.enum?.[0] ?? "" : "";
537
+ const value = spec.default ?? fallback;
538
+ const base = spec.type === "number" ? "z.number()" : spec.type === "boolean" ? "z.boolean()" : spec.type === "enum" ? `z.enum(${JSON.stringify(spec.enum ?? [])})` : "z.string()";
539
+ const todo = spec.default === undefined ? ` // TODO 0.11: ${spec.name} had no default; this one was invented` : "";
540
+ return `${base}.default(${JSON.stringify(value)})${describe}${todo}`;
541
+ }
542
+ function zodOfLiteral(expression) {
543
+ if (/^-?\d+(\.\d+)?$/.test(expression))
544
+ return `z.number().default(${expression})`;
545
+ if (/^(true|false)$/.test(expression))
546
+ return `z.boolean().default(${expression})`;
547
+ if (/^(["'])(?:(?!\1).)*\1$/.test(expression))
548
+ return `z.string().default(${JSON.stringify(expression.slice(1, -1))})`;
549
+ if (/^\[(\s*(["'])(?:(?!\2).)*\2\s*,?\s*)*\]$/.test(expression)) {
550
+ const items = expression.slice(1, -1).split(",").map((item) => item.trim()).filter(Boolean).map((item) => item.slice(1, -1));
551
+ return `z.array(z.string()).default(${JSON.stringify(items)})`;
552
+ }
553
+ return undefined;
554
+ }
555
+ // ── routes ────────────────────────────────────────────────────────────────
556
+ function migrateRoutesFile(file, moduleDir, name, report) {
557
+ const source = readFileSync(file, "utf8");
558
+ if (!source.includes("defineRoutes("))
559
+ return;
560
+ const label = relative(dirname(moduleDir), file);
561
+ let next = source;
562
+ // The wrapper: `export default defineRoutes(({ app, rai, routes, runtime }) => {` … `});`
563
+ const head = /export default defineRoutes\(\s*(?:async\s*)?\(\s*(\{[^}]*\}|\w+)\s*\)\s*=>\s*\{\n/.exec(next);
564
+ if (!head) {
565
+ report.todos.push(`${label}: defineRoutes() in a shape this migration does not rewrite — make it a plain (ctx: BackendCtx) => Router | void function`);
566
+ return;
567
+ }
568
+ const params = head[1];
569
+ const names = params.startsWith("{") ? params.slice(1, -1).split(",").map((part) => part.trim()).filter(Boolean) : [];
570
+ const usesRuntime = names.includes("runtime") || params === "runtime";
571
+ const bound = names.filter((part) => part !== "runtime");
572
+ const fn = `${camel(basename(file).replace(/\.routes\.ts$/, ""))}Routes`;
573
+ const tail = /\n\}\);\s*$/.exec(next);
574
+ if (!tail) {
575
+ report.todos.push(`${label}: could not find the end of defineRoutes() — rewrite by hand`);
576
+ return;
577
+ }
578
+ next = next.slice(0, head.index) + `export default function ${fn}(ctx: BackendCtx): void {\n` + (bound.length ? ` const { ${bound.join(", ")} } = ctx;\n` : "") + next.slice(head.index + head[0].length, tail.index) + "\n}\n";
579
+ // Controllers: a runtime() that only instantiates one becomes a static import.
580
+ const controller = /const (\w+) = runtime\(async\s*\(?(\w+)\)?\s*=>\s*\{\s*const \{ (\w+) \} = await import\(["']([^"']+)["']\);\s*return new \3\(\2\);\s*\}\);/g;
581
+ const statics = [];
582
+ next = next.replace(controller, (_match, variable, _ctx, Controller, path) => {
583
+ statics.push(`import { ${Controller} } from "${path.replace(/\.js$/, ".ts")}";`);
584
+ return `const ${variable} = new ${Controller}(ctx);`;
585
+ });
586
+ for (const line of statics) {
587
+ const variable = /import \{ (\w+) \}/.exec(line)[1];
588
+ const instances = [...next.matchAll(new RegExp(`const (\\w+) = new ${variable}\\(ctx\\);`, "g"))].map((match) => match[1]);
589
+ for (const instance of instances) {
590
+ next = next.replace(new RegExp(`\\b${instance}\\(\\s*\\w+\\s*=>\\s*\\w+\\.(\\w+)(\\([^)]*\\))?\\s*\\)`, "g"), (_match, member, call) => `${instance}.${member}${call ?? ""}`);
591
+ }
592
+ }
593
+ // Whatever runtime() remains keeps its semantics through a local helper.
594
+ let helperNeeded = false;
595
+ if (usesRuntime && /\bruntime\(/.test(next)) {
596
+ helperNeeded = true;
597
+ next = next.replace(/\bruntime\(\s*async/g, "runtime(ctx, async");
598
+ report.todos.push(`${label}: runtime() kept through helpers/runtime.ts — build from ctx.config and the environment directly where you can (see the reference app's lazy())`);
599
+ }
600
+ // Mounting: routes.mount(app, path, router) → routes.mount(router, path).
601
+ next = next.replace(/routes\.mount\(\s*app\s*,\s*([^,]+?)\s*,\s*(\w+)\s*\)/g, "routes.mount($2, $1)");
602
+ // Imports: no wrapper, the backend context, the controllers, the helper.
603
+ next = next.replace(/import \{ defineRoutes \} from "@tulipes\/core\/http";\n/, "").replace(/import \{ defineRoutes, /, "import { ").replace(/, defineRoutes \}/, " }");
604
+ const added = [`import type { BackendCtx } from "@tulipes/core/boot";`, ...statics, ...(helperNeeded ? [`import { runtime } from "../helpers/runtime.ts";`] : [])];
605
+ const at = importHeaderEnd(next);
606
+ next = next.slice(0, at) + added.join("\n") + "\n" + next.slice(at);
607
+ if (helperNeeded) {
608
+ const helpersDir = join(moduleDir, "helpers");
609
+ mkdirSync(helpersDir, { recursive: true });
610
+ const helper = join(helpersDir, "runtime.ts");
611
+ if (!existsSync(helper))
612
+ writeFileSync(helper, RUNTIME_HELPER);
613
+ }
614
+ writeFileSync(file, next);
615
+ report.changed.push(`${label}: defineRoutes/runtime → plain routes function${statics.length ? ` (${statics.length} controller(s) imported statically)` : ""}`);
616
+ void name;
617
+ }
618
+ const RUNTIME_HELPER = `import type { RequestHandler } from "express";
619
+ import type { BackendCtx } from "@tulipes/core/boot";
620
+
621
+ /**
622
+ * Written by \`tulipes migrate 0.11\`: the 0.10 \`runtime()\` of defineRoutes,
623
+ * kept so a routes file that built middleware from the environment keeps
624
+ * working unchanged — the build runs on the first request, never while the
625
+ * routes are inspected. TODO 0.11: settings are live at ctx.config and the
626
+ * environment is resolved before routes build, so most of this can become
627
+ * plain code; see the reference app's security routes.
628
+ */
629
+ export function runtime<T>(ctx: BackendCtx, build: (ctx: BackendCtx) => Promise<T>) {
630
+ let pending: Promise<T> | undefined;
631
+ return (pick: (value: T) => RequestHandler): RequestHandler => (req, res, next) => {
632
+ pending ??= build(ctx);
633
+ pending.then((value) => pick(value)(req, res, next), next);
634
+ };
635
+ }
636
+ `;
637
+ // ── every file ────────────────────────────────────────────────────────────
638
+ const SPECIFIER = /((?:from|import)\s*\(?\s*['"])(\.{1,2}\/[^'"]+?)\.js(['"])/g;
639
+ export function rewriteSpecifiers(source) {
640
+ return source.replace(SPECIFIER, "$1$2.ts$3");
641
+ }
642
+ /** `constructor(private readonly ctx: Ctx) {}` → a declared field assigned in the body. */
643
+ export function rewriteParameterProperties(source, file, report) {
644
+ let next = source;
645
+ let from = 0;
646
+ let touched = false;
647
+ for (;;) {
648
+ const match = /^([ \t]*)constructor\(/m.exec(next.slice(from));
649
+ if (!match)
650
+ break;
651
+ const open = from + match.index + match[0].length - 1;
652
+ // The parameter list, by depth: types carry parentheses and commas of their own.
653
+ let depth = 0;
654
+ let close = -1;
655
+ for (let i = open; i < next.length; i++) {
656
+ if (next[i] === "(")
657
+ depth++;
658
+ else if (next[i] === ")") {
659
+ depth--;
660
+ if (depth === 0) {
661
+ close = i;
662
+ break;
663
+ }
664
+ }
665
+ }
666
+ if (close === -1)
667
+ break;
668
+ const list = next.slice(open + 1, close);
669
+ const params = splitEntries(list);
670
+ const fields = params.map((part) => /^((?:(?:private|public|protected)\s+)?(?:readonly\s+)?)(\w+)\s*:\s*([\s\S]+)$/.exec(part.trim())).filter((parsed) => parsed !== null)
671
+ .map((parsed) => ({ modifiers: parsed[1].trim(), name: parsed[2], type: parsed[3].trim().replace(/\s+/g, " "), property: parsed[1].trim().length > 0 }));
672
+ const brace = /^\s*\{([ \t]*\})?/.exec(next.slice(close + 1));
673
+ if (fields.length !== params.length || !fields.some((field) => field.property) || !brace) {
674
+ from = close + 1;
675
+ continue;
676
+ }
677
+ const indent = match[1];
678
+ const empty = brace[1] !== undefined;
679
+ const declarations = fields.filter((field) => field.property).map((field) => `${indent}${field.modifiers} ${field.name}: ${field.type};`);
680
+ const assignments = fields.filter((field) => field.property).map((field) => `${indent} this.${field.name} = ${field.name};`);
681
+ const signature = fields.map((field) => `${field.name}: ${field.type}`).join(", ");
682
+ const replacement = `${declarations.join("\n")}\n\n${indent}constructor(${signature}) {\n${assignments.join("\n")}${empty ? `\n${indent}}` : ""}`;
683
+ next = next.slice(0, from + match.index) + replacement + next.slice(close + 1 + brace[0].length);
684
+ from = from + match.index + replacement.length;
685
+ touched = true;
686
+ }
687
+ if (touched)
688
+ report.todos.push(`${file}: parameter properties became declared fields (native type stripping refuses them)`);
689
+ return next;
690
+ }
691
+ /** `await inspectRoutes({ rootDir })` (0.10) → the context of an inspection boot. */
692
+ export function rewriteInspection(source) {
693
+ if (!source.includes("inspectRoutes"))
694
+ return source;
695
+ let next = source;
696
+ for (;;) {
697
+ const start = next.indexOf("await inspectRoutes(");
698
+ if (start === -1)
699
+ break;
700
+ // The call's closing parenthesis, by depth.
701
+ let depth = 0;
702
+ let end = -1;
703
+ for (let i = start + "await inspectRoutes".length; i < next.length; i++) {
704
+ if (next[i] === "(")
705
+ depth++;
706
+ else if (next[i] === ")") {
707
+ depth--;
708
+ if (depth === 0) {
709
+ end = i;
710
+ break;
711
+ }
712
+ }
713
+ }
714
+ if (end === -1)
715
+ break;
716
+ const inner = next.slice(start + "await inspectRoutes(".length, end).trim();
717
+ const options = inner.startsWith("{") ? inner.slice(1, -1).trim() : "";
718
+ next = next.slice(0, start) + `(await boot({ inspect: true, mode: "backend", handleSignals: false, ${options.replace(/,\s*$/, "")} })).ctx` + next.slice(end + 1);
719
+ }
720
+ return next.replace(/\binspectRoutes,\s*/g, "").replace(/,\s*inspectRoutes\b/g, "");
721
+ }
722
+ // ── helpers ───────────────────────────────────────────────────────────────
723
+ /** The offset just after the last import statement, multi-line imports included. */
724
+ function importHeaderEnd(source) {
725
+ const lines = source.split("\n");
726
+ let offset = 0;
727
+ let end = 0;
728
+ let open = false;
729
+ for (const line of lines) {
730
+ const next = offset + line.length + 1;
731
+ if (open) {
732
+ if (/\bfrom\s+['"][^'"]+['"];?\s*$/.test(line) || /^\s*\}\s*;?\s*$/.test(line)) {
733
+ open = false;
734
+ end = next;
735
+ }
736
+ }
737
+ else if (/^import\b/.test(line)) {
738
+ if (/\bfrom\s+['"][^'"]+['"];?\s*$/.test(line) || /^import\s+['"][^'"]+['"];?\s*$/.test(line))
739
+ end = next;
740
+ else
741
+ open = true;
742
+ }
743
+ else if (line.trim() && !line.trim().startsWith("//") && end > 0 && !open) {
744
+ break;
745
+ }
746
+ offset = next;
747
+ }
748
+ return end;
749
+ }
750
+ export function walkTs(dir) {
751
+ const files = [];
752
+ const visit = (current) => {
753
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
754
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith("."))
755
+ continue;
756
+ const path = join(current, entry.name);
757
+ if (entry.isDirectory())
758
+ visit(path);
759
+ else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts"))
760
+ files.push(path);
761
+ }
762
+ };
763
+ visit(dir);
764
+ return files.sort();
765
+ }
766
+ function camel(name) {
767
+ return name.replace(/-([a-z0-9])/g, (_match, char) => char.toUpperCase());
768
+ }
769
+ const readJson = (file) => JSON.parse(readFileSync(file, "utf8"));
770
+ const writeJson = (file, value) => writeFileSync(file, JSON.stringify(value, null, 2) + "\n");
771
+ void renameSync;
772
+ //# sourceMappingURL=migrate.js.map