@mettlecast/domain-cli 0.2.61 → 0.2.62
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 +20 -2
- package/dist/commands/check-hashes.d.ts +2 -0
- package/dist/commands/check-hashes.js +8 -0
- package/dist/commands/doctor.js +4 -4
- package/dist/commands/regenerate-modules-hashes.d.ts +25 -0
- package/dist/commands/regenerate-modules-hashes.js +58 -0
- package/dist/commands/update-all.d.ts +21 -0
- package/dist/commands/update-all.js +62 -0
- package/dist/utils/manifest.d.ts +1 -0
- package/dist/utils/manifest.js +16 -1
- package/package.json +1 -1
- package/src/__tests__/commands/build-flows.test.ts +55 -0
- package/src/__tests__/commands/check-hashes.test.ts +31 -0
- package/src/__tests__/commands/regenerate-modules-hashes.test.ts +170 -0
- package/src/__tests__/commands/update-all.test.ts +322 -0
- package/src/__tests__/doctor.test.ts +73 -0
- package/src/__tests__/utils/manifest.test.ts +128 -0
- package/src/cli.ts +22 -2
- package/src/commands/check-hashes.ts +12 -0
- package/src/commands/doctor.ts +4 -4
- package/src/commands/regenerate-modules-hashes.ts +89 -0
- package/src/commands/update-all.ts +79 -0
- package/src/utils/manifest.ts +16 -1
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { rmSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
vi.mock('../../commands/build.js', () => ({
|
|
8
|
+
runBuild: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('../../commands/build-catalog.js', () => ({
|
|
12
|
+
runBuildCatalog: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('../../commands/build-flows.js', () => ({
|
|
16
|
+
runBuildFlows: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('../../commands/build-ui.js', () => ({
|
|
20
|
+
runBuildUi: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock('../../commands/regenerate-modules-hashes.js', () => ({
|
|
24
|
+
runRegenerateModulesHashes: vi.fn(),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock('../../commands/doctor.js', () => ({
|
|
28
|
+
runDoctor: vi.fn(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock('../../utils/scaffold-config.js', () => ({
|
|
32
|
+
readScaffoldConfig: vi.fn(),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import { runUpdateAll } from '../../commands/update-all.js';
|
|
36
|
+
import { runBuild } from '../../commands/build.js';
|
|
37
|
+
import { runBuildCatalog } from '../../commands/build-catalog.js';
|
|
38
|
+
import { runBuildFlows } from '../../commands/build-flows.js';
|
|
39
|
+
import { runBuildUi } from '../../commands/build-ui.js';
|
|
40
|
+
import { runRegenerateModulesHashes } from '../../commands/regenerate-modules-hashes.js';
|
|
41
|
+
import { runDoctor } from '../../commands/doctor.js';
|
|
42
|
+
import { readScaffoldConfig } from '../../utils/scaffold-config.js';
|
|
43
|
+
|
|
44
|
+
const mockRunBuild = vi.mocked(runBuild);
|
|
45
|
+
const mockRunBuildCatalog = vi.mocked(runBuildCatalog);
|
|
46
|
+
const mockRunBuildFlows = vi.mocked(runBuildFlows);
|
|
47
|
+
const mockRunBuildUi = vi.mocked(runBuildUi);
|
|
48
|
+
const mockRunRegenerateModulesHashes = vi.mocked(runRegenerateModulesHashes);
|
|
49
|
+
const mockRunDoctor = vi.mocked(runDoctor);
|
|
50
|
+
const mockReadScaffoldConfig = vi.mocked(readScaffoldConfig);
|
|
51
|
+
|
|
52
|
+
/** Build a minimal but valid DoctorReport. */
|
|
53
|
+
function makeDoctorReport(pass: boolean) {
|
|
54
|
+
return {
|
|
55
|
+
checks: [
|
|
56
|
+
{
|
|
57
|
+
name: 'sample-check',
|
|
58
|
+
status: pass ? 'PASS' : 'FAIL',
|
|
59
|
+
message: pass ? 'all good' : 'something failed',
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
exitCode: pass ? 0 : 1,
|
|
63
|
+
pass,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Build a minimal but valid ScaffoldConfig with the given domainIds. */
|
|
68
|
+
function makeScaffoldConfig(domainIds: string[]) {
|
|
69
|
+
return {
|
|
70
|
+
scaffoldVersion: '1.0.0',
|
|
71
|
+
enabledModules: ['core'],
|
|
72
|
+
projectName: 'test-project',
|
|
73
|
+
awsRegion: 'eu-north-1',
|
|
74
|
+
awsAccountId: '123456789012',
|
|
75
|
+
domainIds,
|
|
76
|
+
flowIds: [],
|
|
77
|
+
scaffoldBucket: 'mc-scaffold',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('update-all command', () => {
|
|
82
|
+
let tempDir: string;
|
|
83
|
+
|
|
84
|
+
beforeEach(async () => {
|
|
85
|
+
tempDir = await mkdtemp(join(tmpdir(), 'tib-update-all-'));
|
|
86
|
+
vi.clearAllMocks();
|
|
87
|
+
|
|
88
|
+
// Default mocks so each test only needs to override what's relevant.
|
|
89
|
+
mockRunBuild.mockResolvedValue('/tmp/.mc/domain-registry.json');
|
|
90
|
+
mockRunBuildCatalog.mockResolvedValue({
|
|
91
|
+
version: 2,
|
|
92
|
+
generatedAt: new Date().toISOString(),
|
|
93
|
+
domains: [],
|
|
94
|
+
actions: [],
|
|
95
|
+
events: [],
|
|
96
|
+
subscribers: [],
|
|
97
|
+
jobs: [],
|
|
98
|
+
schedules: [],
|
|
99
|
+
integrations: [],
|
|
100
|
+
});
|
|
101
|
+
mockRunBuildFlows.mockResolvedValue('/tmp/.mc/flows-registry.json');
|
|
102
|
+
mockRunBuildUi.mockResolvedValue(undefined);
|
|
103
|
+
mockRunRegenerateModulesHashes.mockResolvedValue('/tmp/.mc/modules-hashes.json');
|
|
104
|
+
mockRunDoctor.mockResolvedValue(makeDoctorReport(true));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('reads scaffold-config from the project root', async () => {
|
|
112
|
+
mockReadScaffoldConfig.mockResolvedValue(makeScaffoldConfig([]));
|
|
113
|
+
|
|
114
|
+
await runUpdateAll({ projectRoot: tempDir });
|
|
115
|
+
|
|
116
|
+
expect(mockReadScaffoldConfig).toHaveBeenCalledTimes(1);
|
|
117
|
+
expect(mockReadScaffoldConfig).toHaveBeenCalledWith(tempDir);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('builds all domains from scaffold-config', async () => {
|
|
121
|
+
mockReadScaffoldConfig.mockResolvedValue(
|
|
122
|
+
makeScaffoldConfig(['auth', 'data-management', 'orgs']),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await runUpdateAll({ projectRoot: tempDir });
|
|
126
|
+
|
|
127
|
+
expect(mockRunBuild).toHaveBeenCalledTimes(3);
|
|
128
|
+
expect(mockRunBuild).toHaveBeenCalledWith({
|
|
129
|
+
domainRoot: join(tempDir, 'domains', 'auth'),
|
|
130
|
+
});
|
|
131
|
+
expect(mockRunBuild).toHaveBeenCalledWith({
|
|
132
|
+
domainRoot: join(tempDir, 'domains', 'data-management'),
|
|
133
|
+
});
|
|
134
|
+
expect(mockRunBuild).toHaveBeenCalledWith({
|
|
135
|
+
domainRoot: join(tempDir, 'domains', 'orgs'),
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('runs build-catalog after all domains are built', async () => {
|
|
140
|
+
mockReadScaffoldConfig.mockResolvedValue(makeScaffoldConfig(['auth', 'orgs']));
|
|
141
|
+
|
|
142
|
+
await runUpdateAll({ projectRoot: tempDir });
|
|
143
|
+
|
|
144
|
+
expect(mockRunBuildCatalog).toHaveBeenCalledTimes(1);
|
|
145
|
+
expect(mockRunBuildCatalog).toHaveBeenCalledWith(join(tempDir, '.mc'));
|
|
146
|
+
|
|
147
|
+
// build-catalog must come after every runBuild call.
|
|
148
|
+
const catalogOrder = mockRunBuildCatalog.mock.invocationCallOrder[0]!;
|
|
149
|
+
for (const order of mockRunBuild.mock.invocationCallOrder) {
|
|
150
|
+
expect(order).toBeLessThan(catalogOrder);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('runs build-flows after build-catalog', async () => {
|
|
155
|
+
mockReadScaffoldConfig.mockResolvedValue(makeScaffoldConfig(['auth']));
|
|
156
|
+
|
|
157
|
+
await runUpdateAll({ projectRoot: tempDir });
|
|
158
|
+
|
|
159
|
+
expect(mockRunBuildFlows).toHaveBeenCalledTimes(1);
|
|
160
|
+
expect(mockRunBuildFlows).toHaveBeenCalledWith({ projectRoot: tempDir });
|
|
161
|
+
|
|
162
|
+
const flowsOrder = mockRunBuildFlows.mock.invocationCallOrder[0]!;
|
|
163
|
+
const catalogOrder = mockRunBuildCatalog.mock.invocationCallOrder[0]!;
|
|
164
|
+
expect(catalogOrder).toBeLessThan(flowsOrder);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('runs build-ui after build-flows', async () => {
|
|
168
|
+
mockReadScaffoldConfig.mockResolvedValue(makeScaffoldConfig(['auth']));
|
|
169
|
+
|
|
170
|
+
await runUpdateAll({ projectRoot: tempDir });
|
|
171
|
+
|
|
172
|
+
expect(mockRunBuildUi).toHaveBeenCalledTimes(1);
|
|
173
|
+
expect(mockRunBuildUi).toHaveBeenCalledWith({ projectRoot: tempDir });
|
|
174
|
+
|
|
175
|
+
const uiOrder = mockRunBuildUi.mock.invocationCallOrder[0]!;
|
|
176
|
+
const flowsOrder = mockRunBuildFlows.mock.invocationCallOrder[0]!;
|
|
177
|
+
expect(flowsOrder).toBeLessThan(uiOrder);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('runs regenerate-modules-hashes after build-ui', async () => {
|
|
181
|
+
mockReadScaffoldConfig.mockResolvedValue(makeScaffoldConfig(['auth']));
|
|
182
|
+
|
|
183
|
+
await runUpdateAll({ projectRoot: tempDir });
|
|
184
|
+
|
|
185
|
+
expect(mockRunRegenerateModulesHashes).toHaveBeenCalledTimes(1);
|
|
186
|
+
expect(mockRunRegenerateModulesHashes).toHaveBeenCalledWith({
|
|
187
|
+
projectRoot: tempDir,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const hashesOrder = mockRunRegenerateModulesHashes.mock.invocationCallOrder[0]!;
|
|
191
|
+
const uiOrder = mockRunBuildUi.mock.invocationCallOrder[0]!;
|
|
192
|
+
expect(uiOrder).toBeLessThan(hashesOrder);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('runs doctor as the final step and returns success when doctor passes', async () => {
|
|
196
|
+
mockReadScaffoldConfig.mockResolvedValue(makeScaffoldConfig(['auth']));
|
|
197
|
+
mockRunDoctor.mockResolvedValue(makeDoctorReport(true));
|
|
198
|
+
|
|
199
|
+
const result = await runUpdateAll({ projectRoot: tempDir });
|
|
200
|
+
|
|
201
|
+
expect(mockRunDoctor).toHaveBeenCalledTimes(1);
|
|
202
|
+
expect(mockRunDoctor).toHaveBeenCalledWith({ projectRoot: tempDir });
|
|
203
|
+
|
|
204
|
+
// doctor must be the last command invoked.
|
|
205
|
+
const doctorOrder = mockRunDoctor.mock.invocationCallOrder[0]!;
|
|
206
|
+
for (const call of [
|
|
207
|
+
mockRunBuild,
|
|
208
|
+
mockRunBuildCatalog,
|
|
209
|
+
mockRunBuildFlows,
|
|
210
|
+
mockRunBuildUi,
|
|
211
|
+
mockRunRegenerateModulesHashes,
|
|
212
|
+
]) {
|
|
213
|
+
const order = call.mock.invocationCallOrder[0];
|
|
214
|
+
if (order !== undefined) {
|
|
215
|
+
expect(order).toBeLessThan(doctorOrder);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
expect(result.success).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('returns success: false when doctor fails', async () => {
|
|
223
|
+
mockReadScaffoldConfig.mockResolvedValue(makeScaffoldConfig(['auth']));
|
|
224
|
+
mockRunDoctor.mockResolvedValue(makeDoctorReport(false));
|
|
225
|
+
|
|
226
|
+
const result = await runUpdateAll({ projectRoot: tempDir });
|
|
227
|
+
|
|
228
|
+
expect(result.success).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('continues building remaining domains after one domain build fails', async () => {
|
|
232
|
+
mockReadScaffoldConfig.mockResolvedValue(
|
|
233
|
+
makeScaffoldConfig(['auth', 'broken', 'orgs']),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// The middle domain throws; the others should still be built.
|
|
237
|
+
mockRunBuild.mockImplementation(async (options: { domainRoot: string }) => {
|
|
238
|
+
if (options.domainRoot.endsWith('broken')) {
|
|
239
|
+
throw new Error('synthetic build failure');
|
|
240
|
+
}
|
|
241
|
+
return '/tmp/.mc/domain-registry.json';
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const result = await runUpdateAll({ projectRoot: tempDir });
|
|
245
|
+
|
|
246
|
+
// All three domains are attempted even though one throws.
|
|
247
|
+
expect(mockRunBuild).toHaveBeenCalledTimes(3);
|
|
248
|
+
expect(mockRunBuild).toHaveBeenCalledWith({
|
|
249
|
+
domainRoot: join(tempDir, 'domains', 'auth'),
|
|
250
|
+
});
|
|
251
|
+
expect(mockRunBuild).toHaveBeenCalledWith({
|
|
252
|
+
domainRoot: join(tempDir, 'domains', 'broken'),
|
|
253
|
+
});
|
|
254
|
+
expect(mockRunBuild).toHaveBeenCalledWith({
|
|
255
|
+
domainRoot: join(tempDir, 'domains', 'orgs'),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// The remaining pipeline still runs to completion.
|
|
259
|
+
expect(mockRunBuildCatalog).toHaveBeenCalledTimes(1);
|
|
260
|
+
expect(mockRunBuildFlows).toHaveBeenCalledTimes(1);
|
|
261
|
+
expect(mockRunBuildUi).toHaveBeenCalledTimes(1);
|
|
262
|
+
expect(mockRunRegenerateModulesHashes).toHaveBeenCalledTimes(1);
|
|
263
|
+
expect(mockRunDoctor).toHaveBeenCalledTimes(1);
|
|
264
|
+
expect(result.success).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('propagates upstream failures (catalog/flows/ui/hashes) instead of swallowing them', async () => {
|
|
268
|
+
mockReadScaffoldConfig.mockResolvedValue(makeScaffoldConfig(['auth']));
|
|
269
|
+
mockRunBuildCatalog.mockRejectedValue(new Error('catalog boom'));
|
|
270
|
+
|
|
271
|
+
await expect(runUpdateAll({ projectRoot: tempDir })).rejects.toThrow(
|
|
272
|
+
/catalog boom/,
|
|
273
|
+
);
|
|
274
|
+
// Pipeline stops before later stages.
|
|
275
|
+
expect(mockRunBuildFlows).not.toHaveBeenCalled();
|
|
276
|
+
expect(mockRunBuildUi).not.toHaveBeenCalled();
|
|
277
|
+
expect(mockRunRegenerateModulesHashes).not.toHaveBeenCalled();
|
|
278
|
+
expect(mockRunDoctor).not.toHaveBeenCalled();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('handles empty domainIds gracefully', async () => {
|
|
282
|
+
mockReadScaffoldConfig.mockResolvedValue(makeScaffoldConfig([]));
|
|
283
|
+
|
|
284
|
+
const result = await runUpdateAll({ projectRoot: tempDir });
|
|
285
|
+
|
|
286
|
+
// No per-domain builds run.
|
|
287
|
+
expect(mockRunBuild).not.toHaveBeenCalled();
|
|
288
|
+
|
|
289
|
+
// Downstream pipeline still completes.
|
|
290
|
+
expect(mockRunBuildCatalog).toHaveBeenCalledTimes(1);
|
|
291
|
+
expect(mockRunBuildFlows).toHaveBeenCalledTimes(1);
|
|
292
|
+
expect(mockRunBuildUi).toHaveBeenCalledTimes(1);
|
|
293
|
+
expect(mockRunRegenerateModulesHashes).toHaveBeenCalledTimes(1);
|
|
294
|
+
expect(mockRunDoctor).toHaveBeenCalledTimes(1);
|
|
295
|
+
|
|
296
|
+
expect(result.success).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('returns a structured summary that includes the domain count and doctor result', async () => {
|
|
300
|
+
mockReadScaffoldConfig.mockResolvedValue(
|
|
301
|
+
makeScaffoldConfig(['auth', 'data-management', 'orgs']),
|
|
302
|
+
);
|
|
303
|
+
mockRunDoctor.mockResolvedValue(makeDoctorReport(true));
|
|
304
|
+
|
|
305
|
+
const result = await runUpdateAll({ projectRoot: tempDir });
|
|
306
|
+
|
|
307
|
+
expect(result).toEqual({
|
|
308
|
+
success: true,
|
|
309
|
+
summary: 'Updated 3 domains; doctor PASS',
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('reports zero domains in the summary when scaffold-config has none', async () => {
|
|
314
|
+
mockReadScaffoldConfig.mockResolvedValue(makeScaffoldConfig([]));
|
|
315
|
+
mockRunDoctor.mockResolvedValue(makeDoctorReport(false));
|
|
316
|
+
|
|
317
|
+
const result = await runUpdateAll({ projectRoot: tempDir });
|
|
318
|
+
|
|
319
|
+
expect(result.summary).toBe('Updated 0 domains; doctor FAIL');
|
|
320
|
+
expect(result.success).toBe(false);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -621,4 +621,77 @@ describe('runDoctor', () => {
|
|
|
621
621
|
expect(reportPlain.exitCode).toBe(1);
|
|
622
622
|
});
|
|
623
623
|
});
|
|
624
|
+
|
|
625
|
+
describe('scaffold-config.json missing and root-level flows', () => {
|
|
626
|
+
it('reports WARN when scaffold-config.json is missing (friendly skip, not FAIL)', async () => {
|
|
627
|
+
// Set up minimal project but omit .mc/scaffold-config.json.
|
|
628
|
+
await mkdir(join(tempDir, '.mc'), { recursive: true });
|
|
629
|
+
await mkdir(join(tempDir, 'domains'), { recursive: true });
|
|
630
|
+
await mkdir(join(tempDir, '.husky'), { recursive: true });
|
|
631
|
+
await writeFile(join(tempDir, '.husky', 'pre-commit'), '#!/bin/sh');
|
|
632
|
+
|
|
633
|
+
const report = await runDoctor({ projectRoot: tempDir });
|
|
634
|
+
|
|
635
|
+
const check = report.checks.find(c => c.name === 'scaffold-config.json matches on-disk');
|
|
636
|
+
expect(check).toBeDefined();
|
|
637
|
+
expect(check?.status).toBe('WARN');
|
|
638
|
+
expect(check?.status).not.toBe('FAIL');
|
|
639
|
+
// Doctor exits 0 — WARNs are non-fatal.
|
|
640
|
+
// (Other checks may FAIL on this minimal scaffold, so we don't assert
|
|
641
|
+
// exitCode === 0 — we only assert that THIS check is a WARN, not FAIL.)
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('reports FAIL when root-level flows/ contains .ts files', async () => {
|
|
645
|
+
// Create root-level flows/ with a .ts file (and a .json file for
|
|
646
|
+
// completeness — both should be detected).
|
|
647
|
+
await mkdir(join(tempDir, '.mc'), { recursive: true });
|
|
648
|
+
await mkdir(join(tempDir, 'domains'), { recursive: true });
|
|
649
|
+
await mkdir(join(tempDir, '.husky'), { recursive: true });
|
|
650
|
+
await writeFile(
|
|
651
|
+
join(tempDir, '.mc', 'scaffold-config.json'),
|
|
652
|
+
JSON.stringify({ domainIds: [] })
|
|
653
|
+
);
|
|
654
|
+
await writeFile(join(tempDir, '.husky', 'pre-commit'), '#!/bin/sh');
|
|
655
|
+
|
|
656
|
+
const flowsDir = join(tempDir, 'flows');
|
|
657
|
+
await mkdir(flowsDir, { recursive: true });
|
|
658
|
+
// Minimal but valid TS file. Content is irrelevant — the check just
|
|
659
|
+
// looks for the file extension.
|
|
660
|
+
await writeFile(
|
|
661
|
+
join(flowsDir, 'legacy-flow.ts'),
|
|
662
|
+
'export const legacy = {};\n'
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
const report = await runDoctor({ projectRoot: tempDir });
|
|
666
|
+
|
|
667
|
+
const check = report.checks.find(c => c.name === 'No root-level flows (deprecated)');
|
|
668
|
+
expect(check).toBeDefined();
|
|
669
|
+
expect(check?.status).toBe('FAIL');
|
|
670
|
+
expect(check?.message).toMatch(/legacy-flow\.ts/);
|
|
671
|
+
expect(report.exitCode).toBe(1);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it('reports PASS when no root-level flows/ directory exists', async () => {
|
|
675
|
+
// Default project — no flows/ at the root.
|
|
676
|
+
await mkdir(join(tempDir, '.mc'), { recursive: true });
|
|
677
|
+
await mkdir(join(tempDir, 'domains'), { recursive: true });
|
|
678
|
+
await mkdir(join(tempDir, '.husky'), { recursive: true });
|
|
679
|
+
await writeFile(
|
|
680
|
+
join(tempDir, '.mc', 'scaffold-config.json'),
|
|
681
|
+
JSON.stringify({ domainIds: [] })
|
|
682
|
+
);
|
|
683
|
+
await writeFile(join(tempDir, '.husky', 'pre-commit'), '#!/bin/sh');
|
|
684
|
+
|
|
685
|
+
// Do NOT create flows/.
|
|
686
|
+
const flowsDir = join(tempDir, 'flows');
|
|
687
|
+
const { existsSync } = await import('node:fs');
|
|
688
|
+
expect(existsSync(flowsDir)).toBe(false);
|
|
689
|
+
|
|
690
|
+
const report = await runDoctor({ projectRoot: tempDir });
|
|
691
|
+
|
|
692
|
+
const check = report.checks.find(c => c.name === 'No root-level flows (deprecated)');
|
|
693
|
+
expect(check).toBeDefined();
|
|
694
|
+
expect(check?.status).toBe('PASS');
|
|
695
|
+
});
|
|
696
|
+
});
|
|
624
697
|
});
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
upsertManifestFile,
|
|
20
20
|
inferPolicyFromPath,
|
|
21
21
|
getManifestFile,
|
|
22
|
+
pruneManifest,
|
|
22
23
|
type ManifestFileEntry,
|
|
23
24
|
type TibManifest,
|
|
24
25
|
} from '../../utils/manifest.js';
|
|
@@ -271,4 +272,131 @@ describe('manifest utilities', () => {
|
|
|
271
272
|
await rm(tmpDir, { recursive: true, force: true });
|
|
272
273
|
});
|
|
273
274
|
});
|
|
275
|
+
|
|
276
|
+
describe('pruneManifest', () => {
|
|
277
|
+
it('removes entries for files that do not exist on disk', async () => {
|
|
278
|
+
const manifest = createManifest('1.0.0', 'test-project', 'eu-north-1', ['core']);
|
|
279
|
+
// Two entries: one file exists, one does not.
|
|
280
|
+
const existingPath = 'infra/main.tf';
|
|
281
|
+
const missingPath = 'infra/deleted.ts';
|
|
282
|
+
await mkdir(join(tmpDir, 'infra'), { recursive: true });
|
|
283
|
+
await writeFile(join(tmpDir, existingPath), 'content');
|
|
284
|
+
|
|
285
|
+
upsertManifestFile(manifest, {
|
|
286
|
+
path: existingPath,
|
|
287
|
+
module: 'core',
|
|
288
|
+
moduleVersion: '1.0.0',
|
|
289
|
+
sha256: 'abc',
|
|
290
|
+
wasTemplate: false,
|
|
291
|
+
installedAt: '2026-01-01T00:00:00Z',
|
|
292
|
+
policy: 'managed',
|
|
293
|
+
});
|
|
294
|
+
upsertManifestFile(manifest, {
|
|
295
|
+
path: missingPath,
|
|
296
|
+
module: 'core',
|
|
297
|
+
moduleVersion: '1.0.0',
|
|
298
|
+
sha256: 'def',
|
|
299
|
+
wasTemplate: false,
|
|
300
|
+
installedAt: '2026-01-01T00:00:00Z',
|
|
301
|
+
policy: 'managed',
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const pruned = await pruneManifest(tmpDir, manifest);
|
|
305
|
+
|
|
306
|
+
expect(pruned).toBe(1);
|
|
307
|
+
expect(manifest.files).toHaveLength(1);
|
|
308
|
+
expect(manifest.files[0]?.path).toBe(existingPath);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('keeps entries for files that exist on disk', async () => {
|
|
312
|
+
const manifest = createManifest('1.0.0', 'test-project', 'eu-north-1', ['core']);
|
|
313
|
+
const filePath = 'infra/modules/app.ts';
|
|
314
|
+
await mkdir(join(tmpDir, 'infra', 'modules'), { recursive: true });
|
|
315
|
+
await writeFile(join(tmpDir, filePath), '// app');
|
|
316
|
+
|
|
317
|
+
upsertManifestFile(manifest, {
|
|
318
|
+
path: filePath,
|
|
319
|
+
module: 'core',
|
|
320
|
+
moduleVersion: '1.0.0',
|
|
321
|
+
sha256: 'ghi',
|
|
322
|
+
wasTemplate: true,
|
|
323
|
+
installedAt: '2026-01-01T00:00:00Z',
|
|
324
|
+
policy: 'managed',
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const pruned = await pruneManifest(tmpDir, manifest);
|
|
328
|
+
|
|
329
|
+
expect(pruned).toBe(0);
|
|
330
|
+
expect(manifest.files).toHaveLength(1);
|
|
331
|
+
expect(manifest.files[0]?.path).toBe(filePath);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('returns count of pruned entries', async () => {
|
|
335
|
+
const manifest = createManifest('1.0.0', 'test-project', 'eu-north-1', ['core']);
|
|
336
|
+
// Three entries, all missing on disk.
|
|
337
|
+
const paths = ['a/missing1.ts', 'b/missing2.ts', 'c/missing3.ts'];
|
|
338
|
+
for (const path of paths) {
|
|
339
|
+
upsertManifestFile(manifest, {
|
|
340
|
+
path,
|
|
341
|
+
module: 'core',
|
|
342
|
+
moduleVersion: '1.0.0',
|
|
343
|
+
sha256: 'x',
|
|
344
|
+
wasTemplate: false,
|
|
345
|
+
installedAt: '2026-01-01T00:00:00Z',
|
|
346
|
+
policy: 'managed',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const pruned = await pruneManifest(tmpDir, manifest);
|
|
351
|
+
|
|
352
|
+
expect(pruned).toBe(3);
|
|
353
|
+
expect(manifest.files).toHaveLength(0);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('handles empty manifest.files array', async () => {
|
|
357
|
+
const manifest = createManifest('1.0.0', 'test-project', 'eu-north-1', ['core']);
|
|
358
|
+
// manifest.files is [] by default.
|
|
359
|
+
|
|
360
|
+
const pruned = await pruneManifest(tmpDir, manifest);
|
|
361
|
+
|
|
362
|
+
expect(pruned).toBe(0);
|
|
363
|
+
expect(manifest.files).toEqual([]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('mutates manifest.files in place', async () => {
|
|
367
|
+
const manifest = createManifest('1.0.0', 'test-project', 'eu-north-1', ['core']);
|
|
368
|
+
const survivor = 'frontend/src/pages/auth/login.tsx';
|
|
369
|
+
await mkdir(join(tmpDir, 'frontend', 'src', 'pages', 'auth'), { recursive: true });
|
|
370
|
+
await writeFile(join(tmpDir, survivor), '// login');
|
|
371
|
+
|
|
372
|
+
upsertManifestFile(manifest, {
|
|
373
|
+
path: survivor,
|
|
374
|
+
module: 'frontend',
|
|
375
|
+
moduleVersion: '1.0.0',
|
|
376
|
+
sha256: 'kept',
|
|
377
|
+
wasTemplate: true,
|
|
378
|
+
installedAt: '2026-01-01T00:00:00Z',
|
|
379
|
+
policy: 'seed',
|
|
380
|
+
});
|
|
381
|
+
upsertManifestFile(manifest, {
|
|
382
|
+
path: 'frontend/src/pages/gone.tsx',
|
|
383
|
+
module: 'frontend',
|
|
384
|
+
moduleVersion: '1.0.0',
|
|
385
|
+
sha256: 'gone',
|
|
386
|
+
wasTemplate: true,
|
|
387
|
+
installedAt: '2026-01-01T00:00:00Z',
|
|
388
|
+
policy: 'seed',
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const initialLength = manifest.files.length;
|
|
392
|
+
const pruned = await pruneManifest(tmpDir, manifest);
|
|
393
|
+
|
|
394
|
+
// Mutation is observable through the same manifest reference — caller
|
|
395
|
+
// does not need a return value to update its own copy.
|
|
396
|
+
expect(manifest.files).not.toHaveLength(initialLength);
|
|
397
|
+
expect(manifest.files).toHaveLength(1);
|
|
398
|
+
expect(manifest.files[0]?.path).toBe(survivor);
|
|
399
|
+
expect(pruned).toBe(1);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
274
402
|
});
|
package/src/cli.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { runAddModule } from './commands/add-module.js';
|
|
|
13
13
|
import { runBuildFlows } from './commands/build-flows.js';
|
|
14
14
|
import { runDoctor } from './commands/doctor.js';
|
|
15
15
|
import { runCheckHashes } from './commands/check-hashes.js';
|
|
16
|
+
import { runUpdateAll } from './commands/update-all.js';
|
|
17
|
+
import { runRegenerateModulesHashes } from './commands/regenerate-modules-hashes.js';
|
|
16
18
|
import { runUpgradeBackend } from './commands/upgrade-backend.js';
|
|
17
19
|
import { runAddPage } from './commands/add-page.js';
|
|
18
20
|
import { runCreateProject } from './commands/create-project.js';
|
|
@@ -195,11 +197,29 @@ program
|
|
|
195
197
|
program
|
|
196
198
|
.command('check-hashes')
|
|
197
199
|
.description('Verify infra/modules/ has not been hand-edited since last scaffold')
|
|
198
|
-
.
|
|
199
|
-
|
|
200
|
+
.option('--write', 'Regenerate modules-hashes.json from current disk state instead of verifying')
|
|
201
|
+
.action(async (opts: { write?: boolean }) => {
|
|
202
|
+
const result = await runCheckHashes({ write: opts.write });
|
|
200
203
|
process.exit(result.ok ? 0 : 1);
|
|
201
204
|
});
|
|
202
205
|
|
|
206
|
+
program
|
|
207
|
+
.command('update-all')
|
|
208
|
+
.description('Full refresh: build all domains, build catalog, build flows, build UI, regenerate hashes, run doctor')
|
|
209
|
+
.option('--project-root <path>', 'Root of the project (defaults to cwd)')
|
|
210
|
+
.action(async (opts: { projectRoot?: string }) => {
|
|
211
|
+
const result = await runUpdateAll({ projectRoot: opts.projectRoot });
|
|
212
|
+
process.exit(result.success ? 0 : 1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
program
|
|
216
|
+
.command('regenerate-modules-hashes')
|
|
217
|
+
.description('Walk infra/modules/, compute SHA256 hashes, and write .mc/modules-hashes.json')
|
|
218
|
+
.option('--project-root <path>', 'Root of the project (defaults to cwd)')
|
|
219
|
+
.action(async (opts: { projectRoot?: string }) => {
|
|
220
|
+
await runRegenerateModulesHashes({ projectRoot: opts.projectRoot });
|
|
221
|
+
});
|
|
222
|
+
|
|
203
223
|
program
|
|
204
224
|
.command('upgrade-backend <target-major>')
|
|
205
225
|
.description('Run jscodeshift/ts-morph migrations between major versions of domain-runtime')
|
|
@@ -9,6 +9,8 @@ import { cliLogger } from '../utils/logger.js';
|
|
|
9
9
|
export interface CheckHashesOptions {
|
|
10
10
|
/** Root directory of the project (defaults to cwd). */
|
|
11
11
|
projectRoot?: string;
|
|
12
|
+
/** If true, regenerate .mc/modules-hashes.json from current infra/modules/ before checking. */
|
|
13
|
+
write?: boolean;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
/**
|
|
@@ -57,6 +59,16 @@ interface ModulesHashesManifest {
|
|
|
57
59
|
*/
|
|
58
60
|
export async function runCheckHashes(opts: CheckHashesOptions = {}): Promise<CheckHashesResult> {
|
|
59
61
|
const root = opts.projectRoot ?? process.cwd();
|
|
62
|
+
|
|
63
|
+
// --write bootstraps the manifest from current infra/modules/ before verification.
|
|
64
|
+
// Must run before the manifest read below so it can create a missing manifest.
|
|
65
|
+
if (opts.write) {
|
|
66
|
+
const { runRegenerateModulesHashes } = await import('./regenerate-modules-hashes.js');
|
|
67
|
+
const outPath = await runRegenerateModulesHashes({ projectRoot: root });
|
|
68
|
+
cliLogger.info({ outPath }, 'modules-hashes.json regenerated');
|
|
69
|
+
return { ok: true, drifted: [], missing: [], unexpected: [] };
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
const manifestPath = join(root, '.mc', 'modules-hashes.json');
|
|
61
73
|
const modulesDir = join(root, 'infra', 'modules');
|
|
62
74
|
|
package/src/commands/doctor.ts
CHANGED
|
@@ -613,9 +613,9 @@ async function checkScaffoldConfigMatchesDisk(projectRoot: string): Promise<Doct
|
|
|
613
613
|
} catch {
|
|
614
614
|
return {
|
|
615
615
|
name: 'scaffold-config.json matches on-disk',
|
|
616
|
-
status: '
|
|
617
|
-
message: '
|
|
618
|
-
fixHint: '
|
|
616
|
+
status: 'WARN',
|
|
617
|
+
message: '.mc/scaffold-config.json not found or unreadable — skipping domain list sync check (expected on fresh clones before first scaffold)',
|
|
618
|
+
fixHint: 'Run mc-domain-module update-all to regenerate scaffold metadata',
|
|
619
619
|
kNodeRef: 'K:runbook:add-domain',
|
|
620
620
|
};
|
|
621
621
|
}
|
|
@@ -728,7 +728,7 @@ async function checkRootLevelFlows(projectRoot: string): Promise<DoctorCheck> {
|
|
|
728
728
|
}
|
|
729
729
|
return {
|
|
730
730
|
name: 'No root-level flows (deprecated)',
|
|
731
|
-
status: '
|
|
731
|
+
status: 'FAIL',
|
|
732
732
|
message: `${flowFiles.length} flow(s) still in root-level flows/: ${flowFiles.join(', ')}`,
|
|
733
733
|
fixHint: 'Move these flows to domains/{owningDomain}/flows/ and delete the root-level copies.',
|
|
734
734
|
kNodeRef: 'K:convention:flow-vs-subscriber-rule',
|