@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
@@ -58,25 +58,25 @@ const RULE_EXPLANATIONS: Record<string, RuleExplanation> = {
58
58
  ruleId: 'no-raw-http-server',
59
59
  title: 'No raw HTTP server in domain code',
60
60
  description:
61
- '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.',
61
+ '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.',
62
62
  severity: 'error',
63
63
  category: 'structure',
64
64
  kNodeRef: 'K:convention:tier-1-foundations',
65
- fixHint: 'Wrap your HTTP handler with defineApi({ ... }). The scaffold handles API Gateway wiring.',
65
+ fixHint: 'Wrap your HTTP handler with defineAction({ exposure: { type: \'api\', ... } }). The scaffold handles API Gateway wiring.',
66
66
  exampleBad: "import express from 'express';\nconst app = express();",
67
- exampleGood: "export const myApi = defineApi({ id: 'my-api', path: '/v1/my-api', ... });",
67
+ exampleGood: "export const myAction = defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action', method: 'GET', auth: 'required', tenancy: 'required' }, ... });",
68
68
  },
69
69
  'require-define-primitive': {
70
70
  ruleId: 'require-define-primitive',
71
71
  title: 'Require define primitive factories',
72
72
  description:
73
- '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.',
73
+ '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.',
74
74
  severity: 'error',
75
75
  category: 'structure',
76
76
  kNodeRef: 'K:runbook:add-domain',
77
77
  fixHint: "Wrap your handler with the appropriate define* factory from @mettlecast/domain-runtime.",
78
78
  exampleBad: "export const handler = async (event) => ({ statusCode: 200 });",
79
- exampleGood: "export const myApi = defineApi({ id: 'my-api', ... });",
79
+ exampleGood: "export const myAction = defineAction({ id: 'my-action', exposure: { type: 'api', ... }, ... });",
80
80
  },
