@endora-commerce/cli 0.100.0

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 (356) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +37 -0
  3. package/dist/bin/endora.d.ts +28 -0
  4. package/dist/bin/endora.d.ts.map +1 -0
  5. package/dist/bin/endora.js +926 -0
  6. package/dist/bin/endora.js.map +1 -0
  7. package/dist/check/estate.d.ts +189 -0
  8. package/dist/check/estate.d.ts.map +1 -0
  9. package/dist/check/estate.js +1037 -0
  10. package/dist/check/estate.js.map +1 -0
  11. package/dist/check/hosts.d.ts +25 -0
  12. package/dist/check/hosts.d.ts.map +1 -0
  13. package/dist/check/hosts.js +1347 -0
  14. package/dist/check/hosts.js.map +1 -0
  15. package/dist/check/index.d.ts +60 -0
  16. package/dist/check/index.d.ts.map +1 -0
  17. package/dist/check/index.js +111 -0
  18. package/dist/check/index.js.map +1 -0
  19. package/dist/check/layout.d.ts +136 -0
  20. package/dist/check/layout.d.ts.map +1 -0
  21. package/dist/check/layout.js +262 -0
  22. package/dist/check/layout.js.map +1 -0
  23. package/dist/check/ledger.d.ts +98 -0
  24. package/dist/check/ledger.d.ts.map +1 -0
  25. package/dist/check/ledger.js +173 -0
  26. package/dist/check/ledger.js.map +1 -0
  27. package/dist/check/peer-owners.d.ts +116 -0
  28. package/dist/check/peer-owners.d.ts.map +1 -0
  29. package/dist/check/peer-owners.js +225 -0
  30. package/dist/check/peer-owners.js.map +1 -0
  31. package/dist/check/report.d.ts +33 -0
  32. package/dist/check/report.d.ts.map +1 -0
  33. package/dist/check/report.js +107 -0
  34. package/dist/check/report.js.map +1 -0
  35. package/dist/check/run.d.ts +147 -0
  36. package/dist/check/run.d.ts.map +1 -0
  37. package/dist/check/run.js +111 -0
  38. package/dist/check/run.js.map +1 -0
  39. package/dist/checks.d.ts +17 -0
  40. package/dist/checks.d.ts.map +1 -0
  41. package/dist/checks.js +17 -0
  42. package/dist/checks.js.map +1 -0
  43. package/dist/dev/index.d.ts +83 -0
  44. package/dist/dev/index.d.ts.map +1 -0
  45. package/dist/dev/index.js +298 -0
  46. package/dist/dev/index.js.map +1 -0
  47. package/dist/generate/divergence.d.ts +38 -0
  48. package/dist/generate/divergence.d.ts.map +1 -0
  49. package/dist/generate/divergence.js +237 -0
  50. package/dist/generate/divergence.js.map +1 -0
  51. package/dist/generate/index.d.ts +90 -0
  52. package/dist/generate/index.d.ts.map +1 -0
  53. package/dist/generate/index.js +369 -0
  54. package/dist/generate/index.js.map +1 -0
  55. package/dist/index.d.ts +23 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +35 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/inputs/declaration.d.ts +18 -0
  60. package/dist/inputs/declaration.d.ts.map +1 -0
  61. package/dist/inputs/declaration.js +64 -0
  62. package/dist/inputs/declaration.js.map +1 -0
  63. package/dist/inputs/env-file.d.ts +73 -0
  64. package/dist/inputs/env-file.d.ts.map +1 -0
  65. package/dist/inputs/env-file.js +134 -0
  66. package/dist/inputs/env-file.js.map +1 -0
  67. package/dist/inputs/prompt.d.ts +21 -0
  68. package/dist/inputs/prompt.d.ts.map +1 -0
  69. package/dist/inputs/prompt.js +59 -0
  70. package/dist/inputs/prompt.js.map +1 -0
  71. package/dist/inputs/resolve.d.ts +163 -0
  72. package/dist/inputs/resolve.d.ts.map +1 -0
  73. package/dist/inputs/resolve.js +290 -0
  74. package/dist/inputs/resolve.js.map +1 -0
  75. package/dist/install/host.d.ts +27 -0
  76. package/dist/install/host.d.ts.map +1 -0
  77. package/dist/install/host.js +90 -0
  78. package/dist/install/host.js.map +1 -0
  79. package/dist/install/index.d.ts +173 -0
  80. package/dist/install/index.d.ts.map +1 -0
  81. package/dist/install/index.js +793 -0
  82. package/dist/install/index.js.map +1 -0
  83. package/dist/install/wizard.d.ts +144 -0
  84. package/dist/install/wizard.d.ts.map +1 -0
  85. package/dist/install/wizard.js +362 -0
  86. package/dist/install/wizard.js.map +1 -0
  87. package/dist/lib/admin-artefacts.d.ts +70 -0
  88. package/dist/lib/admin-artefacts.d.ts.map +1 -0
  89. package/dist/lib/admin-artefacts.js +354 -0
  90. package/dist/lib/admin-artefacts.js.map +1 -0
  91. package/dist/lib/admin-surfaces.d.ts +298 -0
  92. package/dist/lib/admin-surfaces.d.ts.map +1 -0
  93. package/dist/lib/admin-surfaces.js +669 -0
  94. package/dist/lib/admin-surfaces.js.map +1 -0
  95. package/dist/lib/delegated-composer.d.ts +85 -0
  96. package/dist/lib/delegated-composer.d.ts.map +1 -0
  97. package/dist/lib/delegated-composer.js +241 -0
  98. package/dist/lib/delegated-composer.js.map +1 -0
  99. package/dist/lib/divergence-artefacts.d.ts +305 -0
  100. package/dist/lib/divergence-artefacts.d.ts.map +1 -0
  101. package/dist/lib/divergence-artefacts.js +828 -0
  102. package/dist/lib/divergence-artefacts.js.map +1 -0
  103. package/dist/lib/divergence.d.ts +337 -0
  104. package/dist/lib/divergence.d.ts.map +1 -0
  105. package/dist/lib/divergence.js +1005 -0
  106. package/dist/lib/divergence.js.map +1 -0
  107. package/dist/lib/docs-artefacts.d.ts +395 -0
  108. package/dist/lib/docs-artefacts.d.ts.map +1 -0
  109. package/dist/lib/docs-artefacts.js +781 -0
  110. package/dist/lib/docs-artefacts.js.map +1 -0
  111. package/dist/lib/emitted-exports.d.ts +21 -0
  112. package/dist/lib/emitted-exports.d.ts.map +1 -0
  113. package/dist/lib/emitted-exports.js +96 -0
  114. package/dist/lib/emitted-exports.js.map +1 -0
  115. package/dist/lib/emitted-freshness.d.ts +112 -0
  116. package/dist/lib/emitted-freshness.d.ts.map +1 -0
  117. package/dist/lib/emitted-freshness.js +288 -0
  118. package/dist/lib/emitted-freshness.js.map +1 -0
  119. package/dist/lib/entity-index-artefact.d.ts +95 -0
  120. package/dist/lib/entity-index-artefact.d.ts.map +1 -0
  121. package/dist/lib/entity-index-artefact.js +210 -0
  122. package/dist/lib/entity-index-artefact.js.map +1 -0
  123. package/dist/lib/instance-build-inputs.d.ts +108 -0
  124. package/dist/lib/instance-build-inputs.d.ts.map +1 -0
  125. package/dist/lib/instance-build-inputs.js +165 -0
  126. package/dist/lib/instance-build-inputs.js.map +1 -0
  127. package/dist/lib/module-docs.d.ts +473 -0
  128. package/dist/lib/module-docs.d.ts.map +1 -0
  129. package/dist/lib/module-docs.js +711 -0
  130. package/dist/lib/module-docs.js.map +1 -0
  131. package/dist/lib/module-package-subpaths.d.ts +56 -0
  132. package/dist/lib/module-package-subpaths.d.ts.map +1 -0
  133. package/dist/lib/module-package-subpaths.js +223 -0
  134. package/dist/lib/module-package-subpaths.js.map +1 -0
  135. package/dist/lib/module-packages.d.ts +200 -0
  136. package/dist/lib/module-packages.d.ts.map +1 -0
  137. package/dist/lib/module-packages.js +580 -0
  138. package/dist/lib/module-packages.js.map +1 -0
  139. package/dist/lib/module-population.d.ts +129 -0
  140. package/dist/lib/module-population.d.ts.map +1 -0
  141. package/dist/lib/module-population.js +172 -0
  142. package/dist/lib/module-population.js.map +1 -0
  143. package/dist/lib/module-roots.d.ts +337 -0
  144. package/dist/lib/module-roots.d.ts.map +1 -0
  145. package/dist/lib/module-roots.js +586 -0
  146. package/dist/lib/module-roots.js.map +1 -0
  147. package/dist/lib/nested-checkouts.d.ts +33 -0
  148. package/dist/lib/nested-checkouts.d.ts.map +1 -0
  149. package/dist/lib/nested-checkouts.js +160 -0
  150. package/dist/lib/nested-checkouts.js.map +1 -0
  151. package/dist/lib/platform-root.d.ts +43 -0
  152. package/dist/lib/platform-root.d.ts.map +1 -0
  153. package/dist/lib/platform-root.js +134 -0
  154. package/dist/lib/platform-root.js.map +1 -0
  155. package/dist/lib/platform-surface.d.ts +235 -0
  156. package/dist/lib/platform-surface.d.ts.map +1 -0
  157. package/dist/lib/platform-surface.js +393 -0
  158. package/dist/lib/platform-surface.js.map +1 -0
  159. package/dist/lib/port-registrations.d.ts +223 -0
  160. package/dist/lib/port-registrations.d.ts.map +1 -0
  161. package/dist/lib/port-registrations.js +532 -0
  162. package/dist/lib/port-registrations.js.map +1 -0
  163. package/dist/lib/read-size.d.ts +154 -0
  164. package/dist/lib/read-size.d.ts.map +1 -0
  165. package/dist/lib/read-size.js +182 -0
  166. package/dist/lib/read-size.js.map +1 -0
  167. package/dist/lib/registration-owners.d.ts +79 -0
  168. package/dist/lib/registration-owners.d.ts.map +1 -0
  169. package/dist/lib/registration-owners.js +77 -0
  170. package/dist/lib/registration-owners.js.map +1 -0
  171. package/dist/lib/release-index.d.ts +53 -0
  172. package/dist/lib/release-index.d.ts.map +1 -0
  173. package/dist/lib/release-index.js +162 -0
  174. package/dist/lib/release-index.js.map +1 -0
  175. package/dist/lib/repeating-timers.d.ts +79 -0
  176. package/dist/lib/repeating-timers.d.ts.map +1 -0
  177. package/dist/lib/repeating-timers.js +189 -0
  178. package/dist/lib/repeating-timers.js.map +1 -0
  179. package/dist/lib/source-text.d.ts +34 -0
  180. package/dist/lib/source-text.d.ts.map +1 -0
  181. package/dist/lib/source-text.js +80 -0
  182. package/dist/lib/source-text.js.map +1 -0
  183. package/dist/lib/specifiers.d.ts +20 -0
  184. package/dist/lib/specifiers.d.ts.map +1 -0
  185. package/dist/lib/specifiers.js +130 -0
  186. package/dist/lib/specifiers.js.map +1 -0
  187. package/dist/lib/sql-tables.d.ts +166 -0
  188. package/dist/lib/sql-tables.d.ts.map +1 -0
  189. package/dist/lib/sql-tables.js +464 -0
  190. package/dist/lib/sql-tables.js.map +1 -0
  191. package/dist/lib/switchable-modules.d.ts +54 -0
  192. package/dist/lib/switchable-modules.d.ts.map +1 -0
  193. package/dist/lib/switchable-modules.js +104 -0
  194. package/dist/lib/switchable-modules.js.map +1 -0
  195. package/dist/lib/tailwind-sources.d.ts +136 -0
  196. package/dist/lib/tailwind-sources.d.ts.map +1 -0
  197. package/dist/lib/tailwind-sources.js +307 -0
  198. package/dist/lib/tailwind-sources.js.map +1 -0
  199. package/dist/lib/ui-layer.d.ts +54 -0
  200. package/dist/lib/ui-layer.d.ts.map +1 -0
  201. package/dist/lib/ui-layer.js +57 -0
  202. package/dist/lib/ui-layer.js.map +1 -0
  203. package/dist/lib/workspace-packages.d.ts +186 -0
  204. package/dist/lib/workspace-packages.d.ts.map +1 -0
  205. package/dist/lib/workspace-packages.js +351 -0
  206. package/dist/lib/workspace-packages.js.map +1 -0
  207. package/dist/new-instance/deploy.d.ts +211 -0
  208. package/dist/new-instance/deploy.d.ts.map +1 -0
  209. package/dist/new-instance/deploy.js +1381 -0
  210. package/dist/new-instance/deploy.js.map +1 -0
  211. package/dist/new-instance/docs-toolchain.d.ts +66 -0
  212. package/dist/new-instance/docs-toolchain.d.ts.map +1 -0
  213. package/dist/new-instance/docs-toolchain.js +69 -0
  214. package/dist/new-instance/docs-toolchain.js.map +1 -0
  215. package/dist/new-instance/host.d.ts +124 -0
  216. package/dist/new-instance/host.d.ts.map +1 -0
  217. package/dist/new-instance/host.js +276 -0
  218. package/dist/new-instance/host.js.map +1 -0
  219. package/dist/new-instance/index.d.ts +118 -0
  220. package/dist/new-instance/index.d.ts.map +1 -0
  221. package/dist/new-instance/index.js +567 -0
  222. package/dist/new-instance/index.js.map +1 -0
  223. package/dist/new-instance/modules.d.ts +188 -0
  224. package/dist/new-instance/modules.d.ts.map +1 -0
  225. package/dist/new-instance/modules.js +392 -0
  226. package/dist/new-instance/modules.js.map +1 -0
  227. package/dist/new-instance/template.d.ts +505 -0
  228. package/dist/new-instance/template.d.ts.map +1 -0
  229. package/dist/new-instance/template.js +1886 -0
  230. package/dist/new-instance/template.js.map +1 -0
  231. package/dist/new-module/emit.d.ts +67 -0
  232. package/dist/new-module/emit.d.ts.map +1 -0
  233. package/dist/new-module/emit.js +1393 -0
  234. package/dist/new-module/emit.js.map +1 -0
  235. package/dist/new-module/host.d.ts +67 -0
  236. package/dist/new-module/host.d.ts.map +1 -0
  237. package/dist/new-module/host.js +224 -0
  238. package/dist/new-module/host.js.map +1 -0
  239. package/dist/new-module/index.d.ts +32 -0
  240. package/dist/new-module/index.d.ts.map +1 -0
  241. package/dist/new-module/index.js +193 -0
  242. package/dist/new-module/index.js.map +1 -0
  243. package/dist/new-module/spec.d.ts +188 -0
  244. package/dist/new-module/spec.d.ts.map +1 -0
  245. package/dist/new-module/spec.js +404 -0
  246. package/dist/new-module/spec.js.map +1 -0
  247. package/dist/new-module/text.d.ts +15 -0
  248. package/dist/new-module/text.d.ts.map +1 -0
  249. package/dist/new-module/text.js +22 -0
  250. package/dist/new-module/text.js.map +1 -0
  251. package/dist/new-storefront/dockerfile.d.ts +20 -0
  252. package/dist/new-storefront/dockerfile.d.ts.map +1 -0
  253. package/dist/new-storefront/dockerfile.js +131 -0
  254. package/dist/new-storefront/dockerfile.js.map +1 -0
  255. package/dist/new-storefront/gitignore.d.ts +23 -0
  256. package/dist/new-storefront/gitignore.d.ts.map +1 -0
  257. package/dist/new-storefront/gitignore.js +39 -0
  258. package/dist/new-storefront/gitignore.js.map +1 -0
  259. package/dist/new-storefront/index.d.ts +93 -0
  260. package/dist/new-storefront/index.d.ts.map +1 -0
  261. package/dist/new-storefront/index.js +329 -0
  262. package/dist/new-storefront/index.js.map +1 -0
  263. package/dist/new-storefront/npmrc.d.ts +98 -0
  264. package/dist/new-storefront/npmrc.d.ts.map +1 -0
  265. package/dist/new-storefront/npmrc.js +217 -0
  266. package/dist/new-storefront/npmrc.js.map +1 -0
  267. package/dist/new-storefront/reference.d.ts +189 -0
  268. package/dist/new-storefront/reference.d.ts.map +1 -0
  269. package/dist/new-storefront/reference.js +430 -0
  270. package/dist/new-storefront/reference.js.map +1 -0
  271. package/dist/new-storefront/rewrite.d.ts +171 -0
  272. package/dist/new-storefront/rewrite.d.ts.map +1 -0
  273. package/dist/new-storefront/rewrite.js +701 -0
  274. package/dist/new-storefront/rewrite.js.map +1 -0
  275. package/dist/release-index.json +284 -0
  276. package/dist/rules/action-route-permissions.d.ts +141 -0
  277. package/dist/rules/action-route-permissions.d.ts.map +1 -0
  278. package/dist/rules/action-route-permissions.js +556 -0
  279. package/dist/rules/action-route-permissions.js.map +1 -0
  280. package/dist/rules/bundle-pairing.d.ts +74 -0
  281. package/dist/rules/bundle-pairing.d.ts.map +1 -0
  282. package/dist/rules/bundle-pairing.js +281 -0
  283. package/dist/rules/bundle-pairing.js.map +1 -0
  284. package/dist/rules/channel-resolution.d.ts +17 -0
  285. package/dist/rules/channel-resolution.d.ts.map +1 -0
  286. package/dist/rules/channel-resolution.js +382 -0
  287. package/dist/rules/channel-resolution.js.map +1 -0
  288. package/dist/rules/command-coverage.d.ts +210 -0
  289. package/dist/rules/command-coverage.d.ts.map +1 -0
  290. package/dist/rules/command-coverage.js +714 -0
  291. package/dist/rules/command-coverage.js.map +1 -0
  292. package/dist/rules/container-imports.d.ts +60 -0
  293. package/dist/rules/container-imports.d.ts.map +1 -0
  294. package/dist/rules/container-imports.js +158 -0
  295. package/dist/rules/container-imports.js.map +1 -0
  296. package/dist/rules/default-language-prose.d.ts +212 -0
  297. package/dist/rules/default-language-prose.d.ts.map +1 -0
  298. package/dist/rules/default-language-prose.js +710 -0
  299. package/dist/rules/default-language-prose.js.map +1 -0
  300. package/dist/rules/diacritic-folds.d.ts +238 -0
  301. package/dist/rules/diacritic-folds.d.ts.map +1 -0
  302. package/dist/rules/diacritic-folds.js +681 -0
  303. package/dist/rules/diacritic-folds.js.map +1 -0
  304. package/dist/rules/entity-tenant-classification.d.ts +171 -0
  305. package/dist/rules/entity-tenant-classification.d.ts.map +1 -0
  306. package/dist/rules/entity-tenant-classification.js +323 -0
  307. package/dist/rules/entity-tenant-classification.js.map +1 -0
  308. package/dist/rules/entry-presence.d.ts +142 -0
  309. package/dist/rules/entry-presence.d.ts.map +1 -0
  310. package/dist/rules/entry-presence.js +339 -0
  311. package/dist/rules/entry-presence.js.map +1 -0
  312. package/dist/rules/entry-scope.d.ts +91 -0
  313. package/dist/rules/entry-scope.d.ts.map +1 -0
  314. package/dist/rules/entry-scope.js +404 -0
  315. package/dist/rules/entry-scope.js.map +1 -0
  316. package/dist/rules/env-inputs.d.ts +222 -0
  317. package/dist/rules/env-inputs.d.ts.map +1 -0
  318. package/dist/rules/env-inputs.js +951 -0
  319. package/dist/rules/env-inputs.js.map +1 -0
  320. package/dist/rules/kernel-boundary.d.ts +37 -0
  321. package/dist/rules/kernel-boundary.d.ts.map +1 -0
  322. package/dist/rules/kernel-boundary.js +195 -0
  323. package/dist/rules/kernel-boundary.js.map +1 -0
  324. package/dist/rules/nul-bytes.d.ts +233 -0
  325. package/dist/rules/nul-bytes.d.ts.map +1 -0
  326. package/dist/rules/nul-bytes.js +332 -0
  327. package/dist/rules/nul-bytes.js.map +1 -0
  328. package/dist/rules/platform-surface.d.ts +479 -0
  329. package/dist/rules/platform-surface.d.ts.map +1 -0
  330. package/dist/rules/platform-surface.js +749 -0
  331. package/dist/rules/platform-surface.js.map +1 -0
  332. package/dist/rules/port-catches.d.ts +225 -0
  333. package/dist/rules/port-catches.d.ts.map +1 -0
  334. package/dist/rules/port-catches.js +1374 -0
  335. package/dist/rules/port-catches.js.map +1 -0
  336. package/dist/rules/port-shape.d.ts +213 -0
  337. package/dist/rules/port-shape.d.ts.map +1 -0
  338. package/dist/rules/port-shape.js +670 -0
  339. package/dist/rules/port-shape.js.map +1 -0
  340. package/dist/rules/queue-names.d.ts +108 -0
  341. package/dist/rules/queue-names.d.ts.map +1 -0
  342. package/dist/rules/queue-names.js +395 -0
  343. package/dist/rules/queue-names.js.map +1 -0
  344. package/dist/rules/singleton-identity.d.ts +205 -0
  345. package/dist/rules/singleton-identity.d.ts.map +1 -0
  346. package/dist/rules/singleton-identity.js +830 -0
  347. package/dist/rules/singleton-identity.js.map +1 -0
  348. package/dist/rules/subscribe-seam.d.ts +121 -0
  349. package/dist/rules/subscribe-seam.d.ts.map +1 -0
  350. package/dist/rules/subscribe-seam.js +594 -0
  351. package/dist/rules/subscribe-seam.js.map +1 -0
  352. package/dist/rules/transaction-context.d.ts +40 -0
  353. package/dist/rules/transaction-context.d.ts.map +1 -0
  354. package/dist/rules/transaction-context.js +294 -0
  355. package/dist/rules/transaction-context.js.map +1 -0
  356. package/package.json +59 -0
