@mettlecast/domain-cli 0.2.89 → 0.2.90

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.
@@ -33,88 +33,135 @@ const TOOLCHAIN_NPM_NAMES = {
33
33
  * Write exact toolchain versions into infra/modules/package.json.
34
34
  * Replaces the @mettlecast/* dependency versions with the pinned exact versions
35
35
  * from the toolchain manifest. Preserves all other fields unchanged.
36
+ * Only writes the file if at least one version actually changes.
36
37
  *
37
38
  * @param projectRoot - Project root directory.
38
39
  * @param manifest - The resolved toolchain manifest.
40
+ * @returns Whether any dependency version was actually changed.
39
41
  */
40
42
  async function pinInfraPackageVersions(projectRoot, manifest) {
41
43
  const infraPkgPath = join(projectRoot, 'infra', 'modules', 'package.json');
42
44
  if (!existsSync(infraPkgPath)) {
43
45
  cliLogger.warn({ path: infraPkgPath }, 'infra/modules/package.json not found — skipping version pinning');
44
- return;
46
+ return { changed: false };
45
47
  }
46
48
  const raw = await readFile(infraPkgPath, 'utf-8');
47
49
  const pkg = JSON.parse(raw);
48
50
  const pkgDeps = (pkg.dependencies ?? {});
49
51
  const pkgDevDeps = (pkg.devDependencies ?? {});
52
+ let changed = false;
50
53
  // Pin @mettlecast/domain-cdk-packer
51
54
  if (TOOLCHAIN_NPM_NAMES.domainCdkPacker in pkgDeps) {
52
- pkgDeps[TOOLCHAIN_NPM_NAMES.domainCdkPacker] = manifest.packages.domainCdkPacker;
55
+ if (pkgDeps[TOOLCHAIN_NPM_NAMES.domainCdkPacker] !== manifest.packages.domainCdkPacker) {
56
+ pkgDeps[TOOLCHAIN_NPM_NAMES.domainCdkPacker] = manifest.packages.domainCdkPacker;
57
+ changed = true;
58
+ }
53
59
  }
54
60
  // Pin @mettlecast/domain-runtime
55
61
  if (TOOLCHAIN_NPM_NAMES.domainRuntime in pkgDeps) {
56
- pkgDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] = manifest.packages.domainRuntime;
62
+ if (pkgDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] !== manifest.packages.domainRuntime) {
63
+ pkgDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] = manifest.packages.domainRuntime;
64
+ changed = true;
65
+ }
57
66
  }
58
67
  // Pin @mettlecast/domain-cli
59
68
  if (TOOLCHAIN_NPM_NAMES.domainCli in pkgDevDeps) {
60
- pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] = manifest.packages.domainCli;
69
+ if (pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] !== manifest.packages.domainCli) {
70
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] = manifest.packages.domainCli;
71
+ changed = true;
72
+ }
61
73
  }
62
74
  // Pin @mettlecast/eslint-plugin-domain-module
63
75
  if (TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule in pkgDevDeps) {
64
- pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] = manifest.packages.eslintPluginDomainModule;
76
+ if (pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] !== manifest.packages.eslintPluginDomainModule) {
77
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] = manifest.packages.eslintPluginDomainModule;
78
+ changed = true;
79
+ }
65
80
  }
66
- pkg.dependencies = pkgDeps;
67
- pkg.devDependencies = pkgDevDeps;
68
- await writeFile(infraPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
69
- cliLogger.info({ path: infraPkgPath }, 'Pinned toolchain versions in infra/modules/package.json');
81
+ if (changed) {
82
+ pkg.dependencies = pkgDeps;
83
+ pkg.devDependencies = pkgDevDeps;
84
+ await writeFile(infraPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
85
+ cliLogger.info({ path: infraPkgPath }, 'Pinned toolchain versions in infra/modules/package.json');
86
+ }
87
+ else {
88
+ cliLogger.info({ path: infraPkgPath }, 'infra/modules/package.json already up-to-date');
89
+ }
90
+ return { changed };
70
91
  }
71
92
  /**
72
93
  * Write exact toolchain versions into root package.json.
73
94
  * Replaces the @mettlecast/* devDependency versions with the pinned exact versions
74
95
  * from the toolchain manifest. Preserves all other fields unchanged.
96
+ * Only writes the file if at least one version actually changes.
75
97
  *
76
98
  * @param projectRoot - Project root directory.
77
99
  * @param manifest - The resolved toolchain manifest.
100
+ * @returns Whether any devDependency version was actually changed.
78
101
  */
79
102
  async function pinRootPackageVersions(projectRoot, manifest) {
80
103
  const rootPkgPath = join(projectRoot, 'package.json');
81
104
  if (!existsSync(rootPkgPath)) {
82
105
  cliLogger.warn({ path: rootPkgPath }, 'root package.json not found — skipping version pinning');
83
- return;
106
+ return { changed: false };
84
107
  }
85
108
  const raw = await readFile(rootPkgPath, 'utf-8');
86
109
  const pkg = JSON.parse(raw);
87
110
  const pkgDevDeps = (pkg.devDependencies ?? {});
111
+ let changed = false;
88
112
  // Pin @mettlecast/domain-cli (root devDependency)
89
113
  if (TOOLCHAIN_NPM_NAMES.domainCli in pkgDevDeps) {
90
- pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] = manifest.packages.domainCli;
114
+ if (pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] !== manifest.packages.domainCli) {
115
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] = manifest.packages.domainCli;
116
+ changed = true;
117
+ }
91
118
  }
