@mettlecast/domain-cli 0.2.86 → 0.2.88

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.
@@ -15,20 +15,19 @@ import type {
15
15
  import { walkDomainDir } from '../utils/file-helpers.js';
16
16
  import { loadModuleExports, type RawPrimitiveExport } from './load-module.js';
17
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
-
22
18
  type ActionBackendAccess = 'private' | 'domain' | 'platform';
23
19
 
20
+ type ValidAuth = 'required' | 'none' | 'service';
21
+ type ValidTenancy = 'required' | 'none' | 'system';
22
+
24
23
  type ActionExposure =
25
24
  | { type: 'internal' }
26
25
  | {
27
26
  type: 'api';
28
27
  path: string;
29
28
  method: string;
30
- auth: 'required' | 'none' | 'service';
31
- tenancy: 'required' | 'none' | 'system';
29
+ auth: ValidAuth;
30
+ tenancy: ValidTenancy;
32
31
  roles?: string[];
33
32
  securityException?: { reason: string };
34
33
  authDeclared?: boolean;
@@ -36,68 +35,94 @@ type ActionExposure =
36
35
  };
37
36
 
38
37
  /**
39
- * Coerce a raw value into a valid backendAccess scope, defaulting to
40
- * 'private' when the value is missing or unrecognized. Used by the
41
- * builder when reading the new-style `backendAccess` field directly.
38
+ * Validate that a raw value is a valid backendAccess scope.
39
+ * Throws with diagnostics on invalid/missing values.
42
40
  */
43
- function toBackendAccess(raw: unknown): ActionBackendAccess {
44
- return raw === 'domain' || raw === 'platform' ? raw : 'private';
45
- }
46
-
47
- /**
48
- * Map a legacy `visibility` scope to a `backendAccess` scope for the
49
- * action-first migration. The legacy `workspace` scope (which previously
50
- * permitted unauthenticated Function URL exposure) collapses to `domain`
51
- * so that all cross-domain callers must go through `ctx.actions`.
52
- */
53
- function visibilityToBackendAccess(visibility: unknown): ActionBackendAccess {
54
- if (visibility === 'workspace') return 'domain';
55
- if (visibility === 'domain') return 'domain';
56
- return 'private';
41
+ function validateBackendAccess(raw: unknown, actionId: string): ActionBackendAccess {
42
+ if (raw === 'domain' || raw === 'platform' || raw === 'private') {
43
+ return raw;
44
+ }
45
+ throw new Error(
46
+ `Action '${actionId}' has invalid or missing \`backendAccess\`. ` +
47
+ `Must be one of: 'private', 'domain', 'platform'. Got: ${JSON.stringify(raw)}`,
48
+ );
57
49
  }
58
50
 
59
51
  /**
60
- * Best-effort reverse mapping from `backendAccess` to the legacy
61
- * `visibility` field. Used to keep the deprecated field populated so
62
- * existing CDK constructs that read it continue to behave the same way.
63
- *
64
- * - private -> private
65
- * - domain -> domain
66
- * - platform -> workspace (closest legacy equivalent for platform-level)
52
+ * Validate and parse an action's `exposure` field.
53
+ * Throws with actionable diagnostics when exposure is missing, null, or malformed.
67
54
  */
68
- function backendAccessToVisibility(backendAccess: ActionBackendAccess): 'private' | 'domain' | 'workspace' {
69
- if (backendAccess === 'platform') return 'workspace';
70
- return backendAccess;
71
- }
55
+ function validateExposure(raw: unknown, actionId: string): { exposure: ActionExposure; authDeclared: boolean; tenancyDeclared: boolean } {
56
+ if (!raw || typeof raw !== 'object') {
57
+ throw new Error(
58
+ `Action '${actionId}' has missing or invalid \`exposure\`. ` +
59
+ `Every action must declare \`exposure\` explicitly. ` +
60
+ `Use \`exposure: { type: 'internal' }\` for internal-only actions, or ` +
61
+ `\`exposure: { type: 'api', path: '...', method: '...', auth: '...', tenancy: '...' }\` for API-exposed actions.`,
62
+ );
63
+ }
72
64
 
73
- /**
74
- * Type guard + sanitizer for an action's `exposure` field. Returns a
75
- * well-typed `ActionExposure` (extended with `authDeclared` /
76
- * `tenancyDeclared` tracking flags) or the provided fallback when the
77
- * raw value does not match a supported exposure shape.
78
- *
79
- * The `authDeclared` / `tenancyDeclared` flags record whether the source
80
- * code explicitly declared each field, or whether the builder fell back
81
- * to the safe default. They are validation-only and consumed by the
82
- * domain CLI's validate command (Wave 6 Task 6.1) to enforce the
83
- * action-first security model (#4619).
84
- */
85
- function toExposure(raw: unknown, fallback: ActionExposure): ActionExposure {
86
- if (!raw || typeof raw !== 'object') return fallback;
87
65
  const candidate = raw as { type?: unknown };
88
- if (candidate.type === 'internal') return { type: 'internal' };
89
- if (candidate.type !== 'api') return fallback;
90
- // Best-effort validation of api exposure fields; any missing required
91
- // string field falls back to the supplied default exposure.
66
+ if (candidate.type === 'internal') {
67
+ // Internal must be exactly { type: 'internal' } — no extra keys
68
+ const extraKeys = Object.keys(raw as object).filter(k => k !== 'type');
69
+ if (extraKeys.length > 0) {
70
+ throw new Error(
71
+ `Action '${actionId}' has \`exposure.type: 'internal'\` with unexpected keys: ${extraKeys.join(', ')}. ` +
72
+ `Internal exposure must be exactly \`{ type: 'internal' }\`.`,
73
+ );
74
+ }
75
+ return { exposure: { type: 'internal' }, authDeclared: false, tenancyDeclared: false };
76
+ }
77
+
78
+ if (candidate.type !== 'api') {
79
+ throw new Error(
80
+ `Action '${actionId}' has \`exposure.type\` set to ${JSON.stringify(candidate.type)}. ` +
81
+ `Must be 'api' or 'internal'.`,
82
+ );
83
+ }
84
+
85
+ // Validate API exposure
92
86
  const api = raw as { path?: unknown; method?: unknown; auth?: unknown; tenancy?: unknown; roles?: unknown; securityException?: unknown };
93
- if (typeof api.path !== 'string' || typeof api.method !== 'string') return fallback;
87
+
88
+ if (typeof api.path !== 'string' || api.path.trim().length === 0) {
89
+ throw new Error(
90
+ `Action '${actionId}' has \`exposure.type: 'api'\` but \`exposure.path\` is missing or empty. ` +
91
+ `Provide a non-empty path string (e.g. '/v1/tenants/{tenantId}/users').`,
92
+ );
93
+ }
94
+
95
+ if (typeof api.method !== 'string' || api.method.trim().length === 0) {
96
+ throw new Error(
97
+ `Action '${actionId}' has \`exposure.type: 'api'\` but \`exposure.method\` is missing or empty. ` +
98
+ `Provide an HTTP method (e.g. 'GET', 'POST', 'PUT', 'PATCH', 'DELETE').`,
99
+ );
100
+ }
101
+
94
102
  const authRaw = api.auth;
95
103
  const tenancyRaw = api.tenancy;
96
- const auth: 'required' | 'none' | 'service' =
97
- authRaw === 'required' || authRaw === 'none' || authRaw === 'service' ? authRaw : 'required';
98
- const tenancy: 'required' | 'none' | 'system' =
99
- tenancyRaw === 'required' || tenancyRaw === 'none' || tenancyRaw === 'system' ? tenancyRaw : 'required';
100
- const out: ActionExposure & { type: 'api'; authDeclared?: boolean; tenancyDeclared?: boolean } = {
104
+
105
+ let auth: ValidAuth;
106
+ if (authRaw === 'required' || authRaw === 'none' || authRaw === 'service') {
107
+ auth = authRaw;
108
+ } else {
109
+ throw new Error(
110
+ `Action '${actionId}' has \`exposure.type: 'api'\` but \`exposure.auth\` is missing or invalid. ` +
111
+ `Must be 'required', 'none', or 'service'. Got: ${JSON.stringify(authRaw)}`,
112
+ );
113
+ }
114
+
115
+ let tenancy: ValidTenancy;
116
+ if (tenancyRaw === 'required' || tenancyRaw === 'none' || tenancyRaw === 'system') {
117
+ tenancy = tenancyRaw;
118
+ } else {
119
+ throw new Error(
120
+ `Action '${actionId}' has \`exposure.type: 'api'\` but \`exposure.tenancy\` is missing or invalid. ` +
121
+ `Must be 'required', 'none', or 'system'. Got: ${JSON.stringify(tenancyRaw)}`,
122
+ );
123
+ }
124
+
125
+ const out: ActionExposure & { authDeclared?: boolean; tenancyDeclared?: boolean } = {
101
126
  type: 'api',
102
127
  path: api.path,
103
128
  method: api.method,
@@ -106,6 +131,7 @@ function toExposure(raw: unknown, fallback: ActionExposure): ActionExposure {
106
131
  authDeclared: authRaw === 'required' || authRaw === 'none' || authRaw === 'service',
107
132
  tenancyDeclared: tenancyRaw === 'required' || tenancyRaw === 'none' || tenancyRaw === 'system',
108
133
  };
134
+
109
135
  if (Array.isArray(api.roles)) {
110
136
  out.roles = api.roles.filter((r): r is string => typeof r === 'string');
111
137
  }
@@ -115,7 +141,8 @@ function toExposure(raw: unknown, fallback: ActionExposure): ActionExposure {
115
141
  out.securityException = { reason };
116
142
  }
117
143
  }
118
- return out;
144
+
145
+ return { exposure: out, authDeclared: out.authDeclared ?? false, tenancyDeclared: out.tenancyDeclared ?? false };
119
146
  }
120
147
 
121
148
  /** Returns true if a value looks like a JSON Schema object (has a 'type' or '$schema' property). */
@@ -138,10 +165,16 @@ export interface BuildResult {
138
165
  * Build a DomainRegistry from a domain source directory.
139
166
  * Walks the conventional directory structure, loads each primitive definition
140
167
  * file via the tsx child-process loader, and assembles the registry snapshot.
141
- * Zod schema fields are omitted in Phase 1e (schema extraction is v1.x scope).
168
+ *
169
+ * Strict contract (#5090): every action must have `kind:'action'`, id,
170
+ * handlerFile, backendAccess, idempotent, and explicit exposure. API requires
171
+ * path/method/auth/tenancy; internal must be exactly `{type:'internal'}`.
172
+ * Missing or invalid fields cause a build failure with actionable diagnostics.
173
+ *
142
174
  * @param domainRoot - Absolute path to the domain root directory.
143
175
  * @returns BuildResult containing the assembled registry and any warnings.
144
- * @throws If domain.config.ts is missing or does not export a 'domain' primitive.
176
+ * @throws If domain.config.ts is missing or does not export a 'domain' primitive,
177
+ * or if any action has missing/invalid required fields.
145
178
  */
146
179
  export async function buildRegistry(domainRoot: string): Promise<BuildResult> {
147
180
  // Resolve to absolute path so downstream fs operations and tsx eval imports
@@ -266,43 +299,34 @@ const [webhookExports, subscriberExports, actionExports,
266
299
  (actionExports[i] ?? [])
267
300
  .filter(e => e['_kind'] === 'action')
268
301
  .map(e => {
269
- // Resolve backendAccess. New-style actions carry `backendAccess`
270
- // directly. Legacy actions only carry `visibility`; in that case
271
- // we collapse workspace -> domain per the migration spec, and we
272
- // also remember the legacy flag so we can default exposure to
273
- // `{ type: 'internal' }` for actions that have not opted in yet.
274
- const hasBackendAccess = 'backendAccess' in e;
275
- const rawBackendAccess = e['backendAccess'];
276
- const rawVisibility = e['visibility'];
277
- const backendAccess: ActionBackendAccess = hasBackendAccess
278
- ? toBackendAccess(rawBackendAccess)
279
- : visibilityToBackendAccess(rawVisibility);
280
- const legacyVisibility = backendAccessToVisibility(backendAccess);
281
-
282
- // Resolve exposure. New-style actions must declare their exposure;
283
- // legacy actions that did not opt in default to `{ type: 'internal' }`
284
- // so existing internal-only behavior is preserved during migration.
285
- const rawExposure = e['exposure'];
286
- const exposure: ActionExposure = toExposure(rawExposure, { type: 'internal' });
287
- // Record whether the source explicitly declared the `exposure` field.
288
- // Validation-only; consumed by the domain CLI's validate command
289
- // (Wave 6 Task 6.1) to enforce `ACTION_EXPOSURE_REQUIRED`.
290
- const exposureDeclared = rawExposure !== undefined && rawExposure !== null
291
- && typeof rawExposure === 'object';
302
+ const actionId = String(e['id']);
303
+
304
+ // Validate required fields strict contract (#5090)
305
+ if (!actionId) {
306
+ throw new Error(`Action in ${relPath(filePath)} has missing or empty \`id\`.`);
307
+ }
308
+
309
+ // Validate backendAccess — required, no legacy default
310
+ const backendAccess = validateBackendAccess(e['backendAccess'], actionId);
311
+
312
+ // Validate exposure — required, no legacy default
313
+ const { exposure } = validateExposure(e['exposure'], actionId);
314
+
315
+ // Validate idempotent required boolean
316
+ if (typeof e['idempotent'] !== 'boolean') {
317
+ throw new Error(
318
+ `Action '${actionId}' has missing or invalid \`idempotent\`. ` +
319
+ `Must be a boolean (true or false). Got: ${JSON.stringify(e['idempotent'])}`,
320
+ );
321
+ }
292
322
 
293
323
  return {
294
- id: String(e['id']),
324
+ id: actionId,
295
325
  kind: 'action' as const,
296
326
  handlerFile: relPath(filePath),
297
327
  backendAccess,
298
328
  exposure,
299
- exposureDeclared,
300
- // Keep the legacy field populated so CDK constructs that still
301
- // read `visibility` (e.g. action-construct.ts) keep working
302
- // through the migration window. New constructs should read
303
- // `backendAccess` and `exposure` instead.
304
- visibility: legacyVisibility,
305
- idempotent: Boolean(e['idempotent'] ?? false),
329
+ idempotent: Boolean(e['idempotent']),
306
330
  description: typeof e['description'] === 'string' ? e['description'] : undefined,
307
331
  deployment: deployment(e),
308
332
  outboundAccess: outboundAccess(e),
@@ -105,30 +105,26 @@ async function parseTarball(buf: Buffer): Promise<TarEntry[]> {
105
105
  const entries: TarEntry[] = [];
106
106
 
107
107
  await new Promise<void>((resolve, reject) => {
108
- const parser = new tar.Parser({
109
- gzip: true,
110
- onentry(entry: tar.ReadEntry) {
111
- if (entry.type !== 'File') {
112
- entry.resume();
113
- return;
114
- }
108
+ const parser = new tar.Parser({ gzip: true });
109
+ parser.on('entry', (entry: tar.ReadEntry) => {
110
+ if (entry.type !== 'File') {
111
+ entry.resume();
112
+ return;
113
+ }
115
114
 
116
- const chunks: Buffer[] = [];
117
- entry.on('data', (chunk: Buffer) => {
118
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBuffer));
119
- });
120
- entry.on('end', () => {
121
- entries.push({ path: entry.path, content: Buffer.concat(chunks) });
122
- });
123
- entry.on('error', reject);
124
- },
115
+ const chunks: Buffer[] = [];
116
+ entry.on('data', (chunk: Buffer) => {
117
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBuffer));
118
+ });
119
+ entry.on('end', () => {
120
+ entries.push({ path: entry.path, content: Buffer.concat(chunks) });
121
+ });
122
+ entry.on('error', reject);
125
123
  });
126
-
127
124
  parser.on('finish', resolve);
128
125
  parser.on('error', reject);
129
126
 
130
- const readable = Readable.from(buf);
131
- readable.pipe(parser);
127
+ parser.end(buf);
132
128
  });
133
129
 
134
130
  return entries;
@@ -1,6 +1,13 @@
1
1
  import { join } from 'node:path';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { execSync } from 'node:child_process';
2
5
  import { cliLogger } from '../utils/logger.js';
3
6
  import { readScaffoldConfig } from '../utils/scaffold-config.js';
7
+ import {
8
+ loadToolchainManifest,
9
+ type ToolchainManifest,
10
+ } from '../utils/toolchain-manifest.js';
4
11
  import { runBuild } from './build.js';
5
12
  import { runBuildCatalog } from './build-catalog.js';
6
13
  import { runBuildFlows } from './build-flows.js';
@@ -8,8 +15,181 @@ import { runBuildUi } from './build-ui.js';
8
15
  import { runRegenerateModulesHashes } from './regenerate-modules-hashes.js';
9
16
  import { runDoctor } from './doctor.js';
10
17
 
18
+ /**
19
+ * Expected packages in the toolchain manifest.
20
+ */
21
+ const TOOLCHAIN_PACKAGE_KEYS = [
22
+ 'domainCli',
23
+ 'domainCdkPacker',
24
+ 'domainRuntime',
25
+ 'eslintPluginDomainModule',
26
+ ] as const;
27
+
28
+ /**
29
+ * Map from toolchain key to npm package name.
30
+ */
31
+ const TOOLCHAIN_NPM_NAMES: Record<string, string> = {
32
+ domainCli: '@mettlecast/domain-cli',
33
+ domainCdkPacker: '@mettlecast/domain-cdk-packer',
34
+ domainRuntime: '@mettlecast/domain-runtime',
35
+ eslintPluginDomainModule: '@mettlecast/eslint-plugin-domain-module',
36
+ };
37
+
38
+ /**
39
+ * Write exact toolchain versions into infra/modules/package.json.
40
+ * Replaces the @mettlecast/* dependency versions with the pinned exact versions
41
+ * from the toolchain manifest. Preserves all other fields unchanged.
42
+ *
43
+ * @param projectRoot - Project root directory.
44
+ * @param manifest - The resolved toolchain manifest.
45
+ */
46
+ async function pinInfraPackageVersions(
47
+ projectRoot: string,
48
+ manifest: ToolchainManifest,
49
+ ): Promise<void> {
50
+ const infraPkgPath = join(projectRoot, 'infra', 'modules', 'package.json');
51
+
52
+ if (!existsSync(infraPkgPath)) {
53
+ cliLogger.warn({ path: infraPkgPath }, 'infra/modules/package.json not found — skipping version pinning');
54
+ return;
55
+ }
56
+
57
+ const raw = await readFile(infraPkgPath, 'utf-8');
58
+ const pkg = JSON.parse(raw) as Record<string, unknown>;
59
+
60
+ const pkgDeps = (pkg.dependencies ?? {}) as Record<string, string>;
61
+ const pkgDevDeps = (pkg.devDependencies ?? {}) as Record<string, string>;
62
+
63
+ // Pin @mettlecast/domain-cdk-packer
64
+ if (TOOLCHAIN_NPM_NAMES.domainCdkPacker in pkgDeps) {
65
+ pkgDeps[TOOLCHAIN_NPM_NAMES.domainCdkPacker] = manifest.packages.domainCdkPacker;
66
+ }
67
+
68
+ // Pin @mettlecast/domain-runtime
69
+ if (TOOLCHAIN_NPM_NAMES.domainRuntime in pkgDeps) {
70
+ pkgDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] = manifest.packages.domainRuntime;
71
+ }
72
+
73
+ // Pin @mettlecast/domain-cli
74
+ if (TOOLCHAIN_NPM_NAMES.domainCli in pkgDevDeps) {
75
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] = manifest.packages.domainCli;
76
+ }
77
+
78
+ // Pin @mettlecast/eslint-plugin-domain-module
79
+ if (TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule in pkgDevDeps) {
80
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] = manifest.packages.eslintPluginDomainModule;
81
+ }
82
+
83
+ pkg.dependencies = pkgDeps;
84
+ pkg.devDependencies = pkgDevDeps;
85
+
86
+ await writeFile(infraPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
87
+ cliLogger.info({ path: infraPkgPath }, 'Pinned toolchain versions in infra/modules/package.json');
88
+ }
89
+
90
+ /**
91
+ * Write exact toolchain versions into root package.json.
92
+ * Replaces the @mettlecast/* devDependency versions with the pinned exact versions
93
+ * from the toolchain manifest. Preserves all other fields unchanged.
94
+ *
95
+ * @param projectRoot - Project root directory.
96
+ * @param manifest - The resolved toolchain manifest.
97
+ */
98
+ async function pinRootPackageVersions(
99
+ projectRoot: string,
100
+ manifest: ToolchainManifest,
101
+ ): Promise<void> {
102
+ const rootPkgPath = join(projectRoot, 'package.json');
103
+
104
+ if (!existsSync(rootPkgPath)) {
105
+ cliLogger.warn({ path: rootPkgPath }, 'root package.json not found — skipping version pinning');
106
+ return;
107
+ }
108
+
109
+ const raw = await readFile(rootPkgPath, 'utf-8');
110
+ const pkg = JSON.parse(raw) as Record<string, unknown>;
111
+
112
+ const pkgDevDeps = (pkg.devDependencies ?? {}) as Record<string, string>;
113
+
114
+ // Pin @mettlecast/domain-cli (root devDependency)
115
+ if (TOOLCHAIN_NPM_NAMES.domainCli in pkgDevDeps) {
116
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] = manifest.packages.domainCli;
117
+ }
118
+
119
+ // Pin @mettlecast/domain-runtime (root devDependency)
120
+ if (TOOLCHAIN_NPM_NAMES.domainRuntime in pkgDevDeps) {
121
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] = manifest.packages.domainRuntime;
122
+ }
123
+
124
+ // Pin @mettlecast/eslint-plugin-domain-module (root devDependency)
125
+ if (TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule in pkgDevDeps) {
126
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] = manifest.packages.eslintPluginDomainModule;
127
+ }
128
+
129
+ pkg.devDependencies = pkgDevDeps;
130
+
131
+ await writeFile(rootPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
132
+ cliLogger.info({ path: rootPkgPath }, 'Pinned toolchain versions in root package.json');
133
+ }
134
+
135
+ /**
136
+ * Regenerate lockfiles for root and infra/modules after version pinning.
137
+ * Runs `npm install --package-lock-only` (without modifying package.json) to
138
+ * produce deterministic package-lock.json files that match the pinned exact
139
+ * versions. The `--package-lock` flag overrides any `package-lock=false` in
140
+ * the project's .npmrc so lockfiles are always generated.
141
+ *
142
+ * @param projectRoot - Project root directory.
143
+ */
144
+ async function regenerateLockfiles(projectRoot: string): Promise<void> {
145
+ const rootPkgPath = join(projectRoot, 'package.json');
146
+ const infraPkgPath = join(projectRoot, 'infra', 'modules', 'package.json');
147
+
148
+ const npmLockCmd = 'npm install --package-lock-only --package-lock --no-save';
149
+
150
+ // Regenerate root lockfile if root package.json exists
151
+ if (existsSync(rootPkgPath)) {
152
+ try {
153
+ cliLogger.info({ projectRoot }, 'Regenerating root package-lock.json');
154
+ execSync(npmLockCmd, {
155
+ cwd: projectRoot,
156
+ stdio: 'pipe',
157
+ timeout: 120_000,
158
+ });
159
+ cliLogger.info({ projectRoot }, 'Root package-lock.json regenerated');
160
+ } catch (err) {
161
+ const message = err instanceof Error ? err.message : String(err);
162
+ cliLogger.warn(
163
+ { err: message },
164
+ 'Root lockfile regeneration failed (non-fatal — continuing)',
165
+ );
166
+ }
167
+ }
168
+
169
+ // Regenerate infra/modules lockfile if infra/modules/package.json exists
170
+ if (existsSync(infraPkgPath)) {
171
+ try {
172
+ const infraDir = join(projectRoot, 'infra', 'modules');
173
+ cliLogger.info({ path: infraDir }, 'Regenerating infra/modules package-lock.json');
174
+ execSync(npmLockCmd, {
175
+ cwd: infraDir,
176
+ stdio: 'pipe',
177
+ timeout: 120_000,
178
+ });
179
+ cliLogger.info({ path: infraDir }, 'infra/modules package-lock.json regenerated');
180
+ } catch (err) {
181
+ const message = err instanceof Error ? err.message : String(err);
182
+ cliLogger.warn(
183
+ { err: message },
184
+ 'infra/modules lockfile regeneration failed (non-fatal — continuing)',
185
+ );
186
+ }
187
+ }
188
+ }
189
+
11
190
  /**
12
191
  * Run the full scaffold update pipeline:
192
+ * 0. Resolve toolchain manifest and pin exact versions + regenerate lockfiles
13
193
  * 1. Build each domain registry from `.mc/scaffold-config.json`
14
194
  * 2. Build the combined domain catalog
15
195
  * 3. Build flows registry
@@ -28,6 +208,42 @@ export async function runUpdateAll(opts?: { projectRoot?: string }): Promise<{ s
28
208
 
29
209
  cliLogger.info({ projectRoot }, 'update-all: starting');
30
210
 
211
+ // 0. Resolve toolchain manifest and pin exact versions
212
+ cliLogger.info({ projectRoot }, 'update-all: resolving toolchain manifest');
213
+ let manifest: ToolchainManifest;
214
+ try {
215
+ manifest = await loadToolchainManifest(projectRoot);
216
+ cliLogger.info(
217
+ {
218
+ domainCli: manifest.packages.domainCli,
219
+ domainRuntime: manifest.packages.domainRuntime,
220
+ domainCdkPacker: manifest.packages.domainCdkPacker,
221
+ eslintPluginDomainModule: manifest.packages.eslintPluginDomainModule,
222
+ registrySchemaVersion: manifest.registrySchemaVersion,
223
+ },
224
+ 'update-all: toolchain manifest resolved',
225
+ );
226
+ } catch (err) {
227
+ const message = err instanceof Error ? err.message : String(err);
228
+ cliLogger.error({ err: message }, 'update-all: toolchain resolution failed — aborting');
229
+ return { success: false, summary: `Toolchain resolution FAIL: ${message}` };
230
+ }
231
+
232
+ // Pin exact versions from manifest into infra/modules/package.json and root package.json
233
+ try {
234
+ await pinInfraPackageVersions(projectRoot, manifest);
235
+ await pinRootPackageVersions(projectRoot, manifest);
236
+ } catch (err) {
237
+ const message = err instanceof Error ? err.message : String(err);
238
+ cliLogger.error({ err: message }, 'update-all: version pinning failed — aborting');
239
+ return { success: false, summary: `Version pinning FAIL: ${message}` };
240
+ }
241
+
242
+ // Regenerate lockfiles so that root and infra/modules lockfiles exist
243
+ // and match the pinned exact versions before generated CI uses `npm ci`.
244
+ cliLogger.info({ projectRoot }, 'update-all: regenerating lockfiles');
245
+ await regenerateLockfiles(projectRoot);
246
+
31
247
  // 1. Read scaffold-config to discover domains
32
248
  const scaffoldConfig = await readScaffoldConfig(projectRoot);
33
249
  const domainIds = scaffoldConfig.domainIds ?? [];