81
81
  'flow-domain-ownership': {
82
82
  ruleId: 'flow-domain-ownership',
@@ -106,25 +106,25 @@ const RULE_EXPLANATIONS: Record<string, RuleExplanation> = {
106
106
  ruleId: 'apis-have-versions',
107
107
  title: 'APIs declare versions',
108
108
  description:
109
- 'Every defineApi must have a versions field with at least one version entry. Versioning is mandatory for API evolution.',
109
+ '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).',
110
110
  severity: 'error',
111
111
  category: 'correctness',
112
112
  kNodeRef: 'K:runbook:add-domain',
113
- fixHint: 'Add versions: { v1: { status, input, output, handler } } to your defineApi call.',
114
- exampleBad: "defineApi({ id: 'my-api', path: '/v1/my-api' }) // missing versions",
115
- exampleGood: "defineApi({ id: 'my-api', path: '/v1/my-api', versions: { v1: { ... } } });",
113
+ fixHint: 'Add Zod input/output schemas to your defineAction call (e.g. z.object({...}).default({...})).',
114
+ exampleBad: "defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action', ... }, handler: ... }) // missing input/output schemas",
115
+ exampleGood: "defineAction({ id: 'my-action', exposure: { type: 'api', ... }, input: z.object({...}).default({...}), output: z.object({...}).default({...}), handler: ... });",
116
116
  },
117
117
  'apis-have-tenancy': {
118
118
  ruleId: 'apis-have-tenancy',
119
119
  title: 'APIs declare tenancy',
120
120
  description:
121
- 'Every defineApi must declare its tenancy mode: required (tenant-scoped), none (tenant-agnostic like registration), or system (system-internal admin).',
121
+ '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).',
122
122
  severity: 'error',
123
123
  category: 'correctness',
124
124
  kNodeRef: 'K:convention:tier-1-foundations',
125
- fixHint: "Add tenancy: 'required' | 'none' | 'system' to your defineApi call.",
126
- exampleBad: "defineApi({ id: 'my-api', path: '/v1/my-api' }) // missing tenancy",
127
- exampleGood: "defineApi({ id: 'my-api', path: '/v1/my-api', tenancy: 'required' });",
125
+ fixHint: "Add `exposure.tenancy: 'required' | 'none' | 'system'` to your defineAction call.",
126
+ exampleBad: "defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action' }, ... }) // missing exposure.tenancy",
127
+ exampleGood: "defineAction({ id: 'my-action', exposure: { type: 'api', path: '/v1/my-action', method: 'GET', auth: 'required', tenancy: 'required' }, ... });",
128
128
  },
129
129
  'no-cross-domain-imports': {
130
130
  ruleId: 'no-cross-domain-imports',
@@ -1,7 +1,12 @@
1
1
  /**
2
2
  * generate-openapi — read a domain's registry and produce an OpenAPI 3.1
3
3
  * specification as JSON. The registry is consumed at build time; each
4
- * `defineApi` entry contributes one path under the domain's route prefix.
4
+ * `defineAction({ exposure: { type: 'api', ... } })` entry contributes
5
+ * one path under the domain's route prefix.
6
+ *
7
+ * Issue #4689: the legacy `defineApi` factory was removed and the
8
+ * `registry.apis` slot is now always empty in new registries. This
9
+ * command therefore reads exclusively from `actions[]`.
5
10
  *
6
11
  * Usage: npx mc-domain-module generate-openapi <domain>
7
12
  */
@@ -17,51 +22,24 @@ export interface GenerateOpenapiOptions {
17
22
  output?: string;
18
23
  }
19
24
 
20
- interface ApiRegistryEntry {
21
- id: string;
22
- path: string;
23
- method: string;
24
- tenancy: string;
25
- versions: Record<string, {
26
- status: string;
27
- inputSchema?: unknown;
28
- outputSchema?: unknown;
29
- input?: unknown;
30
- output?: unknown;
31
- }>;
32
- examples?: { request?: unknown; response?: unknown };
33
- }
34
-
35
25
  interface DomainRegistry {
36
26
  domain: { id: string };
37
- apis: ApiRegistryEntry[];
27
+ actions: Array<{
28
+ id: string;
29
+ exposure?: {
30
+ type: 'api' | 'internal';
31
+ path?: string;
32
+ method?: string;
33
+ };
34
+ inputSchema?: Record<string, unknown>;
35
+ outputSchema?: Record<string, unknown>;
36
+ }>;
38
37
  }
39
38
 
40
39
  /**
41
- * Extract a Zod schema shape from a registry entry version.
42
- * The registry may store schemas as `input`/`output` (raw Zod
43
- * objects) or `inputSchema`/`outputSchema` (JSON Schema shapes).
44
- * Returns a best-effort JSON Schema object for the OpenAPI spec.
40
+ * Run the generate-openapi command: read the domain's registry and emit
41
+ * an OpenAPI 3.1 spec covering every API-exposed action.
45
42
  */
46
- function extractSchema(version: ApiRegistryEntry['versions'][string], field: 'input' | 'output'): unknown {
47
- // Prefer JSON Schema if present
48
- const schemaField = field === 'input' ? version.inputSchema : version.outputSchema;
49
- if (schemaField && typeof schemaField === 'object' && schemaField !== null) {
50
- return schemaField;
51
- }
52
- // Fall back to the raw Zod shape — try to produce a minimal JSON
53
- // Schema from the Zod _def. For full support, add zod-to-json-schema
54
- // to the CLI dependencies and call `zodToJsonSchema(zodSchema)`.
55
- const raw = field === 'input' ? version.input : version.output;
56
- if (raw && typeof raw === 'object' && raw !== null) {
57
- // Attempt minimal mapping: if the Zod shape has a `type` field
58
- // from its _def, describe it as JSON Schema.
59
- const def = raw as { type?: string; items?: unknown; properties?: unknown; required?: string[] };
60
- return { type: def.type ?? 'object', properties: def.properties, required: def.required };
61
- }
62
- return { type: 'object' };
63
- }
64
-
65
43
  export async function runGenerateOpenapi(options: GenerateOpenapiOptions): Promise<string> {
66
44
  const projectRoot = options.projectRoot ?? process.cwd();
67
45
  const domain = options.domain;
@@ -74,22 +52,22 @@ export async function runGenerateOpenapi(options: GenerateOpenapiOptions): Promi
74
52
 
75
53
  const paths: Record<string, unknown> = {};
76
54
 
77
- for (const api of registry.apis) {
78
- const method = (api.method ?? 'get').toLowerCase();
79
- const fullPath = `/v1/${domain}${api.path}`;
80
- const v1 = api.versions['v1'] ?? api.versions[Object.keys(api.versions)[0]];
81
- if (!v1) continue;
55
+ for (const action of registry.actions ?? []) {
56
+ if (action.exposure?.type !== 'api') continue;
57
+ const method = (action.exposure.method ?? 'get').toLowerCase();
58
+ const fullPath = `/v1/${domain}${action.exposure.path}`;
59
+ const inputSchema = action.inputSchema ?? { type: 'object' };
60
+ const outputSchema = action.outputSchema ?? { type: 'object' };
82
61
 
83
62
  if (!paths[fullPath]) paths[fullPath] = {};
84
-
85
63
  (paths[fullPath] as Record<string, unknown>)[method] = {
86
- operationId: `${domain}.${api.id}`,
87
- summary: `${domain}.${api.id}`,
88
- description: `Version: ${v1.status}`,
64
+ operationId: `${domain}.${action.id}`,
65
+ summary: `${domain}.${action.id}`,
66
+ description: 'Action-first contract (defineAction + exposure.type=api).',
89
67
  requestBody: {
90
68
  content: {
91
69
  'application/json': {
92
- schema: extractSchema(v1, 'input'),
70
+ schema: inputSchema,
93
71
  },
94
72
  },
95
73
  },
@@ -98,7 +76,7 @@ export async function runGenerateOpenapi(options: GenerateOpenapiOptions): Promi
98
76
  description: 'OK',
99
77
  content: {
100
78
  'application/json': {
101
- schema: extractSchema(v1, 'output'),
79
+ schema: outputSchema,
102
80
  },
103
81
  },
104
82
  },
@@ -151,4 +129,4 @@ export async function runGenerateOpenapiCli(domain: string, opts: { projectRoot?
151
129
  const outputPath = await runGenerateOpenapi({ domain, ...opts });
152
130
  // eslint-disable-next-line no-console
153
131
  console.log(`OpenAPI spec written to: ${outputPath}`);
154
- }
132
+ }
@@ -0,0 +1,89 @@
1
+ import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
2
+ import { join, relative } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { cliLogger } from '../utils/logger.js';
5
+
6
+ /**
7
+ * Options for the regenerate-modules-hashes command.
8
+ */
9
+ export interface RegenerateModulesHashesOptions {
10
+ /** Root directory of the project (defaults to cwd). */
11
+ projectRoot?: string;
12
+ }
13
+
14
+ /**
15
+ * Manifest of infra/modules/ files with their SHA256 hashes.
16
+ */
17
+ export interface ModulesHashesManifest {
18
+ /** Schema version. */
19
+ version: '1';
20
+ /** ISO timestamp when the manifest was generated. */
21
+ generatedAt: string;
22
+ /** Map of relative file path to SHA256 hex digest. */
23
+ files: Record<string, string>;
24
+ }
25
+
26
+ /**
27
+ * Walk infra/modules/, compute a SHA256 for every file, and write the result
28
+ * to .mc/modules-hashes.json. Used to refresh the scaffold drift baseline.
29
+ * @param opts Options including project root directory.
30
+ * @returns Absolute path to the written manifest file.
31
+ */
32
+ export async function runRegenerateModulesHashes(
33
+ opts: RegenerateModulesHashesOptions = {},
34
+ ): Promise<string> {
35
+ const root = opts.projectRoot ?? process.cwd();
36
+ const mcDir = join(root, '.mc');
37
+ const manifestPath = join(mcDir, 'modules-hashes.json');
38
+ const modulesDir = join(root, 'infra', 'modules');
39
+
40
+ // Files/directories to skip when walking infra/modules/
41
+ const SKIP_NAMES = new Set(['node_modules', 'dist', 'cdk.out', 'package-lock.json', '.npmrc']);
42
+
43
+ /** Normalize CRLF → LF so hashes match across platforms (Windows vs Linux). */
44
+ function normalizeLineEndings(buf: Buffer): Buffer {
45
+ const str = buf.toString('utf8');
46
+ if (!str.includes('\r\n')) return buf;
47
+ return Buffer.from(str.replace(/\r\n/g, '\n'), 'utf8');
48
+ }
49
+
50
+ // Walk infra/modules/ and collect every file path
51
+ async function walk(dir: string): Promise<string[]> {
52
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
53
+ const files: string[] = [];
54
+ for (const entry of entries) {
55
+ if (SKIP_NAMES.has(entry.name)) continue;
56
+ const full = join(dir, entry.name);
57
+ if (entry.isDirectory()) files.push(...await walk(full));
58
+ else if (entry.isFile()) files.push(full);
59
+ }
60
+ return files;
61
+ }
62
+
63
+ const onDiskFiles = await walk(modulesDir);
64
+
65
+ // Compute SHA256 for each file (line-ending-normalised)
66
+ const files: Record<string, string> = {};
67
+ for (const absPath of onDiskFiles) {
68
+ const relPath = relative(root, absPath).replace(/\\/g, '/');
69
+ const content = await readFile(absPath);
70
+ const normalized = normalizeLineEndings(content);
71
+ files[relPath] = createHash('sha256').update(normalized).digest('hex');
72
+ }
73
+
74
+ const manifest: ModulesHashesManifest = {
75
+ version: '1',
76
+ generatedAt: new Date().toISOString(),
77
+ files,
78
+ };
79
+
80
+ await mkdir(mcDir, { recursive: true });
81
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
82
+
83
+ cliLogger.info(
84
+ { path: manifestPath, fileCount: Object.keys(files).length },
85
+ 'modules-hashes manifest regenerated',
86
+ );
87
+
88
+ return manifestPath;
89
+ }
@@ -21,10 +21,10 @@ export interface ShowDomainOptions {
21
21
  const KEBAB_REGEX = /^[a-z][a-z0-9-]*$/;
22
22
 
23
23
  /**
24
- * Represents a primitive entry (API, subscriber, action, or job).
24
+ * Represents a primitive entry (action, subscriber, or job).
25
25
  */
26
26
  interface PrimitiveEntry {
27
- type: 'api' | 'subscriber' | 'action' | 'job';
27
+ type: 'subscriber' | 'action' | 'job';
28
28
  id: string;
29
29
  file: string;
30
30
  }
@@ -35,7 +35,6 @@ interface PrimitiveEntry {
35
35
  interface DomainSummary {
36
36
  domain: string;
37
37
  primitives: {
38
- apis: PrimitiveEntry[];
39
38
  subscribers: PrimitiveEntry[];
40
39
  actions: PrimitiveEntry[];
41
40
  jobs: PrimitiveEntry[];
@@ -114,7 +113,6 @@ export async function runShowDomain(opts: ShowDomainOptions): Promise<DomainSumm
114
113
  }
115
114
 
116
115
  // Scan primitives
117
- const apis = await scanPrimitives(join(domainDir, 'api'), 'api');
118
116
  const subscribers = await scanPrimitives(join(domainDir, 'subscribers'), 'subscriber');
119
117
  const actions = await scanPrimitives(join(domainDir, 'actions'), 'action');
120
118
  const jobs = await scanPrimitives(join(domainDir, 'jobs'), 'job');
@@ -155,7 +153,6 @@ export async function runShowDomain(opts: ShowDomainOptions): Promise<DomainSumm
155
153
  const summary: DomainSummary = {
156
154
  domain: opts.domain,
157
155
  primitives: {
158
- apis,
159
156
  subscribers,
160
157
  actions,
161
158
  jobs,
@@ -38,7 +38,6 @@ export async function runTest(options: TestOptions): Promise<void> {
38
38
  const { registry } = await buildRegistry(domainRoot);
39
39
 
40
40
  const allEntries: RegistryEntry[] = [
41
- ...registry.apis,
42
41
  ...registry.webhooks,
43
42
  ...registry.subscribers,
44
43
  ...registry.schedules,
@@ -0,0 +1,79 @@
1
+ import { join } from 'node:path';
2
+ import { cliLogger } from '../utils/logger.js';
3
+ import { readScaffoldConfig } from '../utils/scaffold-config.js';
4
+ import { runBuild } from './build.js';
5
+ import { runBuildCatalog } from './build-catalog.js';
6
+ import { runBuildFlows } from './build-flows.js';
7
+ import { runBuildUi } from './build-ui.js';
8
+ import { runRegenerateModulesHashes } from './regenerate-modules-hashes.js';
9
+ import { runDoctor } from './doctor.js';
10
+
11
+ /**
12
+ * Run the full scaffold update pipeline:
13
+ * 1. Build each domain registry from `.mc/scaffold-config.json`
14
+ * 2. Build the combined domain catalog
15
+ * 3. Build flows registry
16
+ * 4. Build UI manifests
17
+ * 5. Regenerate module hashes
18
+ * 6. Run doctor
19
+ *
20
+ * Per-domain build failures are logged and skipped so that one bad domain does
21
+ * not abort the whole pipeline. The remaining steps still run.
22
+ *
23
+ * @param opts - Optional projectRoot override. Defaults to cwd.
24
+ * @returns success derived from doctor pass/fail and a human-readable summary.
25
+ */
26
+ export async function runUpdateAll(opts?: { projectRoot?: string }): Promise<{ success: boolean; summary: string }> {
27
+ const projectRoot = opts?.projectRoot ?? process.cwd();
28
+
29
+ cliLogger.info({ projectRoot }, 'update-all: starting');
30
+
31
+ // 1. Read scaffold-config to discover domains
32
+ const scaffoldConfig = await readScaffoldConfig(projectRoot);
33
+ const domainIds = scaffoldConfig.domainIds ?? [];
34
+
35
+ cliLogger.info({ domainCount: domainIds.length }, 'update-all: building domain registries');
36
+
37
+ // 2. Build each domain. Per-domain failures are logged and skipped.
38
+ for (const domainId of domainIds) {
39
+ const domainRoot = join(projectRoot, 'domains', domainId);
40
+ try {
41
+ cliLogger.info({ domainId, domainRoot }, 'update-all: building domain');
42
+ await runBuild({ domainRoot });
43
+ } catch (err) {
44
+ cliLogger.error(
45
+ { domainId, err: err instanceof Error ? err.message : String(err) },
46
+ 'update-all: domain build failed — continuing',
47
+ );
48
+ }
49
+ }
50
+
51
+ // 3. Build catalog from .mc
52
+ const mcDir = join(projectRoot, '.mc');
53
+ cliLogger.info({ mcDir }, 'update-all: building domain catalog');
54
+ await runBuildCatalog(mcDir);
55
+
56
+ // 4. Build flows
57
+ cliLogger.info({ projectRoot }, 'update-all: building flows registry');
58
+ await runBuildFlows({ projectRoot });
59
+
60
+ // 5. Build UI manifests
61
+ cliLogger.info({ projectRoot }, 'update-all: building UI manifests');
62
+ await runBuildUi({ projectRoot });
63
+
64
+ // 6. Regenerate module hashes
65
+ cliLogger.info({ projectRoot }, 'update-all: regenerating module hashes');
66
+ await runRegenerateModulesHashes({ projectRoot });
67
+
68
+ // 7. Run doctor — final gate
69
+ cliLogger.info({ projectRoot }, 'update-all: running doctor');
70
+ const report = await runDoctor({ projectRoot });
71
+
72
+ cliLogger.info(
73
+ { pass: report.pass, exitCode: report.exitCode, checkCount: report.checks.length },
74
+ 'update-all: doctor complete',
75
+ );
76
+
77
+ const summary = `Updated ${domainIds.length} domains; doctor ${report.pass ? 'PASS' : 'FAIL'}`;
78
+ return { success: report.pass, summary };
79
+ }
@@ -22,10 +22,10 @@ const MIGRATIONS: Migration[] = [
22
22
  {
23
23
  fromMajor: 1,
24
24
  toMajor: 2,
25
- description: 'Migrate defineApi from v1 to v2: rename `versions.v1.handler` shape',
25
+ description: 'Audit v1 domain backends for action-first API exposure requirements',
26
26
  transform(repoRoot: string, dryRun: boolean): void {
27
- // Placeholder: real migration uses ts-morph to rewrite handler signatures
28
- console.log(`[G20] Would apply: defineApi v1→v2 migration in ${repoRoot}/domains/**`);
27
+ // Placeholder: real migration uses ts-morph to rewrite handler signatures.
28
+ console.log(`[G20] Would audit action-first API exposure in ${repoRoot}/domains/**`);
29
29
  if (!dryRun) {
30
30
  console.log('[G20] ts-morph transform: (not yet implemented — add jscodeshift transforms here)');
31
31
  }
@@ -149,44 +149,17 @@ async function checkRawPathViolations(
149
149
  * Each rule maps 1:1 to an aspect annotation code so downstream tooling
150
150
  * can correlate build-time and synth-time failures.
151
151
  *
152
- * NOTE on `SECURITY_MISSING_SECURITY_EXCEPTION` for legacy `defineApi`
153
- * entries: the registry builder does not propagate `securityException`
154
- * from legacy sourcethat field only exists on action-style API
155
- * exposures, which are covered by the existing `AUTH_NONE_REQUIRES_EXCEPTION`
156
- * rule in `checkActionFirstSecurity`. The aspect's corresponding check
157
- * (`assertAnonymousRoutesHaveException`) catches the legacy case at
158
- * synth time so a developer migrating to action-style APIs gets the
159
- * right guidance at the right layer.
152
+ * Issue #4689: the legacy `defineApi` factory and `registry.apis` field
153
+ * were removed. Deployment-time API invariants are enforced against the
154
+ * `actions[]` API-exposure surfacesee `checkActionFirstSecurity`.
160
155
  */
161
- function checkDeploymentSecurity(
162
- apis: import('@mettlecast/domain-cdk-packer').ApiRegistryEntry[],
163
- actions: ActionRegistryEntry[],
164
- ): ValidationError[] {
165
- const errors: ValidationError[] = [];
166
-
167
- for (const api of apis) {
168
- // Non-anonymous APIs whose path lacks the tenant placeholder cannot
169
- // bind `ctx.tenant.id` from the URL. The aspect surfaces this as
170
- // `SECURITY_MISSING_TENANT_PATH`; the CLI version is a faster gate so
171
- // CI doesn't pay the synth cost.
172
- if (api.authType !== 'none' && !api.path.includes('/v1/tenants/{tenantId}/')) {
173
- errors.push({
174
- code: 'SECURITY_MISSING_TENANT_PATH',
175
- message: `API '${api.id}' (${api.method} ${api.path}) is JWT-protected but its path does not include the canonical '/v1/tenants/{tenantId}/' placeholder. The runtime cannot bind ctx.tenant.id from a path that lacks the placeholder.`,
176
- });
177
- }
178
- }
179
-
156
+ function checkDeploymentSecurity(_actions: ActionRegistryEntry[]): ValidationError[] {
180
157
  // Action API exposures are already covered by `checkActionFirstSecurity`
181
158
  // for `tenancy: 'required'` paths and `auth: 'none'` exceptions. The
182
159
  // codes there (TENANT_API_PATH_REQUIRED, AUTH_NONE_REQUIRES_EXCEPTION)
183
160
  // are kept stable for back-compat — they map to the same aspect codes.
184
-
185
- // Suppress the unused-actions lint while keeping the parameter shape
186
- // for the future where actions gain new deployment-time invariants.
187
- void actions;
188
-
189
- return errors;
161
+ //
162
+ return [];
190
163
  }
191
164
 
192
165
  /**
@@ -308,28 +281,6 @@ function checkApiExposureSecurity(actionId: string, exposure: ActionApiExposureW
308
281
  return errors;
309
282
  }
310
283
 
311
- /**
312
- * DEFINE_API_LEGACY_USAGE — the action-first migration replaces
313
- * standalone `defineApi` calls with `defineAction` + `exposure.type:
314
- * 'api'`. Every `defineApi` call still in the source produces a row in
315
- * `registry.apis`; the validator emits one error per legacy API to
316
- * enforce the alpha breaking migration. The validate command's fixture
317
- * suite intentionally does not use `defineApi`, so this rule does not
318
- * break existing unit tests.
319
- */
320
- function checkDefineApiLegacyUsage(apiCount: number, apis: RegistryEntry[]): ValidationError[] {
321
- if (apiCount === 0) return [];
322
- const errors: ValidationError[] = [];
323
- for (const api of apis) {
324
- if (api.kind !== 'api') continue;
325
- errors.push({
326
- code: 'DEFINE_API_LEGACY_USAGE',
327
- message: `API '${api.id}' uses legacy \`defineApi\`. Migrate to \`defineAction\` with \`exposure: { type: 'api', path, method, auth, tenancy }\` (#4619).`,
328
- });
329
- }
330
- return errors;
331
- }
332
-
333
284
  /**
334
285
  * Run the validate command: build the registry and perform structural validation.
335
286
  * Exits the process with code 1 if validation fails (CI gate usage).
@@ -352,7 +303,6 @@ export async function runValidate(
352
303
  }
353
304
 
354
305
  errors.push(
355
- ...checkDuplicateIds(registry.apis, 'api'),
356
306
  ...checkDuplicateIds(registry.webhooks, 'webhook'),
357
307
  ...checkDuplicateIds(registry.subscribers, 'subscriber'),
358
308
  ...checkDuplicateIds(registry.schedules, 'schedule'),
@@ -363,7 +313,6 @@ export async function runValidate(
363
313
  );
364
314
 
365
315
  const entriesWithFiles = [
366
- ...registry.apis,
367
316
  ...registry.webhooks,
368
317
  ...registry.subscribers,
369
318
  ...registry.schedules,
@@ -387,42 +336,9 @@ export async function runValidate(
387
336
 
388
337
  errors.push(...checkSubscriberSemverRanges(registry.subscribers));
389
338
 
390
- const VALID_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
391
- for (const api of registry.apis) {
392
- const entry = api as unknown as { deprecatedAt?: string; sunsetAt?: string };
393
- errors.push(...checkLifecycleConsistency(entry, `api '${api.id}'`));
394
- if (!api.requestSchema) {
395
- errors.push({
396
- code: 'MISSING_API_REQUEST_SCHEMA',
397
- message: `API '${api.id}' (${api.method} ${api.path}) has no request schema. Define input/output in defineApi versions.`,
398
- });
399
- }
400
- if (!api.responseSchema) {
401
- errors.push({
402
- code: 'MISSING_API_RESPONSE_SCHEMA',
403
- message: `API '${api.id}' (${api.method} ${api.path}) has no response schema. Define input/output in defineApi versions.`,
404
- });
405
- }
406
- if (!VALID_METHODS.has(api.method)) {
407
- errors.push({
408
- code: 'INVALID_API_METHOD',
409
- message: `API '${api.id}' (${api.method} ${api.path}) has invalid method "${api.method}". Use one of: ${[...VALID_METHODS].join(', ')}.`,
410
- });
411
- }
412
- const isVoidInput = api.requestSchema?.type === 'null';
413
- if (!isVoidInput && !api.examples?.request) {
414
- errors.push({
415
- code: 'MISSING_API_REQUEST_EXAMPLE',
416
- message: `API '${api.id}' (${api.method} ${api.path}) has no request example. Add examples: { request: {...}, response: {...} } to the defineApi config.`,
417
- });
418
- }
419
- if (!api.examples?.response) {
420
- errors.push({
421
- code: 'MISSING_API_RESPONSE_EXAMPLE',
422
- message: `API '${api.id}' (${api.method} ${api.path}) has no response example. Add examples: { request: {...}, response: {...} } to the defineApi config.`,
423
- });
424
- }
425
- }
339
+ // Issue #4689: defineApi and registry.apis were removed. The action-first
340
+ // validation rules in `checkActionFirstSecurity` cover the action surface
341
+ // that now owns all HTTP endpoints.
426
342
 
427
343
  const rawPathErrors = await checkRawPathViolations(registry.domainRoot, config);
428
344
  errors.push(...rawPathErrors);
@@ -431,14 +347,13 @@ export async function runValidate(
431
347
  // Runs against the registry built above so the rules see the same
432
348
  // shape the CDK packer will eventually consume.
433
349
  errors.push(...checkActionFirstSecurity(registry.actions));
434
- errors.push(...checkDefineApiLegacyUsage(registry.apis.length, registry.apis));
435
350
 
436
351
  // Issue #4662 Task D — deployment-time security gates. These mirror
437
352
  // the CDK synth-time `SecurityAssertionAspect` so violations are
438
353
  // caught before any AWS deployment is attempted.
439
- errors.push(...checkDeploymentSecurity(registry.apis, registry.actions));
354
+ errors.push(...checkDeploymentSecurity(registry.actions));
440
355
 
441
- const totalPrimitives = registry.apis.length + registry.webhooks.length +
356
+ const totalPrimitives = registry.webhooks.length +
442
357
  registry.subscribers.length + registry.schedules.length +
443
358
  registry.jobs.length + registry.actions.length;
444
359
  if (totalPrimitives === 0) {
@@ -1,5 +1,5 @@
1
1
  import Fastify, { type FastifyInstance } from 'fastify';
2
- import type { DomainRegistry } from '@mettlecast/domain-cdk-packer';
2
+ import type { DomainRegistry } from '../types.js';
3
3
  import { mountRoutes } from './mount-routes.js';
4
4
 
5
5
  /**
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import type { FastifyInstance } from 'fastify';
3
- import type { DomainRegistry } from '@mettlecast/domain-cdk-packer';
3
+ import type { DomainRegistry } from '../types.js';
4
4
  import { hydrateLocalCtx } from '../runtime-stubs/hydrate-local-ctx.js';
5
5
 
6
6
  /**
@@ -21,30 +21,41 @@ function toFastifyPath(path: string): string {
21
21
 
22
22
  /**
23
23
  * Mount all API routes from the DomainRegistry onto the Fastify server.
24
- * Each route dynamically imports its handler file and hydrates a local dev ctx per request.
24
+ *
25
+ * Issue #4689: the only HTTP endpoint surface in the new registry is
26
+ * `actions[]` whose `exposure.type === 'api'`. `defineApi` was removed
27
+ * and `registry.apis` is always empty in fresh registries, so the
28
+ * dev server iterates the action array and mounts each API exposure
29
+ * directly. Internal-only actions are intentionally skipped — they
30
+ * are reachable only through `ctx.actions`.
31
+ *
32
+ * Each route dynamically imports its handler file and hydrates a local
33
+ * dev ctx per request.
25
34
  * @param options - MountRoutesOptions.
26
35
  */
27
36
  export async function mountRoutes(options: MountRoutesOptions): Promise<void> {
28
37
  const { server, registry, domainRoot } = options;
29
38
 
30
- for (const api of registry.apis) {
31
- const handlerAbsPath = join(domainRoot, api.handlerFile);
32
- const fastifyPath = toFastifyPath(api.path);
39
+ for (const action of registry.actions) {
40
+ if (action.exposure?.type !== 'api') continue;
41
+ const exposure = action.exposure;
42
+ const handlerAbsPath = join(domainRoot, action.handlerFile);
43
+ const fastifyPath = toFastifyPath(exposure.path);
33
44
 
34
45
  const mod = await import(handlerAbsPath) as Record<string, unknown>;
35
46
  const def = Object.values(mod).find(
36
- v => v && typeof v === 'object' && (v as Record<string, unknown>)['id'] === api.id
47
+ v => v && typeof v === 'object' && (v as Record<string, unknown>)['id'] === action.id
37
48
  ) as Record<string, unknown> | undefined;
38
49
 
39
50
  if (!def || typeof def['handler'] !== 'function') {
40
- server.log.warn({ apiId: api.id, handlerAbsPath }, 'No handler function found, skipping route');
51
+ server.log.warn({ actionId: action.id, handlerAbsPath }, 'No handler function found, skipping route');
41
52
  continue;
42
53
  }
43
54
 
44
55
  const handler = def['handler'] as (event: unknown, ctx: unknown) => Promise<unknown>;
45
56
 
46
57
  server.route({
47
- method: api.method === 'ANY' ? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] : [api.method as never],
58
+ method: [exposure.method as never],
48
59
  url: fastifyPath,
49
60
  handler: async (request, reply) => {
50
61
  const { ctx } = await hydrateLocalCtx({ domainRoot });
@@ -63,6 +74,6 @@ export async function mountRoutes(options: MountRoutesOptions): Promise<void> {
63
74
  },
64
75
  });
65
76
 
66
- server.log.info({ method: api.method, path: fastifyPath, apiId: api.id }, 'Route mounted');
77
+ server.log.info({ method: exposure.method, path: fastifyPath, actionId: action.id }, 'Route mounted');
67
78
  }
68
- }
79
+ }