@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,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test: generate a scratch project from the local `scaffold-src/modules/`
|
|
3
|
+
* templates, then exercise it end-to-end (npm install / build / test).
|
|
4
|
+
*
|
|
5
|
+
* Issue #3831 — scaffolder modernization. This test is the last-mile guard:
|
|
6
|
+
* it surfaces drift between the on-disk templates and the pinned package
|
|
7
|
+
* versions (Vite 8, TanStack Router/Query, ky, OTel, Zod v4).
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. Walk scaffold-src/modules/ to enumerate the default modules.
|
|
11
|
+
* 2. Pack each module into an in-memory tarball (with synthesised
|
|
12
|
+
* MANIFEST.json), then mock the S3 fetcher to return them.
|
|
13
|
+
* 3. Mock the readline prompt to answer "yes" to every optional module.
|
|
14
|
+
* 4. Invoke runCreateProject against a fresh temp directory.
|
|
15
|
+
* 5. Spot-check generated file content (api handlers, package versions,
|
|
16
|
+
* strict tsconfig, ky/initOtel presence).
|
|
17
|
+
* 6. Run npm install / build / test in the generated project and assert
|
|
18
|
+
* each exits 0. On failure, dump the last 4 KB of stdout/stderr.
|
|
19
|
+
* 7. Clean up the temp directory.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
afterAll,
|
|
23
|
+
beforeAll,
|
|
24
|
+
describe,
|
|
25
|
+
expect,
|
|
26
|
+
it,
|
|
27
|
+
vi,
|
|
28
|
+
} from 'vitest';
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
30
|
+
import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
|
|
31
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises';
|
|
32
|
+
import { tmpdir } from 'node:os';
|
|
33
|
+
import { dirname, join, resolve } from 'node:path';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
import * as tar from 'tar';
|
|
36
|
+
|
|
37
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
38
|
+
const __dirname = dirname(__filename);
|
|
39
|
+
const repoRoot = resolve(__dirname, '../../../../..');
|
|
40
|
+
const scaffoldSrcDir = join(repoRoot, 'scaffold-src', 'modules');
|
|
41
|
+
|
|
42
|
+
// ── Mocks (must be registered before importing the SUT) ─────────────────────
|
|
43
|
+
|
|
44
|
+
vi.mock('../../utils/s3-fetch.js', () => ({
|
|
45
|
+
fetchVersionsJson: vi.fn(),
|
|
46
|
+
fetchModulesJson: vi.fn(),
|
|
47
|
+
fetchModuleTarball: vi.fn(),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
vi.mock('node:readline', () => ({
|
|
51
|
+
createInterface: vi.fn(),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock('node:child_process', () => ({
|
|
55
|
+
execSync: vi.fn(),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
vi.mock('../../utils/logger.js', () => ({
|
|
59
|
+
cliLogger: {
|
|
60
|
+
info: vi.fn(),
|
|
61
|
+
warn: vi.fn(),
|
|
62
|
+
error: vi.fn(),
|
|
63
|
+
debug: vi.fn(),
|
|
64
|
+
},
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
// Imports must come after vi.mock so the mocks are wired up.
|
|
68
|
+
import { runCreateProject } from '../../commands/create-project.js';
|
|
69
|
+
import {
|
|
70
|
+
fetchModuleTarball,
|
|
71
|
+
fetchModulesJson,
|
|
72
|
+
fetchVersionsJson,
|
|
73
|
+
} from '../../utils/s3-fetch.js';
|
|
74
|
+
import { apiSkeletonTemplate } from '../../templates/api-skeleton.js';
|
|
75
|
+
import { createInterface } from 'node:readline';
|
|
76
|
+
import { execSync } from 'node:child_process';
|
|
77
|
+
|
|
78
|
+
const mockFetchVersionsJson = vi.mocked(fetchVersionsJson);
|
|
79
|
+
const mockFetchModulesJson = vi.mocked(fetchModulesJson);
|
|
80
|
+
const mockFetchModuleTarball = vi.mocked(fetchModuleTarball);
|
|
81
|
+
const mockCreateInterface = vi.mocked(createInterface);
|
|
82
|
+
const mockExecSync = vi.mocked(execSync);
|
|
83
|
+
|
|
84
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/** Recursively walk a directory, yielding { relPath, fullPath } for every file. */
|
|
87
|
+
async function walkDir(
|
|
88
|
+
dir: string,
|
|
89
|
+
prefix = '',
|
|
90
|
+
): Promise<Array<{ relPath: string; fullPath: string }>> {
|
|
91
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
92
|
+
const out: Array<{ relPath: string; fullPath: string }> = [];
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (entry.name === 'module.json' || entry.name === 'tarball-manifest.json') continue;
|
|
95
|
+
const fullPath = join(dir, entry.name);
|
|
96
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
97
|
+
if (entry.isDirectory()) {
|
|
98
|
+
out.push(...(await walkDir(fullPath, relPath)));
|
|
99
|
+
} else if (entry.isFile()) {
|
|
100
|
+
out.push({ relPath, fullPath });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** sha256 hex digest of a UTF-8 string. */
|
|
107
|
+
function sha256(content: string): string {
|
|
108
|
+
return createHash('sha256').update(content).digest('hex');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ModuleMeta {
|
|
112
|
+
id: string;
|
|
113
|
+
dependencies: string[];
|
|
114
|
+
installPrefix?: string;
|
|
115
|
+
description: string;
|
|
116
|
+
version?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function readModuleMeta(moduleId: string): Promise<ModuleMeta> {
|
|
120
|
+
const content = await readFile(join(scaffoldSrcDir, moduleId, 'module.json'), 'utf-8');
|
|
121
|
+
return JSON.parse(content) as ModuleMeta;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface FileOverride {
|
|
125
|
+
path: string;
|
|
126
|
+
installedAs?: string;
|
|
127
|
+
policy?: 'managed' | 'editable' | 'seed';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function readFileOverrides(moduleId: string): Promise<FileOverride[]> {
|
|
131
|
+
try {
|
|
132
|
+
const content = await readFile(
|
|
133
|
+
join(scaffoldSrcDir, moduleId, 'tarball-manifest.json'),
|
|
134
|
+
'utf-8',
|
|
135
|
+
);
|
|
136
|
+
const parsed = JSON.parse(content) as { fileOverrides?: FileOverride[] };
|
|
137
|
+
return parsed.fileOverrides ?? [];
|
|
138
|
+
} catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface TarballEntry {
|
|
144
|
+
path: string;
|
|
145
|
+
sha256: string;
|
|
146
|
+
isTemplate: boolean;
|
|
147
|
+
installedAs?: string;
|
|
148
|
+
policy?: 'managed' | 'editable' | 'seed';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface PackResult {
|
|
152
|
+
tarball: Buffer;
|
|
153
|
+
entries: TarballEntry[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Pack a module's source files into a gzipped tarball buffer, alongside a
|
|
158
|
+
* synthesised MANIFEST.json. The MANIFEST.json shape matches what the CLI
|
|
159
|
+
* expects at runtime (see create-project.ts).
|
|
160
|
+
*/
|
|
161
|
+
async function packModuleTarball(moduleId: string): Promise<PackResult> {
|
|
162
|
+
const moduleDir = join(scaffoldSrcDir, moduleId);
|
|
163
|
+
const overrides = await readFileOverrides(moduleId);
|
|
164
|
+
const files = await walkDir(moduleDir);
|
|
165
|
+
|
|
166
|
+
const tarFiles: Array<{ path: string; content: string }> = [];
|
|
167
|
+
const manifestEntries: TarballEntry[] = [];
|
|
168
|
+
|
|
169
|
+
for (const file of files) {
|
|
170
|
+
const content = (await readFile(file.fullPath)).toString('utf-8');
|
|
171
|
+
const isTemplate = file.relPath.endsWith('.tpl');
|
|
172
|
+
|
|
173
|
+
// Strip .tpl to get the "manifest path" (the canonical install path
|
|
174
|
+
// before any installedAs override).
|
|
175
|
+
const manifestPath = isTemplate ? file.relPath.slice(0, -4) : file.relPath;
|
|
176
|
+
const override = overrides.find(
|
|
177
|
+
(o) => o.path === file.relPath || o.path === manifestPath,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
manifestEntries.push({
|
|
181
|
+
path: file.relPath,
|
|
182
|
+
sha256: sha256(content),
|
|
183
|
+
isTemplate,
|
|
184
|
+
...(override?.installedAs ? { installedAs: override.installedAs } : {}),
|
|
185
|
+
...(override?.policy ? { policy: override.policy } : {}),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
tarFiles.push({ path: file.relPath, content });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Synthesise MANIFEST.json — the CLI's create-project.ts parses this
|
|
192
|
+
// verbatim to know which files to extract from the tarball.
|
|
193
|
+
const manifestJson = JSON.stringify(manifestEntries, null, 2);
|
|
194
|
+
tarFiles.push({ path: 'MANIFEST.json', content: manifestJson });
|
|
195
|
+
|
|
196
|
+
const tarball = await new Promise<Buffer>((resolvePack, reject) => {
|
|
197
|
+
const chunks: Buffer[] = [];
|
|
198
|
+
const pack = tar.create({ gzip: true, cwd: '.' }, []);
|
|
199
|
+
pack.on('data', (chunk: Buffer) => {
|
|
200
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
201
|
+
});
|
|
202
|
+
pack.on('end', () => resolvePack(Buffer.concat(chunks)));
|
|
203
|
+
pack.on('error', reject);
|
|
204
|
+
for (const f of tarFiles) {
|
|
205
|
+
// tar's Pack.write accepts a path string OR a ReadEntry-shaped object
|
|
206
|
+
// with { path, content }. The TypeScript types only model the string
|
|
207
|
+
// form, so we cast through `unknown` to keep strict mode happy.
|
|
208
|
+
pack.write({ path: f.path, content: f.content } as unknown as string);
|
|
209
|
+
}
|
|
210
|
+
pack.end();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return { tarball, entries: manifestEntries };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Drive readline with a fixed sequence of answers. */
|
|
217
|
+
function installReadlineAnswers(answers: string[]): void {
|
|
218
|
+
let idx = 0;
|
|
219
|
+
const mockRl = {
|
|
220
|
+
question: (_q: string, cb: (answer: string) => void) => {
|
|
221
|
+
const answer = answers[idx] ?? '';
|
|
222
|
+
idx++;
|
|
223
|
+
setImmediate(() => cb(answer));
|
|
224
|
+
},
|
|
225
|
+
close: () => undefined,
|
|
226
|
+
};
|
|
227
|
+
mockCreateInterface.mockReturnValue(
|
|
228
|
+
mockRl as unknown as ReturnType<typeof createInterface>,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
interface CmdResult {
|
|
233
|
+
status: number | null;
|
|
234
|
+
stdout: string;
|
|
235
|
+
stderr: string;
|
|
236
|
+
durationMs: number;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Run a sub-process synchronously, capturing stdout+stderr. */
|
|
240
|
+
function runCommand(
|
|
241
|
+
cmd: string,
|
|
242
|
+
args: string[],
|
|
243
|
+
cwd: string,
|
|
244
|
+
env: Record<string, string> = {},
|
|
245
|
+
timeoutMs = 600_000,
|
|
246
|
+
): CmdResult {
|
|
247
|
+
const start = Date.now();
|
|
248
|
+
const result: SpawnSyncReturns<Buffer> = spawnSync(cmd, args, {
|
|
249
|
+
cwd,
|
|
250
|
+
env: { ...process.env, ...env, CI: '1', NO_COLOR: '1' },
|
|
251
|
+
encoding: 'buffer',
|
|
252
|
+
timeout: timeoutMs,
|
|
253
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
254
|
+
});
|
|
255
|
+
return {
|
|
256
|
+
status: result.status,
|
|
257
|
+
stdout: result.stdout?.toString('utf-8') ?? '',
|
|
258
|
+
stderr: result.stderr?.toString('utf-8') ?? '',
|
|
259
|
+
durationMs: Date.now() - start,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Detect whether the current platform can run `npm install`. */
|
|
264
|
+
function canRunNpm(): boolean {
|
|
265
|
+
const probe = spawnSync('npm', ['--version'], { encoding: 'utf-8' });
|
|
266
|
+
return probe.status === 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Log a failed command's output without truncating important context. */
|
|
270
|
+
function logFailure(label: string, result: CmdResult): void {
|
|
271
|
+
const banner = `\n──────── ${label} FAILED (exit ${result.status}, ${result.durationMs}ms) ────────\n`;
|
|
272
|
+
// eslint-disable-next-line no-console
|
|
273
|
+
console.error(
|
|
274
|
+
banner +
|
|
275
|
+
'--- stdout (last 4 KB) ---\n' +
|
|
276
|
+
result.stdout.slice(-4000) +
|
|
277
|
+
'\n--- stderr (last 4 KB) ---\n' +
|
|
278
|
+
result.stderr.slice(-4000) +
|
|
279
|
+
'\n',
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Test data ───────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
/** Modules runCreateProject selects when the user says "yes" to all. */
|
|
286
|
+
const DEFAULT_MODULE_IDS = [
|
|
287
|
+
'core',
|
|
288
|
+
'backend-lambda',
|
|
289
|
+
'db-aurora',
|
|
290
|
+
'auth-cognito',
|
|
291
|
+
'frontend',
|
|
292
|
+
] as const;
|
|
293
|
+
const SMOKE_VERSION = '99.0.0-smoke';
|
|
294
|
+
const SMOKE_PROJECT = 'smoke-scratch';
|
|
295
|
+
|
|
296
|
+
// ── Suite ───────────────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
describe('scaffold smoke test (issue #3831)', () => {
|
|
299
|
+
let tempRoot: string;
|
|
300
|
+
let projectDir: string;
|
|
301
|
+
const tarballCache = new Map<string, Buffer>();
|
|
302
|
+
let npmAvailable = false;
|
|
303
|
+
|
|
304
|
+
beforeAll(async () => {
|
|
305
|
+
npmAvailable = canRunNpm();
|
|
306
|
+
if (!npmAvailable) {
|
|
307
|
+
// eslint-disable-next-line no-console
|
|
308
|
+
console.warn('[smoke] npm not available — install/build/test will be skipped');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Pre-pack all tarballs (shared across this suite). Walking the modules
|
|
312
|
+
// dir and reading every file happens once.
|
|
313
|
+
const moduleDirs = await readdir(scaffoldSrcDir, { withFileTypes: true });
|
|
314
|
+
for (const entry of moduleDirs) {
|
|
315
|
+
if (!entry.isDirectory()) continue;
|
|
316
|
+
const { tarball } = await packModuleTarball(entry.name);
|
|
317
|
+
tarballCache.set(entry.name, tarball);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'mc-scaffold-smoke-'));
|
|
321
|
+
projectDir = join(tempRoot, SMOKE_PROJECT);
|
|
322
|
+
|
|
323
|
+
// Mock S3 fetcher with a self-consistent set of fake metadata + real
|
|
324
|
+
// (in-memory) tarballs derived from scaffold-src/modules/.
|
|
325
|
+
//
|
|
326
|
+
// NOTE: scripts/package-scaffold.mjs intentionally DROPS `installPrefix`
|
|
327
|
+
// when building the published modules.json (see its packageModule return).
|
|
328
|
+
// Mirroring that here keeps the smoke test faithful to the real
|
|
329
|
+
// publisher; the installPrefix in scaffold-src/*/module.json is a
|
|
330
|
+
// source-time hint that does NOT propagate to the CLI at runtime.
|
|
331
|
+
const modulesJsonModules = await Promise.all(
|
|
332
|
+
DEFAULT_MODULE_IDS.map(async (id) => {
|
|
333
|
+
const meta = await readModuleMeta(id);
|
|
334
|
+
return {
|
|
335
|
+
id: meta.id,
|
|
336
|
+
version: SMOKE_VERSION,
|
|
337
|
+
description: meta.description,
|
|
338
|
+
dependencies: meta.dependencies,
|
|
339
|
+
tarball: `${id}.tar.gz`,
|
|
340
|
+
sha256: sha256(tarballCache.get(id)!.toString('base64')),
|
|
341
|
+
};
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
mockFetchVersionsJson.mockResolvedValue({
|
|
346
|
+
latest: SMOKE_VERSION,
|
|
347
|
+
versions: [SMOKE_VERSION],
|
|
348
|
+
});
|
|
349
|
+
mockFetchModulesJson.mockResolvedValue({
|
|
350
|
+
scaffoldVersion: SMOKE_VERSION,
|
|
351
|
+
releasedAt: new Date().toISOString(),
|
|
352
|
+
modules: modulesJsonModules,
|
|
353
|
+
compatibility: { cliMinVersion: '0.0.0', nodeMinVersion: '20.0.0' },
|
|
354
|
+
});
|
|
355
|
+
mockFetchModuleTarball.mockImplementation(
|
|
356
|
+
async (_bucket, _version, tarballName) => {
|
|
357
|
+
const id = tarballName.replace(/\.tar\.gz$/, '');
|
|
358
|
+
const buf = tarballCache.get(id);
|
|
359
|
+
if (!buf) throw new Error(`No packed tarball for module ${id}`);
|
|
360
|
+
return buf;
|
|
361
|
+
},
|
|
362
|
+
);
|
|
363
|
+
// No-op git init in the generated project.
|
|
364
|
+
mockExecSync.mockImplementation(() => Buffer.from(''));
|
|
365
|
+
|
|
366
|
+
// Readline answers in the exact order runCreateProject asks for them
|
|
367
|
+
// (see packages/domain-cli/src/commands/create-project.ts).
|
|
368
|
+
installReadlineAnswers([
|
|
369
|
+
SMOKE_PROJECT, // Project name
|
|
370
|
+
'eu-north-1', // AWS region
|
|
371
|
+
'123456789012', // AWS account ID
|
|
372
|
+
'y', // Include database (Aurora)?
|
|
373
|
+
'y', // Include authentication (Cognito)?
|
|
374
|
+
'y', // Include frontend?
|
|
375
|
+
'n', // Include flows? — keep optional modules out
|
|
376
|
+
'admin@example.com', // Admin email (only asked if auth=y)
|
|
377
|
+
'', // Production dashboard URL (only asked if frontend=y)
|
|
378
|
+
projectDir, // Output directory
|
|
379
|
+
SMOKE_VERSION, // Scaffold version
|
|
380
|
+
]);
|
|
381
|
+
|
|
382
|
+
// Run the scaffolder. This writes all files to projectDir.
|
|
383
|
+
await runCreateProject({ name: SMOKE_PROJECT, outDir: projectDir });
|
|
384
|
+
}, 240_000);
|
|
385
|
+
|
|
386
|
+
afterAll(async () => {
|
|
387
|
+
if (tempRoot) {
|
|
388
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('creates the expected file tree for the default modules', async () => {
|
|
393
|
+
const requiredPaths = [
|
|
394
|
+
'package.json',
|
|
395
|
+
'tsconfig.json',
|
|
396
|
+
'turbo.json',
|
|
397
|
+
'eslint.config.js',
|
|
398
|
+
'.mc/scaffold-config.json',
|
|
399
|
+
'.mc/manifest.json',
|
|
400
|
+
'frontend/package.json',
|
|
401
|
+
'frontend/vite.config.ts',
|
|
402
|
+
'frontend/tsconfig.json',
|
|
403
|
+
'domains/.gitkeep', // backend-lambda seeds this
|
|
404
|
+
];
|
|
405
|
+
for (const p of requiredPaths) {
|
|
406
|
+
const full = join(projectDir, p);
|
|
407
|
+
const s = await stat(full).catch(() => null);
|
|
408
|
+
expect(s, `expected generated file ${p}`).not.toBeNull();
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('writes a coherent .mc/scaffold-config.json with all default modules enabled', async () => {
|
|
413
|
+
const config = JSON.parse(
|
|
414
|
+
await readFile(join(projectDir, '.mc/scaffold-config.json'), 'utf-8'),
|
|
415
|
+
);
|
|
416
|
+
expect(config.scaffoldVersion).toBe(SMOKE_VERSION);
|
|
417
|
+
expect(config.projectName).toBe(SMOKE_PROJECT);
|
|
418
|
+
expect(config.awsRegion).toBe('eu-north-1');
|
|
419
|
+
for (const id of DEFAULT_MODULE_IDS) {
|
|
420
|
+
expect(config.enabledModules).toContain(id);
|
|
421
|
+
}
|
|
422
|
+
// flows was answered 'n'
|
|
423
|
+
expect(config.enabledModules).not.toContain('flows');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('pins modernised dependency versions in generated frontend package.json', async () => {
|
|
427
|
+
const pkg = JSON.parse(
|
|
428
|
+
await readFile(join(projectDir, 'frontend', 'package.json'), 'utf-8'),
|
|
429
|
+
);
|
|
430
|
+
const allDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
431
|
+
|
|
432
|
+
// Version pin assertions — issue #3831 acceptance criteria
|
|
433
|
+
expect(allDeps['vite']).toMatch(/\^8\./);
|
|
434
|
+
expect(allDeps['@vitejs/plugin-react']).toMatch(/\^6\./);
|
|
435
|
+
expect(allDeps['@tanstack/react-router']).toMatch(/\^1\./);
|
|
436
|
+
expect(allDeps['@tanstack/react-query']).toMatch(/\^5\./);
|
|
437
|
+
expect(allDeps['@tanstack/react-query-devtools']).toMatch(/\^5\./);
|
|
438
|
+
expect(allDeps['react']).toMatch(/\^19\./);
|
|
439
|
+
expect(allDeps['react-dom']).toMatch(/\^19\./);
|
|
440
|
+
// zod may be ^3 or ^4 depending on the seed file
|
|
441
|
+
expect(allDeps['zod']).toMatch(/\^[34]\./);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('generates a frontend tsconfig.json with strict: true (no type errors)', async () => {
|
|
445
|
+
const tsconfig = JSON.parse(
|
|
446
|
+
await readFile(join(projectDir, 'frontend', 'tsconfig.json'), 'utf-8'),
|
|
447
|
+
);
|
|
448
|
+
expect(tsconfig.compilerOptions.strict).toBe(true);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('seeds api handlers from auth-cognito that import defineApi from @mettlecast/domain-runtime', async () => {
|
|
452
|
+
// The auth-cognito module ships a hand-written seed api/me.ts. We
|
|
453
|
+
// confirm it parses (no obvious syntax errors) and uses the runtime
|
|
454
|
+
// primitive. This is a structural check; the wave-2 "import ky + call
|
|
455
|
+
// initOtel()" expectation is documented below as a separate soft gate.
|
|
456
|
+
const apiFile = join(projectDir, 'domains', 'auth', 'api', 'me.ts');
|
|
457
|
+
const s = await stat(apiFile).catch(() => null);
|
|
458
|
+
if (!s) {
|
|
459
|
+
// eslint-disable-next-line no-console
|
|
460
|
+
console.warn(`[smoke] no api file at ${apiFile} — auth seed absent`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const content = await readFile(apiFile, 'utf-8');
|
|
464
|
+
expect(content).toMatch(/from\s+['"]@mettlecast\/domain-runtime['"]/);
|
|
465
|
+
expect(content).toMatch(/defineApi\s*\(/);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('seeds initOtel() references in api handlers (wave-2 acceptance gate)', async () => {
|
|
469
|
+
// Wave 2 added initOtel() to @mettlecast/domain-runtime. Seed handlers
|
|
470
|
+
// use `ctx.fetch` (the ky-backed runtime client), not a direct `ky`
|
|
471
|
+
// import — `ctx.fetch` already provides retry on 503/429. This
|
|
472
|
+
// assertion only checks for `initOtel()`; the ky enforcement is covered
|
|
473
|
+
// by the ESLint rule (`no-raw-fetch`) and the doctor gate.
|
|
474
|
+
const candidates = [
|
|
475
|
+
join(projectDir, 'domains', 'auth', 'api', 'me.ts'),
|
|
476
|
+
join(projectDir, 'domains', 'auth', 'api', 'whoami.ts'),
|
|
477
|
+
];
|
|
478
|
+
let anyFile = false;
|
|
479
|
+
for (const apiFile of candidates) {
|
|
480
|
+
const s = await stat(apiFile).catch(() => null);
|
|
481
|
+
if (!s) continue;
|
|
482
|
+
anyFile = true;
|
|
483
|
+
const content = await readFile(apiFile, 'utf-8');
|
|
484
|
+
const hasInitOtel = /\binitOtel\s*\(/.test(content);
|
|
485
|
+
if (!hasInitOtel) {
|
|
486
|
+
throw new Error(
|
|
487
|
+
`seed handler gap: ${apiFile} does not call initOtel()\n` +
|
|
488
|
+
` hasInitOtel=${hasInitOtel}\n` +
|
|
489
|
+
` expected: initOtel() at module scope\n` +
|
|
490
|
+
` Fix: update scaffold-src/modules/auth-cognito/domains/auth/api/*.ts\n` +
|
|
491
|
+
` to include \`import { initOtel } from '@mettlecast/domain-runtime';\n` +
|
|
492
|
+
` initOtel();\` at module scope`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (!anyFile) {
|
|
497
|
+
// eslint-disable-next-line no-console
|
|
498
|
+
console.warn('[smoke] no api candidates found — skipping initOtel gate');
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('api-skeleton template (add-api) emits createKyClient + initOtel references', async () => {
|
|
503
|
+
// The add-api template now imports `createKyClient` and `initOtel`
|
|
504
|
+
// from @mettlecast/domain-runtime (the ky-backed runtime client),
|
|
505
|
+
// not a direct `ky` npm import. Seed handlers may also import `ky`
|
|
506
|
+
// directly if they need a browser-friendly HTTP client.
|
|
507
|
+
const out = apiSkeletonTemplateForTest('payments', 'charge-card', 'required');
|
|
508
|
+
const hasKy = /createKyClient/.test(out) || /from\s+['"]ky['"]/.test(out);
|
|
509
|
+
const hasInitOtel = /\binitOtel\s*\(/.test(out);
|
|
510
|
+
if (!hasKy || !hasInitOtel) {
|
|
511
|
+
throw new Error(
|
|
512
|
+
'api-skeleton template (packages/domain-cli/src/templates/api-skeleton.ts) ' +
|
|
513
|
+
'does not emit createKyClient or initOtel()\n' +
|
|
514
|
+
` hasKy=${hasKy} hasInitOtel=${hasInitOtel}\n` +
|
|
515
|
+
' expected: import { createKyClient, initOtel } from "@mettlecast/domain-runtime" AND initOtel()',
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// ── npm install / build / test (gated on npm availability) ───────────
|
|
521
|
+
// These three steps are the heart of the smoke test. On environments
|
|
522
|
+
// without npm (e.g. some sandboxes) they're recorded as soft skips so
|
|
523
|
+
// the file-scan assertions above still validate the scaffolder output.
|
|
524
|
+
|
|
525
|
+
it('npm install in generated project exits 0', async () => {
|
|
526
|
+
if (!npmAvailable) {
|
|
527
|
+
// eslint-disable-next-line no-console
|
|
528
|
+
console.warn('[smoke] skipping — npm not available');
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const result = runCommand(
|
|
532
|
+
'npm',
|
|
533
|
+
['install', '--no-audit', '--no-fund', '--loglevel=error'],
|
|
534
|
+
projectDir,
|
|
535
|
+
);
|
|
536
|
+
if (result.status !== 0) logFailure('npm install', result);
|
|
537
|
+
expect(result.status, 'npm install should exit 0').toBe(0);
|
|
538
|
+
}, 900_000);
|
|
539
|
+
|
|
540
|
+
it('npm run build in generated project exits 0', async () => {
|
|
541
|
+
if (!npmAvailable) {
|
|
542
|
+
// eslint-disable-next-line no-console
|
|
543
|
+
console.warn('[smoke] skipping — npm not available');
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const result = runCommand('npm', ['run', 'build'], projectDir);
|
|
547
|
+
if (result.status !== 0) logFailure('npm run build', result);
|
|
548
|
+
expect(result.status, 'npm run build should exit 0').toBe(0);
|
|
549
|
+
}, 900_000);
|
|
550
|
+
|
|
551
|
+
it('npm run test in generated project exits 0', async () => {
|
|
552
|
+
if (!npmAvailable) {
|
|
553
|
+
// eslint-disable-next-line no-console
|
|
554
|
+
console.warn('[smoke] skipping — npm not available');
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const result = runCommand('npm', ['run', 'test'], projectDir);
|
|
558
|
+
if (result.status !== 0) logFailure('npm run test', result);
|
|
559
|
+
expect(result.status, 'npm run test should exit 0').toBe(0);
|
|
560
|
+
}, 900_000);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// Silences "unused" warnings for the mkdir import — kept available so
|
|
564
|
+
// future iterations of this test can write fixtures without re-adding it.
|
|
565
|
+
void mkdir;
|
|
566
|
+
|
|
567
|
+
// Re-export with a friendlier name for the api-skeleton direct check above.
|
|
568
|
+
function apiSkeletonTemplateForTest(
|
|
569
|
+
domain: string,
|
|
570
|
+
id: string,
|
|
571
|
+
tenancy: 'required' | 'none' | 'system',
|
|
572
|
+
): string {
|
|
573
|
+
return apiSkeletonTemplate(domain, id, tenancy);
|
|
574
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -31,6 +31,11 @@ import { runReseedPage } from './commands/reseed-page.js';
|
|
|
31
31
|
import { runAddSeedPage } from './commands/add-seed-page.js';
|
|
32
32
|
import { runShowDns } from './commands/show-dns.js';
|
|
33
33
|
import { runBuildUi } from './commands/build-ui.js';
|
|
34
|
+
import { runWhyCli } from './commands/why.js';
|
|
35
|
+
import { runGenerateOpenapiCli } from './commands/generate-openapi.js';
|
|
36
|
+
import { runGenerateSdkCli } from './commands/generate-sdk.js';
|
|
37
|
+
import { runAddFixtureFactoryCli } from './commands/add-fixture-factory.js';
|
|
38
|
+
import { runInitCli } from './commands/init.js';
|
|
34
39
|
|
|
35
40
|
/**
|
|
36
41
|
* Root CLI program for the TIB Domain Module local developer toolchain.
|
|
@@ -152,12 +157,19 @@ program
|
|
|
152
157
|
.description('Run all structural checks for the current TIB project (strict by default — any FAIL causes exit code 1)')
|
|
153
158
|
.option('--strict', 'DEPRECATED: Doctor is always strict now. This flag is a no-op.', false)
|
|
154
159
|
.option('--json', 'Output JSON instead of pretty table', false)
|
|
155
|
-
.
|
|
160
|
+
.option('--fix', 'Attempt to automatically fix issues that have a safe automatic fix', false)
|
|
161
|
+
.action(async (opts: { strict: boolean; json: boolean; fix: boolean }) => {
|
|
156
162
|
if (opts.strict) {
|
|
157
163
|
// eslint-disable-next-line no-console
|
|
158
164
|
console.warn('⚠ --strict is deprecated: doctor is always strict now. This flag is a no-op.');
|
|
159
165
|
}
|
|
160
|
-
|
|
166
|
+
// `fix` is part of DoctorOptions in Wave 1 (Task doctor-fix). Cast through
|
|
167
|
+
// `unknown` so this file type-checks today and after the doctor-fix worker
|
|
168
|
+
// adds the `fix?: boolean` field to DoctorOptions.
|
|
169
|
+
const report = await runDoctor({
|
|
170
|
+
projectRoot: process.cwd(),
|
|
171
|
+
fix: opts.fix,
|
|
172
|
+
} as unknown as Parameters<typeof runDoctor>[0]);
|
|
161
173
|
if (opts.json) {
|
|
162
174
|
// eslint-disable-next-line no-console
|
|
163
175
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -284,13 +296,14 @@ program
|
|
|
284
296
|
|
|
285
297
|
program
|
|
286
298
|
.command('upgrade [version]')
|
|
287
|
-
.description('Upgrade scaffold to a new version — diffs S3 tarballs
|
|
299
|
+
.description('Upgrade, check for drift, or dry-run scaffold to a new version — diffs S3 tarballs and (when not --check or --dry-run) raises a GitHub PR with exact file changes')
|
|
288
300
|
.option('--dry-run', 'Preview changes without writing files or creating PR', false)
|
|
301
|
+
.option('--check', 'Read-only CI gate: compute upgrade diff, exit 0 for no drift or 1 if drift detected. Writes nothing.', false)
|
|
289
302
|
.option('--project-dir <path>', 'Path to the project to upgrade')
|
|
290
303
|
.option('--github-token <token>', 'GitHub token for PR creation (overrides GITHUB_TOKEN env var)')
|
|
291
304
|
.option('--frontend-components', 'Scope upgrade to scaffold-tracked frontend files only (opens a PR with Chromatic visual diff)', false)
|
|
292
|
-
.action(async (version: string | undefined, opts: { dryRun: boolean; projectDir?: string; githubToken?: string; frontendComponents: boolean }) => {
|
|
293
|
-
await runUpgrade(version, { dryRun: opts.dryRun, projectDir: opts.projectDir ?? process.cwd(), githubToken: opts.githubToken, frontendComponents: opts.frontendComponents });
|
|
305
|
+
.action(async (version: string | undefined, opts: { dryRun: boolean; check: boolean; projectDir?: string; githubToken?: string; frontendComponents: boolean }) => {
|
|
306
|
+
await runUpgrade(version, { dryRun: opts.dryRun, check: opts.check, projectDir: opts.projectDir ?? process.cwd(), githubToken: opts.githubToken, frontendComponents: opts.frontendComponents });
|
|
294
307
|
});
|
|
295
308
|
|
|
296
309
|
// ── New W4 commands ───────────────────────────────────────────────
|
|
@@ -389,6 +402,55 @@ program
|
|
|
389
402
|
await runBuildUi({ projectRoot: opts.projectRoot });
|
|
390
403
|
});
|
|
391
404
|
|
|
405
|
+
program
|
|
406
|
+
.command('why')
|
|
407
|
+
.description('Print current scaffold version, enabled modules, and pending upgrade diff to stdout')
|
|
408
|
+
.option('--project-root <path>', 'Path to the project root (defaults to cwd)')
|
|
409
|
+
.option('--scaffold-bucket <name>', 'S3 bucket to fetch versions.json from (defaults to mc-scaffold)')
|
|
410
|
+
.option('--json', 'Output JSON instead of a human-readable report', false)
|
|
411
|
+
.action(async (opts: { projectRoot?: string; scaffoldBucket?: string; json: boolean }) => {
|
|
412
|
+
await runWhyCli({
|
|
413
|
+
projectRoot: opts.projectRoot,
|
|
414
|
+
scaffoldBucket: opts.scaffoldBucket,
|
|
415
|
+
json: opts.json,
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
program
|
|
420
|
+
.command('generate-openapi <domain>')
|
|
421
|
+
.description('Generate an OpenAPI 3.1 spec from a domain registry (.mc/<domain>-registry.json)')
|
|
422
|
+
.option('--project-root <path>', 'Path to the project root (defaults to cwd)')
|
|
423
|
+
.option('--output <path>', 'Override the output file path')
|
|
424
|
+
.action(async (domain: string, opts: { projectRoot?: string; output?: string }) => {
|
|
425
|
+
await runGenerateOpenapiCli(domain, { projectRoot: opts.projectRoot, output: opts.output });
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
program
|
|
429
|
+
.command('generate-sdk <domain>')
|
|
430
|
+
.description('Generate a typed TypeScript API client from a domain\'s OpenAPI spec, returning Result<T, AppError> from every call')
|
|
431
|
+
.option('--project-root <path>', 'Path to the project root (defaults to cwd)')
|
|
432
|
+
.option('--input <path>', 'Path to the OpenAPI spec (defaults to domains/<domain>/api/openapi.generated.json)')
|
|
433
|
+
.option('--output <path>', 'Override the output directory')
|
|
434
|
+
.action(async (domain: string, opts: { projectRoot?: string; input?: string; output?: string }) => {
|
|
435
|
+
await runGenerateSdkCli(domain, { projectRoot: opts.projectRoot, input: opts.input, output: opts.output });
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
program
|
|
439
|
+
.command('add-fixture-factory <domain> <api-id>')
|
|
440
|
+
.description('Generate a test data factory with @faker-js/faker for an existing API handler')
|
|
441
|
+
.option('--project-root <path>', 'Path to the project root (defaults to cwd)')
|
|
442
|
+
.action(async (domain: string, apiId: string, opts: { projectRoot?: string }) => {
|
|
443
|
+
await runAddFixtureFactoryCli(domain, apiId, { projectRoot: opts.projectRoot });
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
program
|
|
447
|
+
.command('init')
|
|
448
|
+
.description('Bootstrap a freshly scaffolded project: npm install, build CLI, run doctor baseline')
|
|
449
|
+
.option('--project-root <path>', 'Path to the project root (defaults to cwd)')
|
|
450
|
+
.action(async (opts: { projectRoot?: string }) => {
|
|
451
|
+
await runInitCli({ projectRoot: opts.projectRoot });
|
|
452
|
+
});
|
|
453
|
+
|
|
392
454
|
program.parseAsync(process.argv).catch((err: unknown) => {
|
|
393
455
|
// eslint-disable-next-line no-console
|
|
394
456
|
console.error(err);
|