@@ -0,0 +1,1393 @@
1
+ /**
2
+ * What `endora new module` writes (`contracts/module-scaffold-output.md` §1).
3
+ *
4
+ * The template is **code that composes files**, not a directory of files with
5
+ * placeholders (`research.md` §7): most of what a module package holds is
6
+ * conditional on the author's answers, and a placeholder language inside `.ts`
7
+ * files is a language neither `tsc` nor `eslint` can read. What is emitted here
8
+ * is ordinary source, compiled by this package's own build and asserted by tests
9
+ * over the real output.
10
+ *
11
+ * **`package.json` is not in this file, deliberately.** It has an author
12
+ * already — the platform's manifest generator — and a second one is D-100's
13
+ * subject. See `manifest-render.ts`.
14
+ */
15
+ import { adminApiPathFor, adminRoutesOf, adminScreenRouteOf, camelOf, camelOfActionId, gatingPermissionOf, manifestObjectFor, navLabelKeyOf, pageComponentOf, pascalOf, segmentOf, slugOf, TENANT_SCOPE_DECORATORS, } from './spec.js';
16
+ import { flatJson, quote, rootPrefixFor } from './text.js';
17
+ /**
18
+ * Every file except `package.json`, in the order they are written.
19
+ *
20
+ * `depth` is how far the target directory sits below the checkout root, which is
21
+ * what the two tsconfigs' `extends` and the vitest configuration's base import
22
+ * are relative to. It is passed in rather than computed here so the emitter
23
+ * stays a pure function of the specification plus that one fact.
24
+ */
25
+ export function emitModuleFiles(spec, depth) {
26
+ const files = [
27
+ { path: 'tsconfig.json', content: typecheckConfig(spec, depth) },
28
+ { path: 'tsconfig.build.json', content: buildConfig() },
29
+ ];
30
+ if (spec.layers.admin !== null) {
31
+ files.push({ path: 'tsconfig.ui.json', content: uiConfig() });
32
+ }
33
+ files.push({ path: 'vitest.config.ts', content: vitestConfig(spec, depth) }, { path: 'src/manifest.ts', content: moduleManifest(spec) }, { path: 'src/backend/index.ts', content: backendIndex(spec) }, { path: `src/backend/services/${slugOf(spec.id)}.service.ts`, content: service(spec) });
34
+ if (spec.permissions.length > 0) {
35
+ files.push({ path: 'src/backend/routes.admin.ts', content: adminRoutes(spec) });
36
+ }
37
+ if (spec.layers.entities) {
38
+ files.push({ path: `src/backend/entities/${entityFileOf(spec)}`, content: entity(spec) });
39
+ files.push({ path: `src/migrations/${migrationFileOf(spec)}`, content: migration(spec) });
40
+ files.push({ path: 'src/migrations/index.ts', content: migrationsIndex(spec) });
41
+ }
42
+ if (spec.layers.worker) {
43
+ files.push({ path: `src/backend/workers/${slugOf(spec.id)}.worker.ts`, content: worker(spec) });
44
+ }
45
+ if (spec.layers.ports) {
46
+ files.push({ path: 'src/ports/index.ts', content: ports(spec) });
47
+ }
48
+ if (spec.layers.admin !== null) {
49
+ files.push({ path: 'src/admin/index.ts', content: adminIndex(spec) });
50
+ files.push({ path: `src/admin/pages/${pageComponentOf(spec)}.tsx`, content: adminPage(spec) });
51
+ }
52
+ files.push({ path: 'i18n/en.json', content: bundle(spec) });
53
+ files.push({ path: 'i18n/pl.json', content: bundle(spec) });
54
+ files.push({ path: 'test/unit/manifest.test.ts', content: manifestTest(spec) });
55
+ if (spec.layers.admin !== null) {
56
+ files.push({ path: 'test/unit/admin-contributions.test.ts', content: adminTest(spec) });
57
+ }
58
+ return files;
59
+ }
60
+ /** `QuickOrderItem` — the module's own aggregate, when it owns one. */
61
+ export function entityClassOf(spec) {
62
+ return `${pascalOf(spec.id)}Item`;
63
+ }
64
+ /** `quick_order_items` — the table that entity maps, owned by this module alone. */
65
+ export function tableNameOf(spec) {
66
+ return `${segmentOf(spec.id)}_items`;
67
+ }
68
+ export function entityFileOf(spec) {
69
+ return `${slugOf(spec.id)}-item.entity.ts`;
70
+ }
71
+ /**
72
+ * `<stamp>_<segment>_init.ts` — the filename
73
+ * `specs/065-manifest-aware-migrations/contracts/naming-convention.md` §1
74
+ * recognises, with the tail beginning at the owning module's segment so the
75
+ * class name derived from it is scoped (feature 081, `unscoped-name`).
76
+ */
77
+ export function migrationFileOf(spec) {
78
+ return `${spec.migrationStamp}_${segmentOf(spec.id)}_init.ts`;
79
+ }
80
+ /** `Migration<STAMP><PascalCaseTail>` — §2's mechanical derivation from the filename. */
81
+ export function migrationClassOf(spec) {
82
+ const tail = `${segmentOf(spec.id)}_init`
83
+ .split('_')
84
+ .filter((part) => part.length > 0)
85
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
86
+ .join('');
87
+ return `Migration${spec.migrationStamp}${tail}`;
88
+ }
89
+ /** `<Pascal>ReadPort` — the interface `./ports` publishes when the module owns one. */
90
+ export function portInterfaceOf(spec) {
91
+ return `${pascalOf(spec.id)}ReadPort`;
92
+ }
93
+ /** `<camel>ReadPort` — the container name that interface is registered under. */
94
+ export function portNameOf(spec) {
95
+ return `${camelOf(spec.id)}ReadPort`;
96
+ }
97
+ // --- the two build configurations, a constant of the layout ----------------
98
+ function typecheckConfig(spec, depth) {
99
+ const hasAdmin = spec.layers.admin !== null;
100
+ const excluded = hasAdmin
101
+ ? ['src/admin/**/*', 'src/**/*.test.ts', 'src/**/*.spec.ts']
102
+ : ['src/**/*.test.ts', 'src/**/*.spec.ts'];
103
+ const adminExclusionComment = hasAdmin
104
+ ? `
105
+ //
106
+ // \`src/admin/\` is compiled by \`tsconfig.ui.json\` and by nothing else. It is
107
+ // excluded here rather than merged in because this configuration — and
108
+ // \`tsconfig.build.json\`, which extends it — is the one that must **not**
109
+ // carry \`jsx\` or the \`DOM\` lib: a service file that referenced \`document\`
110
+ // would otherwise compile clean, in the layer that runs in Node.`
111
+ : '';
112
+ return `{
113
+ // Type-check configuration, and the two-config split every package under
114
+ // \`packages/\` uses. \`tsconfig.base.json\`'s \`paths\` block is active here, so
115
+ // \`@endora-commerce/contracts\` resolves at its *source* and this package
116
+ // type-checks against the branch it is on (issue #255). \`noEmit\` is set for
117
+ // that reason: with \`paths\` active an emit would land beside another
118
+ // package's source. The emitting configuration is \`tsconfig.build.json\`.
119
+ //
120
+ // \`@endora-commerce/platform\` is deliberately **not** in that \`paths\` block
121
+ // and is resolved here through its own \`exports\` map, at its built \`.d.ts\`.
122
+ // That is the same resolution the running application gets, which is what
123
+ // keeps one copy of \`HttpError\`, \`SalesChannel\` and \`effectiveState\` in the
124
+ // process. This package is refused a \`paths\` entry for its own sources on the
125
+ // same grounds, one step sharper: \`paths\` is honoured by \`tsx\` and not by
126
+ // \`node\`, so an entry would make a \`tsx\` process load this package's sources
127
+ // rather than its \`dist\`, which is the split D-164 measured killing a
128
+ // decorated entity.
129
+ "extends": "${rootPrefixFor(depth)}/tsconfig.base.json",
130
+ "compilerOptions": {
131
+ "outDir": "./dist",
132
+ "noEmit": true,
133
+ "types": ["node"]
134
+ },
135
+ "include": ["src/**/*"],
136
+ // A co-located test must not be compiled into \`dist\`: it imports \`vitest\`, a
137
+ // devDependency, which is an unresolvable specifier in every consumer's
138
+ // install. That is this exclusion's whole job, and it is an *emit* concern —
139
+ // \`tsconfig.build.json\` extends this file, which is how it gets there. It
140
+ // does not stop those tests running: \`vitest\` reads its own \`include\` and
141
+ // transpiles what it collects.${adminExclusionComment}
142
+ "exclude": [${excluded.map((pattern) => `"${pattern}"`).join(', ')}]
143
+ }
144
+ `;
145
+ }
146
+ function buildConfig() {
147
+ return `{
148
+ // Emit configuration — the only configuration in this package allowed to
149
+ // write files, and the declaration the platform's composer reads to learn
150
+ // where this package's sources end up.
151
+ //
152
+ // \`paths\` is cleared, so \`@endora-commerce/contracts\` resolves through its
153
+ // own \`exports\` map to its built \`.d.ts\`. \`rootDir\` with an active \`paths\`
154
+ // block is the TS6059 trap: \`tsc\` reports the sibling's source as outside
155
+ // \`rootDir\`, exits 2, and emits that sibling's \`.js\`/\`.d.ts\` beside its
156
+ // source anyway.
157
+ //
158
+ // The artefact format is the package format, not a per-package judgement
159
+ // (D-164): the manifest generator derives every \`exports\` target from this
160
+ // file's \`rootDir\`/\`outDir\`, so a source-shipping module package is not
161
+ // expressible, and a package that shipped source would be the one whose
162
+ // specifiers resolved differently under \`tsx\` than under \`node\`.
163
+ "extends": "./tsconfig.json",
164
+ "compilerOptions": {
165
+ "paths": {},
166
+ "rootDir": "./src",
167
+ "noEmit": false,
168
+ // A compile that failed must ship nothing. Without this a diagnostic is not
169
+ // a stop — the artefacts are written before the exit code is.
170
+ "noEmitOnError": true
171
+ }
172
+ }
173
+ `;
174
+ }
175
+ function uiConfig() {
176
+ return `{
177
+ // Emit configuration for this package's **admin** layer.
178
+ //
179
+ // A second configuration, and not a widening of the first one, because the
180
+ // two layers run in two runtimes: \`jsx: "react-jsx"\` and the \`DOM\` lib are
181
+ // what a React screen needs and what a service file must not have. One config
182
+ // for both lets a file under \`src/backend/\` reference \`document\` and compile
183
+ // clean, in the layer that runs in Node — the compiler prevents that class of
184
+ // defect for free, and "we will be careful" is not a mechanism.
185
+ //
186
+ // \`paths\` is cleared for \`tsconfig.build.json\`'s reason: \`rootDir\` with an
187
+ // active \`paths\` block is TS6059, which exits 2 and emits the sibling
188
+ // package's output beside that sibling's source. So
189
+ // \`@endora-commerce/admin-kit/*\` resolves through its own \`exports\` map at
190
+ // its built \`.d.ts\`, which is the resolution the admin's Vite build gets too.
191
+ //
192
+ // \`rootDir\` and \`outDir\` are \`tsconfig.build.json\`'s, deliberately: the
193
+ // package publishes one \`dist\`, and the manifest generator derives the
194
+ // \`"./admin"\` target from that single emit layout. Two layouts would be two
195
+ // answers to "where does src/admin/index.ts end up".
196
+ "extends": "./tsconfig.json",
197
+ "compilerOptions": {
198
+ "paths": {},
199
+ "rootDir": "./src",
200
+ "noEmit": false,
201
+ "noEmitOnError": true,
202
+ "jsx": "react-jsx",
203
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
204
+ // No ambient \`node\` types: this layer is a browser bundle, and a screen
205
+ // that reaches for \`process.env\` should not compile.
206
+ "types": []
207
+ },
208
+ "include": ["src/admin/**/*"],
209
+ // The inherited \`exclude\` names \`src/admin/**/*\` — that is what keeps the
210
+ // backend program off this layer — so it has to be **replaced** here rather
211
+ // than inherited, or \`tsc\` reports TS18003 over the one directory this
212
+ // program exists to compile. The test patterns are kept for the sibling
213
+ // config's reason: a co-located test compiled into \`dist\` imports \`vitest\`,
214
+ // an unresolvable specifier in every consumer's install.
215
+ "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"]
216
+ }
217
+ `;
218
+ }
219
+ function vitestConfig(spec, depth) {
220
+ return `// This module package's own test run.
221
+ //
222
+ // Two things here are load-bearing rather than boilerplate:
223
+ //
224
+ // * **\`mergeConfig(baseConfig, …)\`** — the base is the one file every
225
+ // workspace's vitest configuration merges, and it is where issue #255's
226
+ // foreign-workspace-link refusal lives. A configuration that skipped it
227
+ // would let this package's run execute *another checkout's* sources while
228
+ // reporting on this branch.
229
+ // * **no \`--passWithNoTests\`, anywhere** — neither here nor in the generated
230
+ // \`test\` script. A run that collects zero files from a package that ships
231
+ // them must exit non-zero.
232
+
233
+ import { defineConfig, mergeConfig } from 'vitest/config';
234
+ import baseConfig from '${rootPrefixFor(depth)}/vitest.config.base.js';
235
+
236
+ export default mergeConfig(
237
+ baseConfig,
238
+ defineConfig({
239
+ test: {
240
+ name: ${quote(`mod-${slugOf(spec.id)}`)},
241
+ environment: 'node',
242
+ // Both roots the layout contract allows: co-located beside the source,
243
+ // and the package-owned \`test/unit/\` tree.
244
+ include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
245
+ },
246
+ }),
247
+ );
248
+ `;
249
+ }
250
+ // --- src/manifest.ts -------------------------------------------------------
251
+ function moduleManifest(spec) {
252
+ const manifest = manifestObjectFor(spec);
253
+ const lines = [];
254
+ lines.push(`import { defineModuleManifest } from '@endora-commerce/contracts';`);
255
+ lines.push('');
256
+ lines.push('/**');
257
+ lines.push(` * ${spec.name} — module manifest.`);
258
+ lines.push(' *');
259
+ lines.push(' * The root export of a module package, and the module\'s identity of record: the');
260
+ lines.push(' * lifecycle registry, the settings store, every permission code and every');
261
+ lines.push(' * migration owner key on the `id` below, which must equal this directory\'s name');
262
+ lines.push(' * and the `endora.id` in `package.json`.');
263
+ lines.push(' *');
264
+ lines.push(' * `version` is **not** wired to `package.json`\'s. They are different facts: the');
265
+ lines.push(' * package version is `0.0.0` for every package in this family, while this one is');
266
+ lines.push(' * what the lifecycle registry stores and what the admin\'s `pending-upgrade` flag');
267
+ lines.push(' * compares. Importing one into the other puts `0.0.0` in the registry and');
268
+ lines.push(' * silently disables drift detection.');
269
+ lines.push(' */');
270
+ lines.push('export const manifest = defineModuleManifest({');
271
+ lines.push(` id: ${quote(spec.id)},`);
272
+ lines.push(` name: ${quote(spec.name)},`);
273
+ lines.push(` description: ${quote(spec.description)},`);
274
+ lines.push(` version: '1.0.0',`);
275
+ if (spec.dependencies.length === 0) {
276
+ lines.push(' // Every module whose port this one resolves belongs here: that is what makes');
277
+ lines.push(' // the edge real to the lifecycle, to the migration order and to an operator');
278
+ lines.push(' // switching the owner off.');
279
+ lines.push(' dependencies: [],');
280
+ }
281
+ else {
282
+ lines.push(' // A module whose port this one resolves belongs here. The declaration is');
283
+ lines.push(' // what makes the edge real to the lifecycle, to the migration order and to');
284
+ lines.push(' // an operator switching the owner off — `check:port-dependencies` refuses a');
285
+ lines.push(' // `lazyPort` resolution the manifest does not declare.');
286
+ lines.push(' dependencies: [');
287
+ for (const dependency of spec.dependencies)
288
+ lines.push(` ${quote(dependency)},`);
289
+ lines.push(' ],');
290
+ }
291
+ if (spec.activation.kind === 'setting') {
292
+ lines.push(' // Constitution XVII — the operator activation axis. A module that is off');
293
+ lines.push(' // behaves as if never installed; off is non-destructive and reversible.');
294
+ lines.push(` activation: { settingCode: ${quote(spec.activation.settingCode)}, default: true },`);
295
+ }
296
+ else {
297
+ lines.push(' // Constitution XVII — this module declares that the platform cannot run');
298
+ lines.push(' // without it, so the orchestrator refuses to disable *or* uninstall it, and');
299
+ lines.push(' // a composition that lacks it is refused before any module registers.');
300
+ lines.push(' activation: {');
301
+ lines.push(' nonDeactivatable: true,');
302
+ lines.push(` reason: ${quote(spec.activation.reason)},`);
303
+ lines.push(' },');
304
+ }
305
+ lines.push(` i18n: { bundlesDir: 'i18n' },`);
306
+ if (manifest.permissions !== undefined) {
307
+ lines.push(' // Every code a `requireAdmin(...)` literal in this module enforces, so it is');
308
+ lines.push(' // grantable on /admin-roles. The label an operator reads is the');
309
+ lines.push(' // `adminRoles.permission.<code>` key in this package\'s own bundles.');
310
+ lines.push(' permissions: [');
311
+ for (const permission of manifest.permissions) {
312
+ lines.push(' {');
313
+ lines.push(` code: ${quote(permission.code)},`);
314
+ lines.push(` module: ${quote(permission.module ?? spec.id)},`);
315
+ lines.push(` label: ${quote(permission.label)},`);
316
+ lines.push(' },');
317
+ }
318
+ lines.push(' ],');
319
+ }
320
+ if (manifest.actions !== undefined) {
321
+ lines.push(' // Principle XVI — a module with an admin surface is discoverable under');
322
+ lines.push(' // CTRL+K. `requiredPermission` is the code enforced on this action\'s own');
323
+ lines.push(' // target route, so the palette never advertises a 403.');
324
+ lines.push(' actions: [');
325
+ for (const action of manifest.actions) {
326
+ lines.push(' {');
327
+ lines.push(` id: ${quote(action.id)},`);
328
+ lines.push(` labelKey: ${quote(action.labelKey)},`);
329
+ lines.push(` descriptionKey: ${quote(action.descriptionKey ?? '')},`);
330
+ lines.push(` icon: ${quote(action.icon)},`);
331
+ lines.push(` targetRoute: ${quote(action.targetRoute)},`);
332
+ lines.push(` requiredPermission: ${quote(action.requiredPermission ?? '')},`);
333
+ lines.push(` keywords: [${action.keywords.map(quote).join(', ')}],`);
334
+ lines.push(` weight: ${String(action.weight)},`);
335
+ lines.push(' },');
336
+ }
337
+ lines.push(' ],');
338
+ }
339
+ lines.push('});');
340
+ lines.push('');
341
+ return lines.join('\n');
342
+ }
343
+ // --- src/backend/index.ts --------------------------------------------------
344
+ function backendIndex(spec) {
345
+ const pascal = pascalOf(spec.id);
346
+ const camel = camelOf(spec.id);
347
+ const cradleName = `${pascal}Cradle`;
348
+ const serviceName = `${pascal}Service`;
349
+ const hasRoutes = spec.permissions.length > 0;
350
+ const registersDeferred = hasRoutes || spec.layers.worker;
351
+ const lines = [];
352
+ lines.push(`import type { ModuleContext } from '@endora-commerce/platform/kernel';`);
353
+ if (hasRoutes) {
354
+ lines.push(`import type { RequireAdminFactory } from '@endora-commerce/platform/kernel';`);
355
+ }
356
+ if (spec.layers.entities) {
357
+ lines.push(`import type { EntityManager } from '@mikro-orm/postgresql';`);
358
+ }
359
+ if (spec.layers.worker) {
360
+ lines.push(`import type { Redis } from 'ioredis';`);
361
+ }
362
+ if (spec.layers.ports) {
363
+ lines.push(`import type { ${portInterfaceOf(spec)} } from '../ports/index.js';`);
364
+ }
365
+ lines.push(`import { ${serviceName} } from './services/${slugOf(spec.id)}.service.js';`);
366
+ if (hasRoutes) {
367
+ lines.push(`import { register${pascal}AdminRoutes } from './routes.admin.js';`);
368
+ }
369
+ if (spec.layers.entities) {
370
+ lines.push(`import { ${entityClassOf(spec)} } from './entities/${slugOf(spec.id)}-item.entity.js';`);
371
+ }
372
+ if (spec.layers.worker) {
373
+ lines.push(`import { create${pascal}Worker } from './workers/${slugOf(spec.id)}.worker.js';`);
374
+ }
375
+ lines.push('');
376
+ lines.push('/**');
377
+ lines.push(` * ${spec.name} — composition (\`registerModule\`).`);
378
+ lines.push(' *');
379
+ lines.push(' * The kernel container composes this module; there is no composition root that');
380
+ lines.push(' * constructs its services. Everything it owns is registered here, everything it');
381
+ lines.push(' * borrows is resolved through a seam, and every seam applies the module gate, so');
382
+ lines.push(' * an operator switching this module off stops its routes, its queue consumers and');
383
+ lines.push(' * its subscriptions without a check written per call site.');
384
+ lines.push(' */');
385
+ lines.push('');
386
+ lines.push('/** What this module resolves from the container, and the names it owns. */');
387
+ lines.push(`export interface ${cradleName} {`);
388
+ if (spec.layers.entities)
389
+ lines.push(' readonly emFactory: () => EntityManager;');
390
+ if (hasRoutes)
391
+ lines.push(' readonly requireAdmin: RequireAdminFactory;');
392
+ if (spec.layers.worker) {
393
+ lines.push(' /** The host\'s queue connection, absent in a composition that runs no queue. */');
394
+ lines.push(' readonly moduleQueueRedis: Redis | undefined;');
395
+ }
396
+ lines.push(` readonly ${camel}Service: ${serviceName};`);
397
+ lines.push('}');
398
+ lines.push('');
399
+ if (spec.layers.subscriber) {
400
+ lines.push('/** This module\'s own settings namespace, watched by the subscriber below. */');
401
+ lines.push(`const SETTINGS_NAMESPACE = ${quote(`${spec.id}.`)};`);
402
+ lines.push('');
403
+ lines.push('/** The changed setting\'s code, read off the event payload without trusting it. */');
404
+ lines.push('function settingCodeOf(payload: unknown): string | null {');
405
+ lines.push(' const code = (payload as { settingCode?: unknown } | null)?.settingCode;');
406
+ lines.push(' return typeof code === \'string\' ? code : null;');
407
+ lines.push('}');
408
+ lines.push('');
409
+ }
410
+ lines.push(`export function registerModule(ctx: ModuleContext): void {`);
411
+ lines.push(' ctx.di.register({');
412
+ if (spec.layers.entities) {
413
+ lines.push(` ${camel}Service: ctx`);
414
+ lines.push(` .asFunction(({ emFactory }: ${cradleName}) => new ${serviceName}(emFactory))`);
415
+ lines.push(' .singleton(),');
416
+ }
417
+ else {
418
+ lines.push(` ${camel}Service: ctx.asFunction(() => new ${serviceName}()).singleton(),`);
419
+ }
420
+ lines.push(' });');
421
+ if (spec.layers.ports) {
422
+ lines.push('');
423
+ lines.push(' // The one name another module may resolve. `providePort` wraps the');
424
+ lines.push(' // registration in a transient gate on this module\'s effective state, so a');
425
+ lines.push(' // consumer resolving it while this module is off gets the 503 MODULE_DISABLED');
426
+ lines.push(' // envelope at the call site instead of a half-executed operation.');
427
+ lines.push(' //');
428
+ lines.push(' // A consumer reaches it with');
429
+ lines.push(' // `lazyPort<' + portInterfaceOf(spec) + '>(ctx, ' + quote(portNameOf(spec)) + ')`');
430
+ lines.push(' // and declares this module in its own manifest `dependencies`.');
431
+ lines.push(` ctx.di.providePort<${portInterfaceOf(spec)}>(`);
432
+ lines.push(` ${quote(portNameOf(spec))},`);
433
+ lines.push(' ctx');
434
+ lines.push(` .asFunction(({ ${camel}Service }: ${cradleName}) => ({`);
435
+ if (spec.layers.entities) {
436
+ lines.push(` list: () => ${camel}Service.list(),`);
437
+ }
438
+ else {
439
+ lines.push(` status: () => ${camel}Service.status(),`);
440
+ }
441
+ lines.push(' }))');
442
+ lines.push(' .singleton(),');
443
+ lines.push(' );');
444
+ }
445
+ if (registersDeferred) {
446
+ lines.push('');
447
+ lines.push(' // `ctx.routes` is the gated registration seam: everything registered inside it');
448
+ lines.push(' // stops answering when the module is off, including routes added later.');
449
+ lines.push(' // Never call `defineModuleRoutes` by hand.');
450
+ lines.push(' ctx.routes(async (app) => {');
451
+ const destructured = [
452
+ ...(hasRoutes ? ['requireAdmin'] : []),
453
+ `${camel}Service`,
454
+ ...(spec.layers.worker ? ['moduleQueueRedis'] : []),
455
+ ];
456
+ if (destructured.length > 2) {
457
+ lines.push(' const {');
458
+ for (const name of destructured)
459
+ lines.push(` ${name},`);
460
+ lines.push(` } = ctx.cradle<${cradleName}>();`);
461
+ }
462
+ else {
463
+ lines.push(` const { ${destructured.join(', ')} } = ctx.cradle<${cradleName}>();`);
464
+ }
465
+ if (hasRoutes) {
466
+ lines.push(` await register${pascal}AdminRoutes(app, {`);
467
+ lines.push(` service: ${camel}Service,`);
468
+ lines.push(' requireAdmin,');
469
+ lines.push(' });');
470
+ }
471
+ if (spec.layers.worker) {
472
+ lines.push('');
473
+ lines.push(' // Principle X — a queue consumer, registered through `ctx.worker` so the');
474
+ lines.push(' // module gate can stop it. A bare `new Worker` kept in a local is a');
475
+ lines.push(' // consumer nothing in the platform can pause.');
476
+ lines.push(` if (process.env['BACKEND_ROLE'] !== 'api' && moduleQueueRedis !== undefined) {`);
477
+ lines.push(' ctx.worker(');
478
+ lines.push(` create${pascal}Worker(moduleQueueRedis, async () => ({`);
479
+ if (spec.layers.entities) {
480
+ lines.push(` scanned: (await ${camel}Service.list()).length,`);
481
+ }
482
+ else {
483
+ lines.push(` scanned: ${camel}Service.status().module.length,`);
484
+ }
485
+ lines.push(' })),');
486
+ lines.push(' { logger: app.log },');
487
+ lines.push(' );');
488
+ lines.push(' }');
489
+ }
490
+ lines.push(' });');
491
+ }
492
+ if (spec.layers.subscriber) {
493
+ lines.push('');
494
+ lines.push(' // Constitution XVII item 2 — an EventBus subscription registered through');
495
+ lines.push(' // `ctx.subscribe`, from this file. A bare `eventBus.on` in a service body is a');
496
+ lines.push(' // subscription the module gate cannot reach.');
497
+ lines.push(` ctx.subscribe('settings.value_changed', (payload) => {`);
498
+ lines.push(' const code = settingCodeOf(payload);');
499
+ lines.push(' if (code === null || !code.startsWith(SETTINGS_NAMESPACE)) return;');
500
+ lines.push(' ctx.log.info(');
501
+ lines.push(' { settingCode: code },');
502
+ lines.push(" 'a setting this module owns changed; anything derived from it is stale here',");
503
+ lines.push(' );');
504
+ lines.push(' });');
505
+ }
506
+ lines.push('}');
507
+ if (spec.layers.entities) {
508
+ lines.push('');
509
+ lines.push('/**');
510
+ lines.push(' * The module\'s persisted entity classes, on the `./backend` subpath, as one');
511
+ lines.push(' * array and **no named class export** (D-168).');
512
+ lines.push(' *');
513
+ lines.push(' * This is the shape the platform reads when the package is *installed*: the');
514
+ lines.push(' * boot-time loader and the static declaration reader both take');
515
+ lines.push(' * `exported[\'entities\']`, and a missing array is answered with `[]` — zero');
516
+ lines.push(' * entities registered, no error anywhere.');
517
+ lines.push(' */');
518
+ lines.push(`export const entities = [${entityClassOf(spec)}];`);
519
+ }
520
+ lines.push('');
521
+ return lines.join('\n');
522
+ }
523
+ // --- src/backend/services/<slug>.service.ts --------------------------------
524
+ function service(spec) {
525
+ const pascal = pascalOf(spec.id);
526
+ const lines = [];
527
+ if (spec.layers.entities) {
528
+ lines.push(`import type { EntityManager } from '@mikro-orm/postgresql';`);
529
+ if (spec.layers.ports) {
530
+ lines.push(`import type { ${pascal}Record } from '../../ports/index.js';`);
531
+ }
532
+ lines.push(`import { ${entityClassOf(spec)} } from '../entities/${slugOf(spec.id)}-item.entity.js';`);
533
+ lines.push('');
534
+ if (!spec.layers.ports) {
535
+ lines.push('/** One row as this module hands it out: never the managed entity itself. */');
536
+ lines.push(`export interface ${pascal}Record {`);
537
+ lines.push(' readonly id: string;');
538
+ lines.push(' readonly label: string;');
539
+ lines.push(' readonly createdAt: string;');
540
+ lines.push('}');
541
+ lines.push('');
542
+ }
543
+ lines.push('/**');
544
+ lines.push(` * ${spec.name} — the module's own service.`);
545
+ lines.push(' *');
546
+ lines.push(' * It takes an `EntityManager` **factory** rather than an instance: the request');
547
+ lines.push(' * scope forks one per request, so a captured instance is one transaction serving');
548
+ lines.push(' * every caller.');
549
+ lines.push(' *');
550
+ lines.push(' * This one only reads. A write belongs on the Command Bus (Constitution XIII):');
551
+ lines.push(' * `commandBus.run(...)` is what keeps auditing and undo uniform, and');
552
+ lines.push(' * `check:command-coverage` reports a `persist`/`flush`/`nativeUpdate` that runs');
553
+ lines.push(' * outside one.');
554
+ lines.push(' */');
555
+ lines.push(`export class ${pascal}Service {`);
556
+ lines.push(' constructor(private readonly emFactory: () => EntityManager) {}');
557
+ lines.push('');
558
+ lines.push(` async list(): Promise<readonly ${pascal}Record[]> {`);
559
+ lines.push(` const rows = await this.emFactory().find(`);
560
+ lines.push(` ${entityClassOf(spec)},`);
561
+ lines.push(' {},');
562
+ lines.push(` { orderBy: { createdAt: 'desc' }, limit: 100 },`);
563
+ lines.push(' );');
564
+ lines.push(' return rows.map((row) => ({');
565
+ lines.push(' id: row.id,');
566
+ lines.push(' label: row.label,');
567
+ lines.push(' createdAt: row.createdAt.toISOString(),');
568
+ lines.push(' }));');
569
+ lines.push(' }');
570
+ lines.push('}');
571
+ }
572
+ else {
573
+ if (spec.layers.ports) {
574
+ lines.push(`import type { ${pascal}Status } from '../../ports/index.js';`);
575
+ lines.push('');
576
+ }
577
+ else {
578
+ lines.push('/** What this module reports about itself. */');
579
+ lines.push(`export interface ${pascal}Status {`);
580
+ lines.push(' readonly module: string;');
581
+ lines.push(' readonly name: string;');
582
+ lines.push('}');
583
+ lines.push('');
584
+ }
585
+ lines.push('/**');
586
+ lines.push(` * ${spec.name} — the module's own service.`);
587
+ lines.push(' *');
588
+ lines.push(' * This module owns no table, so the service holds no `EntityManager`. A write');
589
+ lines.push(' * belongs on the Command Bus (Constitution XIII): `commandBus.run(...)` is what');
590
+ lines.push(' * keeps auditing and undo uniform.');
591
+ lines.push(' */');
592
+ lines.push(`export class ${pascal}Service {`);
593
+ lines.push(` status(): ${pascal}Status {`);
594
+ lines.push(' return {');
595
+ lines.push(` module: ${quote(spec.id)},`);
596
+ lines.push(` name: ${quote(spec.name)},`);
597
+ lines.push(' };');
598
+ lines.push(' }');
599
+ lines.push('}');
600
+ }
601
+ lines.push('');
602
+ return lines.join('\n');
603
+ }
604
+ // --- src/backend/routes.admin.ts -------------------------------------------
605
+ function adminRoutes(spec) {
606
+ const pascal = pascalOf(spec.id);
607
+ const gate = gatingPermissionOf(spec);
608
+ if (gate === null)
609
+ throw new Error('adminRoutes emitted without a permission to gate on');
610
+ const lines = [];
611
+ lines.push(`import type { FastifyInstance } from 'fastify';`);
612
+ lines.push(`import type { RequireAdminFactory } from '@endora-commerce/platform/kernel';`);
613
+ lines.push(`import type { ${pascal}Service } from './services/${slugOf(spec.id)}.service.js';`);
614
+ lines.push('');
615
+ lines.push('/**');
616
+ lines.push(` * ${spec.name} — admin routes.`);
617
+ lines.push(' *');
618
+ lines.push(' * The permission code is a **literal** in the `requireAdmin(...)` call, because');
619
+ lines.push(' * that is what the permission inventory and `check:action-route-permissions` read;');
620
+ lines.push(' * a computed code is an argument they refuse rather than skip. It matches the');
621
+ lines.push(' * `permissions` entry in `src/manifest.ts` exactly, which is what makes it');
622
+ lines.push(' * grantable on /admin-roles.');
623
+ lines.push(' */');
624
+ lines.push(`export interface ${pascal}AdminRoutesDeps {`);
625
+ lines.push(` readonly service: ${pascal}Service;`);
626
+ lines.push(' readonly requireAdmin: RequireAdminFactory;');
627
+ lines.push('}');
628
+ lines.push('');
629
+ lines.push(`export async function register${pascal}AdminRoutes(`);
630
+ lines.push(' app: FastifyInstance,');
631
+ lines.push(` deps: ${pascal}AdminRoutesDeps,`);
632
+ lines.push('): Promise<void> {');
633
+ lines.push(' const { service, requireAdmin } = deps;');
634
+ for (const route of adminRoutesOf(spec)) {
635
+ lines.push('');
636
+ lines.push(' app.get(');
637
+ lines.push(` ${quote(adminApiPathFor(route))},`);
638
+ lines.push(` { preHandler: requireAdmin(${quote(gate.code)}) },`);
639
+ if (spec.layers.entities) {
640
+ lines.push(' async () => ({ data: await service.list() }),');
641
+ }
642
+ else {
643
+ lines.push(' async () => ({ data: service.status() }),');
644
+ }
645
+ lines.push(' );');
646
+ }
647
+ lines.push('}');
648
+ lines.push('');
649
+ return lines.join('\n');
650
+ }
651
+ // --- src/backend/entities/<slug>-item.entity.ts ----------------------------
652
+ /** The tenant column each scope adds to the entity and to the table. */
653
+ function tenantColumn(spec) {
654
+ if (spec.tenantScope === 'org-scoped') {
655
+ return { property: 'organizationId', column: 'organization_id' };
656
+ }
657
+ if (spec.tenantScope === 'customer-scoped') {
658
+ return { property: 'customerAccountId', column: 'customer_account_id' };
659
+ }
660
+ return null;
661
+ }
662
+ function entity(spec) {
663
+ const decorator = TENANT_SCOPE_DECORATORS[spec.tenantScope];
664
+ const tenant = tenantColumn(spec);
665
+ const lines = [];
666
+ lines.push(`import { Entity, Index, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';`);
667
+ lines.push(`import { ${decorator} } from '@endora-commerce/platform/tenancy';`);
668
+ lines.push(`import { randomUUID } from 'node:crypto';`);
669
+ lines.push('');
670
+ lines.push('/**');
671
+ lines.push(` * ${spec.name} — this module's own aggregate.`);
672
+ lines.push(' *');
673
+ lines.push(` * It carries exactly one tenant-scope decorator, \`@${decorator}\` (Principle XI).`);
674
+ lines.push(' * That is not documentation: the decorator is what registers the class with the');
675
+ lines.push(' * global filter, and `check-entity-tenant-classification` refuses an entity that');
676
+ lines.push(' * carries none or more than one — including one shipped inside an installed');
677
+ lines.push(' * package, read out of its `./backend` artefact.');
678
+ lines.push(' *');
679
+ lines.push(` * The table is this module's alone. Reading another module's table is a`);
680
+ lines.push(' * `check:module-boundary` finding whether the reach is a specifier or SQL.');
681
+ lines.push(' */');
682
+ lines.push(`@${decorator}()`);
683
+ lines.push(`@Entity({ tableName: ${quote(tableNameOf(spec))} })`);
684
+ lines.push(`export class ${entityClassOf(spec)} {`);
685
+ lines.push(` [OptionalProps]?: 'id' | 'createdAt' | 'updatedAt';`);
686
+ lines.push('');
687
+ lines.push(` @PrimaryKey({ type: 'uuid' })`);
688
+ lines.push(' id: string = randomUUID();');
689
+ if (tenant !== null) {
690
+ lines.push('');
691
+ lines.push(` @Property({ type: 'uuid' })`);
692
+ lines.push(' @Index()');
693
+ lines.push(` ${tenant.property}!: string;`);
694
+ }
695
+ lines.push('');
696
+ lines.push(` @Property({ type: 'string', length: 200 })`);
697
+ lines.push(' label!: string;');
698
+ lines.push('');
699
+ lines.push(` @Property({ type: 'datetime', onCreate: () => new Date() })`);
700
+ lines.push(' createdAt: Date = new Date();');
701
+ lines.push('');
702
+ lines.push(` @Property({ type: 'datetime', onUpdate: () => new Date() })`);
703
+ lines.push(' updatedAt: Date = new Date();');
704
+ lines.push('}');
705
+ lines.push('');
706
+ return lines.join('\n');
707
+ }
708
+ // --- src/migrations/ -------------------------------------------------------
709
+ function migration(spec) {
710
+ const table = tableNameOf(spec);
711
+ const tenant = tenantColumn(spec);
712
+ const columns = [
713
+ ' "id" uuid not null,',
714
+ ...(tenant === null ? [] : [` "${tenant.column}" uuid not null,`]),
715
+ ' "label" varchar(200) not null,',
716
+ ' "created_at" timestamptz not null,',
717
+ ' "updated_at" timestamptz not null,',
718
+ ` constraint "${table}_pkey" primary key ("id")`,
719
+ ];
720
+ const lines = [];
721
+ lines.push(`import { Migration } from '@mikro-orm/migrations';`);
722
+ lines.push('');
723
+ lines.push('/**');
724
+ lines.push(` * ${spec.name} — initial schema.`);
725
+ lines.push(' *');
726
+ lines.push(' * The class name is derived mechanically from the file name and is scoped by the');
727
+ lines.push(' * owning module\'s segment, which is what keeps it unique across every module the');
728
+ lines.push(' * platform can compose. `mikro_orm_migrations` stores that name, so renaming it');
729
+ lines.push(' * after it has been applied makes every migrated database see it as pending.');
730
+ lines.push(' *');
731
+ lines.push(' * Nothing orders this migration against another module\'s except this module\'s');
732
+ lines.push(' * manifest `dependencies`: the timestamp orders a module\'s own migrations and');
733
+ lines.push(' * nothing else. A foreign key into another module\'s table needs that module');
734
+ lines.push(' * declared there.');
735
+ lines.push(' */');
736
+ lines.push(`export class ${migrationClassOf(spec)} extends Migration {`);
737
+ lines.push(' override async up(): Promise<void> {');
738
+ lines.push(' this.addSql(`');
739
+ lines.push(` create table "${table}" (`);
740
+ lines.push(...columns);
741
+ lines.push(' );');
742
+ lines.push(' `);');
743
+ if (tenant !== null) {
744
+ lines.push('');
745
+ lines.push(' this.addSql(`');
746
+ lines.push(` create index "${table}_${tenant.column}_idx"`);
747
+ lines.push(` on "${table}" ("${tenant.column}");`);
748
+ lines.push(' `);');
749
+ }
750
+ lines.push(' }');
751
+ lines.push('');
752
+ lines.push(' override async down(): Promise<void> {');
753
+ lines.push(` this.addSql('drop table if exists "${table}";');`);
754
+ lines.push(' }');
755
+ lines.push('}');
756
+ lines.push('');
757
+ return lines.join('\n');
758
+ }
759
+ function migrationsIndex(spec) {
760
+ const className = migrationClassOf(spec);
761
+ const file = migrationFileOf(spec).replace(/\.ts$/, '.js');
762
+ return `/**
763
+ * The \`./migrations\` subpath — every migration class this module owns, as one
764
+ * ordered \`migrations\` array.
765
+ *
766
+ * The array is what the platform reads when this module is **installed**: the
767
+ * package loader takes \`exported['migrations']\` and refuses the package outright
768
+ * when it is absent (D-168).
769
+ *
770
+ * Listed in ascending timestamp, which is the order of this module's own
771
+ * migrations and of nothing else: a manifest \`dependencies\` array is the only
772
+ * thing ordering this block against another module's.
773
+ *
774
+ * The **named** exports stay beside the array. A migration class name is
775
+ * contract in a way an entity class name is not — \`mikro_orm_migrations\`
776
+ * persists it — and the committed registry imports each class by name from this
777
+ * specifier. A class that is in neither the array nor the barrel is a migration
778
+ * that does not run: nothing reports it pending, and the first symptom is a
779
+ * query against a table nobody created.
780
+ */
781
+
782
+ import { ${className} } from './${file}';
783
+
784
+ export const migrations = [
785
+ ${className},
786
+ ];
787
+
788
+ export {
789
+ ${className},
790
+ };
791
+ `;
792
+ }
793
+ // --- src/backend/workers/<slug>.worker.ts ----------------------------------
794
+ function worker(spec) {
795
+ const pascal = pascalOf(spec.id);
796
+ const queue = `${spec.id}.maintenance`;
797
+ return `import { Queue, Worker, type Processor, type QueueOptions, type WorkerOptions } from 'bullmq';
798
+ import { enterSystemScope } from '@endora-commerce/platform/kernel';
799
+ import type { Redis } from 'ioredis';
800
+
801
+ /**
802
+ * ${spec.name} — its queue consumer (Principle X).
803
+ *
804
+ * The worker is **constructed** here and **registered** through \`ctx.worker\` in
805
+ * \`../index.ts\`. That split matters: the registration seam is what puts the
806
+ * worker in the module's own registry, which is what lets the platform stop it
807
+ * when an operator switches the module off. A \`new Worker\` whose value goes
808
+ * nowhere is a queue consumer nothing in the platform can pause, and
809
+ * \`check:subscribe-seam\` reports one.
810
+ *
811
+ * The processor runs inside \`enterSystemScope\`, because a job has no request to
812
+ * inherit a tenant scope from and every non-HTTP entry point establishes its own.
813
+ */
814
+ export interface ${pascal}JobData {
815
+ /** Why this sweep was enqueued, for the log line that reports it. */
816
+ readonly reason: string;
817
+ }
818
+
819
+ export interface ${pascal}JobResult {
820
+ readonly scanned: number;
821
+ }
822
+
823
+ export const ${segmentOf(spec.id).toUpperCase()}_QUEUE_NAME = ${quote(queue)};
824
+
825
+ export function create${pascal}Queue(
826
+ redis: Redis,
827
+ overrides?: Partial<QueueOptions>,
828
+ ): Queue<${pascal}JobData> {
829
+ const options: QueueOptions = {
830
+ connection: redis,
831
+ defaultJobOptions: {
832
+ attempts: 5,
833
+ backoff: { type: 'exponential', delay: 1_000 },
834
+ removeOnComplete: { count: 100 },
835
+ removeOnFail: { count: 1_000 },
836
+ },
837
+ ...overrides,
838
+ };
839
+ return new Queue<${pascal}JobData>(${segmentOf(spec.id).toUpperCase()}_QUEUE_NAME, options);
840
+ }
841
+
842
+ export function create${pascal}Worker(
843
+ redis: Redis,
844
+ processor: Processor<${pascal}JobData, ${pascal}JobResult>,
845
+ overrides?: Partial<WorkerOptions>,
846
+ ): Worker<${pascal}JobData, ${pascal}JobResult> {
847
+ const options: WorkerOptions = {
848
+ connection: redis,
849
+ concurrency: 1,
850
+ ...overrides,
851
+ };
852
+ return new Worker<${pascal}JobData, ${pascal}JobResult>(
853
+ ${segmentOf(spec.id).toUpperCase()}_QUEUE_NAME,
854
+ (job) => enterSystemScope(${quote(`${spec.id}: maintenance sweep`)}, () => processor(job)),
855
+ options,
856
+ );
857
+ }
858
+ `;
859
+ }
860
+ // --- src/ports/index.ts ----------------------------------------------------
861
+ function ports(spec) {
862
+ const pascal = pascalOf(spec.id);
863
+ const shape = spec.layers.entities
864
+ ? `/** One row as this module hands it out: never the managed entity itself. */
865
+ export interface ${pascal}Record {
866
+ readonly id: string;
867
+ readonly label: string;
868
+ readonly createdAt: string;
869
+ }
870
+
871
+ export interface ${portInterfaceOf(spec)} {
872
+ list(): Promise<readonly ${pascal}Record[]>;
873
+ }
874
+ `
875
+ : `/** What this module reports about itself. */
876
+ export interface ${pascal}Status {
877
+ readonly module: string;
878
+ readonly name: string;
879
+ }
880
+
881
+ export interface ${portInterfaceOf(spec)} {
882
+ status(): ${pascal}Status;
883
+ }
884
+ `;
885
+ return `/**
886
+ * ${spec.name} — the contract surface other modules resolve.
887
+ *
888
+ * **Type-only, and that is the whole design of this subpath.** A module package
889
+ * whose \`./ports\` module exports no runtime binding is contract surface (D-171):
890
+ * a consumer's \`import type\` from it is not a cross-module boundary reach, so
891
+ * publishing an interface here is what turns a coupling into a declaration.
892
+ * Adding a \`const\`, a class or a function to this file takes that back, in the
893
+ * same run.
894
+ *
895
+ * The container name is contract too: this interface is registered under
896
+ * \`${portNameOf(spec)}\`, and that literal is what a consumer writes in
897
+ * \`lazyPort<${portInterfaceOf(spec)}>(ctx, '${portNameOf(spec)}')\`. A consumer that
898
+ * resolves it declares this module in its manifest \`dependencies\`, which is what
899
+ * makes the edge real to the lifecycle and to an operator switching this module
900
+ * off.
901
+ *
902
+ * No method here is optional. Feature detection through a port is impossible by
903
+ * construction — the resolution proxy answers every property with a function, so
904
+ * \`if (port.maybe)\` is always true and the forward throws when the provider has
905
+ * none.
906
+ */
907
+
908
+ ${shape}`;
909
+ }
910
+ // --- src/admin/ ------------------------------------------------------------
911
+ /**
912
+ * `src/admin/index.ts` — the module's admin contributions, and **only** the
913
+ * contributions object.
914
+ *
915
+ * `check:module-boundary`'s D-171 rule designates a subpath as contract surface
916
+ * exactly while the module it resolves to emits no runtime binding. `./admin`
917
+ * deliberately does not qualify — it exports an object — so a consumer reaching
918
+ * into another module's `./admin` stays a counted boundary reach, which is the
919
+ * correct answer. That is what a component, a hook or a service exported from
920
+ * here would quietly turn into a supported pattern.
921
+ */
922
+ function adminIndex(spec) {
923
+ const gate = gatingPermissionOf(spec);
924
+ const route = adminScreenRouteOf(spec);
925
+ const section = spec.layers.admin;
926
+ if (gate === null || route === null || section === null) {
927
+ throw new Error('adminIndex emitted without a gated route to mount the screen on');
928
+ }
929
+ const lines = [];
930
+ lines.push(`import type { AdminContributions } from '@endora-commerce/admin-kit/contributions';`);
931
+ lines.push('');
932
+ lines.push('/**');
933
+ lines.push(` * ${spec.name} — the module's admin surface.`);
934
+ lines.push(' *');
935
+ lines.push(' * The admin renders `[...host, ...registry]`: the generated registry');
936
+ lines.push(' * `admin/src/modules.generated.ts` imports this object by the bare specifier');
937
+ lines.push(' * this package\'s own `exports` map declares, so a screen arrives by the module');
938
+ lines.push(' * existing. Nothing here is registered in a file the module does not own —');
939
+ lines.push(' * `admin/src/App.tsx` and `admin/src/components/AppShell.tsx` are the two');
940
+ lines.push(' * registries this replaces.');
941
+ lines.push(' *');
942
+ lines.push(' * **This file exports data and nothing else.** A component, a hook or a');
943
+ lines.push(' * service exported here would make another module\'s reach into this layer a');
944
+ lines.push(' * supported pattern rather than the counted boundary reach it is.');
945
+ lines.push(' *');
946
+ lines.push(' * **The component is a dynamic-import factory, and it is the only');
947
+ lines.push(' * function-valued field.** A static import would make the registry evaluate');
948
+ lines.push(' * React to be enumerated, and would ship this screen\'s code to an operator');
949
+ lines.push(' * whose role cannot open it; the factory is what makes the bundler emit one');
950
+ lines.push(' * chunk per module by construction.');
951
+ lines.push(' */');
952
+ lines.push('');
953
+ lines.push("/** The one route this module mounts, and the palette action's target. */");
954
+ lines.push(`const ROUTE_PATH = ${quote(route)};`);
955
+ lines.push('');
956
+ lines.push('export const contributions: AdminContributions = {');
957
+ lines.push(' routes: [');
958
+ lines.push(' {');
959
+ lines.push(' path: ROUTE_PATH,');
960
+ lines.push(` component: () => import('./pages/${pageComponentOf(spec)}.js'),`);
961
+ lines.push(' // The code this module\'s own admin routes are gated by, read from the');
962
+ lines.push(' // route rather than copied from a neighbour: `requireAdmin` enforces it');
963
+ lines.push(' // on `/api/v1/admin` + this path, and');
964
+ lines.push(' // `check:action-route-permissions` compares the two.');
965
+ lines.push(` requiredPermission: ${quote(gate.code)},`);
966
+ lines.push(' index: true,');
967
+ lines.push(' },');
968
+ lines.push(' ],');
969
+ lines.push(' nav: [');
970
+ lines.push(' {');
971
+ lines.push(' to: ROUTE_PATH,');
972
+ lines.push(' // Module-relative, resolved in this module\'s own i18n namespace out of');
973
+ lines.push(' // `i18n/en.json` and `i18n/pl.json`. A key written as a nested object');
974
+ lines.push(' // fails the bundle schema, the boot reconciler logs and skips it, and');
975
+ lines.push(' // the sidebar renders the raw key with no error anywhere.');
976
+ lines.push(` labelKey: ${quote(navLabelKeyOf(spec))},`);
977
+ lines.push(` icon: ${quote(spec.icon)},`);
978
+ lines.push(' // A module may not invent a section: an invented heading is one no other');
979
+ lines.push(' // module can join, so two features that belong together become two');
980
+ lines.push(' // headings of one each.');
981
+ lines.push(` section: ${quote(section)},`);
982
+ lines.push(' // Order within the section. Ties break by module id, so it is stable.');
983
+ lines.push(' weight: 100,');
984
+ lines.push(' // The same code the route is gated by, so the sidebar never advertises a');
985
+ lines.push(' // screen the operator would be refused.');
986
+ lines.push(` requiredPermission: ${quote(gate.code)},`);
987
+ lines.push(' },');
988
+ lines.push(' ],');
989
+ lines.push('};');
990
+ lines.push('');
991
+ return lines.join('\n');
992
+ }
993
+ /**
994
+ * The screen itself — small enough to read, and real enough to prove the wiring.
995
+ *
996
+ * It renders through the published design system, resolves every string in this
997
+ * module's own namespace, and reads this module's own admin endpoint through
998
+ * the published client. That is four separate seams, each of which fails
999
+ * differently when it is wrong, and none of which is asserted by the module
1000
+ * type-checking.
1001
+ */
1002
+ function adminPage(spec) {
1003
+ const pascal = pascalOf(spec.id);
1004
+ const route = adminScreenRouteOf(spec);
1005
+ if (route === null)
1006
+ throw new Error('adminPage emitted without a route to read');
1007
+ const component = pageComponentOf(spec);
1008
+ const lines = [];
1009
+ lines.push(`import { useCallback, useEffect, useState, type ReactNode } from 'react';`);
1010
+ lines.push(`import { ApiError, apiClient } from '@endora-commerce/admin-kit/lib';`);
1011
+ lines.push('import {');
1012
+ lines.push(' Alert,');
1013
+ lines.push(' AlertDescription,');
1014
+ lines.push(' Card,');
1015
+ lines.push(' CardContent,');
1016
+ lines.push(' PageHeader,');
1017
+ if (spec.layers.entities) {
1018
+ lines.push(' Table,');
1019
+ lines.push(' TableBody,');
1020
+ lines.push(' TableCell,');
1021
+ lines.push(' TableHead,');
1022
+ lines.push(' TableHeader,');
1023
+ lines.push(' TableRow,');
1024
+ }
1025
+ lines.push(`} from '@endora-commerce/admin-kit/ui';`);
1026
+ lines.push(`import { useTranslation } from '@endora-commerce/admin-kit/i18n';`);
1027
+ lines.push('');
1028
+ lines.push('/**');
1029
+ lines.push(` * ${spec.name} — the module's own admin screen.`);
1030
+ lines.push(' *');
1031
+ lines.push(' * Every specifier above is a **bare** one. `@/…` is the admin application\'s');
1032
+ lines.push(' * own tsconfig and Vite alias: it resolves for a file under `admin/src` and');
1033
+ lines.push(' * for nothing an installed package runs under, which is why');
1034
+ lines.push(' * `check:admin-surface` reports one from here as an `aliased-reach`.');
1035
+ lines.push(' *');
1036
+ lines.push(' * The server is reached only through `apiClient`, which already carries the');
1037
+ lines.push(' * admin\'s API origin. A screen that has to build a URL itself — an');
1038
+ lines.push(' * `<a download>` href, a form action — takes `apiBaseUrl` from');
1039
+ lines.push(' * `@endora-commerce/admin-kit/lib` and never `import.meta.env`: reading the');
1040
+ lines.push(' * environment here would mean a second copy of that fallback plus a');
1041
+ lines.push(' * `vite/client` type dependency this package must not acquire.');
1042
+ lines.push(' *');
1043
+ lines.push(' * Every string resolves in this module\'s own i18n namespace out of');
1044
+ lines.push(' * `i18n/en.json` and `i18n/pl.json`, which the platform\'s boot reconciler');
1045
+ lines.push(' * installs. A module writes into no shared bundle.');
1046
+ lines.push(' */');
1047
+ lines.push('');
1048
+ if (spec.layers.entities) {
1049
+ lines.push(`/** One row, as \`GET ${adminApiPathFor(route)}\` answers. */`);
1050
+ lines.push(`interface ${pascal}Row {`);
1051
+ lines.push(' readonly id: string;');
1052
+ lines.push(' readonly label: string;');
1053
+ lines.push(' readonly createdAt: string;');
1054
+ lines.push('}');
1055
+ }
1056
+ else {
1057
+ lines.push(`/** What \`GET ${adminApiPathFor(route)}\` answers. */`);
1058
+ lines.push(`interface ${pascal}Status {`);
1059
+ lines.push(' readonly module: string;');
1060
+ lines.push(' readonly name: string;');
1061
+ lines.push('}');
1062
+ }
1063
+ lines.push('');
1064
+ lines.push(`export default function ${component}(): ReactNode {`);
1065
+ lines.push(` const t = useTranslation(${quote(spec.id)});`);
1066
+ if (spec.layers.entities) {
1067
+ lines.push(` const [rows, setRows] = useState<readonly ${pascal}Row[]>([]);`);
1068
+ }
1069
+ else {
1070
+ lines.push(` const [status, setStatus] = useState<${pascal}Status | null>(null);`);
1071
+ }
1072
+ lines.push(' const [loading, setLoading] = useState(true);');
1073
+ lines.push(' const [error, setError] = useState<string | null>(null);');
1074
+ lines.push('');
1075
+ lines.push(' const load = useCallback(async (): Promise<void> => {');
1076
+ lines.push(' setLoading(true);');
1077
+ lines.push(' setError(null);');
1078
+ lines.push(' try {');
1079
+ if (spec.layers.entities) {
1080
+ lines.push(` const res = await apiClient.get<{ data: ${pascal}Row[] }>(`);
1081
+ lines.push(` ${quote(adminApiPathFor(route))},`);
1082
+ lines.push(' );');
1083
+ lines.push(' setRows(res.data);');
1084
+ }
1085
+ else {
1086
+ lines.push(` const res = await apiClient.get<{ data: ${pascal}Status }>(`);
1087
+ lines.push(` ${quote(adminApiPathFor(route))},`);
1088
+ lines.push(' );');
1089
+ lines.push(' setStatus(res.data);');
1090
+ }
1091
+ lines.push(' } catch (err) {');
1092
+ lines.push(' // The envelope carries a translated, operator-facing sentence; the');
1093
+ lines.push(' // module\'s own key is the fallback for a failure that produced none.');
1094
+ lines.push(" setError(err instanceof ApiError ? err.envelope.error.message : t('admin.error'));");
1095
+ lines.push(' } finally {');
1096
+ lines.push(' setLoading(false);');
1097
+ lines.push(' }');
1098
+ lines.push(' }, [t]);');
1099
+ lines.push('');
1100
+ lines.push(' useEffect(() => {');
1101
+ lines.push(' void load();');
1102
+ lines.push(' }, [load]);');
1103
+ lines.push('');
1104
+ lines.push(' let body: ReactNode = (');
1105
+ lines.push(" <p className=\"text-sm text-muted-foreground\">{t('admin.loading')}</p>");
1106
+ lines.push(' );');
1107
+ if (spec.layers.entities) {
1108
+ lines.push(' if (!loading) {');
1109
+ lines.push(' body = (');
1110
+ lines.push(' <Table>');
1111
+ lines.push(' <TableHeader>');
1112
+ lines.push(' <TableRow>');
1113
+ lines.push(" <TableHead>{t('admin.column.label')}</TableHead>");
1114
+ lines.push(" <TableHead>{t('admin.column.createdAt')}</TableHead>");
1115
+ lines.push(' </TableRow>');
1116
+ lines.push(' </TableHeader>');
1117
+ lines.push(' <TableBody>');
1118
+ lines.push(' {rows.length === 0 ? (');
1119
+ lines.push(' <TableRow>');
1120
+ lines.push(' <TableCell colSpan={2} className="text-sm text-muted-foreground">');
1121
+ lines.push(" {t('admin.empty')}");
1122
+ lines.push(' </TableCell>');
1123
+ lines.push(' </TableRow>');
1124
+ lines.push(' ) : (');
1125
+ lines.push(' rows.map((row) => (');
1126
+ lines.push(' <TableRow key={row.id}>');
1127
+ lines.push(' <TableCell>{row.label}</TableCell>');
1128
+ lines.push(' <TableCell>{new Date(row.createdAt).toLocaleString()}</TableCell>');
1129
+ lines.push(' </TableRow>');
1130
+ lines.push(' ))');
1131
+ lines.push(' )}');
1132
+ lines.push(' </TableBody>');
1133
+ lines.push(' </Table>');
1134
+ lines.push(' );');
1135
+ lines.push(' }');
1136
+ }
1137
+ else {
1138
+ lines.push(' if (!loading && status !== null) {');
1139
+ lines.push(' body = (');
1140
+ lines.push(' <dl className="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm">');
1141
+ lines.push(" <dt className=\"text-muted-foreground\">{t('admin.field.module')}</dt>");
1142
+ lines.push(' <dd>{status.module}</dd>');
1143
+ lines.push(" <dt className=\"text-muted-foreground\">{t('admin.field.name')}</dt>");
1144
+ lines.push(' <dd>{status.name}</dd>');
1145
+ lines.push(' </dl>');
1146
+ lines.push(' );');
1147
+ lines.push(' }');
1148
+ }
1149
+ lines.push('');
1150
+ lines.push(' return (');
1151
+ lines.push(' <div className="space-y-6">');
1152
+ lines.push(" <PageHeader title={t('admin.title')} description={t('admin.description')} />");
1153
+ lines.push(' {error === null ? null : (');
1154
+ lines.push(' <Alert variant="destructive">');
1155
+ lines.push(' <AlertDescription>{error}</AlertDescription>');
1156
+ lines.push(' </Alert>');
1157
+ lines.push(' )}');
1158
+ lines.push(' <Card>');
1159
+ lines.push(' <CardContent className="pt-6">{body}</CardContent>');
1160
+ lines.push(' </Card>');
1161
+ lines.push(' </div>');
1162
+ lines.push(' );');
1163
+ lines.push('}');
1164
+ lines.push('');
1165
+ return lines.join('\n');
1166
+ }
1167
+ // --- test/unit/admin-contributions.test.ts ---------------------------------
1168
+ /**
1169
+ * What an admin contribution can be wrong about without anything else noticing.
1170
+ *
1171
+ * The type-check proves the declaration's *shape*; none of these is a shape. A
1172
+ * nav entry pointing at a path no route declares renders a blank screen, a
1173
+ * `labelKey` missing from one language renders the raw key in that language
1174
+ * only, and a `requiredPermission` the manifest does not declare is a sidebar
1175
+ * row nobody can ever be granted. The layer's own build says nothing about any
1176
+ * of them.
1177
+ */
1178
+ function adminTest(spec) {
1179
+ return `import { describe, expect, it } from 'vitest';
1180
+ import { readFileSync } from 'node:fs';
1181
+ import { dirname } from 'node:path';
1182
+ import { fileURLToPath } from 'node:url';
1183
+ import {
1184
+ AdminNavDeclarationSchema,
1185
+ AdminRouteDeclarationSchema,
1186
+ } from '@endora-commerce/contracts';
1187
+ import * as adminLayer from '../../src/admin/index.js';
1188
+ import { manifest } from '../../src/manifest.js';
1189
+
1190
+ const packageDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
1191
+
1192
+ function readBundle(language: string): Record<string, unknown> {
1193
+ return JSON.parse(
1194
+ readFileSync(\`\${packageDir}/i18n/\${language}.json\`, 'utf8'),
1195
+ ) as Record<string, unknown>;
1196
+ }
1197
+
1198
+ const bundles = { en: readBundle('en'), pl: readBundle('pl') };
1199
+ const { contributions } = adminLayer;
1200
+
1201
+ describe('${spec.id} admin contributions', () => {
1202
+ it('exports the contributions object and nothing else', () => {
1203
+ // A subpath is contract surface exactly while its module emits no runtime
1204
+ // binding, and \`./admin\` deliberately is not — it exports an object. A
1205
+ // component or a hook exported beside it would turn another module's reach
1206
+ // into this layer from a counted boundary reach into a supported pattern.
1207
+ expect(Object.keys(adminLayer)).toEqual(['contributions']);
1208
+ });
1209
+
1210
+ it('declares every route as a dynamic-import factory', () => {
1211
+ for (const route of contributions.routes ?? []) {
1212
+ const { component, ...data } = route;
1213
+
1214
+ expect(AdminRouteDeclarationSchema.safeParse(data).success, data.path).toBe(true);
1215
+ expect(typeof component, data.path).toBe('function');
1216
+ expect(component.length, data.path).toBe(0);
1217
+ }
1218
+ });
1219
+
1220
+ it('points every nav entry at a route this module declares', () => {
1221
+ const paths = new Set((contributions.routes ?? []).map((route) => route.path));
1222
+
1223
+ for (const item of contributions.nav ?? []) {
1224
+ expect(AdminNavDeclarationSchema.safeParse(item).success, item.to).toBe(true);
1225
+ expect(paths.has(item.to), item.to).toBe(true);
1226
+ }
1227
+ });
1228
+
1229
+ it('resolves every nav label in both shipped languages', () => {
1230
+ for (const item of contributions.nav ?? []) {
1231
+ for (const [language, entries] of Object.entries(bundles)) {
1232
+ expect(entries[item.labelKey], \`\${language}:\${item.to}\`).toEqual(expect.any(String));
1233
+ }
1234
+ }
1235
+ });
1236
+
1237
+ it('gates every surface on a permission this module declares', () => {
1238
+ const declared = new Set((manifest.permissions ?? []).map((entry) => entry.code));
1239
+ const required = [...(contributions.routes ?? []), ...(contributions.nav ?? [])].flatMap(
1240
+ (surface) =>
1241
+ surface.requiredPermission === undefined
1242
+ ? []
1243
+ : [surface.requiredPermission].flat(),
1244
+ );
1245
+
1246
+ expect(required.length).toBeGreaterThan(0);
1247
+ for (const code of required) expect(declared.has(code), code).toBe(true);
1248
+ });
1249
+ });
1250
+ `;
1251
+ }
1252
+ // --- i18n/{en,pl}.json -----------------------------------------------------
1253
+ /** `open-widgets` → `Open widgets`, the mechanical default label for an action. */
1254
+ function titleOfActionId(actionId) {
1255
+ const words = actionId.split('-').filter((part) => part.length > 0);
1256
+ const first = words[0] ?? actionId;
1257
+ return [first.charAt(0).toUpperCase() + first.slice(1), ...words.slice(1)].join(' ');
1258
+ }
1259
+ /**
1260
+ * The keys a bundle carries, in the two namespaces a module's own bundle mixes.
1261
+ *
1262
+ * `actions.*` is **module-relative** — the palette resolves it under this
1263
+ * module's own namespace. `adminRoles.permission.<code>` is **platform-global**
1264
+ * and is written into this module's bundle anyway, because a package cannot
1265
+ * write into the platform's `_i18n` bundle at all. The split is invisible from
1266
+ * the file, which is why it is stated here.
1267
+ *
1268
+ * `nav.*` and `admin.*` are module-relative too, and arrive with the admin
1269
+ * layer: the sidebar resolves a contributed entry's `labelKey` in the
1270
+ * contributing module's namespace, so a module writes into no shared bundle to
1271
+ * be named in the shell.
1272
+ *
1273
+ * Both languages carry the same keys. The Polish values are the author's own
1274
+ * English text: a translation is a human judgement and this command does not
1275
+ * invent one — it names `i18n/pl.json` in its next steps instead.
1276
+ */
1277
+ export function bundleEntries(spec) {
1278
+ const entries = [['module.name', spec.name]];
1279
+ for (const permission of spec.permissions) {
1280
+ entries.push([`adminRoles.permission.${permission.code}`, permission.label]);
1281
+ }
1282
+ for (const action of spec.actions) {
1283
+ const key = camelOfActionId(action.id);
1284
+ entries.push([`actions.${key}.label`, titleOfActionId(action.id)]);
1285
+ entries.push([`actions.${key}.description`, spec.description]);
1286
+ }
1287
+ if (spec.layers.admin !== null) {
1288
+ entries.push([navLabelKeyOf(spec), spec.name]);
1289
+ entries.push(['admin.title', spec.name]);
1290
+ entries.push(['admin.description', spec.description]);
1291
+ entries.push(['admin.loading', 'Loading…']);
1292
+ entries.push(['admin.error', `Could not load ${spec.name}.`]);
1293
+ if (spec.layers.entities) {
1294
+ entries.push(['admin.column.label', 'Label']);
1295
+ entries.push(['admin.column.createdAt', 'Created']);
1296
+ entries.push(['admin.empty', `No ${spec.name} yet.`]);
1297
+ }
1298
+ else {
1299
+ entries.push(['admin.field.module', 'Module']);
1300
+ entries.push(['admin.field.name', 'Name']);
1301
+ }
1302
+ }
1303
+ return entries;
1304
+ }
1305
+ function bundle(spec) {
1306
+ return flatJson(bundleEntries(spec));
1307
+ }
1308
+ // --- test/unit/manifest.test.ts --------------------------------------------
1309
+ function manifestTest(spec) {
1310
+ return `import { describe, expect, it } from 'vitest';
1311
+ import { readFileSync } from 'node:fs';
1312
+ import { basename, dirname } from 'node:path';
1313
+ import { fileURLToPath } from 'node:url';
1314
+ import { manifest } from '../../src/manifest.js';
1315
+
1316
+ /**
1317
+ * What a module manifest can be wrong about without anything else noticing.
1318
+ *
1319
+ * Every assertion here is a rule the platform enforces at a moment that is too
1320
+ * late to be useful: the id disagreement is diagnosed at boot, a missing
1321
+ * permission label renders as a raw key on /admin-roles, a nested i18n bundle is
1322
+ * *logged and skipped* by the boot reconciler so the palette renders raw keys
1323
+ * with no error anywhere, and an action key that resolves in one language and not
1324
+ * the other is invisible until somebody switches language.
1325
+ */
1326
+
1327
+ const packageDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
1328
+
1329
+ function readJson(relative: string): Record<string, unknown> {
1330
+ return JSON.parse(readFileSync(\`\${packageDir}/\${relative}\`, 'utf8')) as Record<string, unknown>;
1331
+ }
1332
+
1333
+ const packageManifest = readJson('package.json');
1334
+ const bundles = {
1335
+ en: readJson('i18n/en.json'),
1336
+ pl: readJson('i18n/pl.json'),
1337
+ };
1338
+
1339
+ describe('${spec.id} manifest', () => {
1340
+ it('agrees with the package and the directory about its own id', () => {
1341
+ const endora = packageManifest['endora'] as { id?: unknown } | undefined;
1342
+
1343
+ expect(manifest.id).toBe(${quote(spec.id)});
1344
+ expect(endora?.id).toBe(manifest.id);
1345
+ expect(basename(packageDir)).toBe(manifest.id);
1346
+ });
1347
+
1348
+ it('declares an activation control or says why it has none', () => {
1349
+ const activation = manifest.activation;
1350
+
1351
+ expect(activation).toBeDefined();
1352
+ if (activation !== undefined && 'nonDeactivatable' in activation) {
1353
+ expect(activation.reason.length).toBeGreaterThan(0);
1354
+ } else {
1355
+ expect(activation).toMatchObject({ default: expect.any(Boolean) });
1356
+ }
1357
+ });
1358
+
1359
+ it('ships flat bundles in both languages', () => {
1360
+ for (const [language, entries] of Object.entries(bundles)) {
1361
+ expect(Object.keys(entries).length, language).toBeGreaterThan(0);
1362
+ for (const [key, value] of Object.entries(entries)) {
1363
+ expect(typeof value, \`\${language}:\${key}\`).toBe('string');
1364
+ }
1365
+ }
1366
+ });
1367
+
1368
+ it('has a label for every permission it declares, in both languages', () => {
1369
+ for (const permission of manifest.permissions ?? []) {
1370
+ for (const [language, entries] of Object.entries(bundles)) {
1371
+ expect(entries[\`adminRoles.permission.\${permission.code}\`], \`\${language}\`).toEqual(
1372
+ expect.any(String),
1373
+ );
1374
+ }
1375
+ }
1376
+ });
1377
+
1378
+ it('resolves every palette action key in both languages', () => {
1379
+ for (const action of manifest.actions ?? []) {
1380
+ for (const [language, entries] of Object.entries(bundles)) {
1381
+ expect(entries[action.labelKey], \`\${language}:\${action.id}\`).toEqual(expect.any(String));
1382
+ if (action.descriptionKey !== undefined) {
1383
+ expect(entries[action.descriptionKey], \`\${language}:\${action.id}\`).toEqual(
1384
+ expect.any(String),
1385
+ );
1386
+ }
1387
+ }
1388
+ }
1389
+ });
1390
+ });
1391
+ `;
1392
+ }
1393
+ //# sourceMappingURL=emit.js.map