92
119
  // Pin @mettlecast/domain-runtime (root devDependency)
93
120
  if (TOOLCHAIN_NPM_NAMES.domainRuntime in pkgDevDeps) {
94
- pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] = manifest.packages.domainRuntime;
121
+ if (pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] !== manifest.packages.domainRuntime) {
122
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] = manifest.packages.domainRuntime;
123
+ changed = true;
124
+ }
95
125
  }
96
126
  // Pin @mettlecast/eslint-plugin-domain-module (root devDependency)
97
127
  if (TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule in pkgDevDeps) {
98
- pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] = manifest.packages.eslintPluginDomainModule;
128
+ if (pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] !== manifest.packages.eslintPluginDomainModule) {
129
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] = manifest.packages.eslintPluginDomainModule;
130
+ changed = true;
131
+ }
132
+ }
133
+ if (changed) {
134
+ pkg.devDependencies = pkgDevDeps;
135
+ await writeFile(rootPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
136
+ cliLogger.info({ path: rootPkgPath }, 'Pinned toolchain versions in root package.json');
99
137
  }
100
- pkg.devDependencies = pkgDevDeps;
101
- await writeFile(rootPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
102
- cliLogger.info({ path: rootPkgPath }, 'Pinned toolchain versions in root package.json');
138
+ else {
139
+ cliLogger.info({ path: rootPkgPath }, 'root package.json already up-to-date');
140
+ }
141
+ return { changed };
103
142
  }
104
143
  /**
105
- * Regenerate lockfiles for root and infra/modules after version pinning.
106
- * Runs `npm install --package-lock-only --package-lock` (without modifying
107
- * package.json) to produce deterministic package-lock.json files that match
108
- * the pinned exact versions. The explicit `--package-lock` ensures lockfiles
109
- * are always generated regardless of .npmrc settings.
144
+ * Regenerate lockfiles after version pinning.
145
+ * Always regenerates the root package-lock.json on every run to ensure it
146
+ * fully reconciles with the pinned versions (not just on manifest changes).
147
+ * Infra/modules lockfile is regenerated only when infra deps actually changed.
148
+ *
149
+ * Uses normal npm semantics (`npm install --package-lock-only --package-lock --no-save`)
150
+ * — no forced, legacy-peer-deps, or override flags — to produce deterministic
151
+ * lockfiles that match the pinned exact versions.
152
+ *
153
+ * Root lockfile failure is fatal (deploy blocker); infra/modules lockfile
154
+ * failure is logged as a warning but does not abort the pipeline.
110
155
  *
111
156
  * @param projectRoot - Project root directory.
157
+ * @param options - Options with infraChanged flag.
158
+ * @throws When root lockfile regeneration fails.
112
159
  */
113
- async function regenerateLockfiles(projectRoot) {
114
- const rootPkgPath = join(projectRoot, 'package.json');
115
- const infraPkgPath = join(projectRoot, 'infra', 'modules', 'package.json');
160
+ async function regenerateLockfiles(projectRoot, options) {
116
161
  const npmLockCmd = 'npm install --package-lock-only --package-lock --no-save';
117
- // Regenerate root lockfile if root package.json exists
162
+ // Root lockfile is always regenerated to ensure it is fully reconciled
163
+ // with the pinned exact versions on every update-all run.
164
+ const rootPkgPath = join(projectRoot, 'package.json');
118
165
  if (existsSync(rootPkgPath)) {
119
166
  try {
120
167
  cliLogger.info({ projectRoot }, 'Regenerating root package-lock.json');
@@ -127,24 +174,28 @@ async function regenerateLockfiles(projectRoot) {
127
174
  }
128
175
  catch (err) {
129
176
  const message = err instanceof Error ? err.message : String(err);
130
- cliLogger.warn({ err: message }, 'Root lockfile regeneration failed (non-fatal — continuing)');
177
+ cliLogger.error({ err: message }, 'Root lockfile regeneration FAILED');
178
+ throw new Error(`Root lockfile regeneration FAILED: ${message}`);
131
179
  }
132
180
  }
133
- // Regenerate infra/modules lockfile if infra/modules/package.json exists
134
- if (existsSync(infraPkgPath)) {
135
- try {
136
- const infraDir = join(projectRoot, 'infra', 'modules');
137
- cliLogger.info({ path: infraDir }, 'Regenerating infra/modules package-lock.json');
138
- execSync(npmLockCmd, {
139
- cwd: infraDir,
140
- stdio: 'pipe',
141
- timeout: 120_000,
142
- });
143
- cliLogger.info({ path: infraDir }, 'infra/modules package-lock.json regenerated');
144
- }
145
- catch (err) {
146
- const message = err instanceof Error ? err.message : String(err);
147
- cliLogger.warn({ err: message }, 'infra/modules lockfile regeneration failed (non-fatal — continuing)');
181
+ // Infra/modules lockfile is regenerated only when infra deps actually changed.
182
+ if (options.infraChanged) {
183
+ const infraPkgPath = join(projectRoot, 'infra', 'modules', 'package.json');
184
+ if (existsSync(infraPkgPath)) {
185
+ try {
186
+ const infraDir = join(projectRoot, 'infra', 'modules');
187
+ cliLogger.info({ path: infraDir }, 'Regenerating infra/modules package-lock.json');
188
+ execSync(npmLockCmd, {
189
+ cwd: infraDir,
190
+ stdio: 'pipe',
191
+ timeout: 120_000,
192
+ });
193
+ cliLogger.info({ path: infraDir }, 'infra/modules package-lock.json regenerated');
194
+ }
195
+ catch (err) {
196
+ const message = err instanceof Error ? err.message : String(err);
197
+ cliLogger.warn({ err: message }, 'infra/modules lockfile regeneration failed (non-fatal — continuing)');
198
+ }
148
199
  }
149
200
  }
150
201
  }
@@ -185,20 +236,31 @@ export async function runUpdateAll(opts) {
185
236
  cliLogger.error({ err: message }, 'update-all: toolchain resolution failed — aborting');
186
237
  return { success: false, summary: `Toolchain resolution FAIL: ${message}` };
187
238
  }
188
- // Pin exact versions from manifest into infra/modules/package.json and root package.json
239
+ // Pin exact versions from manifest into infra/modules/package.json and root package.json.
240
+ // Returns whether each manifest actually changed.
241
+ let infraChanged = false;
189
242
  try {
190
- await pinInfraPackageVersions(projectRoot, manifest);
243
+ const infraResult = await pinInfraPackageVersions(projectRoot, manifest);
191
244
  await pinRootPackageVersions(projectRoot, manifest);
245
+ infraChanged = infraResult.changed;
192
246
  }
193
247
  catch (err) {
194
248
  const message = err instanceof Error ? err.message : String(err);
195
249
  cliLogger.error({ err: message }, 'update-all: version pinning failed — aborting');
196
250
  return { success: false, summary: `Version pinning FAIL: ${message}` };
197
251
  }
198
- // Regenerate lockfiles so that root and infra/modules lockfiles exist
199
- // and match the pinned exact versions before generated CI uses `npm ci`.
200
- cliLogger.info({ projectRoot }, 'update-all: regenerating lockfiles');
201
- await regenerateLockfiles(projectRoot);
252
+ // Regenerate lockfiles. Root lockfile is always regenerated (every run) to ensure
253
+ // full reconciliation. Infra/modules lockfile is regenerated only when its deps changed.
254
+ // Root lockfile failure is fatal (deploy blocker); infra/modules failure is not.
255
+ cliLogger.info({ projectRoot, infraChanged }, 'update-all: regenerating root package-lock.json');
256
+ try {
257
+ await regenerateLockfiles(projectRoot, { infraChanged });
258
+ }
259
+ catch (err) {
260
+ const message = err instanceof Error ? err.message : String(err);
261
+ cliLogger.error({ err: message }, 'update-all: root lockfile regeneration failed — aborting');
262
+ return { success: false, summary: `Lockfile regeneration FAIL: ${message}` };
263
+ }
202
264
  // 1. Read scaffold-config to discover domains
203
265
  const scaffoldConfig = await readScaffoldConfig(projectRoot);
204
266
  const domainIds = scaffoldConfig.domainIds ?? [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-cli",
3
- "version": "0.2.89",
3
+ "version": "0.2.90",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
@@ -22,8 +22,8 @@
22
22
  "dependencies": {
23
23
  "@aws-sdk/client-s3": "^3.0.0",
24
24
  "@aws-sdk/client-sfn": "^3.0.0",
25
- "@mettlecast/domain-cdk-packer": "0.2.90",
26
- "@mettlecast/domain-runtime": "0.2.89",
25
+ "@mettlecast/domain-cdk-packer": "0.2.91",
26
+ "@mettlecast/domain-runtime": "0.2.90",
27
27
  "commander": "^12.0.0",
28
28
  "dotenv": "^16.0.0",
29
29
  "fastify": "^5.0.0",
@@ -32,6 +32,12 @@ vi.mock('../../utils/scaffold-config.js', () => ({
32
32
  readScaffoldConfig: vi.fn(),
33
33
  }));
34
34
 
35
+ vi.mock('node:child_process', () => ({
36
+ execSync: vi.fn(),
37
+ }));
38
+
39
+ import { execSync } from 'node:child_process';
40
+
35
41
  import { runUpdateAll } from '../../commands/update-all.js';
36
42
  import { runBuild } from '../../commands/build.js';
37
43
  import { runBuildCatalog } from '../../commands/build-catalog.js';
@@ -48,6 +54,7 @@ const mockRunBuildUi = vi.mocked(runBuildUi);
48
54
  const mockRunRegenerateModulesHashes = vi.mocked(runRegenerateModulesHashes);
49
55
  const mockRunDoctor = vi.mocked(runDoctor);
50
56
  const mockReadScaffoldConfig = vi.mocked(readScaffoldConfig);
57
+ const mockExecSync = vi.mocked(execSync);
51
58
 
52
59
  /** Build a minimal but valid DoctorReport. */
53
60
  function makeDoctorReport(pass: boolean) {
@@ -616,45 +623,112 @@ describe('update-all command', () => {
616
623
  it('regenerates root package-lock.json after version pinning', async () => {
617
624
  mockReadScaffoldConfig.mockResolvedValue(makeScaffoldConfig([]));
618
625
 
619
- // Create a root package.json but no lockfile
620
- const rootPkgPath = join(tempDir, 'package.json');
621
- const rootExists = existsSync(rootPkgPath);
622
- expect(rootExists).toBe(true);
626
+ // Since root package.json has '*' versions and manifest has '0.2.53',
627
+ // pinning WILL change root — execSync should be called for root.
623
628
 
624
- // Remove any existing lockfile
625
- const rootLockPath = join(tempDir, 'package-lock.json');
626
- if (existsSync(rootLockPath)) {
627
- rmSync(rootLockPath, { force: true });
628
- }
629
- const infraLockPath = join(tempDir, 'infra', 'modules', 'package-lock.json');
630
- if (existsSync(infraLockPath)) {
631
- rmSync(infraLockPath, { force: true });
632
- }
633
-
634
- // Run update-all which should regenerate lockfiles
635
629
  const result = await runUpdateAll({ projectRoot: tempDir });
636
630
 
637
631
  // update-all should succeed (doctor mock passes)
638
632
  expect(result.success).toBe(true);
639
633
 
640
- // Lockfiles should now exist after regeneration
641
- // (execSync runs 'npm install --package-lock-only' which may fail
642
- // in test environment without actual npm packages, but the function
643
- // should attempt regeneration without crashing)
644
- expect(existsSync(rootLockPath) || existsSync(infraLockPath)).toBeDefined();
634
+ // execSync must be called at least for root lockfile regeneration
635
+ expect(mockExecSync).toHaveBeenCalledWith(
636
+ 'npm install --package-lock-only --package-lock --no-save',
637
+ expect.objectContaining({ cwd: tempDir }),
638
+ );
639
+ // Both root and infra changed (both had wildcard versions)
640
+ expect(mockExecSync).toHaveBeenCalledTimes(2);
641
+ });
642
+
643
+ it('always regenerates root lockfile even when root package.json already has exact versions', async () => {
644
+ // Overwrite root package.json with already-pinned versions
645
+ await createRootPackageJson(tempDir, {
646
+ '@mettlecast/domain-cli': '0.2.53',
647
+ '@mettlecast/domain-runtime': '0.2.53',
648
+ '@mettlecast/eslint-plugin-domain-module': '0.2.53',
649
+ });
650
+
651
+ mockReadScaffoldConfig.mockResolvedValue(makeScaffoldConfig([]));
652
+
653
+ const result = await runUpdateAll({ projectRoot: tempDir });
654
+
655
+ expect(result.success).toBe(true);
656
+
657
+ // Root lockfile is regenerated unconditionally on every run.
658
+ expect(mockExecSync).toHaveBeenCalledWith(
659
+ 'npm install --package-lock-only --package-lock --no-save',
660
+ expect.objectContaining({ cwd: tempDir }),
661
+ );
662
+ // Infra lockfile is regenerated only when infra deps changed (it still has '*' wildcards)
663
+ const infraDir = join(tempDir, 'infra', 'modules');
664
+ expect(mockExecSync).toHaveBeenCalledWith(
665
+ 'npm install --package-lock-only --package-lock --no-save',
666
+ expect.objectContaining({ cwd: infraDir }),
667
+ );
668
+ expect(mockExecSync).toHaveBeenCalledTimes(2);
669
+ });
670
+
671
+ it('always regenerates root lockfile even when no manifests changed', async () => {
672
+ // Overwrite both package.json files with already-pinned versions
673
+ await createRootPackageJson(tempDir, {
674
+ '@mettlecast/domain-cli': '0.2.53',
675
+ '@mettlecast/domain-runtime': '0.2.53',
676
+ '@mettlecast/eslint-plugin-domain-module': '0.2.53',
677
+ });
678
+ await createInfraPackageJson(tempDir, {
679
+ '@mettlecast/domain-cdk-packer': '0.2.53',
680
+ '@mettlecast/domain-runtime': '0.2.53',
681
+ }, {
682
+ '@mettlecast/domain-cli': '0.2.53',
683
+ '@mettlecast/eslint-plugin-domain-module': '0.2.53',
684
+ });
685
+
686
+ mockReadScaffoldConfig.mockResolvedValue(makeScaffoldConfig([]));
687
+
688
+ const result = await runUpdateAll({ projectRoot: tempDir });
689
+
690
+ expect(result.success).toBe(true);
691
+
692
+ // Root lockfile is always regenerated even with no manifest changes.
693
+ expect(mockExecSync).toHaveBeenCalledTimes(1);
694
+ expect(mockExecSync).toHaveBeenCalledWith(
695
+ 'npm install --package-lock-only --package-lock --no-save',
696
+ expect.objectContaining({ cwd: tempDir }),
697
+ );
698
+ // Infra lockfile is NOT regenerated because infra deps already match.
699
+ });
700
+
701
+ it('fails explicitly when root lockfile regeneration fails', async () => {
702
+ mockReadScaffoldConfig.mockResolvedValue(makeScaffoldConfig([]));
703
+
704
+ // Make execSync throw on first call (root lockfile)
705
+ mockExecSync.mockImplementationOnce(() => {
706
+ throw new Error('npm install failed');
707
+ });
708
+
709
+ const result = await runUpdateAll({ projectRoot: tempDir });
710
+
711
+ // update-all should fail with lockfile error
712
+ expect(result.success).toBe(false);
713
+ expect(result.summary).toContain('Lockfile regeneration FAIL');
714
+ expect(result.summary).toContain('npm install failed');
645
715
  });
646
716
 
647
- it('continues gracefully when lockfile regeneration partially fails', async () => {
717
+ it('continues when infra lockfile fails but root succeeds', async () => {
648
718
  mockReadScaffoldConfig.mockResolvedValue(makeScaffoldConfig([]));
649
719
 
650
- // Remove infra/modules/package.json to simulate partial failure
651
- const infraPkgPath = join(tempDir, 'infra', 'modules', 'package.json');
652
- rmSync(infraPkgPath, { force: true });
720
+ // execSync: first call succeeds (root), second call throws (infra)
721
+ mockExecSync
722
+ .mockImplementationOnce(() => Buffer.from(''))
723
+ .mockImplementationOnce(() => {
724
+ throw new Error('infra npm install failed');
725
+ });
653
726
 
654
- // Should not throw — lockfile regeneration for missing infra dir is graceful
655
727
  const result = await runUpdateAll({ projectRoot: tempDir });
656
728
 
729
+ // update-all should succeed — infra failure is non-fatal
657
730
  expect(result.success).toBe(true);
731
+ expect(mockExecSync).toHaveBeenCalledTimes(2);
658
732
  });
659
733
  });
660
734
  });
@@ -39,19 +39,21 @@ const TOOLCHAIN_NPM_NAMES: Record<string, string> = {
39
39
  * Write exact toolchain versions into infra/modules/package.json.
40
40
  * Replaces the @mettlecast/* dependency versions with the pinned exact versions
41
41
  * from the toolchain manifest. Preserves all other fields unchanged.
42
+ * Only writes the file if at least one version actually changes.
42
43
  *
43
44
  * @param projectRoot - Project root directory.
44
45
  * @param manifest - The resolved toolchain manifest.
46
+ * @returns Whether any dependency version was actually changed.
45
47
  */
46
48
  async function pinInfraPackageVersions(
47
49
  projectRoot: string,
48
50
  manifest: ToolchainManifest,
49
- ): Promise<void> {
51
+ ): Promise<{ changed: boolean }> {
50
52
  const infraPkgPath = join(projectRoot, 'infra', 'modules', 'package.json');
51
53
 
52
54
  if (!existsSync(infraPkgPath)) {
53
55
  cliLogger.warn({ path: infraPkgPath }, 'infra/modules/package.json not found — skipping version pinning');
54
- return;
56
+ return { changed: false };
55
57
  }
56
58
 
57
59
  const raw = await readFile(infraPkgPath, 'utf-8');
@@ -60,50 +62,72 @@ async function pinInfraPackageVersions(
60
62
  const pkgDeps = (pkg.dependencies ?? {}) as Record<string, string>;
61
63
  const pkgDevDeps = (pkg.devDependencies ?? {}) as Record<string, string>;
62
64
 
65
+ let changed = false;
66
+
63
67
  // Pin @mettlecast/domain-cdk-packer
64
68
  if (TOOLCHAIN_NPM_NAMES.domainCdkPacker in pkgDeps) {
65
- pkgDeps[TOOLCHAIN_NPM_NAMES.domainCdkPacker] = manifest.packages.domainCdkPacker;
69
+ if (pkgDeps[TOOLCHAIN_NPM_NAMES.domainCdkPacker] !== manifest.packages.domainCdkPacker) {
70
+ pkgDeps[TOOLCHAIN_NPM_NAMES.domainCdkPacker] = manifest.packages.domainCdkPacker;
71
+ changed = true;
72
+ }
66
73
  }
67
74
 
68
75
  // Pin @mettlecast/domain-runtime
69
76
  if (TOOLCHAIN_NPM_NAMES.domainRuntime in pkgDeps) {
70
- pkgDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] = manifest.packages.domainRuntime;
77
+ if (pkgDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] !== manifest.packages.domainRuntime) {
78
+ pkgDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] = manifest.packages.domainRuntime;
79
+ changed = true;
80
+ }
71
81
  }
72
82
 
73
83
  // Pin @mettlecast/domain-cli
74
84
  if (TOOLCHAIN_NPM_NAMES.domainCli in pkgDevDeps) {
75
- pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] = manifest.packages.domainCli;
85
+ if (pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] !== manifest.packages.domainCli) {
86
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] = manifest.packages.domainCli;
87
+ changed = true;
88
+ }
76
89
  }
77
90
 
78
91
  // Pin @mettlecast/eslint-plugin-domain-module
79
92
  if (TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule in pkgDevDeps) {
80
- pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] = manifest.packages.eslintPluginDomainModule;
93
+ if (pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] !== manifest.packages.eslintPluginDomainModule) {
94
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] = manifest.packages.eslintPluginDomainModule;
95
+ changed = true;
96
+ }
81
97
  }
82
98
 
83
- pkg.dependencies = pkgDeps;
84
- pkg.devDependencies = pkgDevDeps;
99
+ if (changed) {
100
+ pkg.dependencies = pkgDeps;
101
+ pkg.devDependencies = pkgDevDeps;
102
+
103
+ await writeFile(infraPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
104
+ cliLogger.info({ path: infraPkgPath }, 'Pinned toolchain versions in infra/modules/package.json');
105
+ } else {
106
+ cliLogger.info({ path: infraPkgPath }, 'infra/modules/package.json already up-to-date');
107
+ }
85
108
 
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');
109
+ return { changed };
88
110
  }
89
111
 
90
112
  /**
91
113
  * Write exact toolchain versions into root package.json.
92
114
  * Replaces the @mettlecast/* devDependency versions with the pinned exact versions
93
115
  * from the toolchain manifest. Preserves all other fields unchanged.
116
+ * Only writes the file if at least one version actually changes.
94
117
  *
95
118
  * @param projectRoot - Project root directory.
96
119
  * @param manifest - The resolved toolchain manifest.
120
+ * @returns Whether any devDependency version was actually changed.
97
121
  */
98
122
  async function pinRootPackageVersions(
99
123
  projectRoot: string,
100
124
  manifest: ToolchainManifest,
101
- ): Promise<void> {
125
+ ): Promise<{ changed: boolean }> {
102
126
  const rootPkgPath = join(projectRoot, 'package.json');
103
127
 
104
128
  if (!existsSync(rootPkgPath)) {
105
129
  cliLogger.warn({ path: rootPkgPath }, 'root package.json not found — skipping version pinning');
106
- return;
130
+ return { changed: false };
107
131
  }
108
132
 
109
133
  const raw = await readFile(rootPkgPath, 'utf-8');
@@ -111,43 +135,70 @@ async function pinRootPackageVersions(
111
135
 
112
136
  const pkgDevDeps = (pkg.devDependencies ?? {}) as Record<string, string>;
113
137
 
138
+ let changed = false;
139
+
114
140
  // Pin @mettlecast/domain-cli (root devDependency)
115
141
  if (TOOLCHAIN_NPM_NAMES.domainCli in pkgDevDeps) {
116
- pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] = manifest.packages.domainCli;
142
+ if (pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] !== manifest.packages.domainCli) {
143
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] = manifest.packages.domainCli;
144
+ changed = true;
145
+ }
117
146
  }
118
147
 
119
148
  // Pin @mettlecast/domain-runtime (root devDependency)
120
149
  if (TOOLCHAIN_NPM_NAMES.domainRuntime in pkgDevDeps) {
121
- pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] = manifest.packages.domainRuntime;
150
+ if (pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] !== manifest.packages.domainRuntime) {
151
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] = manifest.packages.domainRuntime;
152
+ changed = true;
153
+ }
122
154
  }
