@mettlecast/domain-cli 0.2.21 → 0.2.23

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 (71) hide show
  1. package/dist/builder/load-module.js +5 -1
  2. package/dist/cli.js +60 -3
  3. package/dist/commands/add-api.d.ts +1 -1
  4. package/dist/commands/add-api.js +45 -17
  5. package/dist/commands/add-fixture-factory.d.ts +16 -0
  6. package/dist/commands/add-fixture-factory.js +60 -0
  7. package/dist/commands/add-module.js +4 -5
  8. package/dist/commands/add-seed-page.js +4 -5
  9. package/dist/commands/build-flows.js +1 -1
  10. package/dist/commands/dev.d.ts +30 -3
  11. package/dist/commands/dev.js +52 -11
  12. package/dist/commands/doctor.d.ts +22 -0
  13. package/dist/commands/doctor.js +341 -6
  14. package/dist/commands/generate-openapi.d.ts +21 -0
  15. package/dist/commands/generate-openapi.js +117 -0
  16. package/dist/commands/generate-sdk.d.ts +25 -0
  17. package/dist/commands/generate-sdk.js +98 -0
  18. package/dist/commands/init.d.ts +14 -0
  19. package/dist/commands/init.js +62 -0
  20. package/dist/commands/reseed-page.js +5 -6
  21. package/dist/commands/upgrade.d.ts +2 -0
  22. package/dist/commands/upgrade.js +28 -6
  23. package/dist/commands/why.d.ts +47 -0
  24. package/dist/commands/why.js +129 -0
  25. package/dist/templates/api-skeleton.d.ts +5 -1
  26. package/dist/templates/api-skeleton.js +28 -6
  27. package/dist/templates/patterns/api/create-with-event.d.ts +5 -0
  28. package/dist/templates/patterns/api/create-with-event.js +14 -1
  29. package/dist/templates/patterns/api/idempotent-mutation.d.ts +5 -0
  30. package/dist/templates/patterns/api/idempotent-mutation.js +20 -0
  31. package/dist/templates/patterns/api/paginated-list.d.ts +5 -0
  32. package/dist/templates/patterns/api/paginated-list.js +12 -2
  33. package/dist/templates/patterns/api/simple-crud.d.ts +5 -0
  34. package/dist/templates/patterns/api/simple-crud.js +22 -4
  35. package/dist/templates/patterns/api/streaming-list.d.ts +27 -0
  36. package/dist/templates/patterns/api/streaming-list.js +91 -0
  37. package/dist/templates/patterns/api/system-admin.d.ts +5 -0
  38. package/dist/templates/patterns/api/system-admin.js +14 -4
  39. package/dist/templates/patterns/api/webhook-receiver-style.d.ts +5 -0
  40. package/dist/templates/patterns/api/webhook-receiver-style.js +22 -0
  41. package/dist/utils/s3-fetch.js +23 -32
  42. package/package.json +4 -1
  43. package/src/__tests__/commands/add-api.test.ts +160 -0
  44. package/src/__tests__/commands/dev.test.ts +162 -0
  45. package/src/__tests__/commands/why.test.ts +199 -0
  46. package/src/__tests__/doctor.test.ts +336 -1
  47. package/src/__tests__/smoke/scaffold.test.ts +574 -0
  48. package/src/builder/load-module.ts +5 -1
  49. package/src/cli.ts +67 -5
  50. package/src/commands/add-api.ts +68 -19
  51. package/src/commands/add-fixture-factory.ts +75 -0
  52. package/src/commands/add-module.ts +4 -5
  53. package/src/commands/add-seed-page.ts +4 -5
  54. package/src/commands/build-flows.ts +2 -2
  55. package/src/commands/dev.ts +78 -11
  56. package/src/commands/doctor.ts +379 -12
  57. package/src/commands/generate-openapi.ts +154 -0
  58. package/src/commands/generate-sdk.ts +125 -0
  59. package/src/commands/init.ts +78 -0
  60. package/src/commands/reseed-page.ts +5 -6
  61. package/src/commands/upgrade.ts +26 -6
  62. package/src/commands/why.ts +171 -0
  63. package/src/templates/api-skeleton.ts +32 -6
  64. package/src/templates/patterns/api/create-with-event.ts +15 -1
  65. package/src/templates/patterns/api/idempotent-mutation.ts +21 -0
  66. package/src/templates/patterns/api/paginated-list.ts +13 -2
  67. package/src/templates/patterns/api/simple-crud.ts +23 -4
  68. package/src/templates/patterns/api/streaming-list.ts +91 -0
  69. package/src/templates/patterns/api/system-admin.ts +15 -4
  70. package/src/templates/patterns/api/webhook-receiver-style.ts +23 -0
  71. package/src/utils/s3-fetch.ts +26 -34
