@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,4 +1,4 @@
1
- import type { DomainRegistry } from '@mettlecast/domain-cdk-packer';
1
+ import type { DomainRegistry } from '../types.js';
2
2
  /**
3
3
  * Result of a registry build operation.
4
4
  */
@@ -141,8 +141,7 @@ export async function buildRegistry(domainRoot) {
141
141
  tenancy: String(domainRaw['tenancy']),
142
142
  defaultDeployment: deployment(domainRaw),
143
143
  };
144
- const [apiExports, webhookExports, subscriberExports, actionExports, scheduleExports, jobExports, integrationExports, eventExports] = await Promise.all([
145
- Promise.all(paths.apis.map(load)),
144
+ const [webhookExports, subscriberExports, actionExports, scheduleExports, jobExports, integrationExports, eventExports] = await Promise.all([
146
145
  Promise.all(paths.webhooks.map(load)),
147
146
  Promise.all(paths.subscribers.map(load)),
148
147
  Promise.all(paths.actions.map(load)),
@@ -151,39 +150,6 @@ export async function buildRegistry(domainRoot) {
151
150
  Promise.all(paths.integrations.map(load)),
152
151
  paths.publishes ? load(paths.publishes) : Promise.resolve([]),
153
152
  ]);
154
- const VALID_API_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
155
- const apis = paths.apis.flatMap((filePath, i) => (apiExports[i] ?? [])
156
- .filter(e => e['_kind'] === 'api')
157
- .map(e => {
158
- const rawMethod = typeof e['method'] === 'string' ? e['method'].toUpperCase() : '';
159
- if (!rawMethod || !VALID_API_METHODS.has(rawMethod)) {
160
- warnings.push(`${relPath(filePath)}: defineApi "${e['id']}" has invalid or missing method "${rawMethod || '(none)'}". Use one of: ${[...VALID_API_METHODS].join(', ')}.`);
161
- }
162
- const rawVersions = e['versions'];
163
- const versionSnapshots = rawVersions
164
- ? Object.entries(rawVersions).map(([ver, v]) => ({
165
- version: ver,
166
- requestSchema: isJsonSchema(v?.input) ? v.input : undefined,
167
- responseSchema: isJsonSchema(v?.output) ? v.output : undefined,
168
- }))
169
- : [];
170
- const latestVersion = versionSnapshots[versionSnapshots.length - 1];
171
- return {
172
- id: String(e['id']),
173
- kind: 'api',
174
- handlerFile: relPath(filePath),
175
- path: String(e['path']),
176
- method: VALID_API_METHODS.has(rawMethod) ? rawMethod : 'GET',
177
- authType: e['auth']?.type ?? 'jwt',
178
- description: typeof e['description'] === 'string' ? e['description'] : undefined,
179
- deployment: deployment(e),
180
- outboundAccess: outboundAccess(e),
181
- requestSchema: latestVersion?.requestSchema,
182
- responseSchema: latestVersion?.responseSchema,
183
- versions: versionSnapshots.length > 0 ? versionSnapshots : undefined,
184
- examples: e['examples'],
185
- };
186
- }));
187
153
  const webhooks = paths.webhooks.flatMap((filePath, i) => (webhookExports[i] ?? [])
188
154
  .filter(e => e['_kind'] === 'webhook')
189
155
  .map(e => ({
@@ -298,7 +264,6 @@ export async function buildRegistry(domainRoot) {
298
264
  schemaVersion: '1',
299
265
  domainRoot,
300
266
  domain,
301
- apis,
302
267
  webhooks,
303
268
  subscribers,
304
269
  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
  * Generate a .d.ts file declaring typed `ctx.actions.call(actionId, input)`
4
4
  * overrides for the union of actions across all registries. The generated
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The set of `_kind` discriminant values that identify a domain primitive export.
3
3
  */
4
- export declare const PRIMITIVE_KINDS: Set<"api" | "domain" | "webhook" | "subscriber" | "schedule" | "job" | "action" | "integration" | "event">;
4
+ export declare const PRIMITIVE_KINDS: Set<"domain" | "api" | "webhook" | "subscriber" | "schedule" | "job" | "action" | "integration" | "event">;
5
5
  /**
6
6
  * A raw primitive export extracted from a domain source file.
7
7
  * Functions (handler) and Zod schemas (input/output/versions) are stripped
package/dist/cli.js 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';
@@ -67,7 +69,7 @@ program
67
69
  });
68
70
  program
69
71
  .command('dev <domain>')
70
- .description('Start a local HTTP server simulating API Gateway for all defineApi handlers')
72
+ .description('Start a local HTTP server simulating API Gateway for all API-exposed defineAction handlers')
71
73
  .option('--port <n>', 'Port to listen on', '3000')
72
74
  .action(async (domain, opts) => {
73
75
  await runDev({ domainRoot: domain, port: opts.port ? parseInt(opts.port, 10) : 3000 });
@@ -173,10 +175,26 @@ program
173
175
  program
174
176
  .command('check-hashes')
175
177
  .description('Verify infra/modules/ has not been hand-edited since last scaffold')
176
- .action(async () => {
177
- const result = await runCheckHashes({});
178
+ .option('--write', 'Regenerate modules-hashes.json from current disk state instead of verifying')
179
+ .action(async (opts) => {
180
+ const result = await runCheckHashes({ write: opts.write });
178
181
  process.exit(result.ok ? 0 : 1);
179
182
  });
183
+ program
184
+ .command('update-all')
185
+ .description('Full refresh: build all domains, build catalog, build flows, build UI, regenerate hashes, run doctor')
186
+ .option('--project-root <path>', 'Root of the project (defaults to cwd)')
187
+ .action(async (opts) => {
188
+ const result = await runUpdateAll({ projectRoot: opts.projectRoot });
189
+ process.exit(result.success ? 0 : 1);
190
+ });
191
+ program
192
+ .command('regenerate-modules-hashes')
193
+ .description('Walk infra/modules/, compute SHA256 hashes, and write .mc/modules-hashes.json')
194
+ .option('--project-root <path>', 'Root of the project (defaults to cwd)')
195
+ .action(async (opts) => {
196
+ await runRegenerateModulesHashes({ projectRoot: opts.projectRoot });
197
+ });
180
198
  program
181
199
  .command('upgrade-backend <target-major>')
182
200
  .description('Run jscodeshift/ts-morph migrations between major versions of domain-runtime')
@@ -48,7 +48,7 @@ export async function runAddApi(opts) {
48
48
  catch {
49
49
  throw new Error(`Domain "${opts.domain}" not found at ${domainDir}`);
50
50
  }
51
- const apiFilePath = join(domainDir, 'api', `${opts.id}.ts`);
51
+ const apiFilePath = join(domainDir, 'actions', `${opts.id}.ts`);
52
52
  // Refuse if API already exists
53
53
  try {
54
54
  await access(apiFilePath);
@@ -74,7 +74,7 @@ export async function runAddApi(opts) {
74
74
  // Write API file
75
75
  await writeFile(apiFilePath, apiContent);
76
76
  // Create fixture with the input schema's default shape as the example payload
77
- const apiTestDir = join(domainDir, 'api', '__tests__');
77
+ const apiTestDir = join(domainDir, 'actions', '__tests__');
78
78
  await mkdir(apiTestDir, { recursive: true });
79
79
  await writeFile(join(apiTestDir, `${opts.id}.fixture.json`), apiFixtureSkeleton(opts.domain, opts.id, exampleBody));
80
80
  cliLogger.info({ domain: opts.domain, api: opts.id, method: opts.method ?? 'GET', pattern: opts.pattern ?? 'skeleton' }, 'API added');
@@ -34,13 +34,13 @@ export async function runAddDomain(opts) {
34
34
  }
35
35
  }
36
36
  // Create directory structure
37
- await mkdir(join(domainDir, 'api', '__tests__'), { recursive: true });
37
+ await mkdir(join(domainDir, 'actions', '__tests__'), { recursive: true });
38
38
  await mkdir(join(domainDir, 'subscribers'), { recursive: true });
39
39
  await mkdir(join(domainDir, 'publishes'), { recursive: true });
40
40
  // Write skeleton files
41
41
  await writeFile(join(domainDir, 'domain.config.ts'), domainConfigTemplate(opts.id, opts.tenancy));
42
- await writeFile(join(domainDir, 'api', 'example.ts'), apiSkeletonTemplate(opts.id, 'example', opts.tenancy));
43
- await writeFile(join(domainDir, 'api', '__tests__', 'example.fixture.json'), apiFixtureSkeleton(opts.id, 'example'));
42
+ await writeFile(join(domainDir, 'actions', 'example.ts'), apiSkeletonTemplate(opts.id, 'example', opts.tenancy));
43
+ await writeFile(join(domainDir, 'actions', '__tests__', 'example.fixture.json'), apiFixtureSkeleton(opts.id, 'example'));
44
44
  await writeFile(join(domainDir, 'publishes', 'events.ts'), eventsSkeletonTemplate(opts.id));
45
45
  await writeFile(join(domainDir, 'CLAUDE.md'), claudeMdTemplate(opts.id));
46
46
  await writeFile(join(domainDir, 'README.md'), readmeTemplate(opts.id));
@@ -66,6 +66,6 @@ export async function runAddDomain(opts) {
66
66
  await addDomainToScaffoldConfig(opts.id, configRoot);
67
67
  cliLogger.info({ id: opts.id, dir: domainDir }, 'Domain scaffolded');
68
68
  // eslint-disable-next-line no-console
69
- console.log(`\n✓ Domain "${opts.id}" added at ${domainDir}\nNext: edit api/example.ts, ` +
69
+ console.log(`\n✓ Domain "${opts.id}" added at ${domainDir}\nNext: edit actions/example.ts, ` +
70
70
  `then run \`mc-domain-module build ${opts.id}\` to generate the registry.`);
71
71
  }
@@ -37,18 +37,17 @@ export async function runAddFixtureFactory(options) {
37
37
  const projectRoot = options.projectRoot ?? process.cwd();
38
38
  const domain = options.domain;
39
39
  const apiId = options.apiId;
40
- const apiFile = join(projectRoot, 'domains', domain, 'api', `${apiId}.ts`);
40
+ const apiFile = join(projectRoot, 'domains', domain, 'actions', `${apiId}.ts`);
41
41
  await access(apiFile).catch(() => {
42
- throw new Error(`API file not found: ${apiFile}. Run add-api first.`);
42
+ throw new Error(`Action file not found: ${apiFile}. Run add-api first.`);
43
43
  });
44
- // Read the API file to extract the input type name
44
+ // Read the action file to extract the input type name
45
45
  const content = await readFile(apiFile, 'utf8');
46
- const outputTypeArg = content.match(/import\s+\{\s*[\w\s,]*\s*\}\s+from\s+['"]@mettlecast/);
47
- const factoryFile = join(projectRoot, 'domains', domain, 'api', '__tests__', `${apiId}.factory.ts`);
46
+ const factoryFile = join(projectRoot, 'domains', domain, 'actions', '__tests__', `${apiId}.factory.ts`);
48
47
  const factoryContent = FACTORY_TEMPLATE
49
48
  .replace(/\{domain\}/g, domain)
50
49
  .replace(/\{apiId\}/g, apiId);
51
- await mkdir(join(projectRoot, 'domains', domain, 'api', '__tests__'), { recursive: true });
50
+ await mkdir(join(projectRoot, 'domains', domain, 'actions', '__tests__'), { recursive: true });
52
51
  await writeFile(factoryFile, factoryContent, 'utf8');
53
52
  cliLogger.info({ factoryFile }, 'add-fixture-factory: factory written');
54
53
  return factoryFile;
@@ -1,30 +1,15 @@
1
- /** A single API entry in the catalog. */
2
- export interface CatalogApi {
3
- id: string;
4
- domainId: string;
5
- path: string;
6
- method: string;
7
- authType: string;
8
- description?: string;
9
- requestSchema?: Record<string, unknown>;
10
- responseSchema?: Record<string, unknown>;
11
- versions?: Array<{
12
- version: string;
13
- requestSchema?: Record<string, unknown>;
14
- responseSchema?: Record<string, unknown>;
15
- }>;
16
- examples?: {
17
- request?: Record<string, unknown>;
18
- response?: Record<string, unknown>;
19
- };
20
- }
21
1
  /** A single action entry in the catalog. */
22
2
  export interface CatalogAction {
23
3
  id: string;
24
4
  domainId: string;
25
- visibility: string;
5
+ backendAccess: string;
6
+ exposure: Record<string, unknown>;
7
+ exposureDeclared?: boolean;
26
8
  idempotent: boolean;
27
9
  description?: string;
10
+ handlerFile?: string;
11
+ deployment?: Record<string, unknown>;
12
+ outboundAccess?: string;
28
13
  inputSchema?: Record<string, unknown>;
29
14
  outputSchema?: Record<string, unknown>;
30
15
  }
@@ -81,7 +66,6 @@ export interface DomainCatalog {
81
66
  version: 2;
82
67
  generatedAt: string;
83
68
  domains: CatalogDomain[];
84
- apis: CatalogApi[];
85
69
  actions: CatalogAction[];
86
70
  events: CatalogEvent[];
87
71
  subscribers: CatalogSubscriber[];
@@ -25,7 +25,6 @@ export async function runBuildCatalog(registryDir) {
25
25
  version: 2,
26
26
  generatedAt: new Date().toISOString(),
27
27
  domains: [],
28
- apis: [],
29
28
  actions: [],
30
29
  events: [],
31
30
  subscribers: [],
@@ -55,29 +54,19 @@ export async function runBuildCatalog(registryDir) {
55
54
  tenancy: String(domainEntry['tenancy'] ?? 'none'),
56
55
  description: domainEntry['description'],
57
56
  });
58
- const apis = registry['apis'] ?? [];
59
- for (const api of apis) {
60
- catalog.apis.push({
61
- id: String(api['id']),
62
- domainId,
63
- path: String(api['path']),
64
- method: String(api['method'] ?? 'ANY'),
65
- authType: String(api['authType'] ?? 'jwt'),
66
- description: api['description'],
67
- requestSchema: api['requestSchema'],
68
- responseSchema: api['responseSchema'],
69
- versions: api['versions'],
70
- examples: api['examples'],
71
- });
72
- }
73
57
  const actions = registry['actions'] ?? [];
74
58
  for (const action of actions) {
75
59
  catalog.actions.push({
76
60
  id: String(action['id']),
77
61
  domainId,
78
- visibility: String(action['visibility'] ?? 'private'),
62
+ backendAccess: String(action['backendAccess'] ?? 'private'),
63
+ exposure: action['exposure'] ?? { type: 'internal' },
64
+ exposureDeclared: action['exposureDeclared'],
79
65
  idempotent: Boolean(action['idempotent'] ?? false),
80
66
  description: action['description'],
67
+ handlerFile: action['handlerFile'],
68
+ deployment: action['deployment'],
69
+ outboundAccess: action['outboundAccess'],
81
70
  inputSchema: action['inputSchema'],
82
71
  outputSchema: action['outputSchema'],
83
72
  });
@@ -135,6 +124,6 @@ export async function runBuildCatalog(registryDir) {
135
124
  await mkdir(mcDir, { recursive: true });
136
125
  const outPath = join(mcDir, 'domain-registry.json');
137
126
  await writeFile(outPath, JSON.stringify(catalog), 'utf8');
138
- cliLogger.info({ 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 }, 'Domain catalog written');
127
+ cliLogger.info({ 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 }, 'Domain catalog written');
139
128
  return catalog;
140
129
  }
@@ -23,7 +23,8 @@ export async function runBuild(options) {
23
23
  }
24
24
  await mkdir(join(outFile, '..'), { recursive: true });
25
25
  await writeFile(outFile, JSON.stringify(registry), 'utf8');
26
- cliLogger.info({ outFile, apis: registry.apis.length, events: registry.events.length }, 'Registry written');
26
+ const apiExposedActions = registry.actions.filter(action => action.exposure.type === 'api').length;
27
+ cliLogger.info({ outFile, apiExposedActions, events: registry.events.length }, 'Registry written');
27
28
  // Discover all sibling registries and emit aggregated types
28
29
  const registryDir = join(outFile, '..');
29
30
  const registryFiles = (await readdir(registryDir)).filter(f => f.endsWith('-registry.json'));
@@ -4,6 +4,8 @@
4
4
  export interface CheckHashesOptions {
5
5
  /** Root directory of the project (defaults to cwd). */
6
6
  projectRoot?: string;
7
+ /** If true, regenerate .mc/modules-hashes.json from current infra/modules/ before checking. */
8
+ write?: boolean;
7
9
  }
8
10
  /**
9
11
  * A single hash check result.
@@ -10,6 +10,14 @@ import { cliLogger } from '../utils/logger.js';
10
10
  */
11
11
  export async function runCheckHashes(opts = {}) {
12
12
  const root = opts.projectRoot ?? process.cwd();
13
+ // --write bootstraps the manifest from current infra/modules/ before verification.
14
+ // Must run before the manifest read below so it can create a missing manifest.
15
+ if (opts.write) {
16
+ const { runRegenerateModulesHashes } = await import('./regenerate-modules-hashes.js');
17
+ const outPath = await runRegenerateModulesHashes({ projectRoot: root });
18
+ cliLogger.info({ outPath }, 'modules-hashes.json regenerated');
19
+ return { ok: true, drifted: [], missing: [], unexpected: [] };
20
+ }
13
21
  const manifestPath = join(root, '.mc', 'modules-hashes.json');
14
22
  const modulesDir = join(root, 'infra', 'modules');
15
23
  let manifest;
@@ -21,7 +21,7 @@ export async function startDev(options) {
21
21
  for (const w of warnings) {
22
22
  cliLogger.warn(w);
23
23
  }
24
- cliLogger.info({ domain: registry.domain.id, apis: registry.apis.length, port }, 'Starting local dev server');
24
+ cliLogger.info({ domain: registry.domain.id, apiActions: registry.actions.filter(a => a.exposure?.type === 'api').length, port }, 'Starting local dev server');
25
25
  const server = await createApiServer({ port, registry, domainRoot });
26
26
  // Spawn Vite if frontend/ exists; skip with warning otherwise.
27
27
  const frontendDir = join(projectRoot, 'frontend');
@@ -59,8 +59,10 @@ async function checkHandlersUseResult(projectRoot) {
59
59
  const missing = [];
60
60
  for (const file of apiFiles) {
61
61
  const content = await readFile(file, 'utf8');
62
- // A file contains a handler export — check the return type
63
- if (/defineApi\s*\(/.test(content)) {
62
+ // A file contains a handler export — check the return type.
63
+ // Issue #4689: defineApi was removed. The action-first
64
+ // contract uses defineAction with exposure.type='api'.
65
+ if (/defineAction\s*\(/.test(content)) {
64
66
  // Look for Result<T> in the handler's return type annotation
65
67
  if (!/: .*Result</.test(content)) {
66
68
  missing.push(relative(projectRoot, file));
@@ -361,38 +363,52 @@ async function checkNoCrossDomainImports(projectRoot) {
361
363
  async function checkApisHaveVersions(projectRoot) {
362
364
  try {
363
365
  const domainsDir = join(projectRoot, 'domains');
364
- const allApiFiles = [];
365
- // Find all API files in all domains
366
+ const allActionFiles = [];
367
+ // Find API-exposed action files in both actions/ and api/ directories.
368
+ // Issue #4689: defineApi was removed. API-exposed actions live in
369
+ // domains/*/actions/ but legacy api/ directories may still exist.
366
370
  const entries = await readdir(domainsDir, { withFileTypes: true });
367
371
  for (const entry of entries) {
368
372
  if (!entry.isDirectory())
369
373
  continue;
370
- const apiDir = join(domainsDir, entry.name, 'api');
371
- const apiFiles = findFiles(apiDir, /\.ts$/);
372
- allApiFiles.push(...apiFiles);
374
+ for (const dir of ['actions', 'api']) {
375
+ const actionDir = join(domainsDir, entry.name, dir);
376
+ const files = findFiles(actionDir, /\.ts$/);
377
+ allActionFiles.push(...files);
378
+ }
373
379
  }
374
- const apiFiles = allApiFiles;
375
- if (apiFiles.length === 0) {
380
+ // Only consider files that define an API-exposed action (defineAction
381
+ // with exposure.type === 'api' or exposure: { type: 'api' }).
382
+ const apiExposedFiles = [];
383
+ for (const file of allActionFiles) {
384
+ const content = await readFile(file, 'utf8');
385
+ if (/defineAction\s*\(/.test(content) && /exposure\s*:\s*\{\s*type\s*:\s*['"]api['"]/.test(content)) {
386
+ apiExposedFiles.push(file);
387
+ }
388
+ }
389
+ if (apiExposedFiles.length === 0) {
376
390
  return {
377
391
  name: 'APIs declare versions',
378
392
  status: 'PASS',
379
- message: 'No APIs found (optional)',
393
+ message: 'No API-exposed actions found (optional)',
380
394
  kNodeRef: 'K:runbook:add-domain',
381
395
  };
382
396
  }
383
- // Simple heuristic: grep for 'versions:' in each API file
384
- let missingVersions = 0;
385
- for (const file of apiFiles) {
397
+ // Issue #4689: action-first contract requires input/output Zod schemas
398
+ // with `.default({...})` example data. Check that each API-exposed
399
+ // action file contains `.default(` (indicating example data).
400
+ let missingDefaults = 0;
401
+ for (const file of apiExposedFiles) {
386
402
  const content = await readFile(file, 'utf8');
387
- if (!content.includes('versions:')) {
388
- missingVersions++;
403
+ if (!content.includes('.default(')) {
404
+ missingDefaults++;
389
405
  }
390
406
  }
391
- if (missingVersions === 0) {
407
+ if (missingDefaults === 0) {
392
408
  return {
393
409
  name: 'APIs declare versions',
394
410
  status: 'PASS',
395
- message: `All ${apiFiles.length} API files declare versions`,
411
+ message: `All ${apiExposedFiles.length} API-exposed action(s) have .default() example data`,
396
412
  kNodeRef: 'K:runbook:add-domain',
397
413
  };
398
414
  }
@@ -400,8 +416,8 @@ async function checkApisHaveVersions(projectRoot) {
400
416
  return {
401
417
  name: 'APIs declare versions',
402
418
  status: 'FAIL',
403
- message: `${missingVersions}/${apiFiles.length} API files missing versions: field`,
404
- fixHint: 'Add versions: { v1: { ... } } to defineApi calls',
419
+ 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.`,
420
+ fixHint: "Add `input` and `output` Zod schemas with `.default({...})` to defineAction({ exposure: { type: 'api', ... } }) calls",
405
421
  kNodeRef: 'K:runbook:add-domain',
406
422
  };
407
423
  }
@@ -410,7 +426,7 @@ async function checkApisHaveVersions(projectRoot) {
410
426
  return {
411
427
  name: 'APIs declare versions',
412
428
  status: 'WARN',
413
- message: `Could not check API versions: ${String(err)}`,
429
+ message: `Could not check API schemas: ${String(err)}`,
414
430
  kNodeRef: 'K:runbook:add-domain',
415
431
  };
416
432
  }
@@ -418,28 +434,39 @@ async function checkApisHaveVersions(projectRoot) {
418
434
  async function checkApisHaveTenancy(projectRoot) {
419
435
  try {
420
436
  const domainsDir = join(projectRoot, 'domains');
421
- const allApiFiles = [];
422
- // Find all API files in all domains
437
+ const allActionFiles = [];
438
+ // Find API-exposed action files in both actions/ and api/ directories.
439
+ // Issue #4689: defineApi was removed. Tenancy is now declared on the
440
+ // action's exposure block via `exposure.tenancy`.
423
441
  const entries = await readdir(domainsDir, { withFileTypes: true });
424
442
  for (const entry of entries) {
425
443
  if (!entry.isDirectory())
426
444
  continue;
427
- const apiDir = join(domainsDir, entry.name, 'api');
428
- const apiFiles = findFiles(apiDir, /\.ts$/);
429
- allApiFiles.push(...apiFiles);
445
+ for (const dir of ['actions', 'api']) {
446
+ const actionDir = join(domainsDir, entry.name, dir);
447
+ const files = findFiles(actionDir, /\.ts$/);
448
+ allActionFiles.push(...files);
449
+ }
430
450
  }
431
- const apiFiles = allApiFiles;
432
- if (apiFiles.length === 0) {
451
+ // Only consider files that define an API-exposed action.
452
+ const apiExposedFiles = [];
453
+ for (const file of allActionFiles) {
454
+ const content = await readFile(file, 'utf8');
455
+ if (/defineAction\s*\(/.test(content) && /exposure\s*:\s*\{\s*type\s*:\s*['"]api['"]/.test(content)) {
456
+ apiExposedFiles.push(file);
457
+ }
458
+ }
459
+ if (apiExposedFiles.length === 0) {
433
460
  return {
434
461
  name: 'APIs declare tenancy',
435
462
  status: 'PASS',
436
- message: 'No APIs found (optional)',
463
+ message: 'No API-exposed actions found (optional)',
437
464
  kNodeRef: 'K:convention:tier-1-foundations',
438
465
  };
439
466
  }
440
- // Simple heuristic: grep for 'tenancy:' in each API file
467
+ // Check for `tenancy:` anywhere in each API-exposed action file.
441
468
  let missingTenancy = 0;
442
- for (const file of apiFiles) {
469
+ for (const file of apiExposedFiles) {
443
470
  const content = await readFile(file, 'utf8');
444
471
  if (!content.includes('tenancy:')) {
445
472
  missingTenancy++;
@@ -449,7 +476,7 @@ async function checkApisHaveTenancy(projectRoot) {
449
476
  return {
450
477
  name: 'APIs declare tenancy',
451
478
  status: 'PASS',
452
- message: `All ${apiFiles.length} API files declare tenancy`,
479
+ message: `All ${apiExposedFiles.length} API-exposed action(s) declare tenancy`,
453
480
  kNodeRef: 'K:convention:tier-1-foundations',
454
481
  };
455
482
  }
@@ -457,8 +484,8 @@ async function checkApisHaveTenancy(projectRoot) {
457
484
  return {
458
485
  name: 'APIs declare tenancy',
459
486
  status: 'FAIL',
460
- message: `${missingTenancy}/${apiFiles.length} API files missing tenancy: field`,
461
- fixHint: "Add tenancy: 'required' | 'none' | 'system' to defineApi calls",
487
+ 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'\`.`,
488
+ fixHint: "Add `exposure.tenancy: 'required' | 'none' | 'system'` to defineAction({ exposure: { type: 'api', ... } }) calls",
462
489
  kNodeRef: 'K:convention:tier-1-foundations',
463
490
  };
464
491
  }
@@ -493,9 +520,9 @@ async function checkScaffoldConfigMatchesDisk(projectRoot) {
493
520
  catch {
494
521
  return {
495
522
  name: 'scaffold-config.json matches on-disk',
496
- status: 'FAIL',
497
- message: 'Could not read .mc/scaffold-config.json',
498
- fixHint: 'Ensure .mc/scaffold-config.json exists and is valid JSON',
523
+ status: 'WARN',
524
+ message: '.mc/scaffold-config.json not found or unreadable — skipping domain list sync check (expected on fresh clones before first scaffold)',
525
+ fixHint: 'Run mc-domain-module update-all to regenerate scaffold metadata',
499
526
  kNodeRef: 'K:runbook:add-domain',
500
527
  };
501
528
  }
@@ -607,7 +634,7 @@ async function checkRootLevelFlows(projectRoot) {
607
634
  }
608
635
  return {
609
636
  name: 'No root-level flows (deprecated)',
610
- status: 'WARN',
637
+ status: 'FAIL',
611
638
  message: `${flowFiles.length} flow(s) still in root-level flows/: ${flowFiles.join(', ')}`,
612
639
  fixHint: 'Move these flows to domains/{owningDomain}/flows/ and delete the root-level copies.',
613
640
  kNodeRef: 'K:convention:flow-vs-subscriber-rule',
@@ -776,7 +803,7 @@ async function checkAllRoutesUseTanStackRouter(projectRoot) {
776
803
  }
777
804
  /**
778
805
  * Check W5-4: Lambda handler files contain initOtel() call.
779
- * Scans `domains/*\/api/*.ts` and FAILs if any handler is missing initOtel().
806
+ * Scans `domains/*\/actions/*.ts` and `domains/*\/api/*.ts` and FAILs if any handler is missing initOtel().
780
807
  */
781
808
  async function checkOtelInitInLambdas(projectRoot) {
782
809
  try {
@@ -794,9 +821,11 @@ async function checkOtelInitInLambdas(projectRoot) {
794
821
  for (const entry of entries) {
795
822
  if (!entry.isDirectory())
796
823
  continue;
797
- const apiDir = join(domainsDir, entry.name, 'api');
798
- const files = findFiles(apiDir, /\.ts$/);
799
- handlerFiles.push(...files);
824
+ for (const dir of ['actions', 'api']) {
825
+ const handlerDir = join(domainsDir, entry.name, dir);
826
+ const files = findFiles(handlerDir, /\.ts$/);
827
+ handlerFiles.push(...files);
828
+ }
800
829
  }
801
830
  if (handlerFiles.length === 0) {
802
831
  return { name: 'OTel init in Lambdas', status: 'PASS',
@@ -31,24 +31,24 @@ const RULE_EXPLANATIONS = {
31
31
  'no-raw-http-server': {
32
32
  ruleId: 'no-raw-http-server',
33
33
  title: 'No raw HTTP server in domain code',
34
- description: 'Domain code must not create raw HTTP servers (express, fastify, etc.). All HTTP handling goes through defineApi which is wired to API Gateway by the scaffold.',
34
+ description: 'Domain code must not create raw HTTP servers (express, fastify, etc.). All HTTP handling goes through defineAction with exposure.type=\'api\' which is wired to API Gateway by the scaffold.',
35
35
  severity: 'error',
36
36
  category: 'structure',
37
37
  kNodeRef: 'K:convention:tier-1-foundations',
38
- fixHint: 'Wrap your HTTP handler with defineApi({ ... }). The scaffold handles API Gateway wiring.',
38
+ fixHint: 'Wrap your HTTP handler with defineAction({ exposure: { type: \'api\', ... } }). The scaffold handles API Gateway wiring.',
39
39
  exampleBad: "import express from 'express';\nconst app = express();",
40
- exampleGood: "export const myApi = defineApi({ id: 'my-api', path: '/v1/my-api', ... });",
40
+ exampleGood: "export const myAction = defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action', method: 'GET', auth: 'required', tenancy: 'required' }, ... });",
41
41
  },
42
42
  'require-define-primitive': {
43
43
  ruleId: 'require-define-primitive',
44
44
  title: 'Require define primitive factories',
45
- description: 'All domain handlers must use the appropriate factory function: defineApi for HTTP APIs, defineSubscriber for event subscribers, defineAction for cross-domain actions, defineJob for background jobs.',
45
+ description: 'All domain handlers must use the appropriate factory function: defineAction (HTTP APIs via exposure.type=\'api\', or internal-only actions), defineSubscriber for event subscribers, defineJob for background jobs, defineWebhook for webhooks, defineEvent for event types.',
46
46
  severity: 'error',
47
47
  category: 'structure',
48
48
  kNodeRef: 'K:runbook:add-domain',
49
49
  fixHint: "Wrap your handler with the appropriate define* factory from @mettlecast/domain-runtime.",
50
50
  exampleBad: "export const handler = async (event) => ({ statusCode: 200 });",
51
- exampleGood: "export const myApi = defineApi({ id: 'my-api', ... });",
51
+ exampleGood: "export const myAction = defineAction({ id: 'my-action', exposure: { type: 'api', ... }, ... });",
52
52
  },
53
53
  'flow-domain-ownership': {
54
54
  ruleId: 'flow-domain-ownership',
@@ -75,24 +75,24 @@ const RULE_EXPLANATIONS = {
75
75
  'apis-have-versions': {
76
76
  ruleId: 'apis-have-versions',
77
77
  title: 'APIs declare versions',
78
- description: 'Every defineApi must have a versions field with at least one version entry. Versioning is mandatory for API evolution.',
78
+ description: 'Every API-exposed action (defineAction with exposure.type=\'api\') must declare input and output Zod schemas with .default() so the runtime has a concrete example payload. The action-first contract replaced legacy defineApi\'s versions map (#4689).',
79
79
  severity: 'error',
80
80
  category: 'correctness',
81
81
  kNodeRef: 'K:runbook:add-domain',
82
- fixHint: 'Add versions: { v1: { status, input, output, handler } } to your defineApi call.',
83
- exampleBad: "defineApi({ id: 'my-api', path: '/v1/my-api' }) // missing versions",
84
- exampleGood: "defineApi({ id: 'my-api', path: '/v1/my-api', versions: { v1: { ... } } });",
82
+ fixHint: 'Add Zod input/output schemas to your defineAction call (e.g. z.object({...}).default({...})).',
83
+ exampleBad: "defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action', ... }, handler: ... }) // missing input/output schemas",
84
+ exampleGood: "defineAction({ id: 'my-action', exposure: { type: 'api', ... }, input: z.object({...}).default({...}), output: z.object({...}).default({...}), handler: ... });",
85
85
  },
86
86
  'apis-have-tenancy': {
87
87
  ruleId: 'apis-have-tenancy',
88
88
  title: 'APIs declare tenancy',
89
- description: 'Every defineApi must declare its tenancy mode: required (tenant-scoped), none (tenant-agnostic like registration), or system (system-internal admin).',
89
+ description: 'Every defineAction with exposure.type=\'api\' must declare exposure.tenancy: required (tenant-scoped), none (tenant-agnostic like registration), or system (system-internal admin). The action-first contract enforces this at registration time (#4689).',
90
90
  severity: 'error',
91
91
  category: 'correctness',
92
92
  kNodeRef: 'K:convention:tier-1-foundations',
93
- fixHint: "Add tenancy: 'required' | 'none' | 'system' to your defineApi call.",
94
- exampleBad: "defineApi({ id: 'my-api', path: '/v1/my-api' }) // missing tenancy",
95
- exampleGood: "defineApi({ id: 'my-api', path: '/v1/my-api', tenancy: 'required' });",
93
+ fixHint: "Add `exposure.tenancy: 'required' | 'none' | 'system'` to your defineAction call.",
94
+ exampleBad: "defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action' }, ... }) // missing exposure.tenancy",
95
+ exampleGood: "defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action', method: 'GET', auth: 'required', tenancy: 'required' }, ... });",
96
96
  },
97
97
  'no-cross-domain-imports': {
98
98
  ruleId: 'no-cross-domain-imports',