pnpm-settings-migrator 0.7.0 → 0.8.0

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/README.md CHANGED
@@ -15,6 +15,20 @@ Run in your workspace root:
15
15
  pnpm dlx pnpm-settings-migrator
16
16
  ```
17
17
 
18
+ After migration, the CLI reports how many root settings changed and shows a
19
+ GitHub-style YAML diff. Removed lines are red and added lines are green:
20
+
21
+ ```text
22
+ ℹ 2 settings changed
23
+ packages:
24
+ - packages/*
25
+ + - apps/*
26
+
27
+ overrides:
28
+ foo: 1.0.0
29
+ + bar: 2.0.0
30
+ ```
31
+
18
32
  ## CLI Options
19
33
 
20
34
  ### `--cwd`
@@ -113,6 +127,14 @@ Strategy to handle conflicts when merging settings with existing `pnpm-workspace
113
127
 
114
128
  Disable migrating `resolutions` field in `package.json`.
115
129
 
130
+ ### `--no-show-changes`
131
+
132
+ - **Type**: `boolean`
133
+ - **Default behavior**: `showChanges=true` (use this flag to disable)
134
+
135
+ Disable showing the settings diff after migration. Library consumers can set
136
+ `showChanges: false`.
137
+
116
138
  ### `--no-clean-npmrc`
117
139
 
118
140
  - **Type**: `boolean`
package/dist/cli.mjs CHANGED
@@ -2,7 +2,7 @@ import process from "node:process";
2
2
  import { cac } from "cac";
3
3
  import consola$1, { consola } from "consola";
4
4
  import { resolve } from "pathe";
5
- import { Document, parse } from "yaml";
5
+ import { Document, parse, stringify } from "yaml";
6
6
  import { access, readFile, rm, writeFile } from "node:fs/promises";
7
7
  import { getColor } from "consola/utils";
8
8
  import { isPlainObject, isUndefined, pick } from "@ntnyq/utils";
@@ -10,10 +10,11 @@ import { defu } from "defu";
10
10
  import camelcaseKeys from "camelcase-keys";
11
11
  import { readIniFile } from "read-ini-file";
12
12
  import { kebabCase } from "uncase";
13
+ import { isDeepStrictEqual } from "node:util";
13
14
  import detectIndent from "detect-indent";
14
15
  //#region package.json
15
16
  var name = "pnpm-settings-migrator";
16
- var version = "0.7.0";
17
+ var version = "0.8.0";
17
18
  //#endregion
18
19
  //#region src/constants.ts
19
20
  const NPMRC = ".npmrc";
@@ -158,6 +159,7 @@ const DEFAULT_OPTIONS = {
158
159
  cwd: process.cwd(),
159
160
  newlineBetween: true,
160
161
  replaceDeprecated: false,
162
+ showChanges: true,
161
163
  sortKeys: false,
162
164
  strategy: "merge",
163
165
  yarnResolutions: true
@@ -210,6 +212,7 @@ function resolveOptions(options = {}) {
210
212
  compatibility: resolveCompatibility(options.compatibility),
211
213
  cwd: options.cwd ?? DEFAULT_OPTIONS.cwd,
212
214
  newlineBetween: options.newlineBetween ?? DEFAULT_OPTIONS.newlineBetween,
215
+ showChanges: options.showChanges ?? DEFAULT_OPTIONS.showChanges,
213
216
  sortKeys: options.sortKeys ?? DEFAULT_OPTIONS.sortKeys,
214
217
  strategy: resolveStrategy(options.strategy),
215
218
  yarnResolutions: options.yarnResolutions ?? DEFAULT_OPTIONS.yarnResolutions,
@@ -220,45 +223,73 @@ function resolveOptions(options = {}) {
220
223
  //#endregion
221
224
  //#region src/utils/fs.ts
222
225
  /**
223
- * Check if a path exists
224
- * @param path - given path
225
- * @returns `true` if exists, false otherwise
226
+ * Check whether a filesystem path exists.
227
+ *
228
+ * @param path - Filesystem path to check
229
+ *
230
+ * @returns `true` when the path is accessible, otherwise `false`
226
231
  */
227
232
  async function fsExists(path) {
228
233
  return access(path).then(() => true).catch(() => false);
229
234
  }
230
235
  /**
231
- * Read file content
232
- * @param path - file path
233
- * @returns content of given file
236
+ * Read a UTF-8 text file.
237
+ *
238
+ * @param path - Filesystem path to read
239
+ *
240
+ * @returns Text content of the file
234
241
  */
235
242
  async function fsReadFile(path) {
236
243
  return await readFile(path, "utf-8");
237
244
  }
238
245
  /**
239
246
  * Remove a file if it exists.
240
- * @param path - file path
247
+ *
248
+ * @param path - Filesystem path to remove
249
+ *
250
+ * @returns A promise that resolves after the file is removed
241
251
  */
242
252
  async function fsRemoveFile(path) {
243
253
  await rm(path, { force: true });
244
254
  }
245
255
  /**
246
- * Write file
247
- * @param path - file path
248
- * @param content - file content
256
+ * Write a normalized UTF-8 text file with one trailing newline.
257
+ *
258
+ * @param path - Filesystem path to write
259
+ * @param content - Text content to write
260
+ *
261
+ * @returns A promise that resolves after the file is written
249
262
  */
250
263
  async function fsWriteFile(path, content) {
251
264
  await writeFile(path, `${content.trimEnd()}\n`, "utf-8");
252
265
  }
253
266
  getColor("cyan");
254
267
  getColor("yellow");
268
+ /**
269
+ * Apply dim terminal styling to a value.
270
+ */
255
271
  const dim = getColor("dim");
272
+ /**
273
+ * Apply green terminal styling to a value.
274
+ */
256
275
  const green = getColor("green");
276
+ /**
277
+ * Apply red terminal styling to a value.
278
+ */
257
279
  const red = getColor("red");
280
+ /**
281
+ * Apply bold terminal styling to a value.
282
+ */
258
283
  const bold = getColor("bold");
284
+ /**
285
+ * Apply magenta terminal styling to a value.
286
+ */
259
287
  const magenta = getColor("magenta");
260
288
  //#endregion
261
289
  //#region src/utils/compatibility.ts
