@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
@@ -1,22 +1,24 @@
1
1
  import { relative, resolve } from 'node:path';
2
2
  import type {
3
3
  DomainRegistry,
4
- ApiRegistryEntry,
5
- ApiVersionSnapshot,
4
+ ActionRegistryEntry,
6
5
  SchemaSnapshot,
7
6
  WebhookRegistryEntry,
8
7
  SubscriberRegistryEntry,
9
8
  ScheduleRegistryEntry,
10
9
  JobRegistryEntry,
11
- ActionRegistryEntry,
12
10
  IntegrationRegistryEntry,
13
11
  EventRegistryEntry,
14
12
  DomainRegistryEntry,
15
13
  SerialDeploymentConfig,
16
- } from '@mettlecast/domain-cdk-packer';
14
+ } from '../types.js';
17
15
  import { walkDomainDir } from '../utils/file-helpers.js';
18
16
  import { loadModuleExports, type RawPrimitiveExport } from './load-module.js';
19
17
 
18
+ // Issue #4689: the legacy `defineApi` primitive was removed from
19
+ // `@mettlecast/domain-runtime`. The only HTTP endpoint surface in
20
+ // the new registry is `actions[]` whose `exposure.type === 'api'`.
21
+
20
22
  type ActionBackendAccess = 'private' | 'domain' | 'platform';
21
23
 
22
24
  type ActionExposure =
@@ -190,9 +192,8 @@ export async function buildRegistry(domainRoot: string): Promise<BuildResult> {
190
192
  defaultDeployment: deployment(domainRaw),
191
193
  };
192
194
 
