@mettlecast/domain-cli 0.2.60 → 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.
Files changed (107) hide show
  1. package/dist/builder/build-registry.d.ts +1 -1
  2. package/dist/builder/build-registry.js +1 -36
  3. package/dist/builder/build-types.d.ts +1 -1
  4. package/dist/builder/load-module.d.ts +1 -1
  5. package/dist/cli.js +21 -3
  6. package/dist/commands/add-api.js +2 -2
  7. package/dist/commands/add-domain.js +4 -4
  8. package/dist/commands/add-fixture-factory.js +5 -6
  9. package/dist/commands/build-catalog.d.ts +6 -22
  10. package/dist/commands/build-catalog.js +7 -18
  11. package/dist/commands/build.js +2 -1
  12. package/dist/commands/check-hashes.d.ts +2 -0
  13. package/dist/commands/check-hashes.js +8 -0
  14. package/dist/commands/dev.js +1 -1
  15. package/dist/commands/doctor.js +70 -41
  16. package/dist/commands/explain.js +13 -13
  17. package/dist/commands/generate-openapi.d.ts +10 -1
  18. package/dist/commands/generate-openapi.js +19 -33
  19. package/dist/commands/regenerate-modules-hashes.d.ts +25 -0
  20. package/dist/commands/regenerate-modules-hashes.js +58 -0
  21. package/dist/commands/show.d.ts +2 -3
  22. package/dist/commands/show.js +0 -2
  23. package/dist/commands/test.js +0 -1
  24. package/dist/commands/update-all.d.ts +21 -0
  25. package/dist/commands/update-all.js +62 -0
  26. package/dist/commands/upgrade-backend.js +3 -3
  27. package/dist/commands/validate.js +12 -90
  28. package/dist/server/api-server.d.ts +1 -1
  29. package/dist/server/mount-routes.d.ts +11 -2
  30. package/dist/server/mount-routes.js +20 -8
  31. package/dist/templates/api-skeleton.d.ts +5 -0
  32. package/dist/templates/api-skeleton.js +28 -27
  33. package/dist/templates/claude-md.js +1 -1
  34. package/dist/templates/patterns/api/create-with-event.d.ts +4 -0
  35. package/dist/templates/patterns/api/create-with-event.js +38 -32
  36. package/dist/templates/patterns/api/idempotent-mutation.d.ts +4 -0
  37. package/dist/templates/patterns/api/idempotent-mutation.js +47 -41
  38. package/dist/templates/patterns/api/paginated-list.d.ts +4 -0
  39. package/dist/templates/patterns/api/paginated-list.js +30 -24
  40. package/dist/templates/patterns/api/simple-crud.d.ts +4 -0
  41. package/dist/templates/patterns/api/simple-crud.js +46 -35
  42. package/dist/templates/patterns/api/streaming-list.d.ts +4 -0
  43. package/dist/templates/patterns/api/streaming-list.js +46 -41
  44. package/dist/templates/patterns/api/system-admin.d.ts +4 -0
  45. package/dist/templates/patterns/api/system-admin.js +59 -52
  46. package/dist/templates/patterns/api/webhook-receiver-style.d.ts +4 -0
  47. package/dist/templates/patterns/api/webhook-receiver-style.js +43 -35
  48. package/dist/types.d.ts +100 -0
  49. package/dist/types.js +1 -0
  50. package/dist/utils/file-helpers.d.ts +0 -2
  51. package/dist/utils/file-helpers.js +2 -3
  52. package/dist/utils/manifest.d.ts +1 -0
  53. package/dist/utils/manifest.js +16 -1
  54. package/dist/utils/scaffold-config.d.ts +6 -0
  55. package/dist/utils/scaffold-config.js +2 -0
  56. package/package.json +1 -1
  57. package/src/__tests__/build-registry.test.ts +43 -20
  58. package/src/__tests__/build-types.test.ts +4 -7
  59. package/src/__tests__/builder/walkDomainDir.test.ts +19 -21
  60. package/src/__tests__/commands/add-api.test.ts +12 -10
  61. package/src/__tests__/commands/add-domain.test.ts +8 -5
  62. package/src/__tests__/commands/build-flows.test.ts +55 -0
  63. package/src/__tests__/commands/check-hashes.test.ts +31 -0
  64. package/src/__tests__/commands/create-project.test.ts +5 -5
  65. package/src/__tests__/commands/dev.test.ts +0 -1
  66. package/src/__tests__/commands/regenerate-modules-hashes.test.ts +170 -0
  67. package/src/__tests__/commands/update-all.test.ts +322 -0
  68. package/src/__tests__/doctor.test.ts +73 -0
  69. package/src/__tests__/mount-routes.test.ts +64 -23
  70. package/src/__tests__/package-freshness.test.ts +1 -21
  71. package/src/__tests__/smoke/scaffold.test.ts +13 -15
  72. package/src/__tests__/utils/manifest.test.ts +128 -0
  73. package/src/__tests__/validate.test.ts +21 -103
  74. package/src/builder/build-registry.ts +7 -44
  75. package/src/builder/build-types.ts +1 -1
  76. package/src/cli.ts +23 -3
  77. package/src/commands/add-api.ts +2 -2
  78. package/src/commands/add-domain.ts +4 -4
  79. package/src/commands/add-fixture-factory.ts +5 -6
  80. package/src/commands/build-catalog.ts +13 -35
  81. package/src/commands/build.ts +3 -2
  82. package/src/commands/check-hashes.ts +12 -0
  83. package/src/commands/dev.ts +1 -1
  84. package/src/commands/doctor.ts +72 -41
  85. package/src/commands/explain.ts +13 -13
  86. package/src/commands/generate-openapi.ts +30 -52
  87. package/src/commands/regenerate-modules-hashes.ts +89 -0
  88. package/src/commands/show.ts +2 -5
  89. package/src/commands/test.ts +0 -1
  90. package/src/commands/update-all.ts +79 -0
  91. package/src/commands/upgrade-backend.ts +3 -3
  92. package/src/commands/validate.ts +11 -96
  93. package/src/server/api-server.ts +1 -1
  94. package/src/server/mount-routes.ts +21 -10
  95. package/src/templates/api-skeleton.ts +29 -28
  96. package/src/templates/claude-md.ts +1 -1
  97. package/src/templates/patterns/api/create-with-event.ts +39 -33
  98. package/src/templates/patterns/api/idempotent-mutation.ts +48 -42
  99. package/src/templates/patterns/api/paginated-list.ts +31 -25
  100. package/src/templates/patterns/api/simple-crud.ts +47 -36
  101. package/src/templates/patterns/api/streaming-list.ts +47 -42
  102. package/src/templates/patterns/api/system-admin.ts +60 -53
  103. package/src/templates/patterns/api/webhook-receiver-style.ts +48 -40
  104. package/src/types.ts +128 -0
  105. package/src/utils/file-helpers.ts +2 -5
  106. package/src/utils/manifest.ts +16 -1
  107. package/src/utils/scaffold-config.ts +9 -0
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtemp, writeFile, mkdir } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import { readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import { rmSync } from 'node:fs';
8
+ import { runRegenerateModulesHashes } from '../../commands/regenerate-modules-hashes.js';
9
+
10
+ describe('regenerate-modules-hashes', () => {
11
+ let tempDir: string;
12
+
13
+ beforeEach(async () => {
14
+ tempDir = await mkdtemp(join(tmpdir(), 'tib-regenerate-modules-hashes-'));
15
+ });
16
+
17
+ afterEach(() => {
18
+ rmSync(tempDir, { recursive: true, force: true });
19
+ });
20
+
21
+ it('writes modules-hashes.json with correct version and structure', async () => {
22
+ await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
23
+
24
+ const content = 'export const MyStack = {};';
25
+ await writeFile(join(tempDir, 'infra', 'modules', 'test.ts'), content);
26
+
27
+ const manifestPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
28
+
29
+ expect(manifestPath).toBe(join(tempDir, '.mc', 'modules-hashes.json'));
30
+
31
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
32
+ expect(manifest.version).toBe('1');
33
+ expect(typeof manifest.generatedAt).toBe('string');
34
+ expect(new Date(manifest.generatedAt).toISOString()).toBe(manifest.generatedAt);
35
+ expect(manifest.files).toEqual({
36
+ 'infra/modules/test.ts': createHash('sha256').update(content).digest('hex'),
37
+ });
38
+ });
39
+
40
+ it('computes correct SHA-256 hashes for every file', async () => {
41
+ await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
42
+
43
+ const files: Record<string, string> = {
44
+ 'infra/modules/a.ts': 'export const a = 1;',
45
+ 'infra/modules/b.ts': 'export const b = 2;\nexport const c = 3;',
46
+ 'infra/modules/nested/c.ts': 'export const c = "hello world";',
47
+ };
48
+
49
+ await mkdir(join(tempDir, 'infra', 'modules', 'nested'), { recursive: true });
50
+ for (const [rel, content] of Object.entries(files)) {
51
+ await writeFile(join(tempDir, rel), content);
52
+ }
53
+
54
+ const manifestPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
55
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
56
+
57
+ expect(Object.keys(manifest.files).sort()).toEqual(Object.keys(files).sort());
58
+ for (const [rel, content] of Object.entries(files)) {
59
+ const expected = createHash('sha256').update(content).digest('hex');
60
+ expect(manifest.files[rel]).toBe(expected);
61
+ }
62
+ });
63
+
64
+ it('skips node_modules, dist, cdk.out, package-lock.json, and .npmrc', async () => {
65
+ const root = tempDir;
66
+ const modules = join(root, 'infra', 'modules');
67
+ await mkdir(modules, { recursive: true });
68
+
69
+ // Real file we want hashed
70
+ await writeFile(join(modules, 'real.ts'), 'export const real = true;');
71
+
72
+ // Files/dirs that must be excluded
73
+ await mkdir(join(modules, 'node_modules', 'pkg'), { recursive: true });
74
+ await writeFile(join(modules, 'node_modules', 'pkg', 'index.js'), 'module.exports = {};');
75
+
76
+ await mkdir(join(modules, 'dist'), { recursive: true });
77
+ await writeFile(join(modules, 'dist', 'bundle.js'), 'var x = 1;');
78
+
79
+ await mkdir(join(modules, 'cdk.out'), { recursive: true });
80
+ await writeFile(join(modules, 'cdk.out', 'template.json'), '{}');
81
+
82
+ await writeFile(join(modules, 'package-lock.json'), '{}');
83
+ await writeFile(join(modules, '.npmrc'), 'registry=https://example.com');
84
+
85
+ // Also nested exclude
86
+ await mkdir(join(modules, 'sub', 'node_modules'), { recursive: true });
87
+ await writeFile(join(modules, 'sub', 'node_modules', 'dep.js'), '// skip');
88
+
89
+ await writeFile(join(modules, 'sub', 'kept.ts'), 'export const kept = 1;');
90
+
91
+ const manifestPath = await runRegenerateModulesHashes({ projectRoot: root });
92
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
93
+
94
+ expect(manifest.files).toEqual({
95
+ 'infra/modules/real.ts': createHash('sha256').update('export const real = true;').digest('hex'),
96
+ 'infra/modules/sub/kept.ts': createHash('sha256').update('export const kept = 1;').digest('hex'),
97
+ });
98
+
99
+ // None of the excluded entries appear
100
+ const keys = Object.keys(manifest.files);
101
+ expect(keys.some((k) => k.includes('node_modules'))).toBe(false);
102
+ expect(keys.some((k) => k.includes('dist/'))).toBe(false);
103
+ expect(keys.some((k) => k.includes('cdk.out/'))).toBe(false);
104
+ expect(keys.some((k) => k.endsWith('package-lock.json'))).toBe(false);
105
+ expect(keys.some((k) => k.endsWith('.npmrc'))).toBe(false);
106
+ });
107
+
108
+ it('handles empty infra/modules/ directory by emitting empty files map', async () => {
109
+ await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
110
+
111
+ const manifestPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
112
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
113
+
114
+ expect(manifest.version).toBe('1');
115
+ expect(typeof manifest.generatedAt).toBe('string');
116
+ expect(manifest.files).toEqual({});
117
+ });
118
+
119
+ it('handles missing infra/modules/ directory gracefully', async () => {
120
+ // Do NOT create infra/modules/ — only the project root exists
121
+ const manifestPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
122
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
123
+
124
+ expect(manifestPath).toBe(join(tempDir, '.mc', 'modules-hashes.json'));
125
+ expect(manifest.version).toBe('1');
126
+ expect(typeof manifest.generatedAt).toBe('string');
127
+ expect(manifest.files).toEqual({});
128
+ });
129
+
130
+ it('is idempotent — second run produces identical file map and structure', async () => {
131
+ await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
132
+
133
+ await writeFile(join(tempDir, 'infra', 'modules', 'a.ts'), 'export const a = 1;');
134
+ await mkdir(join(tempDir, 'infra', 'modules', 'sub'), { recursive: true });
135
+ await writeFile(join(tempDir, 'infra', 'modules', 'sub', 'b.ts'), 'export const b = 2;');
136
+
137
+ const firstPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
138
+ const first = JSON.parse(readFileSync(firstPath, 'utf8'));
139
+
140
+ // Wait long enough that a fresh ISO timestamp would differ
141
+ await new Promise((r) => setTimeout(r, 10));
142
+
143
+ const secondPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
144
+ const second = JSON.parse(readFileSync(secondPath, 'utf8'));
145
+
146
+ expect(secondPath).toBe(firstPath);
147
+ expect(second.version).toBe(first.version);
148
+ expect(second.files).toEqual(first.files);
149
+
150
+ // Timestamps may differ, but the structure (ignoring generatedAt) is identical
151
+ const { generatedAt: _g1, ...firstRest } = first;
152
+ const { generatedAt: _g2, ...secondRest } = second;
153
+ expect(secondRest).toEqual(firstRest);
154
+ });
155
+
156
+ it('includes deeply nested files under infra/modules/', async () => {
157
+ await mkdir(join(tempDir, 'infra', 'modules', 'flows', 'sub'), { recursive: true });
158
+
159
+ const nestedContent = 'export const nested = true;';
160
+ const nestedPath = join(tempDir, 'infra', 'modules', 'flows', 'sub', 'nested.ts');
161
+ await writeFile(nestedPath, nestedContent);
162
+
163
+ const manifestPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
164
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
165
+
166
+ expect(manifest.files['infra/modules/flows/sub/nested.ts']).toBe(
167
+ createHash('sha256').update(nestedContent).digest('hex'),
168
+ );
169
+ });
170
+ });
@@ -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
  });