@@ -2,6 +2,14 @@ import { readdir, readFile, access, stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { relative } from 'node:path';
4
4
  import { readdirSync, existsSync } from 'node:fs';
5
+ import ky from 'ky';
6
+
7
+ /**
8
+ * Canonical option-string constant for the --fix flag on the doctor command.
9
+ * Exported so the cli-integration task (packages/domain-cli/src/index.ts) can
10
+ * register the same flag string in commander without string drift.
11
+ */
12
+ export const DOCTOR_FIX_FLAG = '--fix';
5
13
 
6
14
  /**
7
15
  * Options for the doctor command.
@@ -12,6 +20,22 @@ export interface DoctorOptions {
12
20
  projectRoot?: string;
13
21
  /** Flag to run relocation of old-layout scaffold files to new layout. */
14
22
  relocate?: boolean;
23
+ /**
24
+ * Auto-remediate before reporting. --fix is a superset of --relocate: it
25
+ * first runs the relocation routine (auto-moves owned scaffold files outside
26
+ * .mc/ into .mc/ and updates .mc/manifest.json), then continues with the
27
+ * full doctor report. On a project that is already clean, --fix is a no-op
28
+ * (0 files moved, exit 0). Does NOT auto-generate missing fixtures
29
+ * (deferred).
30
+ */
31
+ fix?: boolean;
32
+ /**
33
+ * Explicit strict mode. Doctor is ALWAYS strict — any FAIL causes exit 1.
34
+ * This flag exists so CI can pass `--strict` as a self-documenting,
35
+ * fail-closed gate. Currently informational only; behaviour is identical
36
+ * with or without the flag.
37
+ */
38
+ strict?: boolean;
15
39
  }
16
40
 
