@mettlecast/domain-cli 0.2.22 → 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 (69) hide show
  1. package/dist/cli.js +60 -3
  2. package/dist/commands/add-api.d.ts +1 -1
  3. package/dist/commands/add-api.js +45 -17
  4. package/dist/commands/add-fixture-factory.d.ts +16 -0
  5. package/dist/commands/add-fixture-factory.js +60 -0
  6. package/dist/commands/add-module.js +4 -5
  7. package/dist/commands/add-seed-page.js +4 -5
  8. package/dist/commands/build-flows.js +1 -1
  9. package/dist/commands/dev.d.ts +30 -3
  10. package/dist/commands/dev.js +52 -11
  11. package/dist/commands/doctor.d.ts +22 -0
  12. package/dist/commands/doctor.js +341 -6
  13. package/dist/commands/generate-openapi.d.ts +21 -0
  14. package/dist/commands/generate-openapi.js +117 -0
  15. package/dist/commands/generate-sdk.d.ts +25 -0
  16. package/dist/commands/generate-sdk.js +98 -0
  17. package/dist/commands/init.d.ts +14 -0
  18. package/dist/commands/init.js +62 -0
  19. package/dist/commands/reseed-page.js +5 -6
  20. package/dist/commands/upgrade.d.ts +2 -0
  21. package/dist/commands/upgrade.js +28 -6
  22. package/dist/commands/why.d.ts +47 -0
  23. package/dist/commands/why.js +129 -0
  24. package/dist/templates/api-skeleton.d.ts +5 -1
  25. package/dist/templates/api-skeleton.js +28 -6
  26. package/dist/templates/patterns/api/create-with-event.d.ts +5 -0
  27. package/dist/templates/patterns/api/create-with-event.js +14 -1
  28. package/dist/templates/patterns/api/idempotent-mutation.d.ts +5 -0
  29. package/dist/templates/patterns/api/idempotent-mutation.js +20 -0
  30. package/dist/templates/patterns/api/paginated-list.d.ts +5 -0
  31. package/dist/templates/patterns/api/paginated-list.js +12 -2
  32. package/dist/templates/patterns/api/simple-crud.d.ts +5 -0
  33. package/dist/templates/patterns/api/simple-crud.js +22 -4
  34. package/dist/templates/patterns/api/streaming-list.d.ts +27 -0
  35. package/dist/templates/patterns/api/streaming-list.js +91 -0
  36. package/dist/templates/patterns/api/system-admin.d.ts +5 -0
  37. package/dist/templates/patterns/api/system-admin.js +14 -4
  38. package/dist/templates/patterns/api/webhook-receiver-style.d.ts +5 -0
  39. package/dist/templates/patterns/api/webhook-receiver-style.js +22 -0
  40. package/dist/utils/s3-fetch.js +23 -32
  41. package/package.json +4 -1
  42. package/src/__tests__/commands/add-api.test.ts +160 -0
  43. package/src/__tests__/commands/dev.test.ts +162 -0
  44. package/src/__tests__/commands/why.test.ts +199 -0
  45. package/src/__tests__/doctor.test.ts +336 -1
  46. package/src/__tests__/smoke/scaffold.test.ts +574 -0
  47. package/src/cli.ts +67 -5
  48. package/src/commands/add-api.ts +68 -19
  49. package/src/commands/add-fixture-factory.ts +75 -0
  50. package/src/commands/add-module.ts +4 -5
  51. package/src/commands/add-seed-page.ts +4 -5
  52. package/src/commands/build-flows.ts +2 -2
  53. package/src/commands/dev.ts +78 -11
  54. package/src/commands/doctor.ts +379 -12
  55. package/src/commands/generate-openapi.ts +154 -0
  56. package/src/commands/generate-sdk.ts +125 -0
  57. package/src/commands/init.ts +78 -0
  58. package/src/commands/reseed-page.ts +5 -6
  59. package/src/commands/upgrade.ts +26 -6
  60. package/src/commands/why.ts +171 -0
  61. package/src/templates/api-skeleton.ts +32 -6
  62. package/src/templates/patterns/api/create-with-event.ts +15 -1
  63. package/src/templates/patterns/api/idempotent-mutation.ts +21 -0
  64. package/src/templates/patterns/api/paginated-list.ts +13 -2
  65. package/src/templates/patterns/api/simple-crud.ts +23 -4
  66. package/src/templates/patterns/api/streaming-list.ts +91 -0
  67. package/src/templates/patterns/api/system-admin.ts +15 -4
  68. package/src/templates/patterns/api/webhook-receiver-style.ts +23 -0
  69. package/src/utils/s3-fetch.ts +26 -34