123
155
 
124
156
  // Pin @mettlecast/eslint-plugin-domain-module (root devDependency)
125
157
  if (TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule in pkgDevDeps) {
126
- pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] = manifest.packages.eslintPluginDomainModule;
158
+ if (pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] !== manifest.packages.eslintPluginDomainModule) {
159
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] = manifest.packages.eslintPluginDomainModule;
160
+ changed = true;
161
+ }
127
162
  }
128
163
 
129
- pkg.devDependencies = pkgDevDeps;
164
+ if (changed) {
165
+ pkg.devDependencies = pkgDevDeps;
130
166
 
131
- await writeFile(rootPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
132
- cliLogger.info({ path: rootPkgPath }, 'Pinned toolchain versions in root package.json');
167
+ await writeFile(rootPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
168
+ cliLogger.info({ path: rootPkgPath }, 'Pinned toolchain versions in root package.json');
169
+ } else {
170
+ cliLogger.info({ path: rootPkgPath }, 'root package.json already up-to-date');
171
+ }
172
+
173
+ return { changed };
133
174
  }
134
175
 
135
176
  /**
136
- * Regenerate lockfiles for root and infra/modules after version pinning.
137
- * Runs `npm install --package-lock-only --package-lock` (without modifying
138
- * package.json) to produce deterministic package-lock.json files that match
139
- * the pinned exact versions. The explicit `--package-lock` ensures lockfiles
140
- * are always generated regardless of .npmrc settings.
177
+ * Regenerate lockfiles after version pinning.
178
+ * Always regenerates the root package-lock.json on every run to ensure it
179
+ * fully reconciles with the pinned versions (not just on manifest changes).
180
+ * Infra/modules lockfile is regenerated only when infra deps actually changed.
181
+ *
182
+ * Uses normal npm semantics (`npm install --package-lock-only --package-lock --no-save`)
183
+ * — no forced, legacy-peer-deps, or override flags — to produce deterministic
184
+ * lockfiles that match the pinned exact versions.
185
+ *
186
+ * Root lockfile failure is fatal (deploy blocker); infra/modules lockfile
187
+ * failure is logged as a warning but does not abort the pipeline.
141
188
  *
142
189
  * @param projectRoot - Project root directory.
190
+ * @param options - Options with infraChanged flag.
191
+ * @throws When root lockfile regeneration fails.
143
192
  */
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
-
193
+ async function regenerateLockfiles(
194
+ projectRoot: string,
195
+ options: { infraChanged: boolean },
196
+ ): Promise<void> {
148
197
  const npmLockCmd = 'npm install --package-lock-only --package-lock --no-save';
149
198
 
150
- // Regenerate root lockfile if root package.json exists
199
+ // Root lockfile is always regenerated to ensure it is fully reconciled
200
+ // with the pinned exact versions on every update-all run.
201
+ const rootPkgPath = join(projectRoot, 'package.json');
151
202
  if (existsSync(rootPkgPath)) {
152
203
  try {
153
204
  cliLogger.info({ projectRoot }, 'Regenerating root package-lock.json');
@@ -159,30 +210,31 @@ async function regenerateLockfiles(projectRoot: string): Promise<void> {
159
210
  cliLogger.info({ projectRoot }, 'Root package-lock.json regenerated');
160
211
  } catch (err) {
161
212
  const message = err instanceof Error ? err.message : String(err);
162
- cliLogger.warn(
163
- { err: message },
164
- 'Root lockfile regeneration failed (non-fatal — continuing)',
165
- );
213
+ cliLogger.error({ err: message }, 'Root lockfile regeneration FAILED');
214
+ throw new Error(`Root lockfile regeneration FAILED: ${message}`);
166
215
  }
167
216
  }
168
217
 
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
- );
218
+ // Infra/modules lockfile is regenerated only when infra deps actually changed.
219
+ if (options.infraChanged) {
220
+ const infraPkgPath = join(projectRoot, 'infra', 'modules', 'package.json');
221
+ if (existsSync(infraPkgPath)) {
222
+ try {
223
+ const infraDir = join(projectRoot, 'infra', 'modules');
224
+ cliLogger.info({ path: infraDir }, 'Regenerating infra/modules package-lock.json');
225
+ execSync(npmLockCmd, {
226
+ cwd: infraDir,
227
+ stdio: 'pipe',
228
+ timeout: 120_000,
229
+ });
230
+ cliLogger.info({ path: infraDir }, 'infra/modules package-lock.json regenerated');
231
+ } catch (err) {
232
+ const message = err instanceof Error ? err.message : String(err);
233
+ cliLogger.warn(
234
+ { err: message },
235
+ 'infra/modules lockfile regeneration failed (non-fatal — continuing)',
236
+ );
237
+ }
186
238
  }
187
239
  }
188
240
  }
@@ -229,20 +281,30 @@ export async function runUpdateAll(opts?: { projectRoot?: string }): Promise<{ s
229
281
  return { success: false, summary: `Toolchain resolution FAIL: ${message}` };
230
282
  }
231
283
 
232
- // Pin exact versions from manifest into infra/modules/package.json and root package.json
284
+ // Pin exact versions from manifest into infra/modules/package.json and root package.json.
285
+ // Returns whether each manifest actually changed.
286
+ let infraChanged = false;
233
287
  try {
234
- await pinInfraPackageVersions(projectRoot, manifest);
288
+ const infraResult = await pinInfraPackageVersions(projectRoot, manifest);
235
289
  await pinRootPackageVersions(projectRoot, manifest);
290
+ infraChanged = infraResult.changed;
236
291
  } catch (err) {
237
292
  const message = err instanceof Error ? err.message : String(err);
238
293
  cliLogger.error({ err: message }, 'update-all: version pinning failed — aborting');
239
294
  return { success: false, summary: `Version pinning FAIL: ${message}` };
240
295
  }
241
296
 
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);
297
+ // Regenerate lockfiles. Root lockfile is always regenerated (every run) to ensure
298
+ // full reconciliation. Infra/modules lockfile is regenerated only when its deps changed.
299
+ // Root lockfile failure is fatal (deploy blocker); infra/modules failure is not.
300
+ cliLogger.info({ projectRoot, infraChanged }, 'update-all: regenerating root package-lock.json');
301
+ try {
302
+ await regenerateLockfiles(projectRoot, { infraChanged });
303
+ } catch (err) {
304
+ const message = err instanceof Error ? err.message : String(err);
305
+ cliLogger.error({ err: message }, 'update-all: root lockfile regeneration failed — aborting');
306
+ return { success: false, summary: `Lockfile regeneration FAIL: ${message}` };
307
+ }
246
308
 
247
309
  // 1. Read scaffold-config to discover domains
248
310
  const scaffoldConfig = await readScaffoldConfig(projectRoot);
@@ -2,9 +2,9 @@
2
2
  "schemaVersion": 1,
3
3
  "registrySchemaVersion": "1",
4
4
  "packages": {
5
- "domainCli": "0.2.89",
6
- "domainCdkPacker": "0.2.90",
7
- "domainRuntime": "0.2.89",
8
- "eslintPluginDomainModule": "0.2.89"
5
+ "domainCli": "0.2.90",
6
+ "domainCdkPacker": "0.2.91",
7
+ "domainRuntime": "0.2.90",
8
+ "eslintPluginDomainModule": "0.2.90"
9
9
  }
10
10
  }