17
41
  /**
@@ -62,17 +86,132 @@ function findFiles(dir: string, pattern: RegExp): string[] {
62
86
  const fullPath = join(currentPath, entry.name);
63
87
  if (entry.isDirectory()) {
64
88
  walk(fullPath);
65
- } else if (pattern.test(entry.name)) {
66
- result.push(fullPath);
89
+ } else if (pattern.test(entry.name)) {
90
+ result.push(fullPath);
91
+ }
92
+ }
93
+ } catch {
94
+ // Silently skip inaccessible directories
95
+ }
96
+ }
97
+
98
+ walk(dir);
99
+ return result;
100
+ }
101
+
102
+ /**
103
+ * Check W6-1: All domain handler return types reference `Result`.
104
+ * Scans `domains/*\/api/*.ts` for handler function signatures that
105
+ * lack `Result` in their return type annotation.
106
+ */
107
+ async function checkHandlersUseResult(projectRoot: string): Promise<DoctorCheck> {
108
+ try {
109
+ const domainsDir = join(projectRoot, 'domains');
110
+ if (!existsSync(domainsDir)) {
111
+ return { name: 'Handlers use Result<T>', status: 'PASS',
112
+ message: 'No domains/ — check skipped (optional)',
113
+ kNodeRef: 'K:convention:typed-errors' };
114
+ }
115
+
116
+ const apiFiles = findFiles(domainsDir, /\/api\/[^/]+\.ts$/);
117
+ if (apiFiles.length === 0) {
118
+ return { name: 'Handlers use Result<T>', status: 'PASS',
119
+ message: 'No handler files — check skipped (optional)',
120
+ kNodeRef: 'K:convention:typed-errors' };
121
+ }
122
+
123
+ const missing: string[] = [];
124
+ for (const file of apiFiles) {
125
+ const content = await readFile(file, 'utf8');
126
+ // A file contains a handler export — check the return type
127
+ if (/defineApi\s*\(/.test(content)) {
128
+ // Look for Result<T> in the handler's return type annotation
129
+ if (!/: .*Result</.test(content)) {
130
+ missing.push(relative(projectRoot, file));
67
131
  }
68
132
  }
69
- } catch {
70
- // Silently skip inaccessible directories
71
133
  }
134
+
135
+ if (missing.length === 0) {
136
+ return { name: 'Handlers use Result<T>', status: 'PASS',
137
+ message: `All ${apiFiles.length} handler(s) reference Result<T> in their return type`,
138
+ kNodeRef: 'K:convention:typed-errors' };
139
+ } else {
140
+ return { name: 'Handlers use Result<T>', status: 'FAIL',
141
+ message: `${missing.length}/${apiFiles.length} handler(s) missing Result<T> return type:\n ${missing.join('\n ')}\nEach handler's return type must reference \`Result<T, AppError>\`. Replace bare \`return\` values with \`return ok(value)\` and errors with \`return err({...})\`.`,
142
+ fixHint: 'Add `: Promise<Result<T, AppError>>` to the handler\'s return type and import `{ Result, ok, err }` from `@mettlecast/domain-runtime`',
143
+ kNodeRef: 'K:convention:typed-errors' };
144
+ }
145
+ } catch (err) {
146
+ return { name: 'Handlers use Result<T>', status: 'WARN',
147
+ message: `Could not check handler return types: ${String(err)}`,
148
+ kNodeRef: 'K:convention:typed-errors' };
72
149
  }
150
+ }
73
151
 
74
- walk(dir);
75
- return result;
152
+ /**
153
+ * Check W6-2: Generated API clients are fresh.
154
+ * Compares .genhash files against the registry hash to ensure the
155
+ * generated client has not drifted from the source schemas.
156
+ */
157
+ async function checkGeneratedClientsFresh(projectRoot: string): Promise<DoctorCheck> {
158
+ try {
159
+ const genDir = join(projectRoot, 'frontend', 'src', 'sdk', 'generated');
160
+ if (!existsSync(genDir)) {
161
+ return { name: 'Generated clients fresh', status: 'PASS',
162
+ message: 'No generated clients — check skipped (optional)',
163
+ kNodeRef: 'K:runbook:generated-clients' };
164
+ }
165
+
166
+ const hashFiles = findFiles(genDir, /\.genhash$/);
167
+ if (hashFiles.length === 0) {
168
+ return { name: 'Generated clients fresh', status: 'WARN',
169
+ message: 'Generated client directory exists but no .genhash files — cannot verify freshness',
170
+ kNodeRef: 'K:runbook:generated-clients' };
171
+ }
172
+
173
+ const stale: string[] = [];
174
+ for (const hashFile of hashFiles) {
175
+ const content = await readFile(hashFile, 'utf8').catch(() => null);
176
+ if (!content) {
177
+ stale.push(`${relative(projectRoot, hashFile)} (unreadable)`);
178
+ continue;
179
+ }
180
+ // A .genhash file contains: "<domain> <sha256-of-registry>"
181
+ const parts = content.trim().split(/\s+/);
182
+ if (parts.length !== 2) {
183
+ stale.push(`${relative(projectRoot, hashFile)} (invalid format)`);
184
+ continue;
185
+ }
186
+ const [domain, expectedHash] = parts;
187
+ const registryPath = join(projectRoot, '.mc', `${domain}-registry.json`);
188
+ const registryJson = await readFile(registryPath, 'utf8').catch(() => null);
189
+ if (!registryJson) {
190
+ stale.push(`${relative(projectRoot, hashFile)} (registry ${domain}-registry.json not found)`);
191
+ continue;
192
+ }
193
+ const { createHash } = await import('node:crypto');
194
+ const actualHash = createHash('sha256').update(registryJson).digest('hex');
195
+ if (actualHash !== expectedHash) {
196
+ stale.push(`${relative(projectRoot, hashFile)} (stale — run \`npx mc-domain-module generate-sdk ${domain}\`)`);
197
+ }
198
+ }
199
+
200
+ if (stale.length === 0) {
201
+ return { name: 'Generated clients fresh', status: 'PASS',
202
+ message: `All ${hashFiles.length} generated client(s) are up to date`,
203
+ kNodeRef: 'K:runbook:generated-clients' };
204
+ } else {
205
+ return { name: 'Generated clients fresh', status: 'FAIL',
206
+ message: `${stale.length}/${hashFiles.length} generated client(s) are stale:\n ${stale.join('\n ')}`,
207
+ fixHint: 'Run `npx mc-domain-module generate-sdk <domain>` for each stale domain',
208
+ kNodeRef: 'K:runbook:generated-clients' };
209
+ }
210
+ } catch (err) {
211
+ return { name: 'Generated clients fresh', status: 'WARN',
212
+ message: `Could not check generated client freshness: ${String(err)}`,
213
+ kNodeRef: 'K:runbook:generated-clients' };
214
+ }
76
215
  }
77
216
 
78
217
  /**
@@ -85,12 +224,23 @@ function findFiles(dir: string, pattern: RegExp): string[] {
85
224
  export async function runDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
86
225
  const projectRoot = opts.projectRoot ?? process.cwd();
87
226
 
88
- // Handle --relocate flag early
89
- if (opts.relocate) {
227
+ // --relocate runs ONLY the relocation routine (existing behaviour).
228
+ // --fix is a superset: it runs relocation first, then the full report.
229
+ // When both are set, --fix takes precedence so the report still runs.
230
+ if (opts.relocate && !opts.fix) {
90
231
  return runRelocate(projectRoot);
91
232
  }
92
233
 
93
- const checks: DoctorCheck[] = [];
234
+ // --fix preflight: run the relocation routine and merge its checks into
235
+ // the doctor report. On a clean project (no files outside .mc/ to move)
236
+ // runRelocate returns a no-op summary check (0 moved, 0 warned, 8 skipped).
237
+ let preflightChecks: DoctorCheck[] = [];
238
+ if (opts.fix) {
239
+ const relocationReport = await runRelocate(projectRoot);
240
+ preflightChecks = relocationReport.checks;
241
+ }
242
+
243
+ const checks: DoctorCheck[] = [...preflightChecks];
94
244
 
95
245
  // Check 1: Domain configs valid
96
246
  checks.push(await checkDomainConfigsValid(projectRoot));
@@ -117,7 +267,7 @@ export async function runDoctor(opts: DoctorOptions = {}): Promise<DoctorReport>
117
267
  checks.push(await checkRootLevelFlows(projectRoot));
118
268
 
119
269
  // W2 new assertions
120
- checks.push(await checkApiFixtures(projectRoot));
270
+ checks.push(await checkEveryApiHasFixture(projectRoot));
121
271
  checks.push(await checkEventConsumers(projectRoot));
122
272
  checks.push(await checkMigrationTenancyTrio(projectRoot));
123
273
  checks.push(await checkDomainRlsTest(projectRoot));
@@ -125,6 +275,16 @@ export async function runDoctor(opts: DoctorOptions = {}): Promise<DoctorReport>
125
275
  checks.push(await checkActionsHaveTypes(projectRoot));
126
276
  checks.push(await checkConventionEvidence(projectRoot));
127
277
 
278
+ // W5 new checks (scaffolder modernize — 4 new + 1 reuse of checkApiFixtures)
279
+ checks.push(await checkAllHttpClientsUseKy(projectRoot));
280
+ checks.push(await checkAllRoutesUseTanStackRouter(projectRoot));
281
+ checks.push(await checkOtelInitInLambdas(projectRoot));
282
+ checks.push(await checkFrontendUsesStrictTypescript(projectRoot));
283
+
284
+ // W6 (post-review) new checks
285
+ checks.push(await checkHandlersUseResult(projectRoot));
286
+ checks.push(await checkGeneratedClientsFresh(projectRoot));
287
+
128
288
  // W1 tracked-files boundary checks
129
289
  checks.push(...await checkScaffoldTrackedFilesHaveHeaders(projectRoot));
130
290
  checks.push(...await checkSeedPagesHaveNoHeaders(projectRoot));
@@ -597,6 +757,212 @@ async function checkApiFixtures(projectRoot: string): Promise<DoctorCheck> {
597
757
  }
598
758
  }
599
759
 
760
+ // ---- W5 new checks (scaffolder modernize) ----
761
+
762
+ /**
763
+ * Check W5-1: All HTTP clients in domains/ use ky or ctx.fetch (no raw fetch()).
764
+ * Reuses the W2 fixture check. Per plan-reviewer WARN #6, this is the
765
+ * "5th new check" counted as a reuse of checkApiFixtures — the
766
+ * `checkEveryApiHasFixture` wrapper below invokes the same logic.
767
+ */
768
+ async function checkEveryApiHasFixture(projectRoot: string): Promise<DoctorCheck> {
769
+ return checkApiFixtures(projectRoot);
770
+ }
771
+
772
+ /**
773
+ * Check W5-2: All HTTP clients in domains/ use ky or ctx.fetch.
774
+ * Detects raw `fetch(` calls (the global browser/node fetch) and FAILs.
775
+ * Allows `ctx.fetch(` (domain-runtime helper) and `ky.fetch(` (ky API).
776
+ */
777
+ /**
778
+ * Check W5-1: All domain + CLI HTTP clients use ky (no raw fetch).
779
+ * Scans domains/ AND packages/domain-cli/src/commands/ for raw
780
+ * `fetch(` calls. Excludes `ctx.fetch`, `globalThis.fetch`, and
781
+ * comments.
782
+ */
783
+
784
+ async function checkAllHttpClientsUseKy(projectRoot: string): Promise<DoctorCheck> {
785
+ try {
786
+ const scanDirs = [
787
+ { dir: join(projectRoot, 'domains'), label: 'domains' },
788
+ { dir: join(projectRoot, 'packages', 'domain-cli', 'src', 'commands'), label: 'CLI commands' },
789
+ ];
790
+
791
+ const violations: string[] = [];
792
+ let totalFiles = 0;
793
+
794
+ for (const { dir, label } of scanDirs) {
795
+ if (!existsSync(dir)) continue;
796
+ const files = findFiles(dir, /\.ts$/);
797
+ totalFiles += files.length;
798
+
799
+ for (const file of files) {
800
+ const content = await readFile(file, 'utf8');
801
+ const lines = content.split('\n');
802
+ for (let i = 0; i < lines.length; i++) {
803
+ const line = lines[i];
804
+ const code = line.replace(/\/\/.*$/, '');
805
+ if (/(?<![\w$.])fetch\s*\(/.test(code)) {
806
+ violations.push(`${relative(projectRoot, file)}:${i + 1} (${label})`);
807
+ }
808
+ }
809
+ }
810
+ }
811
+
812
+ if (violations.length === 0) {
813
+ return { name: 'All HTTP clients use ky', status: 'PASS',
814
+ message: `No raw fetch() calls across ${totalFiles} file(s) in domains/ and CLI — all HTTP clients use ky or ctx.fetch`,
815
+ kNodeRef: 'K:convention:http-client-ky' };
816
+ } else {
817
+ return { name: 'All HTTP clients use ky', status: 'FAIL',
818
+ message: `Found ${violations.length} raw fetch() call(s):\n ${violations.join('\n ')}`,
819
+ fixHint: 'Use ky or ctx.fetch from @mettlecast/domain-runtime instead of raw fetch()',
820
+ kNodeRef: 'K:convention:http-client-ky' };
821
+ }
822
+ } catch (err) {
823
+ return { name: 'All HTTP clients use ky', status: 'WARN',
824
+ message: `Could not check HTTP clients: ${String(err)}`,
825
+ kNodeRef: 'K:convention:http-client-ky' };
826
+ }
827
+ }
828
+
829
+ /**
830
+ * Check W5-3: All frontend routes use TanStack Router (no react-router-dom imports).
831
+ * Scans frontend/ for `from 'react-router-dom'` (or double-quoted) imports.
832
+ */
833
+ async function checkAllRoutesUseTanStackRouter(projectRoot: string): Promise<DoctorCheck> {
834
+ try {
835
+ const frontendDir = join(projectRoot, 'frontend');
836
+ if (!existsSync(frontendDir)) {
837
+ return { name: 'Routes use TanStack Router', status: 'PASS',
838
+ message: 'No frontend/ directory — check skipped (optional)',
839
+ kNodeRef: 'K:convention:tanstack-router' };
840
+ }
841
+
842
+ const files = findFiles(frontendDir, /\.(ts|tsx|js|jsx)$/);
843
+ const violations: string[] = [];
844
+
845
+ for (const file of files) {
846
+ const content = await readFile(file, 'utf8');
847
+ if (/from\s+['"]react-router-dom['"]/.test(content)) {
848
+ violations.push(relative(projectRoot, file));
849
+ }
850
+ }
851
+
852
+ if (violations.length === 0) {
853
+ return { name: 'Routes use TanStack Router', status: 'PASS',
854
+ message: 'No react-router-dom imports in frontend/',
855
+ kNodeRef: 'K:convention:tanstack-router' };
856
+ } else {
857
+ return { name: 'Routes use TanStack Router', status: 'FAIL',
858
+ message: `Found ${violations.length} react-router-dom import(s) in frontend/:\n ${violations.join('\n ')}`,
859
+ fixHint: 'Migrate from react-router-dom to @tanstack/react-router',
860
+ kNodeRef: 'K:convention:tanstack-router' };
861
+ }
862
+ } catch (err) {
863
+ return { name: 'Routes use TanStack Router', status: 'WARN',
864
+ message: `Could not check frontend router: ${String(err)}`,
865
+ kNodeRef: 'K:convention:tanstack-router' };
866
+ }
867
+ }
868
+
869
+ /**
870
+ * Check W5-4: Lambda handler files contain initOtel() call.
871
+ * Scans `domains/*\/api/*.ts` and FAILs if any handler is missing initOtel().
872
+ */
873
+ async function checkOtelInitInLambdas(projectRoot: string): Promise<DoctorCheck> {
874
+ try {
875
+ const domainsDir = join(projectRoot, 'domains');
876
+ let entries: { name: string; isDirectory: () => boolean }[] = [];
877
+ try {
878
+ entries = await readdir(domainsDir, { withFileTypes: true });
879
+ } catch {
880
+ return { name: 'OTel init in Lambdas', status: 'PASS',
881
+ message: 'No domains/ — check skipped (optional)',
882
+ kNodeRef: 'K:convention:otel-init' };
883
+ }
884
+
885
+ const handlerFiles: string[] = [];
886
+ for (const entry of entries) {
887
+ if (!entry.isDirectory()) continue;
888
+ const apiDir = join(domainsDir, entry.name, 'api');
889
+ const files = findFiles(apiDir, /\.ts$/);
890
+ handlerFiles.push(...files);
891
+ }
892
+
893
+ if (handlerFiles.length === 0) {
894
+ return { name: 'OTel init in Lambdas', status: 'PASS',
895
+ message: 'No Lambda handler files in domains/ (optional)',
896
+ kNodeRef: 'K:convention:otel-init' };
897
+ }
898
+
899
+ const missing: string[] = [];
900
+ for (const file of handlerFiles) {
901
+ const content = await readFile(file, 'utf8');
902
+ if (!/initOtel\s*\(/.test(content)) {
903
+ missing.push(relative(projectRoot, file));
904
+ }
905
+ }
906
+
907
+ if (missing.length === 0) {
908
+ return { name: 'OTel init in Lambdas', status: 'PASS',
909
+ message: `All ${handlerFiles.length} Lambda handler(s) call initOtel()`,
910
+ kNodeRef: 'K:convention:otel-init' };
911
+ } else {
912
+ return { name: 'OTel init in Lambdas', status: 'FAIL',
913
+ message: `${missing.length}/${handlerFiles.length} Lambda handler(s) missing initOtel():\n ${missing.join('\n ')}`,
914
+ fixHint: 'Add initOtel() (from @mettlecast/domain-runtime) to each Lambda handler file',
915
+ kNodeRef: 'K:convention:otel-init' };
916
+ }
917
+ } catch (err) {
918
+ return { name: 'OTel init in Lambdas', status: 'WARN',
919
+ message: `Could not check OTel init: ${String(err)}`,
920
+ kNodeRef: 'K:convention:otel-init' };
921
+ }
922
+ }
923
+
924
+ /**
925
+ * Check W5-5: Frontend tsconfig.json has `strict: true`.
926
+ * Reads frontend/tsconfig.json and verifies compilerOptions.strict === true.
927
+ */
928
+ async function checkFrontendUsesStrictTypescript(projectRoot: string): Promise<DoctorCheck> {
929
+ try {
930
+ const tsconfigPath = join(projectRoot, 'frontend', 'tsconfig.json');
931
+ if (!existsSync(tsconfigPath)) {
932
+ return { name: 'Frontend uses strict TypeScript', status: 'PASS',
933
+ message: 'No frontend/tsconfig.json — check skipped (optional)',
934
+ kNodeRef: 'K:convention:typescript-strict' };
935
+ }
936
+
937
+ const content = await readFile(tsconfigPath, 'utf8');
938
+ let config: { compilerOptions?: { strict?: unknown } };
939
+ try {
940
+ config = JSON.parse(content);
941
+ } catch (err) {
942
+ return { name: 'Frontend uses strict TypeScript', status: 'FAIL',
943
+ message: `frontend/tsconfig.json is not valid JSON: ${String(err)}`,
944
+ fixHint: 'Fix tsconfig.json JSON syntax',
945
+ kNodeRef: 'K:convention:typescript-strict' };
946
+ }
947
+
948
+ const strict = config.compilerOptions?.strict === true;
949
+ if (strict) {
950
+ return { name: 'Frontend uses strict TypeScript', status: 'PASS',
951
+ message: 'frontend/tsconfig.json has strict: true',
952
+ kNodeRef: 'K:convention:typescript-strict' };
953
+ } else {
954
+ return { name: 'Frontend uses strict TypeScript', status: 'FAIL',
955
+ message: 'frontend/tsconfig.json does not have strict: true',
956
+ fixHint: "Add \"strict\": true to compilerOptions in frontend/tsconfig.json",
957
+ kNodeRef: 'K:convention:typescript-strict' };
958
+ }
959
+ } catch (err) {
960
+ return { name: 'Frontend uses strict TypeScript', status: 'WARN',
961
+ message: `Could not check tsconfig: ${String(err)}`,
962
+ kNodeRef: 'K:convention:typescript-strict' };
963
+ }
964
+ }
965
+
600
966
  /**
601
967
  * Check 10: Every event has consumer or @no-consumers annotation.
602
968
  */
@@ -796,9 +1162,10 @@ async function checkDomainBrainReachable(projectRoot: string): Promise<DoctorChe
796
1162
  try {
797
1163
  const controller = new AbortController();
798
1164
  const timeoutId = setTimeout(() => controller.abort(), 3000);
799
- const response = await fetch(`${brainEndpoint}/api/graph/nodes`, {
800
- method: 'GET', signal: controller.signal,
1165
+ const response = await ky.get(`${brainEndpoint}/api/graph/nodes`, {
1166
+ signal: controller.signal,
801
1167
  headers: { 'Content-Type': 'application/json' },
1168
+ timeout: 3000,
802
1169
  });
803
1170
  clearTimeout(timeoutId);
804
1171
 
@@ -0,0 +1,154 @@
1
+ /**
2
+ * generate-openapi — read a domain's registry and produce an OpenAPI 3.1
3
+ * specification as JSON. The registry is consumed at build time; each
4
+ * `defineApi` entry contributes one path under the domain's route prefix.
5
+ *
6
+ * Usage: npx mc-domain-module generate-openapi <domain>
7
+ */
8
+
9
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { cliLogger } from '../utils/logger.js';
12
+
13
+ export interface GenerateOpenapiOptions {
14
+ domain: string;
15
+ projectRoot?: string;
16
+ /** Output path. Defaults to domains/<domain>/api/openapi.generated.json */
17
+ output?: string;
18
+ }
19
+
20
+ interface ApiRegistryEntry {
21
+ id: string;
22
+ path: string;
23
+ method: string;
24
+ tenancy: string;
25
+ versions: Record<string, {
26
+ status: string;
27
+ inputSchema?: unknown;
28
+ outputSchema?: unknown;
29
+ input?: unknown;
30
+ output?: unknown;
31
+ }>;
32
+ examples?: { request?: unknown; response?: unknown };
33
+ }
34
+
35
+ interface DomainRegistry {
36
+ domain: { id: string };
37
+ apis: ApiRegistryEntry[];
38
+ }
39
+
40
+ /**
41
+ * Extract a Zod schema shape from a registry entry version.
42
+ * The registry may store schemas as `input`/`output` (raw Zod
43
+ * objects) or `inputSchema`/`outputSchema` (JSON Schema shapes).
44
+ * Returns a best-effort JSON Schema object for the OpenAPI spec.
45
+ */
46
+ function extractSchema(version: ApiRegistryEntry['versions'][string], field: 'input' | 'output'): unknown {
47
+ // Prefer JSON Schema if present
48
+ const schemaField = field === 'input' ? version.inputSchema : version.outputSchema;
49
+ if (schemaField && typeof schemaField === 'object' && schemaField !== null) {
50
+ return schemaField;
51
+ }
52
+ // Fall back to the raw Zod shape — try to produce a minimal JSON
53
+ // Schema from the Zod _def. For full support, add zod-to-json-schema
54
+ // to the CLI dependencies and call `zodToJsonSchema(zodSchema)`.
55
+ const raw = field === 'input' ? version.input : version.output;
56
+ if (raw && typeof raw === 'object' && raw !== null) {
57
+ // Attempt minimal mapping: if the Zod shape has a `type` field
58
+ // from its _def, describe it as JSON Schema.
59
+ const def = raw as { type?: string; items?: unknown; properties?: unknown; required?: string[] };
60
+ return { type: def.type ?? 'object', properties: def.properties, required: def.required };
61
+ }
62
+ return { type: 'object' };
63
+ }
64
+
65
+ export async function runGenerateOpenapi(options: GenerateOpenapiOptions): Promise<string> {
66
+ const projectRoot = options.projectRoot ?? process.cwd();
67
+ const domain = options.domain;
68
+ const outputPath = options.output ?? join(projectRoot, 'domains', domain, 'api', 'openapi.generated.json');
69
+
70
+ // Read the domain registry
71
+ const registryPath = join(projectRoot, '.mc', `${domain}-registry.json`);
72
+ const registryJson = await readFile(registryPath, 'utf8');
73
+ const registry = JSON.parse(registryJson) as DomainRegistry;
74
+
75
+ const paths: Record<string, unknown> = {};
76
+
77
+ for (const api of registry.apis) {
78
+ const method = (api.method ?? 'get').toLowerCase();
79
+ const fullPath = `/v1/${domain}${api.path}`;
80
+ const v1 = api.versions['v1'] ?? api.versions[Object.keys(api.versions)[0]];
81
+ if (!v1) continue;
82
+
83
+ if (!paths[fullPath]) paths[fullPath] = {};
84
+
85
+ (paths[fullPath] as Record<string, unknown>)[method] = {
86
+ operationId: `${domain}.${api.id}`,
87
+ summary: `${domain}.${api.id}`,
88
+ description: `Version: ${v1.status}`,
89
+ requestBody: {
90
+ content: {
91
+ 'application/json': {
92
+ schema: extractSchema(v1, 'input'),
93
+ },
94
+ },
95
+ },
96
+ responses: {
97
+ '200': {
98
+ description: 'OK',
99
+ content: {
100
+ 'application/json': {
101
+ schema: extractSchema(v1, 'output'),
102
+ },
103
+ },
104
+ },
105
+ '400': {
106
+ description: 'Validation error',
107
+ content: {
108
+ 'application/json': {
109
+ schema: {
110
+ type: 'object',
111
+ properties: {
112
+ error: {
113
+ type: 'object',
114
+ properties: {
115
+ kind: { type: 'string', enum: ['validation'] },
116
+ message: { type: 'string' },
117
+ fieldErrors: { type: 'object' },
118
+ },
119
+ },
120
+ },
121
+ },
122
+ },
123
+ },
124
+ },
125
+ },
126
+ };
127
+ }
128
+
129
+ const openapi = {
130
+ openapi: '3.1.0',
131
+ info: {
132
+ title: `${domain} API`,
133
+ version: '1.0.0',
134
+ description: `Generated from ${domain}-registry.json at build time. Do not edit manually.`,
135
+ },
136
+ paths,
137
+ };
138
+
139
+ const dir = join(outputPath, '..');
140
+ await mkdir(dir, { recursive: true });
141
+ await writeFile(outputPath, JSON.stringify(openapi, null, 2), 'utf8');
142
+
143
+ cliLogger.info({ outputPath }, 'generate-openapi: spec written');
144
+ return outputPath;
145
+ }
146
+
147
+ /**
148
+ * CLI entry point.
149
+ */
150
+ export async function runGenerateOpenapiCli(domain: string, opts: { projectRoot?: string; output?: string } = {}): Promise<void> {
151
+ const outputPath = await runGenerateOpenapi({ domain, ...opts });
152
+ // eslint-disable-next-line no-console
153
+ console.log(`OpenAPI spec written to: ${outputPath}`);
154
+ }