@mettlecast/domain-cli 0.2.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 (301) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +133 -0
  3. package/dist/builder/build-registry.d.ts +20 -0
  4. package/dist/builder/build-registry.js +178 -0
  5. package/dist/builder/build-types.d.ts +10 -0
  6. package/dist/builder/build-types.js +32 -0
  7. package/dist/builder/load-domain-module-config.d.ts +12 -0
  8. package/dist/builder/load-domain-module-config.js +34 -0
  9. package/dist/builder/load-module.d.ts +26 -0
  10. package/dist/builder/load-module.js +114 -0
  11. package/dist/builder/zod-to-json.d.ts +10 -0
  12. package/dist/builder/zod-to-json.js +15 -0
  13. package/dist/cli.d.ts +2 -0
  14. package/dist/cli.js +338 -0
  15. package/dist/commands/add-api.d.ts +32 -0
  16. package/dist/commands/add-api.js +106 -0
  17. package/dist/commands/add-component.d.ts +15 -0
  18. package/dist/commands/add-component.js +78 -0
  19. package/dist/commands/add-domain.d.ts +21 -0
  20. package/dist/commands/add-domain.js +71 -0
  21. package/dist/commands/add-flow.d.ts +14 -0
  22. package/dist/commands/add-flow.js +108 -0
  23. package/dist/commands/add-hook.d.ts +15 -0
  24. package/dist/commands/add-hook.js +65 -0
  25. package/dist/commands/add-migration.d.ts +26 -0
  26. package/dist/commands/add-migration.js +93 -0
  27. package/dist/commands/add-module.d.ts +7 -0
  28. package/dist/commands/add-module.js +261 -0
  29. package/dist/commands/add-page.d.ts +13 -0
  30. package/dist/commands/add-page.js +170 -0
  31. package/dist/commands/add-seed-page.d.ts +5 -0
  32. package/dist/commands/add-seed-page.js +251 -0
  33. package/dist/commands/add-subscriber.d.ts +30 -0
  34. package/dist/commands/add-subscriber.js +87 -0
  35. package/dist/commands/add-test.d.ts +22 -0
  36. package/dist/commands/add-test.js +285 -0
  37. package/dist/commands/audit.d.ts +39 -0
  38. package/dist/commands/audit.js +171 -0
  39. package/dist/commands/build-catalog.d.ts +68 -0
  40. package/dist/commands/build-catalog.js +105 -0
  41. package/dist/commands/build-flows.d.ts +26 -0
  42. package/dist/commands/build-flows.js +174 -0
  43. package/dist/commands/build.d.ts +17 -0
  44. package/dist/commands/build.js +39 -0
  45. package/dist/commands/check-hashes.d.ts +38 -0
  46. package/dist/commands/check-hashes.js +97 -0
  47. package/dist/commands/create-project.d.ts +5 -0
  48. package/dist/commands/create-project.js +272 -0
  49. package/dist/commands/dev.d.ts +15 -0
  50. package/dist/commands/dev.js +27 -0
  51. package/dist/commands/doctor.d.ts +48 -0
  52. package/dist/commands/doctor.js +1301 -0
  53. package/dist/commands/explain.d.ts +34 -0
  54. package/dist/commands/explain.js +167 -0
  55. package/dist/commands/power-tune.d.ts +1 -0
  56. package/dist/commands/power-tune.js +87 -0
  57. package/dist/commands/reseed-page.d.ts +6 -0
  58. package/dist/commands/reseed-page.js +297 -0
  59. package/dist/commands/show-dns.d.ts +15 -0
  60. package/dist/commands/show-dns.js +101 -0
  61. package/dist/commands/show.d.ts +46 -0
  62. package/dist/commands/show.js +135 -0
  63. package/dist/commands/test.d.ts +18 -0
  64. package/dist/commands/test.js +56 -0
  65. package/dist/commands/upgrade-backend.d.ts +9 -0
  66. package/dist/commands/upgrade-backend.js +46 -0
  67. package/dist/commands/upgrade-event.d.ts +22 -0
  68. package/dist/commands/upgrade-event.js +111 -0
  69. package/dist/commands/upgrade-frontend.d.ts +5 -0
  70. package/dist/commands/upgrade-frontend.js +99 -0
  71. package/dist/commands/upgrade.d.ts +7 -0
  72. package/dist/commands/upgrade.js +495 -0
  73. package/dist/commands/validate.d.ts +30 -0
  74. package/dist/commands/validate.js +148 -0
  75. package/dist/runtime-stubs/hydrate-local-ctx.d.ts +49 -0
  76. package/dist/runtime-stubs/hydrate-local-ctx.js +45 -0
  77. package/dist/runtime-stubs/load-secrets.d.ts +11 -0
  78. package/dist/runtime-stubs/load-secrets.js +37 -0
  79. package/dist/server/api-server.d.ts +21 -0
  80. package/dist/server/api-server.js +14 -0
  81. package/dist/server/mount-routes.d.ts +19 -0
  82. package/dist/server/mount-routes.js +42 -0
  83. package/dist/templates/api-skeleton.d.ts +18 -0
  84. package/dist/templates/api-skeleton.js +69 -0
  85. package/dist/templates/claude-md.d.ts +4 -0
  86. package/dist/templates/claude-md.js +7 -0
  87. package/dist/templates/dashboard-pages/index.d.ts +101 -0
  88. package/dist/templates/dashboard-pages/index.js +22 -0
  89. package/dist/templates/domain-config.d.ts +10 -0
  90. package/dist/templates/domain-config.js +19 -0
  91. package/dist/templates/events-skeleton.d.ts +9 -0
  92. package/dist/templates/events-skeleton.js +23 -0
  93. package/dist/templates/patterns/api/create-with-event.d.ts +5 -0
  94. package/dist/templates/patterns/api/create-with-event.js +69 -0
  95. package/dist/templates/patterns/api/idempotent-mutation.d.ts +5 -0
  96. package/dist/templates/patterns/api/idempotent-mutation.js +99 -0
  97. package/dist/templates/patterns/api/paginated-list.d.ts +5 -0
  98. package/dist/templates/patterns/api/paginated-list.js +71 -0
  99. package/dist/templates/patterns/api/simple-crud.d.ts +6 -0
  100. package/dist/templates/patterns/api/simple-crud.js +95 -0
  101. package/dist/templates/patterns/api/system-admin.d.ts +5 -0
  102. package/dist/templates/patterns/api/system-admin.js +98 -0
  103. package/dist/templates/patterns/api/webhook-receiver-style.d.ts +5 -0
  104. package/dist/templates/patterns/api/webhook-receiver-style.js +125 -0
  105. package/dist/templates/patterns/component/index.d.ts +25 -0
  106. package/dist/templates/patterns/component/index.js +814 -0
  107. package/dist/templates/patterns/flow/autonomous-remediation.d.ts +5 -0
  108. package/dist/templates/patterns/flow/autonomous-remediation.js +98 -0
  109. package/dist/templates/patterns/flow/fan-out-reaction.d.ts +5 -0
  110. package/dist/templates/patterns/flow/fan-out-reaction.js +53 -0
  111. package/dist/templates/patterns/flow/human-in-loop.d.ts +5 -0
  112. package/dist/templates/patterns/flow/human-in-loop.js +90 -0
  113. package/dist/templates/patterns/flow/multi-step-orchestration.d.ts +5 -0
  114. package/dist/templates/patterns/flow/multi-step-orchestration.js +67 -0
  115. package/dist/templates/patterns/hook/index.d.ts +7 -0
  116. package/dist/templates/patterns/hook/index.js +378 -0
  117. package/dist/templates/patterns/migration/column-add.d.ts +5 -0
  118. package/dist/templates/patterns/migration/column-add.js +52 -0
  119. package/dist/templates/patterns/migration/event-store.d.ts +6 -0
  120. package/dist/templates/patterns/migration/event-store.js +97 -0
  121. package/dist/templates/patterns/migration/index.d.ts +5 -0
  122. package/dist/templates/patterns/migration/index.js +52 -0
  123. package/dist/templates/patterns/migration/rls-policy.d.ts +5 -0
  124. package/dist/templates/patterns/migration/rls-policy.js +78 -0
  125. package/dist/templates/patterns/migration/tenant-scoped-table.d.ts +6 -0
  126. package/dist/templates/patterns/migration/tenant-scoped-table.js +81 -0
  127. package/dist/templates/patterns/page/dashboard.d.ts +4 -0
  128. package/dist/templates/patterns/page/dashboard.js +164 -0
  129. package/dist/templates/patterns/page/detail.d.ts +4 -0
  130. package/dist/templates/patterns/page/detail.js +162 -0
  131. package/dist/templates/patterns/page/form.d.ts +4 -0
  132. package/dist/templates/patterns/page/form.js +192 -0
  133. package/dist/templates/patterns/page/index.d.ts +8 -0
  134. package/dist/templates/patterns/page/index.js +14 -0
  135. package/dist/templates/patterns/page/list.d.ts +4 -0
  136. package/dist/templates/patterns/page/list.js +138 -0
  137. package/dist/templates/patterns/page/settings.d.ts +4 -0
  138. package/dist/templates/patterns/page/settings.js +318 -0
  139. package/dist/templates/patterns/page/wizard.d.ts +4 -0
  140. package/dist/templates/patterns/page/wizard.js +293 -0
  141. package/dist/templates/patterns/subscriber/audit-relay.d.ts +5 -0
  142. package/dist/templates/patterns/subscriber/audit-relay.js +51 -0
  143. package/dist/templates/patterns/subscriber/cascade-deletion.d.ts +5 -0
  144. package/dist/templates/patterns/subscriber/cascade-deletion.js +60 -0
  145. package/dist/templates/patterns/subscriber/single-step-projection.d.ts +5 -0
  146. package/dist/templates/patterns/subscriber/single-step-projection.js +36 -0
  147. package/dist/templates/readme.d.ts +4 -0
  148. package/dist/templates/readme.js +7 -0
  149. package/dist/templates/subscriber-skeleton.d.ts +11 -0
  150. package/dist/templates/subscriber-skeleton.js +23 -0
  151. package/dist/utils/checksum.d.ts +18 -0
  152. package/dist/utils/checksum.js +39 -0
  153. package/dist/utils/file-helpers.d.ts +35 -0
  154. package/dist/utils/file-helpers.js +59 -0
  155. package/dist/utils/header-inject.d.ts +15 -0
  156. package/dist/utils/header-inject.js +93 -0
  157. package/dist/utils/install-file.d.ts +17 -0
  158. package/dist/utils/install-file.js +78 -0
  159. package/dist/utils/logger.d.ts +12 -0
  160. package/dist/utils/logger.js +24 -0
  161. package/dist/utils/manifest.d.ts +28 -0
  162. package/dist/utils/manifest.js +93 -0
  163. package/dist/utils/s3-fetch.d.ts +25 -0
  164. package/dist/utils/s3-fetch.js +52 -0
  165. package/dist/utils/scaffold-config.d.ts +95 -0
  166. package/dist/utils/scaffold-config.js +53 -0
  167. package/dist/utils/token-substitution.d.ts +35 -0
  168. package/dist/utils/token-substitution.js +52 -0
  169. package/dist/utils/zod-extractor.d.ts +10 -0
  170. package/dist/utils/zod-extractor.js +28 -0
  171. package/package.json +43 -0
  172. package/src/__tests__/build-registry.test.ts +115 -0
  173. package/src/__tests__/build-types.test.ts +100 -0
  174. package/src/__tests__/builder/walkDomainDir.test.ts +140 -0
  175. package/src/__tests__/commands/add-domain.test.ts +174 -0
  176. package/src/__tests__/commands/add-flow.test.ts +69 -0
  177. package/src/__tests__/commands/add-module.test.ts +207 -0
  178. package/src/__tests__/commands/build-flows.test.ts +154 -0
  179. package/src/__tests__/commands/check-hashes.test.ts +142 -0
  180. package/src/__tests__/commands/create-project.test.ts +712 -0
  181. package/src/__tests__/commands/upgrade.test.ts +762 -0
  182. package/src/__tests__/doctor.test.ts +291 -0
  183. package/src/__tests__/hydrate-local-ctx.test.ts +71 -0
  184. package/src/__tests__/load-domain-module-config.test.ts +10 -0
  185. package/src/__tests__/mount-routes.test.ts +100 -0
  186. package/src/__tests__/scaffold-src/part-a-layout.test.ts +46 -0
  187. package/src/__tests__/scripts/package-scaffold.test.ts +45 -0
  188. package/src/__tests__/utils/checksum.test.ts +90 -0
  189. package/src/__tests__/utils/header-inject.test.ts +89 -0
  190. package/src/__tests__/utils/install-file.test.ts +207 -0
  191. package/src/__tests__/utils/manifest.test.ts +229 -0
  192. package/src/__tests__/utils/token-substitution.test.ts +134 -0
  193. package/src/__tests__/validate.test.ts +18 -0
  194. package/src/builder/build-registry.ts +237 -0
  195. package/src/builder/build-types.ts +36 -0
  196. package/src/builder/load-domain-module-config.ts +43 -0
  197. package/src/builder/load-module.ts +133 -0
  198. package/src/builder/zod-to-json.ts +16 -0
  199. package/src/cli.ts +387 -0
  200. package/src/commands/add-api.ts +145 -0
  201. package/src/commands/add-component.ts +116 -0
  202. package/src/commands/add-domain.ts +100 -0
  203. package/src/commands/add-flow.ts +136 -0
  204. package/src/commands/add-hook.ts +100 -0
  205. package/src/commands/add-migration.ts +129 -0
  206. package/src/commands/add-module.ts +347 -0
  207. package/src/commands/add-page.ts +242 -0
  208. package/src/commands/add-seed-page.ts +338 -0
  209. package/src/commands/add-subscriber.ts +123 -0
  210. package/src/commands/add-test.ts +328 -0
  211. package/src/commands/audit.ts +217 -0
  212. package/src/commands/build-catalog.ts +181 -0
  213. package/src/commands/build-flows.ts +198 -0
  214. package/src/commands/build.ts +59 -0
  215. package/src/commands/check-hashes.ts +150 -0
  216. package/src/commands/create-project.ts +367 -0
  217. package/src/commands/dev.ts +47 -0
  218. package/src/commands/doctor.ts +1362 -0
  219. package/src/commands/explain.ts +209 -0
  220. package/src/commands/power-tune.ts +112 -0
  221. package/src/commands/reseed-page.ts +410 -0
  222. package/src/commands/show-dns.ts +139 -0
  223. package/src/commands/show.ts +179 -0
  224. package/src/commands/test.ts +91 -0
  225. package/src/commands/upgrade-backend.ts +72 -0
  226. package/src/commands/upgrade-event.ts +175 -0
  227. package/src/commands/upgrade-frontend.ts +135 -0
  228. package/src/commands/upgrade.ts +668 -0
  229. package/src/commands/validate.ts +208 -0
  230. package/src/runtime-stubs/hydrate-local-ctx.ts +102 -0
  231. package/src/runtime-stubs/load-secrets.ts +40 -0
  232. package/src/server/api-server.ts +34 -0
  233. package/src/server/mount-routes.ts +68 -0
  234. package/src/templates/api-skeleton.ts +72 -0
  235. package/src/templates/claude-md.ts +7 -0
  236. package/src/templates/dashboard-pages/account/api-keys.tsx +107 -0
  237. package/src/templates/dashboard-pages/account/audit-log.tsx +60 -0
  238. package/src/templates/dashboard-pages/account/index.ts +5 -0
  239. package/src/templates/dashboard-pages/account/members.tsx +107 -0
  240. package/src/templates/dashboard-pages/account/profile.tsx +53 -0
  241. package/src/templates/dashboard-pages/account/workspace-settings.tsx +64 -0
  242. package/src/templates/dashboard-pages/auth/accept-invitation.tsx +62 -0
  243. package/src/templates/dashboard-pages/auth/choose-org.tsx +66 -0
  244. package/src/templates/dashboard-pages/auth/forgot-password.tsx +69 -0
  245. package/src/templates/dashboard-pages/auth/index.ts +10 -0
  246. package/src/templates/dashboard-pages/auth/login.tsx +75 -0
  247. package/src/templates/dashboard-pages/auth/mfa-setup.tsx +56 -0
  248. package/src/templates/dashboard-pages/auth/mfa-verify.tsx +49 -0
  249. package/src/templates/dashboard-pages/auth/reset-password.tsx +64 -0
  250. package/src/templates/dashboard-pages/auth/sign-out.tsx +20 -0
  251. package/src/templates/dashboard-pages/auth/signup.tsx +71 -0
  252. package/src/templates/dashboard-pages/auth/verify-email.tsx +53 -0
  253. package/src/templates/dashboard-pages/index.ts +22 -0
  254. package/src/templates/dashboard-pages/shell/dashboard-home.tsx +41 -0
  255. package/src/templates/dashboard-pages/shell/forbidden.tsx +46 -0
  256. package/src/templates/dashboard-pages/shell/index.ts +5 -0
  257. package/src/templates/dashboard-pages/shell/maintenance.tsx +27 -0
  258. package/src/templates/dashboard-pages/shell/not-found.tsx +34 -0
  259. package/src/templates/dashboard-pages/shell/server-error.tsx +44 -0
  260. package/src/templates/domain-config.ts +20 -0
  261. package/src/templates/events-skeleton.ts +24 -0
  262. package/src/templates/patterns/api/create-with-event.ts +72 -0
  263. package/src/templates/patterns/api/idempotent-mutation.ts +102 -0
  264. package/src/templates/patterns/api/paginated-list.ts +74 -0
  265. package/src/templates/patterns/api/simple-crud.ts +100 -0
  266. package/src/templates/patterns/api/system-admin.ts +101 -0
  267. package/src/templates/patterns/api/webhook-receiver-style.ts +128 -0
  268. package/src/templates/patterns/component/index.ts +833 -0
  269. package/src/templates/patterns/flow/autonomous-remediation.ts +101 -0
  270. package/src/templates/patterns/flow/fan-out-reaction.ts +56 -0
  271. package/src/templates/patterns/flow/human-in-loop.ts +93 -0
  272. package/src/templates/patterns/flow/multi-step-orchestration.ts +70 -0
  273. package/src/templates/patterns/hook/index.ts +382 -0
  274. package/src/templates/patterns/migration/column-add.ts +54 -0
  275. package/src/templates/patterns/migration/event-store.ts +99 -0
  276. package/src/templates/patterns/migration/index.ts +54 -0
  277. package/src/templates/patterns/migration/rls-policy.ts +80 -0
  278. package/src/templates/patterns/migration/tenant-scoped-table.ts +83 -0
  279. package/src/templates/patterns/page/dashboard.ts +164 -0
  280. package/src/templates/patterns/page/detail.ts +162 -0
  281. package/src/templates/patterns/page/form.ts +192 -0
  282. package/src/templates/patterns/page/index.ts +17 -0
  283. package/src/templates/patterns/page/list.ts +138 -0
  284. package/src/templates/patterns/page/settings.ts +318 -0
  285. package/src/templates/patterns/page/wizard.ts +293 -0
  286. package/src/templates/patterns/subscriber/audit-relay.ts +54 -0
  287. package/src/templates/patterns/subscriber/cascade-deletion.ts +63 -0
  288. package/src/templates/patterns/subscriber/single-step-projection.ts +39 -0
  289. package/src/templates/readme.ts +7 -0
  290. package/src/templates/subscriber-skeleton.ts +28 -0
  291. package/src/utils/checksum.ts +43 -0
  292. package/src/utils/file-helpers.ts +91 -0
  293. package/src/utils/header-inject.ts +126 -0
  294. package/src/utils/install-file.ts +99 -0
  295. package/src/utils/logger.ts +26 -0
  296. package/src/utils/manifest.ts +137 -0
  297. package/src/utils/s3-fetch.ts +91 -0
  298. package/src/utils/scaffold-config.ts +172 -0
  299. package/src/utils/token-substitution.ts +80 -0
  300. package/src/utils/zod-extractor.ts +31 -0
  301. package/tsconfig.json +13 -0