193
- const [apiExports, webhookExports, subscriberExports, actionExports,
195
+ const [webhookExports, subscriberExports, actionExports,
194
196
  scheduleExports, jobExports, integrationExports, eventExports] = await Promise.all([
195
- Promise.all(paths.apis.map(load)),
196
197
  Promise.all(paths.webhooks.map(load)),
197
198
  Promise.all(paths.subscribers.map(load)),
198
199
  Promise.all(paths.actions.map(load)),
@@ -202,43 +203,6 @@ export async function buildRegistry(domainRoot: string): Promise<BuildResult> {
202
203
  paths.publishes ? load(paths.publishes) : Promise.resolve([]),
203
204
  ]);
204
205
 
205
- const VALID_API_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
206
-
207
- const apis: ApiRegistryEntry[] = paths.apis.flatMap((filePath, i) =>
208
- (apiExports[i] ?? [])
209
- .filter(e => e['_kind'] === 'api')
210
- .map(e => {
211
- const rawMethod = typeof e['method'] === 'string' ? e['method'].toUpperCase() : '';
212
- if (!rawMethod || !VALID_API_METHODS.has(rawMethod)) {
213
- warnings.push(`${relPath(filePath)}: defineApi "${e['id']}" has invalid or missing method "${rawMethod || '(none)'}". Use one of: ${[...VALID_API_METHODS].join(', ')}.`);
214
- }
215
- const rawVersions = e['versions'] as Record<string, { input?: unknown; output?: unknown }> | undefined;
216
- const versionSnapshots: ApiVersionSnapshot[] = rawVersions
217
- ? Object.entries(rawVersions).map(([ver, v]) => ({
218
- version: ver,
219
- requestSchema: isJsonSchema(v?.input) ? (v.input as SchemaSnapshot) : undefined,
220
- responseSchema: isJsonSchema(v?.output) ? (v.output as SchemaSnapshot) : undefined,
221
- }))
222
- : [];
223
- const latestVersion = versionSnapshots[versionSnapshots.length - 1];
224
- return {
225
- id: String(e['id']),
226
- kind: 'api' as const,
227
- handlerFile: relPath(filePath),
228
- path: String(e['path']),
229
- method: VALID_API_METHODS.has(rawMethod) ? rawMethod : 'GET',
230
- authType: ((e['auth'] as { type?: string } | undefined)?.type as 'jwt' | 'api-key' | 'none' | undefined) ?? 'jwt',
231
- description: typeof e['description'] === 'string' ? e['description'] : undefined,
232
- deployment: deployment(e),
233
- outboundAccess: outboundAccess(e),
234
- requestSchema: latestVersion?.requestSchema,
235
- responseSchema: latestVersion?.responseSchema,
236
- versions: versionSnapshots.length > 0 ? versionSnapshots : undefined,
237
- examples: (e['examples'] as { request?: Record<string, unknown>; response?: Record<string, unknown> } | undefined),
238
- };
239
- })
240
- );
241
-
242
206
  const webhooks: WebhookRegistryEntry[] = paths.webhooks.flatMap((filePath, i) =>
243
207
  (webhookExports[i] ?? [])
244
208
  .filter(e => e['_kind'] === 'webhook')
@@ -374,7 +338,6 @@ export async function buildRegistry(domainRoot: string): Promise<BuildResult> {
374
338
  schemaVersion: '1',
375
339
  domainRoot,
376
340
  domain,
377
- apis,
378
341
  webhooks,
379
342
  subscribers,
380
343
  schedules,
@@ -1,4 +1,4 @@
1
- import type { DomainRegistry } from '@mettlecast/domain-cdk-packer';
1
+ import type { DomainRegistry } from '../types.js';
2
2
 
3
3
  /**
4
4
  * Generate a .d.ts file declaring typed `ctx.actions.call(actionId, input)`
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';
@@ -73,7 +75,7 @@ program
73
75
 
74
76
  program
75
77
  .command('dev <domain>')
76
- .description('Start a local HTTP server simulating API Gateway for all defineApi handlers')
78
+ .description('Start a local HTTP server simulating API Gateway for all API-exposed defineAction handlers')
77
79
  .option('--port <n>', 'Port to listen on', '3000')
78
80
  .action(async (domain: string, opts: { port?: string }) => {
79
81
  await runDev({ domainRoot: domain, port: opts.port ? parseInt(opts.port, 10) : 3000 });
@@ -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
- .action(async () => {
199
- const result = await runCheckHashes({});
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')
@@ -96,7 +96,7 @@ export async function runAddApi(opts: AddApiOptions): Promise<void> {
96
96
  throw new Error(`Domain "${opts.domain}" not found at ${domainDir}`);
97
97
  }
98
98
 
99
- const apiFilePath = join(domainDir, 'api', `${opts.id}.ts`);
99
+ const apiFilePath = join(domainDir, 'actions', `${opts.id}.ts`);
100
100
 
101
101
  // Refuse if API already exists
102
102
  try {
@@ -124,7 +124,7 @@ export async function runAddApi(opts: AddApiOptions): Promise<void> {
124
124
  await writeFile(apiFilePath, apiContent);
125
125
 
126
126
  // Create fixture with the input schema's default shape as the example payload
127
- const apiTestDir = join(domainDir, 'api', '__tests__');
127
+ const apiTestDir = join(domainDir, 'actions', '__tests__');
128
128
  await mkdir(apiTestDir, { recursive: true });
129
129
  await writeFile(
130
130
  join(apiTestDir, `${opts.id}.fixture.json`),
@@ -51,18 +51,18 @@ export async function runAddDomain(opts: AddDomainOptions): Promise<void> {
51
51
  }
52
52
 
53
53
  // Create directory structure
54
- await mkdir(join(domainDir, 'api', '__tests__'), { recursive: true });
54
+ await mkdir(join(domainDir, 'actions', '__tests__'), { recursive: true });
55
55
  await mkdir(join(domainDir, 'subscribers'), { recursive: true });
56
56
  await mkdir(join(domainDir, 'publishes'), { recursive: true });
57
57
 
58
58
  // Write skeleton files
59
59
  await writeFile(join(domainDir, 'domain.config.ts'), domainConfigTemplate(opts.id, opts.tenancy));
60
60
  await writeFile(
61
- join(domainDir, 'api', 'example.ts'),
61
+ join(domainDir, 'actions', 'example.ts'),
62
62
  apiSkeletonTemplate(opts.id, 'example', opts.tenancy)
63
63
  );
64
64
  await writeFile(
65
- join(domainDir, 'api', '__tests__', 'example.fixture.json'),
65
+ join(domainDir, 'actions', '__tests__', 'example.fixture.json'),
66
66
  apiFixtureSkeleton(opts.id, 'example')
67
67
  );
68
68
  await writeFile(join(domainDir, 'publishes', 'events.ts'), eventsSkeletonTemplate(opts.id));
@@ -94,7 +94,7 @@ export async function runAddDomain(opts: AddDomainOptions): Promise<void> {
94
94
  cliLogger.info({ id: opts.id, dir: domainDir }, 'Domain scaffolded');
95
95
  // eslint-disable-next-line no-console
96
96
  console.log(
97
- `\n✓ Domain "${opts.id}" added at ${domainDir}\nNext: edit api/example.ts, ` +
97
+ `\n✓ Domain "${opts.id}" added at ${domainDir}\nNext: edit actions/example.ts, ` +
98
98
  `then run \`mc-domain-module build ${opts.id}\` to generate the registry.`
99
99
  );
100
100
  }
@@ -47,21 +47,20 @@ export async function runAddFixtureFactory(options: AddFixtureFactoryOptions): P
47
47
  const domain = options.domain;
48
48
  const apiId = options.apiId;
49
49
 
50
- const apiFile = join(projectRoot, 'domains', domain, 'api', `${apiId}.ts`);
50
+ const apiFile = join(projectRoot, 'domains', domain, 'actions', `${apiId}.ts`);
51
51
  await access(apiFile).catch(() => {
52
- throw new Error(`API file not found: ${apiFile}. Run add-api first.`);
52
+ throw new Error(`Action file not found: ${apiFile}. Run add-api first.`);
53
53
  });
54
54
 
55
- // Read the API file to extract the input type name
55
+ // Read the action file to extract the input type name
56
56
  const content = await readFile(apiFile, 'utf8');
57
- const outputTypeArg = content.match(/import\s+\{\s*[\w\s,]*\s*\}\s+from\s+['"]@mettlecast/);
58
57
 
59
- const factoryFile = join(projectRoot, 'domains', domain, 'api', '__tests__', `${apiId}.factory.ts`);
58
+ const factoryFile = join(projectRoot, 'domains', domain, 'actions', '__tests__', `${apiId}.factory.ts`);
60
59
  const factoryContent = FACTORY_TEMPLATE
61
60
  .replace(/\{domain\}/g, domain)
62
61
  .replace(/\{apiId\}/g, apiId);
63
62
 
64
- await mkdir(join(projectRoot, 'domains', domain, 'api', '__tests__'), { recursive: true });
63
+ await mkdir(join(projectRoot, 'domains', domain, 'actions', '__tests__'), { recursive: true });
65
64
  await writeFile(factoryFile, factoryContent, 'utf8');
66
65
 
67
66
  cliLogger.info({ factoryFile }, 'add-fixture-factory: factory written');
@@ -2,27 +2,18 @@ import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { cliLogger } from '../utils/logger.js';
4
4
 
5
- /** A single API entry in the catalog. */
6
- export interface CatalogApi {
7
- id: string;
8
- domainId: string;
9
- path: string;
10
- method: string;
11
- authType: string;
12
- description?: string;
13
- requestSchema?: Record<string, unknown>;
14
- responseSchema?: Record<string, unknown>;
15
- versions?: Array<{ version: string; requestSchema?: Record<string, unknown>; responseSchema?: Record<string, unknown> }>;
16
- examples?: { request?: Record<string, unknown>; response?: Record<string, unknown> };
17
- }
18
-
19
5
  /** A single action entry in the catalog. */
20
6
  export interface CatalogAction {
21
7
  id: string;
22
8
  domainId: string;
23
- visibility: string;
9
+ backendAccess: string;
10
+ exposure: Record<string, unknown>;
11
+ exposureDeclared?: boolean;
24
12
  idempotent: boolean;
25
13
  description?: string;
14
+ handlerFile?: string;
15
+ deployment?: Record<string, unknown>;
16
+ outboundAccess?: string;
26
17
  inputSchema?: Record<string, unknown>;
27
18
  outputSchema?: Record<string, unknown>;
28
19
  }
@@ -83,7 +74,6 @@ export interface DomainCatalog {
83
74
  version: 2;
84
75
  generatedAt: string;
85
76
  domains: CatalogDomain[];
86
- apis: CatalogApi[];
87
77
  actions: CatalogAction[];
88
78
  events: CatalogEvent[];
89
79
  subscribers: CatalogSubscriber[];
@@ -118,7 +108,6 @@ export async function runBuildCatalog(registryDir?: string): Promise<DomainCatal
118
108
  version: 2,
119
109
  generatedAt: new Date().toISOString(),
120
110
  domains: [],
121
- apis: [],
122
111
  actions: [],
123
112
  events: [],
124
113
  subscribers: [],
@@ -152,30 +141,19 @@ export async function runBuildCatalog(registryDir?: string): Promise<DomainCatal
152
141
  description: domainEntry['description'] as string | undefined,
153
142
  });
154
143
 
155
- const apis = (registry['apis'] as Record<string, unknown>[] | undefined) ?? [];
156
- for (const api of apis) {
157
- catalog.apis.push({
158
- id: String(api['id']),
159
- domainId,
160
- path: String(api['path']),
161
- method: String(api['method'] ?? 'ANY'),
162
- authType: String(api['authType'] ?? 'jwt'),
163
- description: api['description'] as string | undefined,
164
- requestSchema: api['requestSchema'] as Record<string, unknown> | undefined,
165
- responseSchema: api['responseSchema'] as Record<string, unknown> | undefined,
166
- versions: api['versions'] as CatalogApi['versions'],
167
- examples: api['examples'] as CatalogApi['examples'],
168
- });
169
- }
170
-
171
144
  const actions = (registry['actions'] as Record<string, unknown>[] | undefined) ?? [];
172
145
  for (const action of actions) {
173
146
  catalog.actions.push({
174
147
  id: String(action['id']),
175
148
  domainId,
176
- visibility: String(action['visibility'] ?? 'private'),
149
+ backendAccess: String(action['backendAccess'] ?? 'private'),
150
+ exposure: (action['exposure'] as Record<string, unknown> | undefined) ?? { type: 'internal' },
151
+ exposureDeclared: action['exposureDeclared'] as boolean | undefined,
177
152
  idempotent: Boolean(action['idempotent'] ?? false),
178
153
  description: action['description'] as string | undefined,
154
+ handlerFile: action['handlerFile'] as string | undefined,
155
+ deployment: action['deployment'] as Record<string, unknown> | undefined,
156
+ outboundAccess: action['outboundAccess'] as string | undefined,
179
157
  inputSchema: action['inputSchema'] as Record<string, unknown> | undefined,
180
158
  outputSchema: action['outputSchema'] as Record<string, unknown> | undefined,
181
159
  });
@@ -241,7 +219,7 @@ export async function runBuildCatalog(registryDir?: string): Promise<DomainCatal
241
219
  await writeFile(outPath, JSON.stringify(catalog), 'utf8');
242
220
 
243
221
  cliLogger.info(
244
- { outPath, domains: catalog.domains.length, apis: catalog.apis.length, actions: catalog.actions.length, events: catalog.events.length, jobs: catalog.jobs.length, schedules: catalog.schedules.length, integrations: catalog.integrations.length },
222
+ { outPath, domains: catalog.domains.length, actions: catalog.actions.length, events: catalog.events.length, jobs: catalog.jobs.length, schedules: catalog.schedules.length, integrations: catalog.integrations.length },
245
223
  'Domain catalog written'
246
224
  );
247
225
 
@@ -1,6 +1,6 @@
1
1
  import { writeFile, mkdir, readdir, readFile } from 'node:fs/promises';
2
2
  import { join, resolve, basename } from 'node:path';
3
- import type { DomainRegistry } from '@mettlecast/domain-cdk-packer';
3
+ import type { DomainRegistry } from '../types.js';
4
4
  import { buildRegistry } from '../builder/build-registry.js';
5
5
  import { buildActionsTypes } from '../builder/build-types.js';
6
6
  import { cliLogger } from '../utils/logger.js';
@@ -40,7 +40,8 @@ export async function runBuild(options: BuildOptions): Promise<string> {
40
40
  await mkdir(join(outFile, '..'), { recursive: true });
41
41
  await writeFile(outFile, JSON.stringify(registry), 'utf8');
42
42
 
43
- cliLogger.info({ outFile, apis: registry.apis.length, events: registry.events.length }, 'Registry written');
43
+ const apiExposedActions = registry.actions.filter(action => action.exposure.type === 'api').length;
44
+ cliLogger.info({ outFile, apiExposedActions, events: registry.events.length }, 'Registry written');
44
45
 
45
46
  // Discover all sibling registries and emit aggregated types
46
47
  const registryDir = join(outFile, '..');
@@ -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
 
@@ -55,7 +55,7 @@ export async function startDev(options: DevOptions): Promise<DevHandle> {
55
55
  }
56
56
 
57
57
  cliLogger.info(
58
- { domain: registry.domain.id, apis: registry.apis.length, port },
58
+ { domain: registry.domain.id, apiActions: registry.actions.filter(a => a.exposure?.type === 'api').length, port },
59
59
  'Starting local dev server'
60
60
  );
61
61
 
@@ -121,8 +121,10 @@ async function checkHandlersUseResult(projectRoot: string): Promise<DoctorCheck>
121
121
  const missing: string[] = [];
122
122
  for (const file of apiFiles) {
123
123
  const content = await readFile(file, 'utf8');
124
- // A file contains a handler export — check the return type
125
- if (/defineApi\s*\(/.test(content)) {
124
+ // A file contains a handler export — check the return type.
125
+ // Issue #4689: defineApi was removed. The action-first
126
+ // contract uses defineAction with exposure.type='api'.
127
+ if (/defineAction\s*\(/.test(content)) {
126
128
  // Look for Result<T> in the handler's return type annotation
127
129
  if (!/: .*Result</.test(content)) {
128
130
  missing.push(relative(projectRoot, file));
@@ -448,49 +450,64 @@ async function checkNoCrossDomainImports(projectRoot: string): Promise<DoctorChe
448
450
  async function checkApisHaveVersions(projectRoot: string): Promise<DoctorCheck> {
449
451
  try {
450
452
  const domainsDir = join(projectRoot, 'domains');
451
- const allApiFiles: string[] = [];
453
+ const allActionFiles: string[] = [];
452
454
 
453
- // Find all API files in all domains
455
+ // Find API-exposed action files in both actions/ and api/ directories.
456
+ // Issue #4689: defineApi was removed. API-exposed actions live in
457
+ // domains/*/actions/ but legacy api/ directories may still exist.
454
458
  const entries = await readdir(domainsDir, { withFileTypes: true });
455
459
  for (const entry of entries) {
456
460
  if (!entry.isDirectory()) continue;
457
- const apiDir = join(domainsDir, entry.name, 'api');
458
- const apiFiles = findFiles(apiDir, /\.ts$/);
459
- allApiFiles.push(...apiFiles);
461
+ for (const dir of ['actions', 'api']) {
462
+ const actionDir = join(domainsDir, entry.name, dir);
463
+ const files = findFiles(actionDir, /\.ts$/);
464
+ allActionFiles.push(...files);
465
+ }
460
466
  }
461
- const apiFiles = allApiFiles;
462
467
 
463
- if (apiFiles.length === 0) {
468
+ // Only consider files that define an API-exposed action (defineAction
469
+ // with exposure.type === 'api' or exposure: { type: 'api' }).
470
+ const apiExposedFiles: string[] = [];
471
+ for (const file of allActionFiles) {
472
+ const content = await readFile(file, 'utf8');
473
+ if (/defineAction\s*\(/.test(content) && /exposure\s*:\s*\{\s*type\s*:\s*['"]api['"]/.test(content)) {
474
+ apiExposedFiles.push(file);
475
+ }
476
+ }
477
+
478
+ if (apiExposedFiles.length === 0) {
464
479
  return {
465
480
  name: 'APIs declare versions',
466
481
  status: 'PASS',
467
- message: 'No APIs found (optional)',
482
+ message: 'No API-exposed actions found (optional)',
468
483
  kNodeRef: 'K:runbook:add-domain',
469
484
  };
470
485
  }
471
486
 
472
- // Simple heuristic: grep for 'versions:' in each API file
473
- let missingVersions = 0;
474
- for (const file of apiFiles) {
487
+ // Issue #4689: action-first contract requires input/output Zod schemas
488
+ // with `.default({...})` example data. Check that each API-exposed
489
+ // action file contains `.default(` (indicating example data).
490
+ let missingDefaults = 0;
491
+ for (const file of apiExposedFiles) {
475
492
  const content = await readFile(file, 'utf8');
476
- if (!content.includes('versions:')) {
477
- missingVersions++;
493
+ if (!content.includes('.default(')) {
494
+ missingDefaults++;
478
495
  }
479
496
  }
480
497
 
481
- if (missingVersions === 0) {
498
+ if (missingDefaults === 0) {
482
499
  return {
483
500
  name: 'APIs declare versions',
484
501
  status: 'PASS',
485
- message: `All ${apiFiles.length} API files declare versions`,
502
+ message: `All ${apiExposedFiles.length} API-exposed action(s) have .default() example data`,
486
503
  kNodeRef: 'K:runbook:add-domain',
487
504
  };
488
505
  } else {
489
506
  return {
490
507
  name: 'APIs declare versions',
491
508
  status: 'FAIL',
492
- message: `${missingVersions}/${apiFiles.length} API files missing versions: field`,
493
- fixHint: 'Add versions: { v1: { ... } } to defineApi calls',
509
+ message: `${missingDefaults}/${apiExposedFiles.length} API-exposed action(s) missing .default() example data. Add \`.default({...})\` to the top-level input and output Zod schemas in each defineAction({ exposure: { type: 'api', ... } }) call.`,
510
+ fixHint: "Add `input` and `output` Zod schemas with `.default({...})` to defineAction({ exposure: { type: 'api', ... } }) calls",
494
511
  kNodeRef: 'K:runbook:add-domain',
495
512
  };
496
513
  }
@@ -498,7 +515,7 @@ async function checkApisHaveVersions(projectRoot: string): Promise<DoctorCheck>
498
515
  return {
499
516
  name: 'APIs declare versions',
500
517
  status: 'WARN',
501
- message: `Could not check API versions: ${String(err)}`,
518
+ message: `Could not check API schemas: ${String(err)}`,
502
519
  kNodeRef: 'K:runbook:add-domain',
503
520
  };
504
521
  }
@@ -507,30 +524,42 @@ async function checkApisHaveVersions(projectRoot: string): Promise<DoctorCheck>
507
524
  async function checkApisHaveTenancy(projectRoot: string): Promise<DoctorCheck> {
508
525
  try {
509
526
  const domainsDir = join(projectRoot, 'domains');
510
- const allApiFiles: string[] = [];
527
+ const allActionFiles: string[] = [];
511
528
 
512
- // Find all API files in all domains
529
+ // Find API-exposed action files in both actions/ and api/ directories.
530
+ // Issue #4689: defineApi was removed. Tenancy is now declared on the
531
+ // action's exposure block via `exposure.tenancy`.
513
532
  const entries = await readdir(domainsDir, { withFileTypes: true });
514
533
  for (const entry of entries) {
515
534
  if (!entry.isDirectory()) continue;
516
- const apiDir = join(domainsDir, entry.name, 'api');
517
- const apiFiles = findFiles(apiDir, /\.ts$/);
518
- allApiFiles.push(...apiFiles);
535
+ for (const dir of ['actions', 'api']) {
536
+ const actionDir = join(domainsDir, entry.name, dir);
537
+ const files = findFiles(actionDir, /\.ts$/);
538
+ allActionFiles.push(...files);
539
+ }
519
540
  }
520
- const apiFiles = allApiFiles;
521
541
 
522
- if (apiFiles.length === 0) {
542
+ // Only consider files that define an API-exposed action.
543
+ const apiExposedFiles: string[] = [];
544
+ for (const file of allActionFiles) {
545
+ const content = await readFile(file, 'utf8');
546
+ if (/defineAction\s*\(/.test(content) && /exposure\s*:\s*\{\s*type\s*:\s*['"]api['"]/.test(content)) {
547
+ apiExposedFiles.push(file);
548
+ }
549
+ }
550
+
551
+ if (apiExposedFiles.length === 0) {
523
552
  return {
524
553
  name: 'APIs declare tenancy',
525
554
  status: 'PASS',
526
- message: 'No APIs found (optional)',
555
+ message: 'No API-exposed actions found (optional)',
527
556
  kNodeRef: 'K:convention:tier-1-foundations',
528
557
  };
529
558
  }
530
559
 
531
- // Simple heuristic: grep for 'tenancy:' in each API file
560
+ // Check for `tenancy:` anywhere in each API-exposed action file.
532
561
  let missingTenancy = 0;
533
- for (const file of apiFiles) {
562
+ for (const file of apiExposedFiles) {
534
563
  const content = await readFile(file, 'utf8');
535
564
  if (!content.includes('tenancy:')) {
536
565
  missingTenancy++;
@@ -541,15 +570,15 @@ async function checkApisHaveTenancy(projectRoot: string): Promise<DoctorCheck> {
541
570
  return {
542
571
  name: 'APIs declare tenancy',
543
572
  status: 'PASS',
544
- message: `All ${apiFiles.length} API files declare tenancy`,
573
+ message: `All ${apiExposedFiles.length} API-exposed action(s) declare tenancy`,
545
574
  kNodeRef: 'K:convention:tier-1-foundations',
546
575
  };
547
576
  } else {
548
577
  return {
549
578
  name: 'APIs declare tenancy',
550
579
  status: 'FAIL',
551
- message: `${missingTenancy}/${apiFiles.length} API files missing tenancy: field`,
552
- fixHint: "Add tenancy: 'required' | 'none' | 'system' to defineApi calls",
580
+ message: `${missingTenancy}/${apiExposedFiles.length} API-exposed action(s) missing tenancy. Tenancy must be declared on the action's exposure block: \`exposure.tenancy: 'required' | 'none' | 'system'\`.`,
581
+ fixHint: "Add `exposure.tenancy: 'required' | 'none' | 'system'` to defineAction({ exposure: { type: 'api', ... } }) calls",
553
582
  kNodeRef: 'K:convention:tier-1-foundations',
554
583
  };
555
584
  }
@@ -584,9 +613,9 @@ async function checkScaffoldConfigMatchesDisk(projectRoot: string): Promise<Doct
584
613
  } catch {
585
614
  return {
586
615
  name: 'scaffold-config.json matches on-disk',
587
- status: 'FAIL',
588
- message: 'Could not read .mc/scaffold-config.json',
589
- fixHint: 'Ensure .mc/scaffold-config.json exists and is valid JSON',
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',
590
619
  kNodeRef: 'K:runbook:add-domain',
591
620
  };
592
621
  }
@@ -699,7 +728,7 @@ async function checkRootLevelFlows(projectRoot: string): Promise<DoctorCheck> {
699
728
  }
700
729
  return {
701
730
  name: 'No root-level flows (deprecated)',
702
- status: 'WARN',
731
+ status: 'FAIL',
703
732
  message: `${flowFiles.length} flow(s) still in root-level flows/: ${flowFiles.join(', ')}`,
704
733
  fixHint: 'Move these flows to domains/{owningDomain}/flows/ and delete the root-level copies.',
705
734
  kNodeRef: 'K:convention:flow-vs-subscriber-rule',
@@ -873,7 +902,7 @@ async function checkAllRoutesUseTanStackRouter(projectRoot: string): Promise<Doc
873
902
 
874
903
  /**
875
904
  * Check W5-4: Lambda handler files contain initOtel() call.
876
- * Scans `domains/*\/api/*.ts` and FAILs if any handler is missing initOtel().
905
+ * Scans `domains/*\/actions/*.ts` and `domains/*\/api/*.ts` and FAILs if any handler is missing initOtel().
877
906
  */
878
907
  async function checkOtelInitInLambdas(projectRoot: string): Promise<DoctorCheck> {
879
908
  try {
@@ -890,9 +919,11 @@ async function checkOtelInitInLambdas(projectRoot: string): Promise<DoctorCheck>
890
919
  const handlerFiles: string[] = [];
891
920
  for (const entry of entries) {
892
921
  if (!entry.isDirectory()) continue;
893
- const apiDir = join(domainsDir, entry.name, 'api');
894
- const files = findFiles(apiDir, /\.ts$/);
895
- handlerFiles.push(...files);
922
+ for (const dir of ['actions', 'api']) {
923
+ const handlerDir = join(domainsDir, entry.name, dir);
924
+ const files = findFiles(handlerDir, /\.ts$/);
925
+ handlerFiles.push(...files);
926
+ }
896
927
  }
897
928
 
898
929
  if (handlerFiles.length === 0) {