@mettlecast/domain-cli 0.2.22 → 0.2.24
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.
- package/dist/cli.js +60 -3
- package/dist/commands/add-api.d.ts +1 -1
- package/dist/commands/add-api.js +45 -17
- package/dist/commands/add-fixture-factory.d.ts +16 -0
- package/dist/commands/add-fixture-factory.js +60 -0
- package/dist/commands/add-module.js +4 -5
- package/dist/commands/add-seed-page.js +4 -5
- package/dist/commands/build-flows.js +1 -1
- package/dist/commands/dev.d.ts +30 -3
- package/dist/commands/dev.js +52 -11
- package/dist/commands/doctor.d.ts +22 -0
- package/dist/commands/doctor.js +341 -6
- package/dist/commands/generate-openapi.d.ts +21 -0
- package/dist/commands/generate-openapi.js +117 -0
- package/dist/commands/generate-sdk.d.ts +25 -0
- package/dist/commands/generate-sdk.js +98 -0
- package/dist/commands/init.d.ts +14 -0
- package/dist/commands/init.js +62 -0
- package/dist/commands/reseed-page.js +5 -6
- package/dist/commands/upgrade.d.ts +2 -0
- package/dist/commands/upgrade.js +28 -6
- package/dist/commands/why.d.ts +47 -0
- package/dist/commands/why.js +129 -0
- package/dist/templates/api-skeleton.d.ts +5 -1
- package/dist/templates/api-skeleton.js +28 -6
- package/dist/templates/patterns/api/create-with-event.d.ts +5 -0
- package/dist/templates/patterns/api/create-with-event.js +14 -1
- package/dist/templates/patterns/api/idempotent-mutation.d.ts +5 -0
- package/dist/templates/patterns/api/idempotent-mutation.js +20 -0
- package/dist/templates/patterns/api/paginated-list.d.ts +5 -0
- package/dist/templates/patterns/api/paginated-list.js +12 -2
- package/dist/templates/patterns/api/simple-crud.d.ts +5 -0
- package/dist/templates/patterns/api/simple-crud.js +22 -4
- package/dist/templates/patterns/api/streaming-list.d.ts +27 -0
- package/dist/templates/patterns/api/streaming-list.js +91 -0
- package/dist/templates/patterns/api/system-admin.d.ts +5 -0
- package/dist/templates/patterns/api/system-admin.js +14 -4
- package/dist/templates/patterns/api/webhook-receiver-style.d.ts +5 -0
- package/dist/templates/patterns/api/webhook-receiver-style.js +22 -0
- package/dist/utils/s3-fetch.js +23 -32
- package/package.json +4 -1
- package/src/__tests__/commands/add-api.test.ts +160 -0
- package/src/__tests__/commands/dev.test.ts +162 -0
- package/src/__tests__/commands/why.test.ts +199 -0
- package/src/__tests__/doctor.test.ts +336 -1
- package/src/__tests__/smoke/scaffold.test.ts +574 -0
- package/src/cli.ts +67 -5
- package/src/commands/add-api.ts +68 -19
- package/src/commands/add-fixture-factory.ts +75 -0
- package/src/commands/add-module.ts +4 -5
- package/src/commands/add-seed-page.ts +4 -5
- package/src/commands/build-flows.ts +2 -2
- package/src/commands/dev.ts +78 -11
- package/src/commands/doctor.ts +379 -12
- package/src/commands/generate-openapi.ts +154 -0
- package/src/commands/generate-sdk.ts +125 -0
- package/src/commands/init.ts +78 -0
- package/src/commands/reseed-page.ts +5 -6
- package/src/commands/upgrade.ts +26 -6
- package/src/commands/why.ts +171 -0
- package/src/templates/api-skeleton.ts +32 -6
- package/src/templates/patterns/api/create-with-event.ts +15 -1
- package/src/templates/patterns/api/idempotent-mutation.ts +21 -0
- package/src/templates/patterns/api/paginated-list.ts +13 -2
- package/src/templates/patterns/api/simple-crud.ts +23 -4
- package/src/templates/patterns/api/streaming-list.ts +91 -0
- package/src/templates/patterns/api/system-admin.ts +15 -4
- package/src/templates/patterns/api/webhook-receiver-style.ts +23 -0
- package/src/utils/s3-fetch.ts +26 -34
package/dist/commands/doctor.js
CHANGED
|
@@ -2,6 +2,13 @@ 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
|
+
* Canonical option-string constant for the --fix flag on the doctor command.
|
|
8
|
+
* Exported so the cli-integration task (packages/domain-cli/src/index.ts) can
|
|
9
|
+
* register the same flag string in commander without string drift.
|
|
10
|
+
*/
|
|
11
|
+
export const DOCTOR_FIX_FLAG = '--fix';
|
|
5
12
|
/**
|
|
6
13
|
* Recursively find all files matching a pattern.
|
|
7
14
|
* @param dir - Directory to search
|
|
@@ -30,6 +37,117 @@ function findFiles(dir, pattern) {
|
|
|
30
37
|
walk(dir);
|
|
31
38
|
return result;
|
|
32
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Check W6-1: All domain handler return types reference `Result`.
|
|
42
|
+
* Scans `domains/*\/api/*.ts` for handler function signatures that
|
|
43
|
+
* lack `Result` in their return type annotation.
|
|
44
|
+
*/
|
|
45
|
+
async function checkHandlersUseResult(projectRoot) {
|
|
46
|
+
try {
|
|
47
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
48
|
+
if (!existsSync(domainsDir)) {
|
|
49
|
+
return { name: 'Handlers use Result<T>', status: 'PASS',
|
|
50
|
+
message: 'No domains/ — check skipped (optional)',
|
|
51
|
+
kNodeRef: 'K:convention:typed-errors' };
|
|
52
|
+
}
|
|
53
|
+
const apiFiles = findFiles(domainsDir, /\/api\/[^/]+\.ts$/);
|
|
54
|
+
if (apiFiles.length === 0) {
|
|
55
|
+
return { name: 'Handlers use Result<T>', status: 'PASS',
|
|
56
|
+
message: 'No handler files — check skipped (optional)',
|
|
57
|
+
kNodeRef: 'K:convention:typed-errors' };
|
|
58
|
+
}
|
|
59
|
+
const missing = [];
|
|
60
|
+
for (const file of apiFiles) {
|
|
61
|
+
const content = await readFile(file, 'utf8');
|
|
62
|
+
// A file contains a handler export — check the return type
|
|
63
|
+
if (/defineApi\s*\(/.test(content)) {
|
|
64
|
+
// Look for Result<T> in the handler's return type annotation
|
|
65
|
+
if (!/: .*Result</.test(content)) {
|
|
66
|
+
missing.push(relative(projectRoot, file));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (missing.length === 0) {
|
|
71
|
+
return { name: 'Handlers use Result<T>', status: 'PASS',
|
|
72
|
+
message: `All ${apiFiles.length} handler(s) reference Result<T> in their return type`,
|
|
73
|
+
kNodeRef: 'K:convention:typed-errors' };
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
return { name: 'Handlers use Result<T>', status: 'FAIL',
|
|
77
|
+
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({...})\`.`,
|
|
78
|
+
fixHint: 'Add `: Promise<Result<T, AppError>>` to the handler\'s return type and import `{ Result, ok, err }` from `@mettlecast/domain-runtime`',
|
|
79
|
+
kNodeRef: 'K:convention:typed-errors' };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return { name: 'Handlers use Result<T>', status: 'WARN',
|
|
84
|
+
message: `Could not check handler return types: ${String(err)}`,
|
|
85
|
+
kNodeRef: 'K:convention:typed-errors' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Check W6-2: Generated API clients are fresh.
|
|
90
|
+
* Compares .genhash files against the registry hash to ensure the
|
|
91
|
+
* generated client has not drifted from the source schemas.
|
|
92
|
+
*/
|
|
93
|
+
async function checkGeneratedClientsFresh(projectRoot) {
|
|
94
|
+
try {
|
|
95
|
+
const genDir = join(projectRoot, 'frontend', 'src', 'sdk', 'generated');
|
|
96
|
+
if (!existsSync(genDir)) {
|
|
97
|
+
return { name: 'Generated clients fresh', status: 'PASS',
|
|
98
|
+
message: 'No generated clients — check skipped (optional)',
|
|
99
|
+
kNodeRef: 'K:runbook:generated-clients' };
|
|
100
|
+
}
|
|
101
|
+
const hashFiles = findFiles(genDir, /\.genhash$/);
|
|
102
|
+
if (hashFiles.length === 0) {
|
|
103
|
+
return { name: 'Generated clients fresh', status: 'WARN',
|
|
104
|
+
message: 'Generated client directory exists but no .genhash files — cannot verify freshness',
|
|
105
|
+
kNodeRef: 'K:runbook:generated-clients' };
|
|
106
|
+
}
|
|
107
|
+
const stale = [];
|
|
108
|
+
for (const hashFile of hashFiles) {
|
|
109
|
+
const content = await readFile(hashFile, 'utf8').catch(() => null);
|
|
110
|
+
if (!content) {
|
|
111
|
+
stale.push(`${relative(projectRoot, hashFile)} (unreadable)`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// A .genhash file contains: "<domain> <sha256-of-registry>"
|
|
115
|
+
const parts = content.trim().split(/\s+/);
|
|
116
|
+
if (parts.length !== 2) {
|
|
117
|
+
stale.push(`${relative(projectRoot, hashFile)} (invalid format)`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const [domain, expectedHash] = parts;
|
|
121
|
+
const registryPath = join(projectRoot, '.mc', `${domain}-registry.json`);
|
|
122
|
+
const registryJson = await readFile(registryPath, 'utf8').catch(() => null);
|
|
123
|
+
if (!registryJson) {
|
|
124
|
+
stale.push(`${relative(projectRoot, hashFile)} (registry ${domain}-registry.json not found)`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const { createHash } = await import('node:crypto');
|
|
128
|
+
const actualHash = createHash('sha256').update(registryJson).digest('hex');
|
|
129
|
+
if (actualHash !== expectedHash) {
|
|
130
|
+
stale.push(`${relative(projectRoot, hashFile)} (stale — run \`npx mc-domain-module generate-sdk ${domain}\`)`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (stale.length === 0) {
|
|
134
|
+
return { name: 'Generated clients fresh', status: 'PASS',
|
|
135
|
+
message: `All ${hashFiles.length} generated client(s) are up to date`,
|
|
136
|
+
kNodeRef: 'K:runbook:generated-clients' };
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
return { name: 'Generated clients fresh', status: 'FAIL',
|
|
140
|
+
message: `${stale.length}/${hashFiles.length} generated client(s) are stale:\n ${stale.join('\n ')}`,
|
|
141
|
+
fixHint: 'Run `npx mc-domain-module generate-sdk <domain>` for each stale domain',
|
|
142
|
+
kNodeRef: 'K:runbook:generated-clients' };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
return { name: 'Generated clients fresh', status: 'WARN',
|
|
147
|
+
message: `Could not check generated client freshness: ${String(err)}`,
|
|
148
|
+
kNodeRef: 'K:runbook:generated-clients' };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
33
151
|
/**
|
|
34
152
|
* Run all structural checks for the current TIB project.
|
|
35
153
|
* Each check runs independently; failure of one does not abort the rest.
|
|
@@ -39,11 +157,21 @@ function findFiles(dir, pattern) {
|
|
|
39
157
|
*/
|
|
40
158
|
export async function runDoctor(opts = {}) {
|
|
41
159
|
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
42
|
-
//
|
|
43
|
-
|
|
160
|
+
// --relocate runs ONLY the relocation routine (existing behaviour).
|
|
161
|
+
// --fix is a superset: it runs relocation first, then the full report.
|
|
162
|
+
// When both are set, --fix takes precedence so the report still runs.
|
|
163
|
+
if (opts.relocate && !opts.fix) {
|
|
44
164
|
return runRelocate(projectRoot);
|
|
45
165
|
}
|
|
46
|
-
|
|
166
|
+
// --fix preflight: run the relocation routine and merge its checks into
|
|
167
|
+
// the doctor report. On a clean project (no files outside .mc/ to move)
|
|
168
|
+
// runRelocate returns a no-op summary check (0 moved, 0 warned, 8 skipped).
|
|
169
|
+
let preflightChecks = [];
|
|
170
|
+
if (opts.fix) {
|
|
171
|
+
const relocationReport = await runRelocate(projectRoot);
|
|
172
|
+
preflightChecks = relocationReport.checks;
|
|
173
|
+
}
|
|
174
|
+
const checks = [...preflightChecks];
|
|
47
175
|
// Check 1: Domain configs valid
|
|
48
176
|
checks.push(await checkDomainConfigsValid(projectRoot));
|
|
49
177
|
// Check 2: No raw AWS SDK in domains
|
|
@@ -61,13 +189,21 @@ export async function runDoctor(opts = {}) {
|
|
|
61
189
|
// Check 8: No root-level flows (deprecated)
|
|
62
190
|
checks.push(await checkRootLevelFlows(projectRoot));
|
|
63
191
|
// W2 new assertions
|
|
64
|
-
checks.push(await
|
|
192
|
+
checks.push(await checkEveryApiHasFixture(projectRoot));
|
|
65
193
|
checks.push(await checkEventConsumers(projectRoot));
|
|
66
194
|
checks.push(await checkMigrationTenancyTrio(projectRoot));
|
|
67
195
|
checks.push(await checkDomainRlsTest(projectRoot));
|
|
68
196
|
checks.push(await checkDomainBrainReachable(projectRoot));
|
|
69
197
|
checks.push(await checkActionsHaveTypes(projectRoot));
|
|
70
198
|
checks.push(await checkConventionEvidence(projectRoot));
|
|
199
|
+
// W5 new checks (scaffolder modernize — 4 new + 1 reuse of checkApiFixtures)
|
|
200
|
+
checks.push(await checkAllHttpClientsUseKy(projectRoot));
|
|
201
|
+
checks.push(await checkAllRoutesUseTanStackRouter(projectRoot));
|
|
202
|
+
checks.push(await checkOtelInitInLambdas(projectRoot));
|
|
203
|
+
checks.push(await checkFrontendUsesStrictTypescript(projectRoot));
|
|
204
|
+
// W6 (post-review) new checks
|
|
205
|
+
checks.push(await checkHandlersUseResult(projectRoot));
|
|
206
|
+
checks.push(await checkGeneratedClientsFresh(projectRoot));
|
|
71
207
|
// W1 tracked-files boundary checks
|
|
72
208
|
checks.push(...await checkScaffoldTrackedFilesHaveHeaders(projectRoot));
|
|
73
209
|
checks.push(...await checkSeedPagesHaveNoHeaders(projectRoot));
|
|
@@ -529,6 +665,204 @@ async function checkApiFixtures(projectRoot) {
|
|
|
529
665
|
kNodeRef: 'K:exit-gate:domain-complete' };
|
|
530
666
|
}
|
|
531
667
|
}
|
|
668
|
+
// ---- W5 new checks (scaffolder modernize) ----
|
|
669
|
+
/**
|
|
670
|
+
* Check W5-1: All HTTP clients in domains/ use ky or ctx.fetch (no raw fetch()).
|
|
671
|
+
* Reuses the W2 fixture check. Per plan-reviewer WARN #6, this is the
|
|
672
|
+
* "5th new check" counted as a reuse of checkApiFixtures — the
|
|
673
|
+
* `checkEveryApiHasFixture` wrapper below invokes the same logic.
|
|
674
|
+
*/
|
|
675
|
+
async function checkEveryApiHasFixture(projectRoot) {
|
|
676
|
+
return checkApiFixtures(projectRoot);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Check W5-2: All HTTP clients in domains/ use ky or ctx.fetch.
|
|
680
|
+
* Detects raw `fetch(` calls (the global browser/node fetch) and FAILs.
|
|
681
|
+
* Allows `ctx.fetch(` (domain-runtime helper) and `ky.fetch(` (ky API).
|
|
682
|
+
*/
|
|
683
|
+
/**
|
|
684
|
+
* Check W5-1: All domain + CLI HTTP clients use ky (no raw fetch).
|
|
685
|
+
* Scans domains/ AND packages/domain-cli/src/commands/ for raw
|
|
686
|
+
* `fetch(` calls. Excludes `ctx.fetch`, `globalThis.fetch`, and
|
|
687
|
+
* comments.
|
|
688
|
+
*/
|
|
689
|
+
async function checkAllHttpClientsUseKy(projectRoot) {
|
|
690
|
+
try {
|
|
691
|
+
const scanDirs = [
|
|
692
|
+
{ dir: join(projectRoot, 'domains'), label: 'domains' },
|
|
693
|
+
{ dir: join(projectRoot, 'packages', 'domain-cli', 'src', 'commands'), label: 'CLI commands' },
|
|
694
|
+
];
|
|
695
|
+
const violations = [];
|
|
696
|
+
let totalFiles = 0;
|
|
697
|
+
for (const { dir, label } of scanDirs) {
|
|
698
|
+
if (!existsSync(dir))
|
|
699
|
+
continue;
|
|
700
|
+
const files = findFiles(dir, /\.ts$/);
|
|
701
|
+
totalFiles += files.length;
|
|
702
|
+
for (const file of files) {
|
|
703
|
+
const content = await readFile(file, 'utf8');
|
|
704
|
+
const lines = content.split('\n');
|
|
705
|
+
for (let i = 0; i < lines.length; i++) {
|
|
706
|
+
const line = lines[i];
|
|
707
|
+
const code = line.replace(/\/\/.*$/, '');
|
|
708
|
+
if (/(?<![\w$.])fetch\s*\(/.test(code)) {
|
|
709
|
+
violations.push(`${relative(projectRoot, file)}:${i + 1} (${label})`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (violations.length === 0) {
|
|
715
|
+
return { name: 'All HTTP clients use ky', status: 'PASS',
|
|
716
|
+
message: `No raw fetch() calls across ${totalFiles} file(s) in domains/ and CLI — all HTTP clients use ky or ctx.fetch`,
|
|
717
|
+
kNodeRef: 'K:convention:http-client-ky' };
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
return { name: 'All HTTP clients use ky', status: 'FAIL',
|
|
721
|
+
message: `Found ${violations.length} raw fetch() call(s):\n ${violations.join('\n ')}`,
|
|
722
|
+
fixHint: 'Use ky or ctx.fetch from @mettlecast/domain-runtime instead of raw fetch()',
|
|
723
|
+
kNodeRef: 'K:convention:http-client-ky' };
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
catch (err) {
|
|
727
|
+
return { name: 'All HTTP clients use ky', status: 'WARN',
|
|
728
|
+
message: `Could not check HTTP clients: ${String(err)}`,
|
|
729
|
+
kNodeRef: 'K:convention:http-client-ky' };
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Check W5-3: All frontend routes use TanStack Router (no react-router-dom imports).
|
|
734
|
+
* Scans frontend/ for `from 'react-router-dom'` (or double-quoted) imports.
|
|
735
|
+
*/
|
|
736
|
+
async function checkAllRoutesUseTanStackRouter(projectRoot) {
|
|
737
|
+
try {
|
|
738
|
+
const frontendDir = join(projectRoot, 'frontend');
|
|
739
|
+
if (!existsSync(frontendDir)) {
|
|
740
|
+
return { name: 'Routes use TanStack Router', status: 'PASS',
|
|
741
|
+
message: 'No frontend/ directory — check skipped (optional)',
|
|
742
|
+
kNodeRef: 'K:convention:tanstack-router' };
|
|
743
|
+
}
|
|
744
|
+
const files = findFiles(frontendDir, /\.(ts|tsx|js|jsx)$/);
|
|
745
|
+
const violations = [];
|
|
746
|
+
for (const file of files) {
|
|
747
|
+
const content = await readFile(file, 'utf8');
|
|
748
|
+
if (/from\s+['"]react-router-dom['"]/.test(content)) {
|
|
749
|
+
violations.push(relative(projectRoot, file));
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (violations.length === 0) {
|
|
753
|
+
return { name: 'Routes use TanStack Router', status: 'PASS',
|
|
754
|
+
message: 'No react-router-dom imports in frontend/',
|
|
755
|
+
kNodeRef: 'K:convention:tanstack-router' };
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
return { name: 'Routes use TanStack Router', status: 'FAIL',
|
|
759
|
+
message: `Found ${violations.length} react-router-dom import(s) in frontend/:\n ${violations.join('\n ')}`,
|
|
760
|
+
fixHint: 'Migrate from react-router-dom to @tanstack/react-router',
|
|
761
|
+
kNodeRef: 'K:convention:tanstack-router' };
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
catch (err) {
|
|
765
|
+
return { name: 'Routes use TanStack Router', status: 'WARN',
|
|
766
|
+
message: `Could not check frontend router: ${String(err)}`,
|
|
767
|
+
kNodeRef: 'K:convention:tanstack-router' };
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Check W5-4: Lambda handler files contain initOtel() call.
|
|
772
|
+
* Scans `domains/*\/api/*.ts` and FAILs if any handler is missing initOtel().
|
|
773
|
+
*/
|
|
774
|
+
async function checkOtelInitInLambdas(projectRoot) {
|
|
775
|
+
try {
|
|
776
|
+
const domainsDir = join(projectRoot, 'domains');
|
|
777
|
+
let entries = [];
|
|
778
|
+
try {
|
|
779
|
+
entries = await readdir(domainsDir, { withFileTypes: true });
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
return { name: 'OTel init in Lambdas', status: 'PASS',
|
|
783
|
+
message: 'No domains/ — check skipped (optional)',
|
|
784
|
+
kNodeRef: 'K:convention:otel-init' };
|
|
785
|
+
}
|
|
786
|
+
const handlerFiles = [];
|
|
787
|
+
for (const entry of entries) {
|
|
788
|
+
if (!entry.isDirectory())
|
|
789
|
+
continue;
|
|
790
|
+
const apiDir = join(domainsDir, entry.name, 'api');
|
|
791
|
+
const files = findFiles(apiDir, /\.ts$/);
|
|
792
|
+
handlerFiles.push(...files);
|
|
793
|
+
}
|
|
794
|
+
if (handlerFiles.length === 0) {
|
|
795
|
+
return { name: 'OTel init in Lambdas', status: 'PASS',
|
|
796
|
+
message: 'No Lambda handler files in domains/ (optional)',
|
|
797
|
+
kNodeRef: 'K:convention:otel-init' };
|
|
798
|
+
}
|
|
799
|
+
const missing = [];
|
|
800
|
+
for (const file of handlerFiles) {
|
|
801
|
+
const content = await readFile(file, 'utf8');
|
|
802
|
+
if (!/initOtel\s*\(/.test(content)) {
|
|
803
|
+
missing.push(relative(projectRoot, file));
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (missing.length === 0) {
|
|
807
|
+
return { name: 'OTel init in Lambdas', status: 'PASS',
|
|
808
|
+
message: `All ${handlerFiles.length} Lambda handler(s) call initOtel()`,
|
|
809
|
+
kNodeRef: 'K:convention:otel-init' };
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
return { name: 'OTel init in Lambdas', status: 'FAIL',
|
|
813
|
+
message: `${missing.length}/${handlerFiles.length} Lambda handler(s) missing initOtel():\n ${missing.join('\n ')}`,
|
|
814
|
+
fixHint: 'Add initOtel() (from @mettlecast/domain-runtime) to each Lambda handler file',
|
|
815
|
+
kNodeRef: 'K:convention:otel-init' };
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
return { name: 'OTel init in Lambdas', status: 'WARN',
|
|
820
|
+
message: `Could not check OTel init: ${String(err)}`,
|
|
821
|
+
kNodeRef: 'K:convention:otel-init' };
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Check W5-5: Frontend tsconfig.json has `strict: true`.
|
|
826
|
+
* Reads frontend/tsconfig.json and verifies compilerOptions.strict === true.
|
|
827
|
+
*/
|
|
828
|
+
async function checkFrontendUsesStrictTypescript(projectRoot) {
|
|
829
|
+
try {
|
|
830
|
+
const tsconfigPath = join(projectRoot, 'frontend', 'tsconfig.json');
|
|
831
|
+
if (!existsSync(tsconfigPath)) {
|
|
832
|
+
return { name: 'Frontend uses strict TypeScript', status: 'PASS',
|
|
833
|
+
message: 'No frontend/tsconfig.json — check skipped (optional)',
|
|
834
|
+
kNodeRef: 'K:convention:typescript-strict' };
|
|
835
|
+
}
|
|
836
|
+
const content = await readFile(tsconfigPath, 'utf8');
|
|
837
|
+
let config;
|
|
838
|
+
try {
|
|
839
|
+
config = JSON.parse(content);
|
|
840
|
+
}
|
|
841
|
+
catch (err) {
|
|
842
|
+
return { name: 'Frontend uses strict TypeScript', status: 'FAIL',
|
|
843
|
+
message: `frontend/tsconfig.json is not valid JSON: ${String(err)}`,
|
|
844
|
+
fixHint: 'Fix tsconfig.json JSON syntax',
|
|
845
|
+
kNodeRef: 'K:convention:typescript-strict' };
|
|
846
|
+
}
|
|
847
|
+
const strict = config.compilerOptions?.strict === true;
|
|
848
|
+
if (strict) {
|
|
849
|
+
return { name: 'Frontend uses strict TypeScript', status: 'PASS',
|
|
850
|
+
message: 'frontend/tsconfig.json has strict: true',
|
|
851
|
+
kNodeRef: 'K:convention:typescript-strict' };
|
|
852
|
+
}
|
|
853
|
+
else {
|
|
854
|
+
return { name: 'Frontend uses strict TypeScript', status: 'FAIL',
|
|
855
|
+
message: 'frontend/tsconfig.json does not have strict: true',
|
|
856
|
+
fixHint: "Add \"strict\": true to compilerOptions in frontend/tsconfig.json",
|
|
857
|
+
kNodeRef: 'K:convention:typescript-strict' };
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
catch (err) {
|
|
861
|
+
return { name: 'Frontend uses strict TypeScript', status: 'WARN',
|
|
862
|
+
message: `Could not check tsconfig: ${String(err)}`,
|
|
863
|
+
kNodeRef: 'K:convention:typescript-strict' };
|
|
864
|
+
}
|
|
865
|
+
}
|
|
532
866
|
/**
|
|
533
867
|
* Check 10: Every event has consumer or @no-consumers annotation.
|
|
534
868
|
*/
|
|
@@ -739,9 +1073,10 @@ async function checkDomainBrainReachable(projectRoot) {
|
|
|
739
1073
|
try {
|
|
740
1074
|
const controller = new AbortController();
|
|
741
1075
|
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
|
742
|
-
const response = await
|
|
743
|
-
|
|
1076
|
+
const response = await ky.get(`${brainEndpoint}/api/graph/nodes`, {
|
|
1077
|
+
signal: controller.signal,
|
|
744
1078
|
headers: { 'Content-Type': 'application/json' },
|
|
1079
|
+
timeout: 3000,
|
|
745
1080
|
});
|
|
746
1081
|
clearTimeout(timeoutId);
|
|
747
1082
|
if (response.ok) {
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
export interface GenerateOpenapiOptions {
|
|
9
|
+
domain: string;
|
|
10
|
+
projectRoot?: string;
|
|
11
|
+
/** Output path. Defaults to domains/<domain>/api/openapi.generated.json */
|
|
12
|
+
output?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function runGenerateOpenapi(options: GenerateOpenapiOptions): Promise<string>;
|
|
15
|
+
/**
|
|
16
|
+
* CLI entry point.
|
|
17
|
+
*/
|
|
18
|
+
export declare function runGenerateOpenapiCli(domain: string, opts?: {
|
|
19
|
+
projectRoot?: string;
|
|
20
|
+
output?: string;
|
|
21
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { cliLogger } from '../utils/logger.js';
|
|
11
|
+
/**
|
|
12
|
+
* Extract a Zod schema shape from a registry entry version.
|
|
13
|
+
* The registry may store schemas as `input`/`output` (raw Zod
|
|
14
|
+
* objects) or `inputSchema`/`outputSchema` (JSON Schema shapes).
|
|
15
|
+
* Returns a best-effort JSON Schema object for the OpenAPI spec.
|
|
16
|
+
*/
|
|
17
|
+
function extractSchema(version, field) {
|
|
18
|
+
// Prefer JSON Schema if present
|
|
19
|
+
const schemaField = field === 'input' ? version.inputSchema : version.outputSchema;
|
|
20
|
+
if (schemaField && typeof schemaField === 'object' && schemaField !== null) {
|
|
21
|
+
return schemaField;
|
|
22
|
+
}
|
|
23
|
+
// Fall back to the raw Zod shape — try to produce a minimal JSON
|
|
24
|
+
// Schema from the Zod _def. For full support, add zod-to-json-schema
|
|
25
|
+
// to the CLI dependencies and call `zodToJsonSchema(zodSchema)`.
|
|
26
|
+
const raw = field === 'input' ? version.input : version.output;
|
|
27
|
+
if (raw && typeof raw === 'object' && raw !== null) {
|
|
28
|
+
// Attempt minimal mapping: if the Zod shape has a `type` field
|
|
29
|
+
// from its _def, describe it as JSON Schema.
|
|
30
|
+
const def = raw;
|
|
31
|
+
return { type: def.type ?? 'object', properties: def.properties, required: def.required };
|
|
32
|
+
}
|
|
33
|
+
return { type: 'object' };
|
|
34
|
+
}
|
|
35
|
+
export async function runGenerateOpenapi(options) {
|
|
36
|
+
const projectRoot = options.projectRoot ?? process.cwd();
|
|
37
|
+
const domain = options.domain;
|
|
38
|
+
const outputPath = options.output ?? join(projectRoot, 'domains', domain, 'api', 'openapi.generated.json');
|
|
39
|
+
// Read the domain registry
|
|
40
|
+
const registryPath = join(projectRoot, '.mc', `${domain}-registry.json`);
|
|
41
|
+
const registryJson = await readFile(registryPath, 'utf8');
|
|
42
|
+
const registry = JSON.parse(registryJson);
|
|
43
|
+
const paths = {};
|
|
44
|
+
for (const api of registry.apis) {
|
|
45
|
+
const method = (api.method ?? 'get').toLowerCase();
|
|
46
|
+
const fullPath = `/v1/${domain}${api.path}`;
|
|
47
|
+
const v1 = api.versions['v1'] ?? api.versions[Object.keys(api.versions)[0]];
|
|
48
|
+
if (!v1)
|
|
49
|
+
continue;
|
|
50
|
+
if (!paths[fullPath])
|
|
51
|
+
paths[fullPath] = {};
|
|
52
|
+
paths[fullPath][method] = {
|
|
53
|
+
operationId: `${domain}.${api.id}`,
|
|
54
|
+
summary: `${domain}.${api.id}`,
|
|
55
|
+
description: `Version: ${v1.status}`,
|
|
56
|
+
requestBody: {
|
|
57
|
+
content: {
|
|
58
|
+
'application/json': {
|
|
59
|
+
schema: extractSchema(v1, 'input'),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
responses: {
|
|
64
|
+
'200': {
|
|
65
|
+
description: 'OK',
|
|
66
|
+
content: {
|
|
67
|
+
'application/json': {
|
|
68
|
+
schema: extractSchema(v1, 'output'),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
'400': {
|
|
73
|
+
description: 'Validation error',
|
|
74
|
+
content: {
|
|
75
|
+
'application/json': {
|
|
76
|
+
schema: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
error: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: {
|
|
82
|
+
kind: { type: 'string', enum: ['validation'] },
|
|
83
|
+
message: { type: 'string' },
|
|
84
|
+
fieldErrors: { type: 'object' },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const openapi = {
|
|
96
|
+
openapi: '3.1.0',
|
|
97
|
+
info: {
|
|
98
|
+
title: `${domain} API`,
|
|
99
|
+
version: '1.0.0',
|
|
100
|
+
description: `Generated from ${domain}-registry.json at build time. Do not edit manually.`,
|
|
101
|
+
},
|
|
102
|
+
paths,
|
|
103
|
+
};
|
|
104
|
+
const dir = join(outputPath, '..');
|
|
105
|
+
await mkdir(dir, { recursive: true });
|
|
106
|
+
await writeFile(outputPath, JSON.stringify(openapi, null, 2), 'utf8');
|
|
107
|
+
cliLogger.info({ outputPath }, 'generate-openapi: spec written');
|
|
108
|
+
return outputPath;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* CLI entry point.
|
|
112
|
+
*/
|
|
113
|
+
export async function runGenerateOpenapiCli(domain, opts = {}) {
|
|
114
|
+
const outputPath = await runGenerateOpenapi({ domain, ...opts });
|
|
115
|
+
// eslint-disable-next-line no-console
|
|
116
|
+
console.log(`OpenAPI spec written to: ${outputPath}`);
|
|
117
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generate-sdk — read a domain's OpenAPI 3.1 spec and produce a typed
|
|
3
|
+
* TypeScript client that returns `Result<T, AppError>` from every API
|
|
4
|
+
* call. Uses the domain-runtime's Result types for compile-time safe
|
|
5
|
+
* error handling.
|
|
6
|
+
*
|
|
7
|
+
* Usage: npx mc-domain-module generate-sdk <domain>
|
|
8
|
+
*/
|
|
9
|
+
export interface GenerateSdkOptions {
|
|
10
|
+
domain: string;
|
|
11
|
+
projectRoot?: string;
|
|
12
|
+
/** Path to the OpenAPI spec. Defaults to domains/<domain>/api/openapi.generated.json */
|
|
13
|
+
input?: string;
|
|
14
|
+
/** Output directory. Defaults to frontend/src/sdk/generated/<domain>/ */
|
|
15
|
+
output?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function runGenerateSdk(options: GenerateSdkOptions): Promise<string>;
|
|
18
|
+
/**
|
|
19
|
+
* CLI entry point.
|
|
20
|
+
*/
|
|
21
|
+
export declare function runGenerateSdkCli(domain: string, opts?: {
|
|
22
|
+
projectRoot?: string;
|
|
23
|
+
input?: string;
|
|
24
|
+
output?: string;
|
|
25
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generate-sdk — read a domain's OpenAPI 3.1 spec and produce a typed
|
|
3
|
+
* TypeScript client that returns `Result<T, AppError>` from every API
|
|
4
|
+
* call. Uses the domain-runtime's Result types for compile-time safe
|
|
5
|
+
* error handling.
|
|
6
|
+
*
|
|
7
|
+
* Usage: npx mc-domain-module generate-sdk <domain>
|
|
8
|
+
*/
|
|
9
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { cliLogger } from '../utils/logger.js';
|
|
13
|
+
export async function runGenerateSdk(options) {
|
|
14
|
+
const projectRoot = options.projectRoot ?? process.cwd();
|
|
15
|
+
const domain = options.domain;
|
|
16
|
+
const inputPath = options.input ?? join(projectRoot, 'domains', domain, 'api', 'openapi.generated.json');
|
|
17
|
+
const outputDir = options.output ?? join(projectRoot, 'frontend', 'src', 'sdk', 'generated', domain);
|
|
18
|
+
const specJson = await readFile(inputPath, 'utf8');
|
|
19
|
+
const spec = JSON.parse(specJson);
|
|
20
|
+
const functions = [];
|
|
21
|
+
let functionCount = 0;
|
|
22
|
+
if (spec.paths) {
|
|
23
|
+
for (const [path, methods] of Object.entries(spec.paths)) {
|
|
24
|
+
for (const [method, op] of Object.entries(methods)) {
|
|
25
|
+
const operationId = op?.operationId ?? `${method}_${path.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
26
|
+
const fnName = camelCase(operationId.replace(/\./g, '_'));
|
|
27
|
+
functions.push(generateApiFunction(fnName, path, method, operationId));
|
|
28
|
+
functionCount++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const output = generateSdkFile(domain, functions);
|
|
33
|
+
await mkdir(outputDir, { recursive: true });
|
|
34
|
+
const indexFile = join(outputDir, 'index.ts');
|
|
35
|
+
await writeFile(indexFile, output, 'utf8');
|
|
36
|
+
// Write a .genhash file so the doctor check can verify freshness
|
|
37
|
+
const registryPath = join(projectRoot, '.mc', `${domain}-registry.json`);
|
|
38
|
+
const registryJson = await readFile(registryPath, 'utf8');
|
|
39
|
+
const hash = createHash('sha256').update(registryJson).digest('hex');
|
|
40
|
+
const genhashFile = join(outputDir, '.genhash');
|
|
41
|
+
await writeFile(genhashFile, `${domain} ${hash}`, 'utf8');
|
|
42
|
+
cliLogger.info({ outputDir, functionCount }, 'generate-sdk: client written');
|
|
43
|
+
return outputDir;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Generate a single API function body.
|
|
47
|
+
*/
|
|
48
|
+
function generateApiFunction(name, path, method, _operationId) {
|
|
49
|
+
const payloadParam = method === 'get' ? '' : ', body: unknown';
|
|
50
|
+
return `
|
|
51
|
+
/**
|
|
52
|
+
* ${_operationId}
|
|
53
|
+
* @returns Result containing the typed response or an AppError.
|
|
54
|
+
*/
|
|
55
|
+
export async function ${name}(
|
|
56
|
+
${method === 'get' ? '' : 'body: unknown'}
|
|
57
|
+
): Promise<Result<unknown, AppError>> {
|
|
58
|
+
const res = await fetch('${path}', {
|
|
59
|
+
method: '${method.toUpperCase()}',
|
|
60
|
+
headers: { 'Content-Type': 'application/json' },
|
|
61
|
+
${method !== 'get' ? 'body: JSON.stringify(body),' : ''}
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const err = await res.json().catch(() => ({ kind: 'internal', traceId: '', message: res.statusText }));
|
|
65
|
+
return err as unknown as Result<never, AppError>;
|
|
66
|
+
}
|
|
67
|
+
const value = await res.json();
|
|
68
|
+
return ok(value as unknown);
|
|
69
|
+
}`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Generate the full SDK file.
|
|
73
|
+
*/
|
|
74
|
+
function generateSdkFile(domain, functions) {
|
|
75
|
+
return `// Generated from domains/${domain}/api/openapi.generated.json — do not edit
|
|
76
|
+
// Re-run npx mc-domain-module generate-sdk ${domain} after schema changes.
|
|
77
|
+
import type { Result, AppError } from '@mettlecast/domain-runtime';
|
|
78
|
+
import { ok } from '@mettlecast/domain-runtime';
|
|
79
|
+
|
|
80
|
+
${functions.join('\n')}
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Convert kebab-case or dot-separated to camelCase.
|
|
85
|
+
*/
|
|
86
|
+
function camelCase(s) {
|
|
87
|
+
return s
|
|
88
|
+
.replace(/[-._]([a-z])/g, (_, c) => c.toUpperCase())
|
|
89
|
+
.replace(/^./, (c) => c.toLowerCase());
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* CLI entry point.
|
|
93
|
+
*/
|
|
94
|
+
export async function runGenerateSdkCli(domain, opts = {}) {
|
|
95
|
+
const outputDir = await runGenerateSdk({ domain, ...opts });
|
|
96
|
+
// eslint-disable-next-line no-console
|
|
97
|
+
console.log(`SDK client written to: ${outputDir}`);
|
|
98
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* init — bootstrap a freshly scaffolded project so it is ready for
|
|
3
|
+
* local development and CI. Runs npm install, builds the CLI, and
|
|
4
|
+
* runs a baseline doctor check.
|
|
5
|
+
*
|
|
6
|
+
* Usage: npx mc-domain-module init
|
|
7
|
+
*/
|
|
8
|
+
export interface InitOptions {
|
|
9
|
+
projectRoot?: string;
|
|
10
|
+
/** Skip npm install if node_modules already exists. Default: true */
|
|
11
|
+
skipInstallIfExists?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function runInit(options?: InitOptions): Promise<void>;
|
|
14
|
+
export declare function runInitCli(opts?: InitOptions): Promise<void>;
|