@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
|
@@ -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
|
|
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
|
-
|
|
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) {
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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({}).
|
|
35
|
-
output:
|
|
36
|
-
handler: async (_input, _ctx) => {
|
|
37
|
-
|
|
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(
|
|
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:
|
|
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 = '{}';
|