290
+ /**
291
+ * Legacy build settings that can be replaced for pnpm v10.
292
+ */
262
293
  const PNPM_REPLACEABLE_IN_V10_SETTINGS = [
263
294
  "allowNonAppliedPatches",
264
295
  "ignoredBuiltDependencies",
@@ -268,6 +299,14 @@ const PNPM_REPLACEABLE_IN_V10_SETTINGS = [
268
299
  ];
269
300
  /**
270
301
  * Read packages listed by the legacy `onlyBuiltDependenciesFile` setting.
302
+ *
303
+ * @param cwd - Working directory used to resolve the referenced file
304
+ * @param file - Relative path stored in `onlyBuiltDependenciesFile`
305
+ *
306
+ * @returns Package names read from the referenced JSON file
307
+ *
308
+ * @throws {TypeError} When the referenced JSON value is not a string array
309
+ * @throws {SyntaxError} When the referenced file contains invalid JSON
271
310
  */
272
311
  async function readOnlyBuiltDependenciesFile(cwd, file) {
273
312
  if (!file) return [];
@@ -278,6 +317,11 @@ async function readOnlyBuiltDependenciesFile(cwd, file) {
278
317
  }
279
318
  /**
280
319
  * Build an `allowBuilds` map from legacy build-script settings.
320
+ *
321
+ * @param incomingSettings - Settings containing legacy build declarations
322
+ * @param cwd - Working directory used to resolve referenced files
323
+ *
324
+ * @returns Package-to-permission map, or `undefined` when no entries exist
281
325
  */
282
326
  async function collectAllowBuildsFromLegacy(incomingSettings, cwd) {
283
327
  const allowBuilds = {};
@@ -289,6 +333,10 @@ async function collectAllowBuildsFromLegacy(incomingSettings, cwd) {
289
333
  }
290
334
  /**
291
335
  * Resolve the pnpm v11 replacement for legacy package-manager settings.
336
+ *
337
+ * @param settings - Settings containing legacy package-manager declarations
338
+ *
339
+ * @returns Resolved `pmOnFail` behavior, or `undefined` when not configured
292
340
  */
293
341
  function resolvePmOnFail(settings) {
294
342
  if (settings.pmOnFail) return settings.pmOnFail;
@@ -299,6 +347,11 @@ function resolvePmOnFail(settings) {
299
347
  }
300
348
  /**
301
349
  * Rename legacy audit settings while preserving values for manual ID updates.
350
+ *
351
+ * @param settings - Settings whose audit configuration may be normalized
352
+ * @param warnings - Warning collection updated with manual follow-up guidance
353
+ *
354
+ * @returns Nothing; the settings and warning collection are mutated in place
302
355
  */
303
356
  function normalizeAuditConfig(settings, warnings) {
304
357
  const { auditConfig } = settings;
@@ -310,6 +363,11 @@ function normalizeAuditConfig(settings, warnings) {
310
363
  }
311
364
  /**
312
365
  * Resolve a removed Node.js runtime setting for migration to `package.json`.
366
+ *
367
+ * @param settings - Settings containing legacy runtime declarations
368
+ * @param warnings - Warning collection updated when declarations conflict
369
+ *
370
+ * @returns Runtime version to migrate, or `undefined` when not configured
313
371
  */
314
372
  function resolveRuntimeVersion(settings, warnings) {
315
373
  const { useNodeVersion } = settings;
@@ -319,6 +377,11 @@ function resolveRuntimeVersion(settings, warnings) {
319
377
  }
320
378
  /**
321
379
  * Normalize settings according to the selected compatibility target.
380
+ *
381
+ * @param incomingSettings - Settings to normalize in place
382
+ * @param options - Compatibility target and normalization context
383
+ *
384
+ * @returns Normalization result, extracted runtime version, and warnings
322
385
  */
323
386
  async function normalizeIncomingSettings(incomingSettings, options) {
324
387
  const { compatibility, cwd, replaceDeprecated } = options;
@@ -354,8 +417,15 @@ async function normalizeIncomingSettings(incomingSettings, options) {
354
417
  warnings
355
418
  };
356
419
  }
420
+ //#endregion
421
+ //#region src/utils/compatibility-runtime.ts
357
422
  /**
358
423
  * Move a removed Node.js runtime setting to `package.json#devEngines.runtime`.
424
+ *
425
+ * @param packageJson - Package manifest to update in place
426
+ * @param runtimeVersion - Node.js runtime version extracted from pnpm settings
427
+ *
428
+ * @returns Migration result indicating whether the manifest changed
359
429
  */
360
430
  function migrateRuntimeToPackageJson(packageJson, runtimeVersion) {
361
431
  if (!runtimeVersion) return { changed: false };
@@ -374,6 +444,12 @@ function migrateRuntimeToPackageJson(packageJson, runtimeVersion) {
374
444
  }
375
445
  /**
376
446
  * Select the runtime setting that wins under the configured merge strategy.
447
+ *
448
+ * @param existingVersion - Runtime version from existing workspace settings
449
+ * @param incomingVersion - Runtime version from incoming settings
450
+ * @param strategy - Merge strategy controlling value precedence
451
+ *
452
+ * @returns Selected runtime version, or `undefined` when neither source has one
377
453
  */
378
454
  function resolveRuntimeVersionByStrategy(existingVersion, incomingVersion, strategy) {
379
455
  if (strategy === "overwrite") return incomingVersion ?? existingVersion;
@@ -381,10 +457,22 @@ function resolveRuntimeVersionByStrategy(existingVersion, incomingVersion, strat
381
457
  }
382
458
  //#endregion
383
459
  //#region src/utils/compatibility-target.ts
460
+ /**
461
+ * pnpm major version that introduced the v11 settings schema.
462
+ */
384
463
  const PNPM_V11_MAJOR = 11;
464
+ /**
465
+ * pnpm major version that introduced the v12 compatibility target.
466
+ */
385
467
  const PNPM_V12_MAJOR = 12;
386
468
  /**
387
469
  * Resolve final compatibility target from user option and package manager hint.
470
+ *
471
+ * @param compatibility - Explicit or automatically detected compatibility target
472
+ * @param packageManager - `packageManager` declaration from `package.json`
473
+ * @param devPackageManager - Package manager declaration from `devEngines`
474
+ *
475
+ * @returns Resolved concrete compatibility target
388
476
  */
389
477
  function resolveCompatibilityTarget(compatibility, packageManager, devPackageManager) {
390
478
  if (compatibility !== "auto") return compatibility;
@@ -470,6 +558,9 @@ function mergeByStrategy(existing, incoming, strategy) {
470
558
  }
471
559
  //#endregion
472
560
  //#region src/utils/npmrc.ts
561
+ /**
562
+ * Authentication and registry keys that must remain in `.npmrc`.
563
+ */
473
564
  const NPMRC_AUTH_OR_REGISTRY_KEYS = [
474
565
  "_auth",
475
566
  "_authtoken",
@@ -486,15 +577,26 @@ const NPMRC_AUTH_OR_REGISTRY_KEYS = [
486
577
  "tokenhelper",
487
578
  "username"
488
579
  ];
580
+ /**
581
+ * Pattern for pnpm's channel-specific Node.js mirror keys.
582
+ */
489
583
  const NODE_MIRROR_KEY_PATTERN = /^node-mirror:(?<channel>.+)$/iu;
490
584
  /**
491
585
  * Normalize `.npmrc` key for case-insensitive matching.
586
+ *
587
+ * @param key - Raw `.npmrc` key
588
+ *
589
+ * @returns Trimmed lower-case key without an array suffix
492
590
  */
493
591
  function normalizeNpmrcKey(key) {
494
592
  return key.trim().replace(/\[\]$/u, "").toLowerCase();
495
593
  }
496
594
  /**
497
595
  * Extract key name from a raw `.npmrc` line.
596
+ *
597
+ * @param line - Raw line from an `.npmrc` file
598
+ *
599
+ * @returns Normalized key, or `undefined` for comments and invalid lines
498
600
  */
499
601
  function getNpmrcLineKey(line) {
500
602
  const trimmed = line.trim();
@@ -506,6 +608,10 @@ function getNpmrcLineKey(line) {
506
608
  /**
507
609
  * Check whether a `.npmrc` key is auth/registry-related and should stay in
508
610
  * pnpm v11 and newer.
611
+ *
612
+ * @param key - Raw or normalized `.npmrc` key
613
+ *
614
+ * @returns `true` when the key must remain in `.npmrc`
509
615
  */
510
616
  function isNpmrcAuthOrRegistryKey(key) {
511
617
  const normalized = normalizeNpmrcKey(key);
@@ -521,6 +627,8 @@ function isNpmrcAuthOrRegistryKey(key) {
521
627
  * writes the cleaned content back to the file.
522
628
  *
523
629
  * @param path - Absolute path to the `.npmrc` file
630
+ * @param compatibility - Concrete pnpm compatibility target
631
+ * @param migratedKeys - Original `.npmrc` keys that were migrated
524
632
  *
525
633
  * @returns A promise that resolves when the file has been pruned
526
634
  *
@@ -547,30 +655,18 @@ async function pruneNpmrc(path, compatibility = "v10", migratedKeys = []) {
547
655
  await fsWriteFile(path, updatedContent);
548
656
  }
549
657
  /**
550
- * Read and parse `.npmrc` file with camelCase key conversion.
658
+ * Read `.npmrc` and return settings that should be migrated into workspace config.
551
659
  *
552
- * This function reads an `.npmrc` INI-format file and parses it into an object.
553
- * All keys are automatically converted from kebab-case to camelCase for easier
554
- * JavaScript consumption (e.g., `allow-builds` → `allowBuilds`).
660
+ * - `v10`: returns settings from the legacy whitelist.
661
+ * - `v11+`: excludes auth/registry keys because pnpm still reads them from
662
+ * `.npmrc`.
555
663
  *
556
664
  * @param path - Absolute path to the `.npmrc` file
665
+ * @param compatibility - Concrete pnpm compatibility target
557
666
  *
558
- * @returns A promise that resolves to the parsed `.npmrc` configuration object with camelCase keys
667
+ * @returns Migratable settings and their original `.npmrc` keys
559
668
  *
560
669
  * @throws {Error} When file reading or INI parsing fails
561
- *
562
- * @example
563
- * ```ts
564
- * const config = await readNpmrc('/path/to/.npmrc')
565
- * // config.allowBuilds, config.packageExtensions, etc.
566
- * ```
567
- */
568
- /**
569
- * Read `.npmrc` and return settings that should be migrated into workspace config.
570
- *
571
- * - `v10`: returns settings from the legacy whitelist.
572
- * - `v11+`: excludes auth/registry keys because pnpm still reads them from
573
- * `.npmrc`.
574
670
  */
575
671
  async function readMigratableNpmrc(path, compatibility) {
576
672
  const raw = await readIniFile(path);
@@ -598,11 +694,136 @@ async function readMigratableNpmrc(path, compatibility) {
598
694
  };
599
695
  }
600
696
  //#endregion
697
+ //#region src/utils/settings-change.ts
698
+ /**
699
+ * Collect changed root settings between two workspace configurations.
700
+ *
701
+ * @param before - Workspace settings before migration
702
+ * @param after - Workspace settings after migration
703
+ *
704
+ * @returns Changed root settings with their before and after values
705
+ */
706
+ function collectSettingsChanges(before, after) {
707
+ const beforeSettings = { ...before };
708
+ const afterSettings = { ...after };
709
+ const keys = /* @__PURE__ */ new Set([...Object.keys(beforeSettings), ...Object.keys(afterSettings)]);
710
+ return Array.from(keys).filter((key) => !isDeepStrictEqual(beforeSettings[key], afterSettings[key])).map((key) => ({
711
+ after: afterSettings[key],
712
+ before: beforeSettings[key],
713
+ key
714
+ }));
715
+ }
716
+ /**
717
+ * Create a line-based diff using the longest common subsequence.
718
+ *
719
+ * @param beforeLines - YAML lines before migration
720
+ * @param afterLines - YAML lines after migration
721
+ *
722
+ * @returns Classified diff lines in display order
723
+ */
724
+ function createLineDiff(beforeLines, afterLines) {
725
+ const lengths = Array.from({ length: beforeLines.length + 1 }, () => Array(afterLines.length + 1).fill(0));
726
+ for (let beforeIndex = beforeLines.length - 1; beforeIndex >= 0; beforeIndex--) for (let afterIndex = afterLines.length - 1; afterIndex >= 0; afterIndex--) lengths[beforeIndex][afterIndex] = beforeLines[beforeIndex] === afterLines[afterIndex] ? lengths[beforeIndex + 1][afterIndex + 1] + 1 : Math.max(lengths[beforeIndex + 1][afterIndex], lengths[beforeIndex][afterIndex + 1]);
727
+ const diff = [];
728
+ let beforeIndex = 0;
729
+ let afterIndex = 0;
730
+ while (beforeIndex < beforeLines.length && afterIndex < afterLines.length) if (beforeLines[beforeIndex] === afterLines[afterIndex]) {
731
+ diff.push({
732
+ kind: "unchanged",
733
+ value: beforeLines[beforeIndex]
734
+ });
735
+ beforeIndex++;
736
+ afterIndex++;
737
+ } else if (lengths[beforeIndex + 1][afterIndex] >= lengths[beforeIndex][afterIndex + 1]) {
738
+ diff.push({
739
+ kind: "removed",
740
+ value: beforeLines[beforeIndex]
741
+ });
742
+ beforeIndex++;
743
+ } else {
744
+ diff.push({
745
+ kind: "added",
746
+ value: afterLines[afterIndex]
747
+ });
748
+ afterIndex++;
749
+ }
750
+ while (beforeIndex < beforeLines.length) {
751
+ diff.push({
752
+ kind: "removed",
753
+ value: beforeLines[beforeIndex]
754
+ });
755
+ beforeIndex++;
756
+ }
757
+ while (afterIndex < afterLines.length) {
758
+ diff.push({
759
+ kind: "added",
760
+ value: afterLines[afterIndex]
761
+ });
762
+ afterIndex++;
763
+ }
764
+ return diff;
765
+ }
766
+ /**
767
+ * Format one root setting as YAML lines.
768
+ *
769
+ * @param key - Root setting key
770
+ * @param value - Setting value, or `undefined` when the key is absent
771
+ *
772
+ * @returns YAML lines for the setting, or an empty array when absent
773
+ */
774
+ function formatSettingLines(key, value) {
775
+ if (value === void 0) return [];
776
+ return stringify({ [key]: value }).trimEnd().split("\n");
777
+ }
778
+ /**
779
+ * Format a setting change as a GitHub-style YAML diff.
780
+ *
781
+ * @param change - Root setting change to format
782
+ *
783
+ * @returns Classified YAML diff lines
784
+ */
785
+ function createSettingsDiffLines(change) {
786
+ return createLineDiff(formatSettingLines(change.key, change.before), formatSettingLines(change.key, change.after));
787
+ }
788
+ /**
789
+ * Print a summary and GitHub-style YAML diff for every changed setting.
790
+ *
791
+ * @param changes - Root setting changes to report
792
+ *
793
+ * @returns Nothing; output is written through consola
794
+ */
795
+ function reportSettingsChanges(changes) {
796
+ consola$1.info(`${changes.length} ${changes.length === 1 ? "setting" : "settings"} changed`);
797
+ for (const [index, change] of changes.entries()) {
798
+ if (index > 0) consola$1.log("");
799
+ for (const line of createSettingsDiffLines(change)) if (line.kind === "added") consola$1.log(green(`+ ${line.value}`));
800
+ else if (line.kind === "removed") consola$1.log(red(`- ${line.value}`));
801
+ else consola$1.log(dim(` ${line.value}`));
802
+ }
803
+ }
804
+ //#endregion
601
805
  //#region src/utils/config.ts
806
+ /**
807
+ * Resolve the indentation width for a YAML document.
808
+ *
809
+ * @param content - Raw YAML content
810
+ *
811
+ * @returns Detected indentation width or the project default
812
+ */
602
813
  function resolveYamlIndent(content) {
603
814
  const detectedIndent = detectIndent(content).amount;
604
815
  return detectedIndent > 0 ? detectedIndent : 2;
605
816
  }
817
+ /**
818
+ * Read and parse a package manifest when it exists.
819
+ *
820
+ * @param path - Absolute path to `package.json`
821
+ * @param exists - Whether the package manifest exists
822
+ *
823
+ * @returns Parsed package manifest and detected indentation
824
+ *
825
+ * @throws {SyntaxError} When the package manifest contains invalid JSON
826
+ */
606
827
  async function readPackageJson(path, exists) {
607
828
  if (!exists) return {
608
829
  indent: 2,
@@ -614,6 +835,16 @@ async function readPackageJson(path, exists) {
614
835
  value: JSON.parse(content)
615
836
  };
616
837
  }
838
+ /**
839
+ * Read and parse a pnpm workspace manifest when it exists.
840
+ *
841
+ * @param path - Absolute path to `pnpm-workspace.yaml`
842
+ * @param exists - Whether the workspace manifest exists
843
+ *
844
+ * @returns Parsed workspace settings and detected indentation
845
+ *
846
+ * @throws {Error} When the workspace manifest contains invalid YAML
847
+ */
617
848
  async function readPnpmWorkspace(path, exists) {
618
849
  if (!exists) return {
619
850
  indent: 2,
@@ -625,6 +856,17 @@ async function readPnpmWorkspace(path, exists) {
625
856
  value: parse(content) ?? {}
626
857
  };
627
858
  }
859
+ /**
860
+ * Resolve migratable pnpm settings from a package manifest.
861
+ *
862
+ * Yarn resolutions are merged into pnpm overrides when enabled. Empty
863
+ * overrides are removed from the result.
864
+ *
865
+ * @param packageJson - Parsed package manifest
866
+ * @param yarnResolutions - Whether to convert Yarn resolutions to pnpm overrides
867
+ *
868
+ * @returns Pnpm workspace settings resolved from the package manifest
869
+ */
628
870
  function resolvePackageJsonSettings(packageJson, yarnResolutions) {
629
871
  const pnpmSettings = yarnResolutions && packageJson.resolutions ? {
630
872
  ...packageJson.pnpm,
@@ -649,6 +891,10 @@ function hasMigratableSettings(sources) {
649
891
  function assertCanMigrateRuntime(runtimeVersion, packageJsonExists) {
650
892
  if (runtimeVersion && !packageJsonExists) throw new Error("Cannot migrate the removed Node.js runtime setting without a package.json file.");
651
893
  }
894
+ function reportMigrationChanges(showChanges, before = {}, after = {}) {
895
+ if (!showChanges) return;
896
+ reportSettingsChanges(collectSettingsChanges(before, after));
897
+ }
652
898
  /**
653
899
  * Migrate pnpm settings from legacy locations to `pnpm-workspace.yaml`.
654
900
  *
@@ -666,6 +912,7 @@ function assertCanMigrateRuntime(runtimeVersion, packageJsonExists) {
666
912
  * @param rawOptions.yarnResolutions - Whether to migrate resolutions field (default: true)
667
913
  * @param rawOptions.sortKeys - Whether to sort keys in output YAML (default: false)
668
914
  * @param rawOptions.newlineBetween - Add newlines between root keys (default: true)
915
+ * @param rawOptions.showChanges - Show settings changes after migration (default: true)
669
916
  *
670
917
  * @returns A promise that resolves when migration is complete
671
918
  *
@@ -695,8 +942,12 @@ async function migratePnpmSettings(rawOptions = {}) {
695
942
  fsExists(packageJsonPath),
696
943
  fsExists(pnpmWorkspaceYamlPath)
697
944
  ]);
698
- if (!hasSettingsSources(npmrcExists, packageJsonExists, pnpmWorkspaceExists)) return;
945
+ if (!hasSettingsSources(npmrcExists, packageJsonExists, pnpmWorkspaceExists)) {
946
+ reportMigrationChanges(options.showChanges);
947
+ return;
948
+ }
699
949
  const [packageJson, pnpmWorkspace] = await Promise.all([readPackageJson(packageJsonPath, packageJsonExists), readPnpmWorkspace(pnpmWorkspaceYamlPath, pnpmWorkspaceExists)]);
950
+ const pnpmWorkspaceBefore = structuredClone(pnpmWorkspace.value);
700
951
  const compatibility = resolveCompatibilityTarget(options.compatibility, packageJson.value.packageManager, packageJson.value.devEngines?.packageManager);
701
952
  const npmrcMigratable = npmrcExists ? await readMigratableNpmrc(npmrcPath, compatibility) : {
702
953
  keys: [],
@@ -729,6 +980,7 @@ async function migratePnpmSettings(rawOptions = {}) {
729
980
  yarnResolutions: options.yarnResolutions
730
981
  })) {
731
982
  consola$1.warn("No pnpm settings fields to migrate");
983
+ reportMigrationChanges(options.showChanges);
732
984
  return;
733
985
  }
734
986
  const pnpmWorkspaceResult = mergeByStrategy(pnpmWorkspace.value, incomingSettings, options.strategy);
@@ -752,6 +1004,7 @@ async function migratePnpmSettings(rawOptions = {}) {
752
1004
  }
753
1005
  }
754
1006
  if (packageJsonExists && packageJsonChanged) await fsWriteFile(packageJsonPath, JSON.stringify(packageJson.value, null, packageJson.indent));
1007
+ reportMigrationChanges(options.showChanges, pnpmWorkspaceBefore, pnpmWorkspaceResult);
755
1008
  } catch (err) {
756
1009
  consola$1.error("Failed to migrate pnpm settings:", err);
757
1010
  throw err;
@@ -760,7 +1013,7 @@ async function migratePnpmSettings(rawOptions = {}) {
760
1013
  //#endregion
761
1014
  //#region src/cli.ts
762
1015
  const cli = cac(name);
763
- cli.version(version).option("--cwd [cwd]", "Current working directory").option("--sort-keys", "Sort keys when write pnpm-workspace.yaml").option("--compatibility <compatibility>", "Compatibility target (auto, v10, v11, v12)").option("--replace-deprecated", "Replace deprecated pnpm settings with new ones and remove old keys").option("--strategy <strategy>", "Strategy to handle conflicts (discard, merge, overwrite)").option("--no-yarn-resolutions", "Disable migrating resolutions field in package.json").option("--no-newline-between", "Disable adding newlines between each root keys").option("--no-clean-npmrc", "Disable removing pnpm settings in .npmrc file").option("--no-clean-package-json", "Disable removing pnpm fields in package.json").help();
1016
+ cli.version(version).option("--cwd [cwd]", "Current working directory").option("--sort-keys", "Sort keys when write pnpm-workspace.yaml").option("--compatibility <compatibility>", "Compatibility target (auto, v10, v11, v12)").option("--replace-deprecated", "Replace deprecated pnpm settings with new ones and remove old keys").option("--strategy <strategy>", "Strategy to handle conflicts (discard, merge, overwrite)").option("--no-yarn-resolutions", "Disable migrating resolutions field in package.json").option("--no-newline-between", "Disable adding newlines between each root keys").option("--no-show-changes", "Disable showing settings changes").option("--no-clean-npmrc", "Disable removing pnpm settings in .npmrc file").option("--no-clean-package-json", "Disable removing pnpm fields in package.json").help();
764
1017
  cli.command("").action(async (options) => {
765
1018
  try {
766
1019
  consola.log(`\n${bold(magenta(name))} ${dim(`v${version}`)}`);
package/dist/index.d.mts CHANGED
@@ -51,6 +51,12 @@ interface Options {
51
51
  * @default false
52
52
  */
53
53
  replaceDeprecated?: boolean;
54
+ /**
55
+ * Whether to show a settings diff after migration
56
+ *
57
+ * @default true
58
+ */
59
+ showChanges?: boolean;
54
60
  /**
55
61
  * Sort keys when write `pnpm-workspace.yaml`
56
62
  *
@@ -227,6 +233,7 @@ type PnpmWorkspace = PnpmSettings & PnpmSettingsDeprecated & PnpmSettingsV11 & P
227
233
  * @param rawOptions.yarnResolutions - Whether to migrate resolutions field (default: true)
228
234
  * @param rawOptions.sortKeys - Whether to sort keys in output YAML (default: false)
229
235
  * @param rawOptions.newlineBetween - Add newlines between root keys (default: true)
236
+ * @param rawOptions.showChanges - Show settings changes after migration (default: true)
230
237
  *
231
238
  * @returns A promise that resolves when migration is complete
232
239
  *
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import consola from "consola";
2
2
  import { resolve } from "pathe";
3
- import { Document, parse } from "yaml";
3
+ import { Document, parse, stringify } from "yaml";
4
4
  import process from "node:process";
5
5
  import { access, readFile, rm, writeFile } from "node:fs/promises";
6
6
  import { getColor } from "consola/utils";
@@ -9,6 +9,7 @@ import { defu } from "defu";
9
9
  import camelcaseKeys from "camelcase-keys";
10
10
  import { readIniFile } from "read-ini-file";
11
11
  import { kebabCase } from "uncase";
12
+ import { isDeepStrictEqual } from "node:util";
12
13
  import detectIndent from "detect-indent";
13
14
  //#region src/constants.ts
14
15
  const NPMRC = ".npmrc";
@@ -153,6 +154,7 @@ const DEFAULT_OPTIONS = {
153
154
  cwd: process.cwd(),
154
155
  newlineBetween: true,
155
156
  replaceDeprecated: false,
157
+ showChanges: true,
156
158
  sortKeys: false,
157
159
  strategy: "merge",
158
160
  yarnResolutions: true
@@ -205,6 +207,7 @@ function resolveOptions(options = {}) {
205
207
  compatibility: resolveCompatibility(options.compatibility),
206
208
  cwd: options.cwd ?? DEFAULT_OPTIONS.cwd,
207
209
  newlineBetween: options.newlineBetween ?? DEFAULT_OPTIONS.newlineBetween,
210
+ showChanges: options.showChanges ?? DEFAULT_OPTIONS.showChanges,
208
211
  sortKeys: options.sortKeys ?? DEFAULT_OPTIONS.sortKeys,
209
212
  strategy: resolveStrategy(options.strategy),
210
213
  yarnResolutions: options.yarnResolutions ?? DEFAULT_OPTIONS.yarnResolutions,
@@ -215,45 +218,67 @@ function resolveOptions(options = {}) {
215
218
  //#endregion
216
219
  //#region src/utils/fs.ts
217
220
  /**
218
- * Check if a path exists
219
- * @param path - given path
220
- * @returns `true` if exists, false otherwise
221
+ * Check whether a filesystem path exists.
222
+ *
223
+ * @param path - Filesystem path to check
224
+ *
225
+ * @returns `true` when the path is accessible, otherwise `false`
221
226
  */
222
227
  async function fsExists(path) {
223
228
  return access(path).then(() => true).catch(() => false);
224
229
  }
225
230
  /**
226
- * Read file content
227
- * @param path - file path
228
- * @returns content of given file
231
+ * Read a UTF-8 text file.
232
+ *
233
+ * @param path - Filesystem path to read
234
+ *
235
+ * @returns Text content of the file
229
236
  */
230
237
  async function fsReadFile(path) {
231
238
  return await readFile(path, "utf-8");
232
239
  }
233
240
  /**
234
241
  * Remove a file if it exists.
235
- * @param path - file path
242
+ *
243
+ * @param path - Filesystem path to remove
244
+ *
245
+ * @returns A promise that resolves after the file is removed
236
246
  */
237
247
  async function fsRemoveFile(path) {
238
248
  await rm(path, { force: true });
239
249
  }
240
250
  /**
241
- * Write file
242
- * @param path - file path
243
- * @param content - file content
251
+ * Write a normalized UTF-8 text file with one trailing newline.
252
+ *
253
+ * @param path - Filesystem path to write
254
+ * @param content - Text content to write
255
+ *
256
+ * @returns A promise that resolves after the file is written
244
257
  */
245
258
  async function fsWriteFile(path, content) {
246
259
  await writeFile(path, `${content.trimEnd()}\n`, "utf-8");
247
260
  }
248
261
  getColor("cyan");
249
262
  getColor("yellow");
263
+ /**
264
+ * Apply dim terminal styling to a value.
265
+ */
250
266
  const dim = getColor("dim");
251
- getColor("green");
252
- getColor("red");
267
+ /**
268
+ * Apply green terminal styling to a value.
269
+ */
270
+ const green = getColor("green");
271
+ /**
272
+ * Apply red terminal styling to a value.
273
+ */
274
+ const red = getColor("red");
253
275
  getColor("bold");
254
276
  getColor("magenta");
255
277
  //#endregion
256
278
  //#region src/utils/compatibility.ts
279
+ /**
280
+ * Legacy build settings that can be replaced for pnpm v10.
281
+ */
257
282
  const PNPM_REPLACEABLE_IN_V10_SETTINGS = [
258
283
  "allowNonAppliedPatches",
259
284
  "ignoredBuiltDependencies",
@@ -263,6 +288,14 @@ const PNPM_REPLACEABLE_IN_V10_SETTINGS = [
263
288
  ];
264
289
  /**
265
290
  * Read packages listed by the legacy `onlyBuiltDependenciesFile` setting.
291
+ *
292
+ * @param cwd - Working directory used to resolve the referenced file
293
+ * @param file - Relative path stored in `onlyBuiltDependenciesFile`
294
+ *
295
+ * @returns Package names read from the referenced JSON file
296
+ *
297
+ * @throws {TypeError} When the referenced JSON value is not a string array
298
+ * @throws {SyntaxError} When the referenced file contains invalid JSON
266
299
  */
267
300
  async function readOnlyBuiltDependenciesFile(cwd, file) {
268
301
  if (!file) return [];
@@ -273,6 +306,11 @@ async function readOnlyBuiltDependenciesFile(cwd, file) {
273
306
  }
274
307
  /**
275
308
  * Build an `allowBuilds` map from legacy build-script settings.
309
+ *
310
+ * @param incomingSettings - Settings containing legacy build declarations
311
+ * @param cwd - Working directory used to resolve referenced files
312
+ *
313
+ * @returns Package-to-permission map, or `undefined` when no entries exist
276
314
  */
277
315
  async function collectAllowBuildsFromLegacy(incomingSettings, cwd) {
278
316
  const allowBuilds = {};
@@ -284,6 +322,10 @@ async function collectAllowBuildsFromLegacy(incomingSettings, cwd) {
284
322
  }
285
323
  /**
286
324
  * Resolve the pnpm v11 replacement for legacy package-manager settings.
325
+ *
326
+ * @param settings - Settings containing legacy package-manager declarations
327
+ *
328
+ * @returns Resolved `pmOnFail` behavior, or `undefined` when not configured
287
329
  */
288
330
  function resolvePmOnFail(settings) {
289
331
  if (settings.pmOnFail) return settings.pmOnFail;
@@ -294,6 +336,11 @@ function resolvePmOnFail(settings) {
294
336
  }
295
337
  /**
296
338
  * Rename legacy audit settings while preserving values for manual ID updates.
339
+ *
340
+ * @param settings - Settings whose audit configuration may be normalized
341
+ * @param warnings - Warning collection updated with manual follow-up guidance
342
+ *
343
+ * @returns Nothing; the settings and warning collection are mutated in place
297
344
  */
298
345
  function normalizeAuditConfig(settings, warnings) {
299
346
  const { auditConfig } = settings;
@@ -305,6 +352,11 @@ function normalizeAuditConfig(settings, warnings) {
305
352
  }
306
353
  /**
307
354
  * Resolve a removed Node.js runtime setting for migration to `package.json`.
355
+ *
356
+ * @param settings - Settings containing legacy runtime declarations
357
+ * @param warnings - Warning collection updated when declarations conflict
358
+ *
359
+ * @returns Runtime version to migrate, or `undefined` when not configured
308
360
  */
309
361
  function resolveRuntimeVersion(settings, warnings) {
310
362
  const { useNodeVersion } = settings;
@@ -314,6 +366,11 @@ function resolveRuntimeVersion(settings, warnings) {
314
366
  }
315
367
  /**
316
368
  * Normalize settings according to the selected compatibility target.
369
+ *
370
+ * @param incomingSettings - Settings to normalize in place
371
+ * @param options - Compatibility target and normalization context
372
+ *
373
+ * @returns Normalization result, extracted runtime version, and warnings
317
374
  */
318
375
  async function normalizeIncomingSettings(incomingSettings, options) {
319
376
  const { compatibility, cwd, replaceDeprecated } = options;
@@ -349,8 +406,15 @@ async function normalizeIncomingSettings(incomingSettings, options) {
349
406
  warnings
350
407
  };
351
408
  }
409
+ //#endregion
410
+ //#region src/utils/compatibility-runtime.ts
352
411
  /**
353
412
  * Move a removed Node.js runtime setting to `package.json#devEngines.runtime`.
413
+ *
414
+ * @param packageJson - Package manifest to update in place
415
+ * @param runtimeVersion - Node.js runtime version extracted from pnpm settings
416
+ *
417
+ * @returns Migration result indicating whether the manifest changed
354
418
  */
355
419
  function migrateRuntimeToPackageJson(packageJson, runtimeVersion) {
356
420
  if (!runtimeVersion) return { changed: false };
@@ -369,6 +433,12 @@ function migrateRuntimeToPackageJson(packageJson, runtimeVersion) {
369
433
  }
370
434
  /**
371
435
  * Select the runtime setting that wins under the configured merge strategy.
436
+ *
437
+ * @param existingVersion - Runtime version from existing workspace settings
438
+ * @param incomingVersion - Runtime version from incoming settings
439
+ * @param strategy - Merge strategy controlling value precedence
440
+ *
441
+ * @returns Selected runtime version, or `undefined` when neither source has one
372
442
  */
373
443
  function resolveRuntimeVersionByStrategy(existingVersion, incomingVersion, strategy) {
374
444
  if (strategy === "overwrite") return incomingVersion ?? existingVersion;
@@ -376,10 +446,22 @@ function resolveRuntimeVersionByStrategy(existingVersion, incomingVersion, strat
376
446
  }
377
447
  //#endregion
378
448
  //#region src/utils/compatibility-target.ts
449
+ /**
450
+ * pnpm major version that introduced the v11 settings schema.
451
+ */
379
452
  const PNPM_V11_MAJOR = 11;
453
+ /**
454
+ * pnpm major version that introduced the v12 compatibility target.
455
+ */
380
456
  const PNPM_V12_MAJOR = 12;
381
457
  /**
382
458
  * Resolve final compatibility target from user option and package manager hint.
459
+ *
460
+ * @param compatibility - Explicit or automatically detected compatibility target
461
+ * @param packageManager - `packageManager` declaration from `package.json`
462
+ * @param devPackageManager - Package manager declaration from `devEngines`
463
+ *
464
+ * @returns Resolved concrete compatibility target
383
465
  */
384
466
  function resolveCompatibilityTarget(compatibility, packageManager, devPackageManager) {
385
467
  if (compatibility !== "auto") return compatibility;
@@ -465,6 +547,9 @@ function mergeByStrategy(existing, incoming, strategy) {
465
547
  }
466
548
  //#endregion
467
549
  //#region src/utils/npmrc.ts
550
+ /**
551
+ * Authentication and registry keys that must remain in `.npmrc`.
552
+ */
468
553
  const NPMRC_AUTH_OR_REGISTRY_KEYS = [
469
554
  "_auth",
470
555
  "_authtoken",
@@ -481,15 +566,26 @@ const NPMRC_AUTH_OR_REGISTRY_KEYS = [
481
566
  "tokenhelper",
482
567
  "username"
483
568
  ];
569
+ /**
570
+ * Pattern for pnpm's channel-specific Node.js mirror keys.
571
+ */
484
572
  const NODE_MIRROR_KEY_PATTERN = /^node-mirror:(?<channel>.+)$/iu;
485
573
  /**
486
574
  * Normalize `.npmrc` key for case-insensitive matching.
575
+ *
576
+ * @param key - Raw `.npmrc` key
577
+ *
578
+ * @returns Trimmed lower-case key without an array suffix
487
579
  */
488
580
  function normalizeNpmrcKey(key) {
489
581
  return key.trim().replace(/\[\]$/u, "").toLowerCase();
490
582
  }
491
583
  /**
492
584
  * Extract key name from a raw `.npmrc` line.
585
+ *
586
+ * @param line - Raw line from an `.npmrc` file
587
+ *
588
+ * @returns Normalized key, or `undefined` for comments and invalid lines
493
589
  */
494
590
  function getNpmrcLineKey(line) {
495
591
  const trimmed = line.trim();
@@ -501,6 +597,10 @@ function getNpmrcLineKey(line) {
501
597
  /**
502
598
  * Check whether a `.npmrc` key is auth/registry-related and should stay in
503
599
  * pnpm v11 and newer.
600
+ *
601
+ * @param key - Raw or normalized `.npmrc` key
602
+ *
603
+ * @returns `true` when the key must remain in `.npmrc`
504
604
  */
505
605
  function isNpmrcAuthOrRegistryKey(key) {
506
606
  const normalized = normalizeNpmrcKey(key);
@@ -516,6 +616,8 @@ function isNpmrcAuthOrRegistryKey(key) {
516
616
  * writes the cleaned content back to the file.
517
617
  *
518
618
  * @param path - Absolute path to the `.npmrc` file
619
+ * @param compatibility - Concrete pnpm compatibility target
620
+ * @param migratedKeys - Original `.npmrc` keys that were migrated
519
621
  *
520
622
  * @returns A promise that resolves when the file has been pruned
521
623
  *
@@ -542,30 +644,18 @@ async function pruneNpmrc(path, compatibility = "v10", migratedKeys = []) {
542
644
  await fsWriteFile(path, updatedContent);
543
645
  }
544
646
  /**
545
- * Read and parse `.npmrc` file with camelCase key conversion.
647
+ * Read `.npmrc` and return settings that should be migrated into workspace config.
546
648
  *
547
- * This function reads an `.npmrc` INI-format file and parses it into an object.
548
- * All keys are automatically converted from kebab-case to camelCase for easier
549
- * JavaScript consumption (e.g., `allow-builds` → `allowBuilds`).
649
+ * - `v10`: returns settings from the legacy whitelist.
650
+ * - `v11+`: excludes auth/registry keys because pnpm still reads them from
651
+ * `.npmrc`.
550
652
  *
551
653
  * @param path - Absolute path to the `.npmrc` file
654
+ * @param compatibility - Concrete pnpm compatibility target
552
655
  *
553
- * @returns A promise that resolves to the parsed `.npmrc` configuration object with camelCase keys
656
+ * @returns Migratable settings and their original `.npmrc` keys
554
657
  *
555
658
  * @throws {Error} When file reading or INI parsing fails
556
- *
557
- * @example
558
- * ```ts
559
- * const config = await readNpmrc('/path/to/.npmrc')
560
- * // config.allowBuilds, config.packageExtensions, etc.
561
- * ```
562
- */
563
- /**
564
- * Read `.npmrc` and return settings that should be migrated into workspace config.
565
- *
566
- * - `v10`: returns settings from the legacy whitelist.
567
- * - `v11+`: excludes auth/registry keys because pnpm still reads them from
568
- * `.npmrc`.
569
659
  */
570
660
  async function readMigratableNpmrc(path, compatibility) {
571
661
  const raw = await readIniFile(path);
@@ -593,11 +683,136 @@ async function readMigratableNpmrc(path, compatibility) {
593
683
  };
594
684
  }
595
685
  //#endregion
686
+ //#region src/utils/settings-change.ts
687
+ /**
688
+ * Collect changed root settings between two workspace configurations.
689
+ *
690
+ * @param before - Workspace settings before migration
691
+ * @param after - Workspace settings after migration
692
+ *
693
+ * @returns Changed root settings with their before and after values
694
+ */
695
+ function collectSettingsChanges(before, after) {
696
+ const beforeSettings = { ...before };
697
+ const afterSettings = { ...after };
698
+ const keys = /* @__PURE__ */ new Set([...Object.keys(beforeSettings), ...Object.keys(afterSettings)]);
699
+ return Array.from(keys).filter((key) => !isDeepStrictEqual(beforeSettings[key], afterSettings[key])).map((key) => ({
700
+ after: afterSettings[key],
701
+ before: beforeSettings[key],
702
+ key
703
+ }));
704
+ }
705
+ /**
706
+ * Create a line-based diff using the longest common subsequence.
707
+ *
708
+ * @param beforeLines - YAML lines before migration
709
+ * @param afterLines - YAML lines after migration
710
+ *
711
+ * @returns Classified diff lines in display order
712
+ */
713
+ function createLineDiff(beforeLines, afterLines) {
714
+ const lengths = Array.from({ length: beforeLines.length + 1 }, () => Array(afterLines.length + 1).fill(0));
715
+ for (let beforeIndex = beforeLines.length - 1; beforeIndex >= 0; beforeIndex--) for (let afterIndex = afterLines.length - 1; afterIndex >= 0; afterIndex--) lengths[beforeIndex][afterIndex] = beforeLines[beforeIndex] === afterLines[afterIndex] ? lengths[beforeIndex + 1][afterIndex + 1] + 1 : Math.max(lengths[beforeIndex + 1][afterIndex], lengths[beforeIndex][afterIndex + 1]);
716
+ const diff = [];
717
+ let beforeIndex = 0;
718
+ let afterIndex = 0;
719
+ while (beforeIndex < beforeLines.length && afterIndex < afterLines.length) if (beforeLines[beforeIndex] === afterLines[afterIndex]) {
720
+ diff.push({
721
+ kind: "unchanged",
722
+ value: beforeLines[beforeIndex]
723
+ });
724
+ beforeIndex++;
725
+ afterIndex++;
726
+ } else if (lengths[beforeIndex + 1][afterIndex] >= lengths[beforeIndex][afterIndex + 1]) {
727
+ diff.push({
728
+ kind: "removed",
729
+ value: beforeLines[beforeIndex]
730
+ });
731
+ beforeIndex++;
732
+ } else {
733
+ diff.push({
734
+ kind: "added",
735
+ value: afterLines[afterIndex]
736
+ });
737
+ afterIndex++;
738
+ }
739
+ while (beforeIndex < beforeLines.length) {
740
+ diff.push({
741
+ kind: "removed",
742
+ value: beforeLines[beforeIndex]
743
+ });
744
+ beforeIndex++;
745
+ }
746
+ while (afterIndex < afterLines.length) {
747
+ diff.push({
748
+ kind: "added",
749
+ value: afterLines[afterIndex]
750
+ });
751
+ afterIndex++;
752
+ }
753
+ return diff;
754
+ }
755
+ /**
756
+ * Format one root setting as YAML lines.
757
+ *
758
+ * @param key - Root setting key
759
+ * @param value - Setting value, or `undefined` when the key is absent
760
+ *
761
+ * @returns YAML lines for the setting, or an empty array when absent
762
+ */
763
+ function formatSettingLines(key, value) {
764
+ if (value === void 0) return [];
765
+ return stringify({ [key]: value }).trimEnd().split("\n");
766
+ }
767
+ /**
768
+ * Format a setting change as a GitHub-style YAML diff.
769
+ *
770
+ * @param change - Root setting change to format
771
+ *
772
+ * @returns Classified YAML diff lines
773
+ */
774
+ function createSettingsDiffLines(change) {
775
+ return createLineDiff(formatSettingLines(change.key, change.before), formatSettingLines(change.key, change.after));
776
+ }
777
+ /**
778
+ * Print a summary and GitHub-style YAML diff for every changed setting.
779
+ *
780
+ * @param changes - Root setting changes to report
781
+ *
782
+ * @returns Nothing; output is written through consola
783
+ */
784
+ function reportSettingsChanges(changes) {
785
+ consola.info(`${changes.length} ${changes.length === 1 ? "setting" : "settings"} changed`);
786
+ for (const [index, change] of changes.entries()) {
787
+ if (index > 0) consola.log("");
788
+ for (const line of createSettingsDiffLines(change)) if (line.kind === "added") consola.log(green(`+ ${line.value}`));
789
+ else if (line.kind === "removed") consola.log(red(`- ${line.value}`));
790
+ else consola.log(dim(` ${line.value}`));
791
+ }
792
+ }
793
+ //#endregion
596
794
  //#region src/utils/config.ts
795
+ /**
796
+ * Resolve the indentation width for a YAML document.
797
+ *
798
+ * @param content - Raw YAML content
799
+ *
800
+ * @returns Detected indentation width or the project default
801
+ */
597
802
  function resolveYamlIndent(content) {
598
803
  const detectedIndent = detectIndent(content).amount;
599
804
  return detectedIndent > 0 ? detectedIndent : 2;
600
805
  }
806
+ /**
807
+ * Read and parse a package manifest when it exists.
808
+ *
809
+ * @param path - Absolute path to `package.json`
810
+ * @param exists - Whether the package manifest exists
811
+ *
812
+ * @returns Parsed package manifest and detected indentation
813
+ *
814
+ * @throws {SyntaxError} When the package manifest contains invalid JSON
815
+ */
601
816
  async function readPackageJson(path, exists) {
602
817
  if (!exists) return {
603
818
  indent: 2,
@@ -609,6 +824,16 @@ async function readPackageJson(path, exists) {
609
824
  value: JSON.parse(content)
610
825
  };
611
826
  }
827
+ /**
828
+ * Read and parse a pnpm workspace manifest when it exists.
829
+ *
830
+ * @param path - Absolute path to `pnpm-workspace.yaml`
831
+ * @param exists - Whether the workspace manifest exists
832
+ *
833
+ * @returns Parsed workspace settings and detected indentation
834
+ *
835
+ * @throws {Error} When the workspace manifest contains invalid YAML
836
+ */
612
837
  async function readPnpmWorkspace(path, exists) {
613
838
  if (!exists) return {
614
839
  indent: 2,
@@ -620,6 +845,17 @@ async function readPnpmWorkspace(path, exists) {
620
845
  value: parse(content) ?? {}
621
846
  };
622
847
  }
848
+ /**
849
+ * Resolve migratable pnpm settings from a package manifest.
850
+ *
851
+ * Yarn resolutions are merged into pnpm overrides when enabled. Empty
852
+ * overrides are removed from the result.
853
+ *
854
+ * @param packageJson - Parsed package manifest
855
+ * @param yarnResolutions - Whether to convert Yarn resolutions to pnpm overrides
856
+ *
857
+ * @returns Pnpm workspace settings resolved from the package manifest
858
+ */
623
859
  function resolvePackageJsonSettings(packageJson, yarnResolutions) {
624
860
  const pnpmSettings = yarnResolutions && packageJson.resolutions ? {
625
861
  ...packageJson.pnpm,
@@ -644,6 +880,10 @@ function hasMigratableSettings(sources) {
644
880
  function assertCanMigrateRuntime(runtimeVersion, packageJsonExists) {
645
881
  if (runtimeVersion && !packageJsonExists) throw new Error("Cannot migrate the removed Node.js runtime setting without a package.json file.");
646
882
  }
883
+ function reportMigrationChanges(showChanges, before = {}, after = {}) {
884
+ if (!showChanges) return;
885
+ reportSettingsChanges(collectSettingsChanges(before, after));
886
+ }
647
887
  /**
648
888
  * Migrate pnpm settings from legacy locations to `pnpm-workspace.yaml`.
649
889
  *
@@ -661,6 +901,7 @@ function assertCanMigrateRuntime(runtimeVersion, packageJsonExists) {
661
901
  * @param rawOptions.yarnResolutions - Whether to migrate resolutions field (default: true)
662
902
  * @param rawOptions.sortKeys - Whether to sort keys in output YAML (default: false)
663
903
  * @param rawOptions.newlineBetween - Add newlines between root keys (default: true)
904
+ * @param rawOptions.showChanges - Show settings changes after migration (default: true)
664
905
  *
665
906
  * @returns A promise that resolves when migration is complete
666
907
  *
@@ -690,8 +931,12 @@ async function migratePnpmSettings(rawOptions = {}) {
690
931
  fsExists(packageJsonPath),
691
932
  fsExists(pnpmWorkspaceYamlPath)
692
933
  ]);
693
- if (!hasSettingsSources(npmrcExists, packageJsonExists, pnpmWorkspaceExists)) return;
934
+ if (!hasSettingsSources(npmrcExists, packageJsonExists, pnpmWorkspaceExists)) {
935
+ reportMigrationChanges(options.showChanges);
936
+ return;
937
+ }
694
938
  const [packageJson, pnpmWorkspace] = await Promise.all([readPackageJson(packageJsonPath, packageJsonExists), readPnpmWorkspace(pnpmWorkspaceYamlPath, pnpmWorkspaceExists)]);
939
+ const pnpmWorkspaceBefore = structuredClone(pnpmWorkspace.value);
695
940
  const compatibility = resolveCompatibilityTarget(options.compatibility, packageJson.value.packageManager, packageJson.value.devEngines?.packageManager);
696
941
  const npmrcMigratable = npmrcExists ? await readMigratableNpmrc(npmrcPath, compatibility) : {
697
942
  keys: [],
@@ -724,6 +969,7 @@ async function migratePnpmSettings(rawOptions = {}) {
724
969
  yarnResolutions: options.yarnResolutions
725
970
  })) {
726
971
  consola.warn("No pnpm settings fields to migrate");
972
+ reportMigrationChanges(options.showChanges);
727
973
  return;
728
974
  }
729
975
  const pnpmWorkspaceResult = mergeByStrategy(pnpmWorkspace.value, incomingSettings, options.strategy);
@@ -747,6 +993,7 @@ async function migratePnpmSettings(rawOptions = {}) {
747
993
  }
748
994
  }
749
995
  if (packageJsonExists && packageJsonChanged) await fsWriteFile(packageJsonPath, JSON.stringify(packageJson.value, null, packageJson.indent));
996
+ reportMigrationChanges(options.showChanges, pnpmWorkspaceBefore, pnpmWorkspaceResult);
750
997
  } catch (err) {
751
998
  consola.error("Failed to migrate pnpm settings:", err);
752
999
  throw err;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pnpm-settings-migrator",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Move pnpm settings from `pnpm` field in `package.json` and `.npmrc` file to `pnpm-workspace.yaml`",
5
5
  "keywords": [
6
6
  "migrator",
@@ -52,13 +52,13 @@
52
52
  "yaml": "^2.9.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@types/node": "^26.2.0",
55
+ "@types/node": "^26.3.0",
56
56
  "bumpp": "^12.2.1",
57
57
  "husky": "^9.1.7",
58
58
  "nano-staged": "^1.0.2",
59
59
  "npm-run-all2": "^9.0.3",
60
- "oxfmt": "^0.64.0",
61
- "oxlint": "^1.79.0",
60
+ "oxfmt": "^0.65.0",
61
+ "oxlint": "^1.80.0",
62
62
  "tsdown": "^0.22.14",
63
63
  "typescript": "^7.0.2",
64
64
  "vitest": "^4.1.11"