@@ -0,0 +1,125 @@
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
+
10
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
11
+ import { join } from 'node:path';
12
+ import { createHash } from 'node:crypto';
13
+ import { cliLogger } from '../utils/logger.js';
14
+
15
+ export interface GenerateSdkOptions {
16
+ domain: string;
17
+ projectRoot?: string;
18
+ /** Path to the OpenAPI spec. Defaults to domains/<domain>/api/openapi.generated.json */
19
+ input?: string;
20
+ /** Output directory. Defaults to frontend/src/sdk/generated/<domain>/ */
21
+ output?: string;
22
+ }
23
+
24
+ interface OpenApiSpec {
25
+ paths?: Record<string, Record<string, { operationId?: string; description?: string }>>;
26
+ }
27
+
28
+ export async function runGenerateSdk(options: GenerateSdkOptions): Promise<string> {
29
+ const projectRoot = options.projectRoot ?? process.cwd();
30
+ const domain = options.domain;
31
+ const inputPath = options.input ?? join(projectRoot, 'domains', domain, 'api', 'openapi.generated.json');
32
+ const outputDir = options.output ?? join(projectRoot, 'frontend', 'src', 'sdk', 'generated', domain);
33
+
34
+ const specJson = await readFile(inputPath, 'utf8');
35
+ const spec = JSON.parse(specJson) as OpenApiSpec;
36
+
37
+ const functions: string[] = [];
38
+ let functionCount = 0;
39
+
40
+ if (spec.paths) {
41
+ for (const [path, methods] of Object.entries(spec.paths)) {
42
+ for (const [method, op] of Object.entries(methods)) {
43
+ const operationId = op?.operationId ?? `${method}_${path.replace(/[^a-zA-Z0-9]/g, '_')}`;
44
+ const fnName = camelCase(operationId.replace(/\./g, '_'));
45
+ functions.push(generateApiFunction(fnName, path, method, operationId));
46
+ functionCount++;
47
+ }
48
+ }
49
+ }
50
+
51
+ const output = generateSdkFile(domain, functions);
52
+
53
+ await mkdir(outputDir, { recursive: true });
54
+ const indexFile = join(outputDir, 'index.ts');
55
+ await writeFile(indexFile, output, 'utf8');
56
+
57
+ // Write a .genhash file so the doctor check can verify freshness
58
+ const registryPath = join(projectRoot, '.mc', `${domain}-registry.json`);
59
+ const registryJson = await readFile(registryPath, 'utf8');
60
+ const hash = createHash('sha256').update(registryJson).digest('hex');
61
+ const genhashFile = join(outputDir, '.genhash');
62
+ await writeFile(genhashFile, `${domain} ${hash}`, 'utf8');
63
+
64
+ cliLogger.info({ outputDir, functionCount }, 'generate-sdk: client written');
65
+ return outputDir;
66
+ }
67
+
68
+ /**
69
+ * Generate a single API function body.
70
+ */
71
+ function generateApiFunction(name: string, path: string, method: string, _operationId: string): string {
72
+ const payloadParam = method === 'get' ? '' : ', body: unknown';
73
+
74
+ return `
75
+ /**
76
+ * ${_operationId}
77
+ * @returns Result containing the typed response or an AppError.
78
+ */
79
+ export async function ${name}(
80
+ ${method === 'get' ? '' : 'body: unknown'}
81
+ ): Promise<Result<unknown, AppError>> {
82
+ const res = await fetch('${path}', {
83
+ method: '${method.toUpperCase()}',
84
+ headers: { 'Content-Type': 'application/json' },
85
+ ${method !== 'get' ? 'body: JSON.stringify(body),' : ''}
86
+ });
87
+ if (!res.ok) {
88
+ const err = await res.json().catch(() => ({ kind: 'internal', traceId: '', message: res.statusText }));
89
+ return err as unknown as Result<never, AppError>;
90
+ }
91
+ const value = await res.json();
92
+ return ok(value as unknown);
93
+ }`;
94
+ }
95
+
96
+ /**
97
+ * Generate the full SDK file.
98
+ */
99
+ function generateSdkFile(domain: string, functions: string[]): string {
100
+ return `// Generated from domains/${domain}/api/openapi.generated.json — do not edit
101
+ // Re-run npx mc-domain-module generate-sdk ${domain} after schema changes.
102
+ import type { Result, AppError } from '@mettlecast/domain-runtime';
103
+ import { ok } from '@mettlecast/domain-runtime';
104
+
105
+ ${functions.join('\n')}
106
+ `;
107
+ }
108
+
109
+ /**
110
+ * Convert kebab-case or dot-separated to camelCase.
111
+ */
112
+ function camelCase(s: string): string {
113
+ return s
114
+ .replace(/[-._]([a-z])/g, (_, c) => c.toUpperCase())
115
+ .replace(/^./, (c) => c.toLowerCase());
116
+ }
117
+
118
+ /**
119
+ * CLI entry point.
120
+ */
121
+ export async function runGenerateSdkCli(domain: string, opts: { projectRoot?: string; input?: string; output?: string } = {}): Promise<void> {
122
+ const outputDir = await runGenerateSdk({ domain, ...opts });
123
+ // eslint-disable-next-line no-console
124
+ console.log(`SDK client written to: ${outputDir}`);
125
+ }
@@ -0,0 +1,78 @@
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
+
9
+ import { execSync } from 'node:child_process';
10
+ import { existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { cliLogger } from '../utils/logger.js';
13
+ import { runDoctor } from './doctor.js';
14
+
15
+ export interface InitOptions {
16
+ projectRoot?: string;
17
+ /** Skip npm install if node_modules already exists. Default: true */
18
+ skipInstallIfExists?: boolean;
19
+ }
20
+
21
+ export async function runInit(options: InitOptions = {}): Promise<void> {
22
+ const projectRoot = options.projectRoot ?? process.cwd();
23
+ const skipInstall = options.skipInstallIfExists !== false;
24
+
25
+ // ── 1. npm install ──────────────────────────────────────────────────
26
+ const nodeModulesDir = join(projectRoot, 'node_modules');
27
+ if (skipInstall && existsSync(nodeModulesDir)) {
28
+ cliLogger.info('node_modules exists — skipping npm install');
29
+ } else {
30
+ cliLogger.info('Running npm install...');
31
+ execSync('npm install --legacy-peer-deps', {
32
+ cwd: projectRoot,
33
+ stdio: 'inherit',
34
+ });
35
+ }
36
+
37
+ // ── 2. Build the CLI ────────────────────────────────────────────────
38
+ const cliDir = join(projectRoot, 'packages', 'domain-cli');
39
+ if (existsSync(cliDir)) {
40
+ cliLogger.info('Installing domain-cli dependencies...');
41
+ execSync('npm install --legacy-peer-deps', {
42
+ cwd: cliDir,
43
+ stdio: 'inherit',
44
+ });
45
+ cliLogger.info('Building domain-cli...');
46
+ execSync('npm run build', {
47
+ cwd: cliDir,
48
+ stdio: 'inherit',
49
+ });
50
+ }
51
+
52
+ // ── 3. Baseline doctor ──────────────────────────────────────────────
53
+ cliLogger.info('Running doctor (baseline)...');
54
+ const report = await runDoctor({ projectRoot, strict: true });
55
+ const passes = report.checks.filter(c => c.status === 'PASS').length;
56
+ const fails = report.checks.filter(c => c.status === 'FAIL').length;
57
+ const warns = report.checks.filter(c => c.status === 'WARN').length;
58
+
59
+ // eslint-disable-next-line no-console
60
+ console.log(
61
+ `\nDoctor baseline: ${passes} PASS, ${fails} FAIL, ${warns} WARN`,
62
+ );
63
+
64
+ if (report.exitCode !== 0) {
65
+ // eslint-disable-next-line no-console
66
+ console.log(
67
+ '\nDoctor found issues — this is expected on a fresh scaffold.\n' +
68
+ 'Run `npx mc-domain-module doctor` for details.\n',
69
+ );
70
+ } else {
71
+ // eslint-disable-next-line no-console
72
+ console.log('\nProject is ready!');
73
+ }
74
+ }
75
+
76
+ export async function runInitCli(opts: InitOptions = {}): Promise<void> {
77
+ await runInit(opts);
78
+ }
@@ -3,6 +3,7 @@ import { join, resolve, dirname } from 'node:path';
3
3
  import { execSync } from 'node:child_process';
4
4
  import { createGunzip } from 'node:zlib';
5
5
  import { Readable } from 'node:stream';
6
+ import ky from 'ky';
6
7
  import { cliLogger } from '../utils/logger.js';
7
8
  import {
8
9
  fetchVersionsJson,
@@ -125,21 +126,19 @@ async function createGitHubPR(
125
126
  labels?: string[]
126
127
  ): Promise<{ url: string; number: number }> {
127
128
  const url = `https://api.github.com/repos/${owner}/${repo}/pulls`;
128
- const res = await fetch(url, {
129
- method: 'POST',
129
+ const res = await ky.post(url, {
130
130
  headers: {
131
131
  Authorization: `Bearer ${token}`,
132
132
  Accept: 'application/vnd.github+json',
133
- 'Content-Type': 'application/json',
134
133
  'X-GitHub-Api-Version': '2022-11-28',
135
134
  },
136
- body: JSON.stringify({
135
+ json: {
137
136
  title,
138
137
  body,
139
138
  head: branch,
140
139
  base: 'develop',
141
- labels,
142
- }),
140
+ ...(labels && labels.length > 0 && { labels }),
141
+ },
143
142
  });
144
143
 
145
144
  if (!res.ok) {
@@ -3,6 +3,7 @@ import { Readable } from 'node:stream';
3
3
  import { mkdir, writeFile, unlink } from 'node:fs/promises';
4
4
  import { join, resolve, dirname } from 'node:path';
5
5
  import { execSync } from 'node:child_process';
6
+ import ky from 'ky';
6
7
  import { cliLogger } from '../utils/logger.js';
7
8
  import {
8
9
  fetchVersionsJson,
@@ -180,21 +181,19 @@ async function createGitHubPR(
180
181
  labels?: string[]
181
182
  ): Promise<{ url: string; number: number }> {
182
183
  const url = `https://api.github.com/repos/${owner}/${repo}/pulls`;
183
- const res = await fetch(url, {
184
- method: 'POST',
184
+ const res = await ky.post(url, {
185
185
  headers: {
186
186
  Authorization: `Bearer ${token}`,
187
187
  Accept: 'application/vnd.github+json',
188
- 'Content-Type': 'application/json',
189
188
  'X-GitHub-Api-Version': '2022-11-28',
190
189
  },
191
- body: JSON.stringify({
190
+ json: {
192
191
  title,
193
192
  body,
194
193
  head: branch,
195
194
  base: 'develop',
196
195
  ...(labels && labels.length > 0 && { labels }),
197
- }),
196
+ },
198
197
  });
199
198
 
200
199
  if (!res.ok) {
@@ -344,6 +343,8 @@ function buildFrontendComponentsPrBody(
344
343
 
345
344
  export interface UpgradeOptions {
346
345
  dryRun: boolean;
346
+ /** Read-only variant: compute diffs, print a CI-parseable summary, exit 0 (no drift) or 1 (drift detected). Never writes files. */
347
+ check: boolean;
347
348
  projectDir: string;
348
349
  githubToken?: string;
349
350
  frontendComponents?: boolean;
@@ -355,7 +356,8 @@ export async function runUpgrade(
355
356
  ): Promise<void> {
356
357
  const projectDir = resolve(opts.projectDir ?? process.cwd());
357
358
 
358
- console.log(`\nTIB Upgrade${opts.dryRun ? ' (dry run)' : ''}\n`);
359
+ const actionLabel = opts.check ? 'check' : opts.dryRun ? 'dry run' : 'upgrade';
360
+ console.log(`\nTIB Upgrade (${actionLabel})\n`);
359
361
  console.log(`Project: ${projectDir}\n`);
360
362
 
361
363
  // 1. Read manifest + scaffold-config
@@ -568,6 +570,24 @@ export async function runUpgrade(
568
570
  }
569
571
  }
570
572
 
573
+ if (opts.check) {
574
+ // Read-only CI gate — report drift and exit.
575
+ const totalChanged = added.length + updated.length + deleted.length;
576
+ if (totalChanged === 0 && conflicts.length === 0) {
577
+ console.log('\n✓ No drift — project is up to date with scaffold.\n');
578
+ process.exitCode = 0;
579
+ } else {
580
+ console.log(`\n✗ Drift detected: ${totalChanged} file(s) changed, ${conflicts.length} conflict(s).`);
581
+ if (added.length > 0) console.log(` Added : ${added.map((r) => r.path).join(', ')}`);
582
+ if (updated.length > 0) console.log(` Updated : ${updated.map((r) => r.path).join(', ')}`);
583
+ if (deleted.length > 0) console.log(` Deleted : ${deleted.map((r) => r.path).join(', ')}`);
584
+ if (conflicts.length > 0) console.log(` Conflicts: ${conflicts.map((r) => r.path).join(', ')}`);
585
+ console.log('\nRun `npx mc-domain-module upgrade` to apply changes.\n');
586
+ process.exitCode = 1;
587
+ }
588
+ return;
589
+ }
590
+
571
591
  if (opts.dryRun) {
572
592
  console.log('\nDry run complete — no files were written.\n');
573
593
  return;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Command to explain the project's current scaffold state and any available upgrades.
3
+ * Reads .mc/manifest.json for the current scaffold version + enabled module list,
4
+ * fetches the latest version from S3, and prints a diff to stdout.
5
+ */
6
+
7
+ import { readFile } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import { fetchVersionsJson } from '../utils/s3-fetch.js';
10
+ import { cliLogger } from '../utils/logger.js';
11
+
12
+ /**
13
+ * Options for the why command.
14
+ */
15
+ export interface WhyOptions {
16
+ /** Project root directory. Defaults to process.cwd(). */
17
+ projectRoot?: string;
18
+ /** Override S3 bucket used to fetch versions.json. Defaults to the public TIB bucket. */
19
+ scaffoldBucket?: string;
20
+ /** Output raw JSON instead of a human-readable report. */
21
+ json?: boolean;
22
+ }
23
+
24
+ /**
25
+ * Report describing the project's current scaffold state and any pending upgrade.
26
+ */
27
+ export interface WhyReport {
28
+ /** Scaffold version recorded in .mc/manifest.json (or "unknown" if no manifest). */
29
+ currentVersion: string;
30
+ /** Module IDs currently enabled in the project. */
31
+ enabledModules: string[];
32
+ /** Latest version published to the scaffold S3 bucket. */
33
+ latestVersion: string;
34
+ /** True when the current pinned version is older than the latest. */
35
+ upgradeAvailable: boolean;
36
+ /** Number of releases between current and latest (-1 if versions cannot be compared). */
37
+ versionsBehind: number;
38
+ /** All known scaffold versions (most recent first). */
39
+ allVersions: string[];
40
+ }
41
+
42
+ const DEFAULT_BUCKET = 'mc-scaffold';
43
+ const MANIFEST_PATH_SEGMENTS = ['.mc', 'manifest.json'] as const;
44
+
45
+ /**
46
+ * Read the project's current scaffold state from .mc/manifest.json.
47
+ * Returns sensible defaults when the manifest is missing or unreadable.
48
+ */
49
+ async function readManifestState(projectRoot: string): Promise<{
50
+ scaffoldVersion: string;
51
+ enabledModules: string[];
52
+ }> {
53
+ const manifestPath = join(projectRoot, ...MANIFEST_PATH_SEGMENTS);
54
+ try {
55
+ const content = await readFile(manifestPath, 'utf-8');
56
+ const parsed = JSON.parse(content) as {
57
+ scaffoldVersion?: unknown;
58
+ enabledModules?: unknown;
59
+ };
60
+ const scaffoldVersion =
61
+ typeof parsed.scaffoldVersion === 'string' ? parsed.scaffoldVersion : 'unknown';
62
+ const enabledModules = Array.isArray(parsed.enabledModules)
63
+ ? parsed.enabledModules.filter((m): m is string => typeof m === 'string')
64
+ : [];
65
+ return { scaffoldVersion, enabledModules };
66
+ } catch (err) {
67
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
68
+ return { scaffoldVersion: 'unknown', enabledModules: [] };
69
+ }
70
+ throw err;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Compute the number of releases between two version strings, using the order
76
+ * returned by versions.json (most recent first). Returns -1 when either version
77
+ * is not present in the list.
78
+ */
79
+ function computeVersionsBehind(current: string, latest: string, all: string[]): number {
80
+ if (current === 'unknown') return -1;
81
+ const latestIdx = all.indexOf(latest);
82
+ const currentIdx = all.indexOf(current);
83
+ if (latestIdx === -1 || currentIdx === -1) return -1;
84
+ return Math.max(0, currentIdx - latestIdx);
85
+ }
86
+
87
+ /**
88
+ * Execute the why command: gather local + remote scaffold state, return a report.
89
+ * @param options - WhyOptions with optional projectRoot and scaffoldBucket.
90
+ * @returns A WhyReport describing current vs. latest state.
91
+ */
92
+ export async function runWhy(options: WhyOptions = {}): Promise<WhyReport> {
93
+ const projectRoot = options.projectRoot ?? process.cwd();
94
+ const bucket = options.scaffoldBucket ?? DEFAULT_BUCKET;
95
+
96
+ const { scaffoldVersion, enabledModules } = await readManifestState(projectRoot);
97
+ cliLogger.debug({ scaffoldVersion, enabledModules }, 'why: read local manifest');
98
+
99
+ const versionsJson = await fetchVersionsJson(bucket);
100
+ const latestVersion = versionsJson.latest;
101
+ const allVersions = versionsJson.versions;
102
+ const upgradeAvailable = latestVersion !== scaffoldVersion;
103
+ const versionsBehind = computeVersionsBehind(scaffoldVersion, latestVersion, allVersions);
104
+
105
+ return {
106
+ currentVersion: scaffoldVersion,
107
+ enabledModules,
108
+ latestVersion,
109
+ upgradeAvailable,
110
+ versionsBehind,
111
+ allVersions,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Format a WhyReport as a multi-line human-readable string.
117
+ */
118
+ export function formatWhyReport(report: WhyReport): string {
119
+ const lines: string[] = [];
120
+ lines.push('=== Scaffold State ===');
121
+ lines.push(`Current version : ${report.currentVersion}`);
122
+ lines.push(`Latest version : ${report.latestVersion}`);
123
+ if (report.upgradeAvailable) {
124
+ if (report.versionsBehind > 0) {
125
+ lines.push(`Upgrade available: yes (${report.versionsBehind} release(s) behind)`);
126
+ } else {
127
+ lines.push('Upgrade available: yes');
128
+ }
129
+ } else {
130
+ lines.push('Upgrade available: no — you are on the latest version');
131
+ }
132
+
133
+ lines.push('');
134
+ lines.push(`Enabled modules : ${report.enabledModules.length === 0 ? '(none)' : ''}`);
135
+ for (const m of report.enabledModules) {
136
+ lines.push(` - ${m}`);
137
+ }
138
+
139
+ lines.push('');
140
+ lines.push('Pending upgrade diff:');
141
+ if (!report.upgradeAvailable) {
142
+ lines.push(' (none)');
143
+ } else {
144
+ const idx = report.allVersions.indexOf(report.currentVersion);
145
+ if (idx <= 0) {
146
+ lines.push(` (full jump: ${report.currentVersion} → ${report.latestVersion})`);
147
+ } else {
148
+ const newer = report.allVersions.slice(0, idx);
149
+ for (const v of newer) {
150
+ lines.push(` ${v} (newer than ${report.currentVersion})`);
151
+ }
152
+ }
153
+ }
154
+
155
+ return lines.join('\n') + '\n';
156
+ }
157
+
158
+ /**
159
+ * CLI entry point: print a human-readable (or JSON) report and return the report.
160
+ */
161
+ export async function runWhyCli(options: WhyOptions = {}): Promise<WhyReport> {
162
+ const report = await runWhy(options);
163
+ if (options.json) {
164
+ // eslint-disable-next-line no-console
165
+ console.log(JSON.stringify(report, null, 2));
166
+ } else {
167
+ // eslint-disable-next-line no-console
168
+ process.stdout.write(formatWhyReport(report));
169
+ }
170
+ return report;
171
+ }
@@ -20,8 +20,24 @@ function camelCase(s: string): string {
20
20
  */
21
21
  export function apiSkeletonTemplate(domainId: string, apiId: string, tenancy: string): string {
22
22
  const tenantPath = tenancy === 'required' ? '/v1/tenants/{tenantId}' : '/v1';
23
+ const outputType = `${camelCase(domainId)}${camelCase(apiId)}Output`;
23
24
  return `import { z } from 'zod';
24
25
  import { defineApi } from '@mettlecast/domain-runtime';
26
+ import { createKyClient, initOtel } from '@mettlecast/domain-runtime';
27
+ import type { Result, AppError } from '@mettlecast/domain-runtime';
28
+ import { ok, err, notFound } from '@mettlecast/domain-runtime';
29
+
30
+ // Initialise OpenTelemetry tracing for the process. Idempotent —
31
+ // safe to call at module scope. Sets up OTLP export (when
32
+ // OTEL_EXPORTER_OTLP_ENDPOINT is set) or console-export for
33
+ // local dev. Auto-instruments outbound HTTP calls via ky.
34
+ initOtel();
35
+
36
+ // Shared HTTP client with retry on 503/429. Replace this with
37
+ // your own client if you need custom headers or auth.
38
+ const http = createKyClient();
39
+
40
+ const outputSchema = z.object({ ok: z.boolean() }).default({ ok: true });
25
41
 
26
42
  export const ${camelCase(apiId)} = defineApi({
27
43
  id: '${apiId}',
@@ -31,10 +47,12 @@ export const ${camelCase(apiId)} = defineApi({
31
47
  versions: {
32
48
  v1: {
33
49
  status: 'stable',
34
- input: z.object({}).optional(),
35
- output: z.object({ ok: z.boolean() }),
36
- handler: async (_input, _ctx) => {
37
- return { ok: true };
50
+ input: z.object({}).default({}),
51
+ output: outputSchema,
52
+ handler: async (_input, _ctx): Promise<Result<z.infer<typeof outputSchema>, AppError>> => {
53
+ // Replace this with your handler logic.
54
+ // Return ok(value) on success, err({ ... }) on failure.
55
+ return ok({ ok: true });
38
56
  },
39
57
  },
40
58
  },
@@ -48,11 +66,19 @@ export const ${camelCase(apiId)} = defineApi({
48
66
 
49
67
  /**
50
68
  * Generates a fixture JSON skeleton for API testing.
69
+ * The fixture embeds the input schema's default shape as the example payload
70
+ * in the event body so that the test driver can deserialize it directly.
51
71
  * @param domainId - Domain ID in kebab-case.
52
72
  * @param apiId - API ID in kebab-case.
73
+ * @param exampleBody - JSON-stringified example payload matching the input
74
+ * schema's default shape. Defaults to '{}'.
53
75
  * @returns JSON string with mock event and context for testing.
54
76
  */
55
- export function apiFixtureSkeleton(domainId: string, apiId: string): string {
77
+ export function apiFixtureSkeleton(
78
+ domainId: string,
79
+ apiId: string,
80
+ exampleBody: string = '{}',
81
+ ): string {
56
82
  return JSON.stringify({
57
83
  description: `Fixture for ${domainId}.${apiId}`,
58
84
  event: {
@@ -71,7 +97,7 @@ export function apiFixtureSkeleton(domainId: string, apiId: string): string {
71
97
  },
72
98
  pathParameters: {},
73
99
  headers: { 'content-type': 'application/json', 'accept-version': '1' },
74
- body: null,
100
+ body: exampleBody,
75
101
  },
76
102
  }, null, 2) + '\n';
77
103
  }
@@ -13,17 +13,25 @@ export function createWithEventTemplate(domain: string, id: string, tenancy: str
13
13
 
14
14
  return `import { z } from 'zod';
15
15
  import { defineApi } from '@mettlecast/domain-runtime';
16
+ import { createKyClient, initOtel } from '@mettlecast/domain-runtime';
17
+ import type { Result, AppError } from '@mettlecast/domain-runtime';
18
+ import { ok, err, notFound } from '@mettlecast/domain-runtime';
19
+
20
+ initOtel();
16
21
 
17
22
  // ── Zod schemas ──────────────────────────────────────────────
18
23
 
19
24
  const ${varName}Input = z.object({
20
25
  name: z.string().min(1),
21
26
  payload: z.record(z.unknown()).optional(),
22
- });
27
+ }).default({ name: 'Example' });
23
28
 
24
29
  const ${varName}Output = z.object({
25
30
  id: z.string().uuid(),
26
31
  status: z.enum(['created', 'pending']),
32
+ }).default({
33
+ id: '00000000-0000-0000-0000-000000000000',
34
+ status: 'created',
27
35
  });
28
36
 
29
37
  // ── API definition ───────────────────────────────────────────
@@ -66,3 +74,9 @@ export const ${varName} = defineApi({
66
74
  });
67
75
  `;
68
76
  }
77
+
78
+ /**
79
+ * Example body string used as the event body in the add-api fixture for the
80
+ * create-with-event pattern. Mirrors the default shape of the input schema.
81
+ */
82
+ export const createWithEventExampleBody = JSON.stringify({ name: 'Example' });
@@ -13,18 +13,30 @@ export function idempotentMutationTemplate(domain: string, id: string, tenancy:
13
13
 
14
14
  return `import { z } from 'zod';
15
15
  import { defineApi } from '@mettlecast/domain-runtime';
16
+ import { createKyClient, initOtel } from '@mettlecast/domain-runtime';
17
+ import type { Result, AppError } from '@mettlecast/domain-runtime';
18
+ import { ok, err, notFound } from '@mettlecast/domain-runtime';
19
+
20
+ initOtel();
16
21
 
17
22
  // ── Zod schemas ──────────────────────────────────────────────
18
23
 
19
24
  const ${varName}Input = z.object({
20
25
  id: z.string().uuid(),
21
26
  payload: z.record(z.unknown()),
27
+ }).default({
28
+ id: '00000000-0000-0000-0000-000000000000',
29
+ payload: {},
22
30
  });
23
31
 
24
32
  const ${varName}Output = z.object({
25
33
  id: z.string().uuid(),
26
34
  status: z.enum(['applied', 'already-processed']),
27
35
  idempotencyKey: z.string(),
36
+ }).default({
37
+ id: '00000000-0000-0000-0000-000000000000',
38
+ status: 'applied',
39
+ idempotencyKey: '00000000-0000-0000-0000-000000000000',
28
40
  });
29
41
 
30
42
  // ── API definition ───────────────────────────────────────────
@@ -77,3 +89,12 @@ export const ${varName} = defineApi({
77
89
  });
78
90
  `;
79
91
  }
92
+
93
+ /**
94
+ * Example body string used as the event body in the add-api fixture for the
95
+ * idempotent-mutation pattern. Mirrors the default shape of the input schema.
96
+ */
97
+ export const idempotentMutationExampleBody = JSON.stringify({
98
+ id: '00000000-0000-0000-0000-000000000000',
99
+ payload: {},
100
+ });
@@ -13,6 +13,11 @@ export function paginatedListTemplate(domain: string, id: string, tenancy: strin
13
13
 
14
14
  return `import { z } from 'zod';
15
15
  import { defineApi } from '@mettlecast/domain-runtime';
16
+ import { createKyClient, initOtel } from '@mettlecast/domain-runtime';
17
+ import type { Result, AppError } from '@mettlecast/domain-runtime';
18
+ import { ok, err, notFound } from '@mettlecast/domain-runtime';
19
+
20
+ initOtel();
16
21
 
17
22
  // ── Pagination contract ──────────────────────────────────────
18
23
 
@@ -25,13 +30,13 @@ const PageInput = z.object({
25
30
  .max(100)
26
31
  .default(20),
27
32
  filter: z.string().optional(),
28
- });
33
+ }).default({});
29
34
 
30
35
  const PageOutput = z.object({
31
36
  items: z.array(z.object({ id: z.string().uuid() })),
32
37
  nextCursor: z.string().optional(),
33
38
  total: z.number().int().optional(),
34
- });
39
+ }).default({ items: [], nextCursor: undefined, total: undefined });
35
40
 
36
41
  // ── API definition ───────────────────────────────────────────
37
42
 
@@ -64,3 +69,9 @@ export const ${varName} = defineApi({
64
69
  });
65
70
  `;
66
71
  }
72
+
73
+ /**
74
+ * Example body string used as the event body in the add-api fixture for the
75
+ * paginated-list pattern. Mirrors the default shape of the input schema.
76
+ */
77
+ export const paginatedListExampleBody = '{}';