@@ -0,0 +1,347 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { mkdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { Parser as TarParser } from 'tar';
6
+ import { readScaffoldConfig, writeScaffoldConfig } from '../utils/scaffold-config.js';
7
+ import { readManifest, writeManifest, upsertManifestFile, inferPolicyFromPath } from '../utils/manifest.js';
8
+ import { fetchModulesJson, fetchModuleTarball } from '../utils/s3-fetch.js';
9
+ import { applyTokens, buildTokenContext, stripTplExtension } from '../utils/token-substitution.js';
10
+ import { injectHeader } from '../utils/header-inject.js';
11
+ import { computeChecksumString } from '../utils/checksum.js';
12
+ import { installScaffoldFile } from '../utils/install-file.js';
13
+ import { cliLogger } from '../utils/logger.js';
14
+
15
+ export interface AddModuleOptions {
16
+ moduleId: string;
17
+ githubToken?: string;
18
+ dryRun?: boolean;
19
+ projectDir?: string;
20
+ }
21
+
22
+ interface TarballManifestEntry {
23
+ path: string;
24
+ installedAs?: string;
25
+ isTemplate: boolean;
26
+ policy?: 'owned' | 'tracked' | 'seed';
27
+ }
28
+
29
+ interface TarEntry {
30
+ path: string;
31
+ content: Buffer;
32
+ }
33
+
34
+ async function parseTarball(buf: Buffer): Promise<TarEntry[]> {
35
+ const entries: TarEntry[] = [];
36
+ const chunks: { [key: string]: Buffer[] } = {};
37
+
38
+ await new Promise<void>((resolve, reject) => {
39
+ const parser = new TarParser({
40
+ onentry: (entry) => {
41
+ const p = entry.path;
42
+ chunks[p] = [];
43
+ entry.on('data', (chunk: Buffer) => chunks[p].push(chunk));
44
+ },
45
+ });
46
+ parser.on('finish', resolve);
47
+ parser.on('error', reject);
48
+ Readable.from(buf).pipe(parser);
49
+ });
50
+
51
+ for (const [path, buffers] of Object.entries(chunks)) {
52
+ if (buffers.length > 0) {
53
+ entries.push({ path, content: Buffer.concat(buffers) });
54
+ }
55
+ }
56
+
57
+ return entries;
58
+ }
59
+
60
+ function getTarEntry(entries: TarEntry[], path: string): TarEntry | undefined {
61
+ return entries.find((e) => e.path === path);
62
+ }
63
+
64
+ async function getCliVersion(): Promise<string> {
65
+ try {
66
+ const { createRequire } = await import('node:module');
67
+ const require = createRequire(import.meta.url);
68
+ const pkgJson = require('../../package.json') as { version: string };
69
+ return pkgJson.version;
70
+ } catch {
71
+ return '0.0.0';
72
+ }
73
+ }
74
+
75
+ async function detectRepoInfo(): Promise<{ owner: string; repo: string } | null> {
76
+ try {
77
+ const remoteUrl = execSync('git remote get-url origin', {
78
+ encoding: 'utf8',
79
+ stdio: 'pipe',
80
+ }).trim();
81
+
82
+ // Parse git@github.com:owner/repo.git or https://github.com/owner/repo.git
83
+ let match = /github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/.exec(remoteUrl);
84
+ if (match) {
85
+ return { owner: match[1], repo: match[2].replace(/\.git$/, '') };
86
+ }
87
+ return null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ async function createGitHubPR(
94
+ owner: string,
95
+ repo: string,
96
+ branchName: string,
97
+ title: string,
98
+ body: string,
99
+ token: string
100
+ ): Promise<{ prUrl: string; prNumber: number } | null> {
101
+ try {
102
+ const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls`, {
103
+ method: 'POST',
104
+ headers: {
105
+ Authorization: `Bearer ${token}`,
106
+ 'Content-Type': 'application/json',
107
+ },
108
+ body: JSON.stringify({
109
+ title,
110
+ body,
111
+ head: branchName,
112
+ base: 'develop',
113
+ }),
114
+ });
115
+
116
+ if (!response.ok) {
117
+ const error = await response.json();
118
+ cliLogger.warn(error, 'Failed to create GitHub PR');
119
+ return null;
120
+ }
121
+
122
+ const data = (await response.json()) as { html_url?: string; number?: number };
123
+ if (data.html_url && data.number) {
124
+ return { prUrl: data.html_url, prNumber: data.number };
125
+ }
126
+ return null;
127
+ } catch (err) {
128
+ cliLogger.warn(err, 'Failed to create GitHub PR');
129
+ return null;
130
+ }
131
+ }
132
+
133
+ export async function runAddModule(opts: AddModuleOptions): Promise<void> {
134
+ const projectDir = opts.projectDir ?? process.cwd();
135
+ const moduleId = opts.moduleId;
136
+ const dryRun = opts.dryRun ?? false;
137
+
138
+ // 1. Read .mc/scaffold-config.json
139
+ let config;
140
+ try {
141
+ config = await readScaffoldConfig(projectDir);
142
+ } catch (err) {
143
+ throw new Error(`Failed to read scaffold config from ${projectDir}: ${err}`);
144
+ }
145
+
146
+ // 2. Fetch modules.json from S3
147
+ let modulesJson;
148
+ try {
149
+ modulesJson = await fetchModulesJson(config.scaffoldBucket, config.scaffoldVersion);
150
+ } catch (err) {
151
+ throw new Error(`Failed to fetch modules.json for version ${config.scaffoldVersion}: ${err}`);
152
+ }
153
+
154
+ // 3. Validate moduleId is a known module
155
+ const module = modulesJson.modules.find((m) => m.id === moduleId);
156
+ if (!module) {
157
+ const available = modulesJson.modules.map((m) => m.id).join(', ');
158
+ throw new Error(`Unknown module "${moduleId}". Available modules: ${available}`);
159
+ }
160
+
161
+ // 4. Check moduleId is NOT already installed
162
+ if (config.enabledModules.includes(moduleId as any)) {
163
+ throw new Error(`Module "${moduleId}" is already installed`);
164
+ }
165
+
166
+ // 5. Check all dependencies of moduleId ARE installed
167
+ for (const dep of module.dependencies) {
168
+ if (!config.enabledModules.includes(dep as any)) {
169
+ throw new Error(
170
+ `Module "${moduleId}" requires "${dep}" which is not installed. ` +
171
+ `Install "${dep}" first with: tib add-module ${dep}`
172
+ );
173
+ }
174
+ }
175
+
176
+ // 6. Fetch tarball from S3
177
+ let tarBuf;
178
+ try {
179
+ tarBuf = await fetchModuleTarball(
180
+ config.scaffoldBucket,
181
+ config.scaffoldVersion,
182
+ module.tarball,
183
+ module.sha256
184
+ );
185
+ } catch (err) {
186
+ throw new Error(`Failed to fetch tarball for ${moduleId}: ${err}`);
187
+ }
188
+
189
+ // 7. Parse tarball
190
+ const tarEntries = await parseTarball(tarBuf);
191
+
192
+ // 8. Extract MANIFEST.json
193
+ const manifestEntry = getTarEntry(tarEntries, 'MANIFEST.json');
194
+ if (!manifestEntry) {
195
+ throw new Error(`No MANIFEST.json found in tarball for ${moduleId}`);
196
+ }
197
+
198
+ const tarManifest = JSON.parse(manifestEntry.content.toString('utf-8')) as TarballManifestEntry[];
199
+
200
+ // 9. Read manifest.json
201
+ let manifest = await readManifest(projectDir);
202
+ if (!manifest) {
203
+ throw new Error(`No .mc/manifest.json found in ${projectDir}`);
204
+ }
205
+
206
+ // 10. Get CLI version for header
207
+ const cliVersion = await getCliVersion();
208
+
209
+ // 11. Build token context
210
+ const tokenCtx = buildTokenContext(config, config.scaffoldVersion, cliVersion);
211
+
212
+ // 12. Process files from tarball
213
+ const filesInstalled: string[] = [];
214
+
215
+ for (const fileEntry of tarManifest) {
216
+ const tarEntry = getTarEntry(tarEntries, fileEntry.path);
217
+ if (!tarEntry) {
218
+ cliLogger.warn({ moduleId, path: fileEntry.path }, 'File in MANIFEST.json not found in tarball');
219
+ continue;
220
+ }
221
+
222
+ // Determine install path
223
+ let installPath = fileEntry.installedAs ?? fileEntry.path;
224
+
225
+ // Strip .tpl extension
226
+ if (installPath.endsWith('.tpl')) {
227
+ installPath = stripTplExtension(installPath);
228
+ }
229
+
230
+ // Process content
231
+ let content = tarEntry.content.toString('utf-8');
232
+
233
+ // Token substitution for template files
234
+ if (fileEntry.isTemplate) {
235
+ content = applyTokens(content, tokenCtx);
236
+ }
237
+
238
+ // Inject scaffold header
239
+ content = injectHeader(content, installPath, moduleId, module.version);
240
+
241
+ if (!dryRun) {
242
+ // Determine policy: use fileEntry.policy if present, else infer from path
243
+ const policy = fileEntry.policy ?? inferPolicyFromPath(installPath);
244
+
245
+ // Get current entry if exists
246
+ const currentEntry = manifest.files.find((f) => f.path === installPath);
247
+
248
+ // Install file using policy-based dispatch
249
+ const destPath = join(projectDir, installPath);
250
+ const installResult = await installScaffoldFile(destPath, content, policy, currentEntry, {
251
+ dryRun: false,
252
+ });
253
+
254
+ // Only update manifest if file was not skipped
255
+ if (installResult.status !== 'skipped') {
256
+ const sha256 = computeChecksumString(content);
257
+ upsertManifestFile(manifest, {
258
+ path: installPath,
259
+ module: moduleId,
260
+ moduleVersion: module.version,
261
+ sha256,
262
+ wasTemplate: fileEntry.isTemplate,
263
+ installedAt: new Date().toISOString(),
264
+ policy,
265
+ });
266
+ }
267
+ }
268
+
269
+ filesInstalled.push(installPath);
270
+ }
271
+
272
+ if (dryRun) {
273
+ console.log(`\n✓ Dry run: would install ${filesInstalled.length} file(s) from ${moduleId}@${module.version}`);
274
+ filesInstalled.forEach((f) => console.log(` ${f}`));
275
+ console.log(
276
+ `\nTo apply changes, run: tib add-module ${moduleId}\n`
277
+ );
278
+ return;
279
+ }
280
+
281
+ // 13. Update .mc/manifest.json
282
+ await writeManifest(projectDir, manifest);
283
+
284
+ // 14. Update .mc/scaffold-config.json
285
+ config.enabledModules = [...config.enabledModules, moduleId as any];
286
+ await writeScaffoldConfig(projectDir, config);
287
+
288
+ console.log(
289
+ `\n✓ Module "${moduleId}" installed (${filesInstalled.length} file(s) added)`
290
+ );
291
+
292
+ // 15. Git operations
293
+ const branchName = `tib/add-module-${moduleId}`;
294
+ let commitSha: string | null = null;
295
+
296
+ try {
297
+ // Create new branch
298
+ execSync(`git -C "${projectDir}" checkout -b ${branchName}`, { stdio: 'pipe' });
299
+
300
+ // Stage changes
301
+ execSync(`git -C "${projectDir}" add .mc/`, { stdio: 'pipe' });
302
+
303
+ // Commit
304
+ const commitMsg = `feat: add tib module ${moduleId}@${config.scaffoldVersion}`;
305
+ execSync(`git -C "${projectDir}" commit -m "${commitMsg}"`, { stdio: 'pipe' });
306
+
307
+ commitSha = execSync(`git -C "${projectDir}" rev-parse HEAD`, {
308
+ encoding: 'utf8',
309
+ stdio: 'pipe',
310
+ }).trim();
311
+
312
+ cliLogger.info({ moduleId, commitSha, branchName }, 'Module commit created');
313
+ } catch (err) {
314
+ cliLogger.warn(err, `Failed to create git commit for module ${moduleId}`);
315
+ }
316
+
317
+ // 16. GitHub PR creation if token available
318
+ const githubToken = opts.githubToken ?? process.env.GITHUB_TOKEN;
319
+
320
+ if (githubToken && commitSha) {
321
+ try {
322
+ // Push branch to remote
323
+ execSync(`git -C "${projectDir}" push -u origin ${branchName}`, { stdio: 'pipe' });
324
+
325
+ const repoInfo = await detectRepoInfo();
326
+ if (repoInfo) {
327
+ const prResult = await createGitHubPR(
328
+ repoInfo.owner,
329
+ repoInfo.repo,
330
+ branchName,
331
+ `feat: add tib module ${moduleId}`,
332
+ `Adds optional scaffold module: ${moduleId}@${config.scaffoldVersion}\n\nFiles added: ${filesInstalled.length}`,
333
+ githubToken
334
+ );
335
+
336
+ if (prResult) {
337
+ console.log(`\nPR created: ${prResult.prUrl}`);
338
+ cliLogger.info({ prUrl: prResult.prUrl, prNumber: prResult.prNumber }, 'Module PR created');
339
+ }
340
+ }
341
+ } catch (err) {
342
+ cliLogger.warn(err, 'Failed to push and create PR');
343
+ }
344
+ } else if (commitSha) {
345
+ console.log(`\nBranch "${branchName}" created. Push with: git push -u origin ${branchName}`);
346
+ }
347
+ }
@@ -0,0 +1,242 @@
1
+ import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
2
+ import { join, resolve, dirname, basename } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { Readable } from 'node:stream';
5
+ import { Parser as TarParser } from 'tar';
6
+ import { cliLogger } from '../utils/logger.js';
7
+ import { readScaffoldConfig } from '../utils/scaffold-config.js';
8
+ import { fetchModulesJson, fetchModuleTarball } from '../utils/s3-fetch.js';
9
+ import {
10
+ type PagePattern,
11
+ PAGE_PATTERNS,
12
+ listPageTemplate,
13
+ detailPageTemplate,
14
+ formPageTemplate,
15
+ wizardPageTemplate,
16
+ dashboardPageTemplate,
17
+ settingsPageTemplate,
18
+ } from '../templates/patterns/page/index.js';
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ const TEMPLATES_DIR = resolve(__dirname, '..', 'templates', 'dashboard-pages');
22
+
23
+ /** Map from pattern name to its template function. */
24
+ const PATTERN_TEMPLATES: Record<PagePattern, (name: string) => string> = {
25
+ list: listPageTemplate,
26
+ detail: detailPageTemplate,
27
+ form: formPageTemplate,
28
+ wizard: wizardPageTemplate,
29
+ dashboard: dashboardPageTemplate,
30
+ settings: settingsPageTemplate,
31
+ };
32
+
33
+ const KEBAB_REGEX = /^[a-z][a-z0-9-]*$/;
34
+
35
+ export interface AddPageOptions {
36
+ pageId: string;
37
+ outputDir?: string;
38
+ auth?: boolean;
39
+ protected?: boolean;
40
+ public?: boolean;
41
+ /** Pattern-based scaffolding (list, detail, form, wizard, dashboard, settings). */
42
+ pattern?: PagePattern;
43
+ /** Layout to use: app (default) or auth. */
44
+ layout?: 'app' | 'auth';
45
+ }
46
+
47
+ interface PageEntry {
48
+ id: string;
49
+ category: string;
50
+ path: string;
51
+ name: string;
52
+ }
53
+
54
+ async function loadRegistry(): Promise<PageEntry[]> {
55
+ // Dynamic import avoids tsx compilation issues with .tsx templates
56
+ const mod = await import('../templates/dashboard-pages/index.js') as { PAGE_REGISTRY: readonly PageEntry[] };
57
+ return [...mod.PAGE_REGISTRY];
58
+ }
59
+
60
+ function getCategoryForPageId(pageId: string): string {
61
+ const authPages = ['login', 'signup', 'verify-email', 'forgot-password', 'reset-password', 'mfa-setup', 'mfa-verify', 'choose-org', 'accept-invitation', 'sign-out'];
62
+ const accountPages = ['profile', 'workspace-settings', 'members', 'api-keys', 'audit-log'];
63
+
64
+ if (authPages.includes(pageId)) return 'auth';
65
+ if (accountPages.includes(pageId)) return 'account';
66
+ return 'shell';
67
+ }
68
+
69
+ async function fetchPageFromS3(
70
+ scaffoldBucket: string,
71
+ scaffoldVersion: string,
72
+ pageId: string
73
+ ): Promise<string | null> {
74
+ try {
75
+ const modulesJson = await fetchModulesJson(scaffoldBucket, scaffoldVersion);
76
+ const frontendModule = modulesJson.modules.find(m => m.id === 'frontend');
77
+
78
+ if (!frontendModule) {
79
+ throw new Error(`Frontend module not found in modules.json for version ${scaffoldVersion}`);
80
+ }
81
+
82
+ const tarballBuffer = await fetchModuleTarball(
83
+ scaffoldBucket,
84
+ scaffoldVersion,
85
+ frontendModule.tarball,
86
+ frontendModule.sha256
87
+ );
88
+
89
+ // Extract the page file from the tarball
90
+ const pageFileName = `${pageId}.tsx`;
91
+ const category = getCategoryForPageId(pageId);
92
+ const expectedPath = `frontend/src/pages/${category}/${pageFileName}`;
93
+
94
+ let content = '';
95
+ const chunks: Buffer[] = [];
96
+
97
+ await new Promise<void>((resolve, reject) => {
98
+ const parser = new TarParser({
99
+ onentry: (entry) => {
100
+ if (entry.path === expectedPath) {
101
+ entry.on('data', (chunk: Buffer) => chunks.push(chunk));
102
+ } else {
103
+ entry.resume();
104
+ }
105
+ },
106
+ });
107
+ parser.on('finish', resolve);
108
+ parser.on('error', reject);
109
+ Readable.from(tarballBuffer).pipe(parser);
110
+ });
111
+
112
+ if (chunks.length === 0) {
113
+ throw new Error(`Page template not found at ${expectedPath} in frontend tarball`);
114
+ }
115
+
116
+ content = Buffer.concat(chunks).toString('utf-8');
117
+ return content;
118
+ } catch (err) {
119
+ cliLogger.warn(err, 'Failed to fetch from S3, falling back to local templates');
120
+ return null;
121
+ }
122
+ }
123
+
124
+ async function loadFromLocal(pageId: string, entry: PageEntry): Promise<string> {
125
+ const templatePath = join(TEMPLATES_DIR, entry.path);
126
+ try {
127
+ const content = await readFile(templatePath, 'utf8');
128
+ cliLogger.warn({ pageId }, 'Using local fallback template (scaffold version not available)');
129
+ return content;
130
+ } catch {
131
+ throw new Error(`Template file not found at ${templatePath}`);
132
+ }
133
+ }
134
+
135
+ export async function runAddPage(opts: AddPageOptions): Promise<void> {
136
+ const { pageId, pattern, layout = 'app' } = opts;
137
+
138
+ if (!KEBAB_REGEX.test(pageId)) {
139
+ throw new Error(`Invalid page id "${pageId}" — must be kebab-case`);
140
+ }
141
+
142
+ // ── Pattern mode ──
143
+ if (pattern) {
144
+ if (!PAGE_PATTERNS.includes(pattern)) {
145
+ const available = PAGE_PATTERNS.join(', ');
146
+ throw new Error(
147
+ `Unknown page pattern "${pattern}".\n\nAvailable patterns: ${available}`
148
+ );
149
+ }
150
+
151
+ const templateFn = PATTERN_TEMPLATES[pattern];
152
+ let content = templateFn(pageId);
153
+
154
+ // Prepend layout comment
155
+ const layoutComment = `// Layout: ${layout === 'auth' ? 'AuthLayout' : 'AppLayout'} — import and use in your router config\n\n`;
156
+ content = layoutComment + content;
157
+
158
+ const outDir = opts.outputDir
159
+ ? resolve(opts.outputDir)
160
+ : resolve(process.cwd(), 'src', 'pages', pageId);
161
+
162
+ await mkdir(outDir, { recursive: true });
163
+
164
+ const fileName = `${pageId}.tsx`;
165
+ const outPath = join(outDir, fileName);
166
+
167
+ // Refuse if target already exists
168
+ try {
169
+ await access(outPath);
170
+ throw new Error(`File already exists at ${outPath}. Remove it first or use a different --output directory.`);
171
+ } catch (err) {
172
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
173
+ }
174
+
175
+ await writeFile(outPath, content, 'utf8');
176
+
177
+ cliLogger.info({ pageId, pattern, layout, outPath }, 'Page scaffolded from pattern');
178
+ // eslint-disable-next-line no-console
179
+ console.log(
180
+ `\n✓ Page "${pageId}" (pattern: ${pattern}, layout: ${layout}) added at ${outPath}\n` +
181
+ ` Next: add a route in src/router.tsx for this page\n`
182
+ );
183
+ return;
184
+ }
185
+
186
+ // ── Registry mode (existing behaviour) ──
187
+ const registry = await loadRegistry();
188
+ const entry = registry.find(p => p.id === pageId);
189
+
190
+ if (!entry) {
191
+ const available = registry.map(p => p.id).join(', ');
192
+ throw new Error(
193
+ `Unknown page id "${pageId}".\n\nAvailable pages: ${available}\n` +
194
+ `Tip: use --pattern <pattern> to scaffold with a page pattern. Available patterns: ${PAGE_PATTERNS.join(', ')}`
195
+ );
196
+ }
197
+
198
+ let content: string | null = null;
199
+
200
+ // Try S3 fetch first
201
+ try {
202
+ const config = await readScaffoldConfig(process.cwd());
203
+ if (config.scaffoldVersion && config.scaffoldBucket) {
204
+ content = await fetchPageFromS3(config.scaffoldBucket, config.scaffoldVersion, pageId);
205
+ }
206
+ } catch (err) {
207
+ cliLogger.debug(err, 'Could not read scaffold config');
208
+ }
209
+
210
+ // Fall back to local templates
211
+ if (!content) {
212
+ content = await loadFromLocal(pageId, entry);
213
+ }
214
+
215
+ const outDir = opts.outputDir
216
+ ? resolve(opts.outputDir)
217
+ : resolve(process.cwd(), 'src', 'pages', entry.category);
218
+
219
+ await mkdir(outDir, { recursive: true });
220
+
221
+ const fileName = `${basename(entry.path)}`;
222
+ const outPath = join(outDir, fileName);
223
+
224
+ // Refuse if target already exists
225
+ try {
226
+ await access(outPath);
227
+ throw new Error(`File already exists at ${outPath}. Remove it first or use a different --output directory.`);
228
+ } catch (err) {
229
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
230
+ }
231
+
232
+ await writeFile(outPath, content, 'utf8');
233
+
234
+ cliLogger.info({ pageId, layout, outPath }, 'Page scaffolded');
235
+ // eslint-disable-next-line no-console
236
+ console.log(
237
+ `\n✓ Page "${pageId}" (layout: ${layout}) added at ${outPath}\n` +
238
+ ` Component: ${entry.name}\n` +
239
+ ` Next: import { ${entry.name} } from './${entry.category}/${basename(entry.path, '.tsx')}'\n` +
240
+ ` Then add a route in src/router.tsx\n`
241
+ );
242
+ }