@mettlecast/domain-cli 0.2.85 → 0.2.87
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.
- package/dist/builder/build-registry.d.ts +8 -2
- package/dist/builder/build-registry.js +80 -88
- package/dist/commands/build-catalog.js +2 -3
- package/dist/commands/update-all.d.ts +1 -0
- package/dist/commands/update-all.js +174 -0
- package/dist/commands/validate.js +26 -59
- package/dist/utils/toolchain-manifest.d.ts +90 -0
- package/dist/utils/toolchain-manifest.js +144 -0
- package/package.json +3 -3
- package/scripts/generate-toolchain-manifest.mjs +163 -0
- package/src/__tests__/build-registry.test.ts +125 -3
- package/src/__tests__/commands/build-catalog.test.ts +89 -0
- package/src/__tests__/commands/update-all.test.ts +341 -3
- package/src/__tests__/package-freshness.test.ts +8 -4
- package/src/__tests__/toolchain-manifest.test.ts +185 -0
- package/src/__tests__/validate.test.ts +183 -108
- package/src/builder/build-registry.ts +116 -92
- package/src/commands/build-catalog.ts +2 -3
- package/src/commands/update-all.ts +216 -0
- package/src/commands/validate.ts +29 -70
- package/src/utils/toolchain-manifest.ts +236 -0
- package/toolchain-manifest.json +10 -0
|
@@ -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:
|
|
31
|
-
tenancy:
|
|
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
|
-
*
|
|
40
|
-
*
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
|
69
|
-
if (
|
|
70
|
-
|
|
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')
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const backendAccess
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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:
|
|
324
|
+
id: actionId,
|
|
295
325
|
kind: 'action' as const,
|
|
296
326
|
handlerFile: relPath(filePath),
|
|
297
327
|
backendAccess,
|
|
298
328
|
exposure,
|
|
299
|
-
|
|
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),
|
|
@@ -213,9 +213,8 @@ export async function runBuildCatalog(registryDir?: string): Promise<DomainCatal
|
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
const
|
|
217
|
-
await mkdir(
|
|
218
|
-
const outPath = join(mcDir, 'domain-registry.json');
|
|
216
|
+
const outPath = join(dir, 'domain-registry.json');
|
|
217
|
+
await mkdir(dir, { recursive: true });
|
|
219
218
|
await writeFile(outPath, JSON.stringify(catalog), 'utf8');
|
|
220
219
|
|
|
221
220
|
cliLogger.info(
|
|
@@ -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 ?? [];
|