pnpm-settings-migrator 0.16.0 → 0.17.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
@@ -232,11 +232,37 @@ For example, targeting 12.11.0 migrates
232
232
  to equivalent workspace YAML. Targeting 12.10.1 retains these settings with
233
233
  diagnostics.
234
234
 
235
+ For pnpm 12.12.0 and later stable v12 releases, `addMissingPeerTypes` accepts
236
+ boolean or null values and `remoteCache` accepts the project organization:
237
+ `{ org: 'acme' }`. Empty and null remote-cache sections are preserved. Server,
238
+ team, signing, and publication fields belong in trusted global configuration
239
+ or environment variables. Objects containing those fields, unknown fields,
240
+ or invalid values remain intact in their source with diagnostics; existing
241
+ incompatible workspace values block migration before writes.
242
+
243
+ `remoteCache` and legacy `sideEffectsCache.remote` are kept independently so
244
+ pnpm applies its field-level precedence. The migrator does not insert defaults
245
+ or rewrite `allowBuilds`: pnpm 12.12 itself now honors explicit build denials
246
+ when `dangerouslyAllowAllBuilds` is true.
247
+
248
+ From 12.12.1, the registry prefix `tool` is reserved. Both `namedRegistries`
249
+ and URL-keyed `registries` using that exact prefix remain in their sources;
250
+ existing workspace declarations block migration. This restriction is
251
+ case-sensitive (`ToOl` remains valid), unlike the existing `pkg` restriction.
252
+
235
253
  pnpm v12 reads `filter` and `filterProd` from CLI arguments or `updateConfig`
236
254
  hooks, not workspace YAML. These settings remain in their source with
237
255
  diagnostics for v12 targets; existing YAML containing them blocks migration.
238
256
  The 12.11.2 hook fix does not add workspace support.
239
257
 
258
+ Other legacy fields ignored by the v12 workspace reader also stay in their
259
+ sources with diagnostics. These include `production`, `dev`, `recursive`,
260
+ `lockfileOnly`, `force`, `depth`, `saveDev`, `fetchingConcurrency`,
261
+ `requiredScripts`, `extraBinPaths`, `extraEnv`, and `workspacePackagePatterns`.
262
+ Existing YAML containing ignored fields blocks migration before writes.
263
+ For example, `production=true` cannot be moved into v12 YAML; use
264
+ `pnpm install --prod` to select production dependencies.
265
+
240
266
  </details>
241
267
 
242
268
  <details>
@@ -273,6 +299,9 @@ Notes:
273
299
  such as `globalDir`, `stateDir`, `configDir`, and `scope` stay in `.npmrc`.
274
300
  - Registry declarations containing embedded credentials or dynamic `${...}`
275
301
  URLs stay in their source with a warning.
302
+ - Invalid registry locations, including relative paths and object property names
303
+ such as `__proto__`, remain in their sources with diagnostics. Registry
304
+ conversion never uses inherited object properties as declarations.
276
305
  - Dynamic `${...}` values in `httpProxy`, `httpsProxy`, `noProxy`, `proxy`, and
277
306
  `noproxy` stay in their source for every target. Project YAML does not expand
278
307
  these placeholders; configure them globally in trusted configuration or use
@@ -297,10 +326,16 @@ Notes:
297
326
  `package.json#pnpm` child keys remain in `package.json`.
298
327
  INI sections in `.npmrc` are retained conservatively; their child names are
299
328
  never treated as root keys during cleanup.
329
+ Bare boolean flags such as `save-exact` and quoted keys such as
330
+ `"save-exact"=true` are cleaned when migrated. Retained sections alone do not
331
+ cause repeated cleanup reports or rewrites.
300
332
  - Runtime migration writes `devEngines.runtime` before removing a workspace
301
333
  runtime declaration. A failed write preserves source settings for retry.
302
334
  If an existing runtime declaration conflicts with a workspace runtime, migration
303
335
  stops before writing any files. An identical Node.js declaration is reused.
336
+ - An `executionEnv` object containing fields besides `nodeVersion` remains
337
+ intact in its source with a warning. Existing workspace declarations with
338
+ such fields block migration before writes, so unmigrated values cannot be lost.
304
339
  - YAML aliases remain when their anchors are unchanged and precede them.
305
340
  If replacement, removal, or sorting would invalidate an alias, its original
306
341
  value is written explicitly so unrelated settings keep their values.
@@ -311,17 +346,17 @@ Notes:
311
346
  - The migrator does not update the `packageManager` version, CI environment variables,
312
347
  shell setup, or pnpm commands in scripts. When `packageManager` still pins pnpm 10,
313
348
  pass `--compatibility v11` explicitly and update the pin separately.
314
- - Compatibility checks cover pnpm 11.28.5 and 12.11.2, including 11.28.0,
349
+ - Compatibility checks cover pnpm 11.28.5 and 12.12.1, including 11.28.0,
315
350
  11.28.1, 11.28.2, 11.28.3, 11.28.4, 12.7.0, 12.8.0, 12.8.1, 12.8.2,
316
- 12.9.0, 12.9.1, 12.10.0, 12.10.1, 12.11.0, and 12.11.1.
351
+ 12.9.0, 12.9.1, 12.10.0, 12.10.1, 12.11.0, 12.11.1, 12.11.2, and 12.12.0.
317
352
  Earlier regressions remain at 11.25.0,
318
353
  11.26.0, 11.27.1, 12.2.1, 12.3.4, 12.4.0, 12.4.2, 12.5.0, 12.5.1, and 12.6.0.
319
- As [audited on 2026-10-10](docs/research/pnpm-settings-bump-2026-10-10.md),
320
- npm `latest` is 12.10.1; the highest confirmed stable release is 12.11.2 on
354
+ As [audited on 2026-10-13](docs/research/pnpm-settings-bump-2026-10-13.md),
355
+ npm `latest` is 12.11.2; the highest confirmed stable release is 12.12.1 on
321
356
  `next-12`. The v11 target is 11.28.5 on `next-11`, while `latest-11` is
322
- 11.28.2. The project is pinned to 12.10.1. These checks verify the documented
357
+ 11.28.2. The project is pinned to 12.11.2. These checks verify the documented
323
358
  fixtures, not every setting in every release. v10 has unit regressions and
324
- source review through 10.34.6; real v10 consumption remains unverified.
359
+ source review through 10.34.7; real v10 consumption remains unverified.
325
360
  The removed `pnpm install --resolution-only` CLI flag is outside this settings
326
361
  migrator's scope; replace it with `pnpm peers check` in scripts before upgrading.
327
362
 
package/dist/cli.mjs CHANGED
@@ -627,6 +627,22 @@ const PNPM_V12_11_MINIMUM_VERSION = [
627
627
  0
628
628
  ];
629
629
  /**
630
+ * Release introducing remote cache configuration and missing peer types.
631
+ */
632
+ const PNPM_V12_12_MINIMUM_VERSION = [
633
+ 12,
634
+ 12,
635
+ 0
636
+ ];
637
+ /**
638
+ * Release reserving the tool registry prefix.
639
+ */
640
+ const PNPM_V12_12_1_MINIMUM_VERSION = [
641
+ 12,
642
+ 12,
643
+ 1
644
+ ];
645
+ /**
630
646
  * Workspace settings first consumed by pnpm v12 in 12.11.0.
631
647
  */
632
648
  const PNPM_V12_11_WORKSPACE_SETTINGS = [
@@ -652,6 +668,11 @@ const PNPM_V12_7_WORKSPACE_SETTINGS = [
652
668
  * @see https://github.com/pnpm/pnpm/blob/v12.4.0/pnpm/crates/config/src/workspace_yaml.rs
653
669
  */
654
670
  const PNPM_SETTINGS_CAPABILITIES = [
671
+ {
672
+ minimumVersion: PNPM_V12_12_MINIMUM_VERSION,
673
+ workspaceFields: ["addMissingPeerTypes", "remoteCache"],
674
+ taskFields: []
675
+ },
655
676
  {
656
677
  minimumVersion: PNPM_V12_11_MINIMUM_VERSION,
657
678
  workspaceFields: PNPM_V12_11_WORKSPACE_SETTINGS,
@@ -733,9 +754,82 @@ const PNPM_V12_ONLY_WORKSPACE_SETTINGS = [
733
754
  "globalShims"
734
755
  ];
735
756
  /**
757
+ * Legacy fields absent from pnpm v12's workspace reader through 12.12.1.
758
+ * Removed aliases with explicit migration replacements are kept separately.
759
+ *
760
+ * @see https://github.com/pnpm/pnpm/blob/v12.12.1/pnpm/crates/config/src/workspace_yaml/settings.rs
761
+ */
762
+ const PNPM_V12_IGNORED_WORKSPACE_SETTINGS = [
763
+ "access",
764
+ "allowNew",
765
+ "allowSameVersion",
766
+ "auditIgnorePrune",
767
+ "autoConfirmAllPrompts",
768
+ "binLinks",
769
+ "commitHooks",
770
+ "cpu",
771
+ "depth",
772
+ "description",
773
+ "dev",
774
+ "dryRun",
775
+ "enablePnp",
776
+ "extraBinPaths",
777
+ "extraEnv",
778
+ "fetchingConcurrency",
779
+ "force",
780
+ "git",
781
+ "gitTagVersion",
782
+ "global",
783
+ "globalPath",
784
+ "globalPrefix",
785
+ "ignoreCurrentSpecifiers",
786
+ "json",
787
+ "libc",
788
+ "localAddress",
789
+ "lockfileOnly",
790
+ "long",
791
+ "message",
792
+ "npmPath",
793
+ "only",
794
+ "os",
795
+ "packDestination",
796
+ "packGzipLevel",
797
+ "parseable",
798
+ "pnpmExecPath",
799
+ "preserveAbsolutePaths",
800
+ "production",
801
+ "publishBranch",
802
+ "recursive",
803
+ "registriesByPrefix",
804
+ "registriesByScope",
805
+ "registryOptionsByUrl",
806
+ "requiredScripts",
807
+ "runtime",
808
+ "save",
809
+ "saveDev",
810
+ "saveOptional",
811
+ "saveProd",
812
+ "sideEffectsCacheRead",
813
+ "sideEffectsCacheWrite",
814
+ "signGitTag",
815
+ "strictSsl",
816
+ "tag",
817
+ "tryLoadDefaultPnpmfile",
818
+ "umask",
819
+ "useGitBranchLockfile",
820
+ "useLockfile",
821
+ "useRunningStoreServer",
822
+ "version",
823
+ "workspacePackagePatterns",
824
+ "workspacePackages",
825
+ "workspacePrefix",
826
+ "workspaceRoot",
827
+ "yes"
828
+ ];
829
+ /**
736
830
  * Lookup used to remove v11-only fields from the v12 workspace allowlist.
737
831
  */
738
- const v11OnlyWorkspaceSettings = new Set(PNPM_V11_ONLY_WORKSPACE_SETTINGS);
832
+ const v11OnlyWorkspaceSettings = /* @__PURE__ */ new Set([...PNPM_V11_ONLY_WORKSPACE_SETTINGS, ...PNPM_V12_IGNORED_WORKSPACE_SETTINGS]);
739
833
  /**
740
834
  * Fields accepted by registry declarations with pnpm 12.9 concurrency limits.
741
835
  */
@@ -770,7 +864,7 @@ const PROJECT_REFUSED_SETTINGS = new Set(PNPM_PROJECT_REFUSED_SETTINGS);
770
864
  /**
771
865
  * Fields reported as incompatible when selecting settings for v12.
772
866
  */
773
- const PNPM_V11_ONLY_SETTINGS = new Set(PNPM_V11_ONLY_WORKSPACE_SETTINGS);
867
+ const PNPM_V11_ONLY_SETTINGS = /* @__PURE__ */ new Set([...PNPM_V11_ONLY_WORKSPACE_SETTINGS, ...PNPM_V12_IGNORED_WORKSPACE_SETTINGS]);
774
868
  /**
775
869
  * Allowed workspace fields for the v11 target.
776
870
  */
@@ -1119,6 +1213,32 @@ function formatSettingsIssues(options) {
1119
1213
  return warnings;
1120
1214
  }
1121
1215
  //#endregion
1216
+ //#region src/features/settings/registries.ts
1217
+ /**
1218
+ * Check that a registry location is a URL rather than an object property name.
1219
+ *
1220
+ * @param value - Untrusted registry location
1221
+ *
1222
+ * @returns Whether the location is an absolute or scheme-relative URL
1223
+ */
1224
+ function isRegistryUrl(value) {
1225
+ return isString(value) && URL.canParse(value.startsWith("//") ? `https:${value}` : value);
1226
+ }
1227
+ /**
1228
+ * Validate locations in legacy registry maps and URL-keyed declarations.
1229
+ *
1230
+ * @param key - Canonical workspace setting name
1231
+ * @param value - Untrusted registry configuration
1232
+ *
1233
+ * @returns Whether the complete setting must be retained in its source
1234
+ */
1235
+ function hasInvalidRegistryLocations(key, value) {
1236
+ if (!["namedRegistries", "registries"].includes(key) || value === null) return false;
1237
+ if (!isPlainObject(value)) return true;
1238
+ if (key === "namedRegistries") return Object.values(value).some((url) => !isRegistryUrl(url));
1239
+ return Object.entries(value).some(([url, entry]) => isString(entry) ? !isRegistryUrl(entry) : !isRegistryUrl(url));
1240
+ }
1241
+ //#endregion
1122
1242
  //#region src/features/settings/permissions.ts
1123
1243
  /**
1124
1244
  * Validate a dependency's capability map without dropping unknown permissions.
@@ -1263,6 +1383,22 @@ function hasIncompatibleV12SettingValue(key, value, target) {
1263
1383
  return false;
1264
1384
  }
1265
1385
  //#endregion
1386
+ //#region src/features/settings/remote-cache.ts
1387
+ /**
1388
+ * Accept only the project-owned remote cache organization.
1389
+ * Keep machine configuration and unknown fields intact in their source.
1390
+ *
1391
+ * @param key - Canonical workspace setting name
1392
+ * @param value - Untrusted incoming or existing workspace value
1393
+ *
1394
+ * @returns Whether the remote cache setting cannot migrate to project YAML
1395
+ */
1396
+ function hasInvalidRemoteCacheValue(key, value) {
1397
+ if (key !== "remoteCache" || value === null) return false;
1398
+ if (!value || typeof value !== "object" || isArray(value)) return true;
1399
+ return Object.entries(value).some(([field, entry]) => field !== "org" || entry !== null && !isString(entry));
1400
+ }
1401
+ //#endregion
1266
1402
  //#region src/features/settings/versioned-values.ts
1267
1403
  /**
1268
1404
  * Bounds of the signed 32-bit integer used by pnpm's task priority.
@@ -1311,7 +1447,7 @@ function hasInvalidScalarValue(key, value, target) {
1311
1447
  "ndjson",
1312
1448
  "silent"
1313
1449
  ].includes(value);
1314
- if (key === "provenance") return value !== null && !isBoolean(value);
1450
+ if (key === "provenance" || key === "addMissingPeerTypes") return value !== null && !isBoolean(value);
1315
1451
  if (key === "progress") return !isBoolean(value);
1316
1452
  if (key === "tagVersionPrefix") return !isString(value);
1317
1453
  if (key === "loglevel") return !isString(value) || !value.includes("${") && ![
@@ -1341,6 +1477,17 @@ function hasInvalidReleaseValue(key, value, target) {
1341
1477
  return false;
1342
1478
  }
1343
1479
  /**
1480
+ * Check release-specific reserved aliases without broadening case sensitivity.
1481
+ *
1482
+ * @param prefix - Registry alias as declared
1483
+ * @param target - Confirmed target version
1484
+ *
1485
+ * @returns Whether the alias shadows a protocol introduced by these releases
1486
+ */
1487
+ function isReservedRegistryPrefix(prefix, target) {
1488
+ return prefix.toLowerCase() === "pkg" && supportsMinimumVersion(target.version, PNPM_V12_5_MINIMUM_VERSION) || prefix === "tool" && supportsMinimumVersion(target.version, PNPM_V12_12_1_MINIMUM_VERSION);
1489
+ }
1490
+ /**
1344
1491
  * Check value shapes that changed within a pnpm major release.
1345
1492
  *
1346
1493
  * Reject the whole setting so cleanup cannot discard unsupported nested values.
@@ -1352,7 +1499,7 @@ function hasInvalidReleaseValue(key, value, target) {
1352
1499
  * @returns Whether the value uses a shape unsupported by the selected release
1353
1500
  */
1354
1501
  function hasIncompatibleSettingValue(key, value, target) {
1355
- if (hasInvalidPermissionSettings(key, value) || hasInvalidReleaseValue(key, value, target) || hasInvalidV11PatchValue(key, value, target) || hasIncompatibleV12SettingValue(key, value, target)) return true;
1502
+ if (hasInvalidPermissionSettings(key, value) || hasInvalidRemoteCacheValue(key, value) || hasInvalidReleaseValue(key, value, target) || hasInvalidV11PatchValue(key, value, target) || hasIncompatibleV12SettingValue(key, value, target)) return true;
1356
1503
  if (hasInvalidScalarValue(key, value, target)) return true;
1357
1504
  const supportsV12_5 = supportsMinimumVersion(target.version, PNPM_V12_5_MINIMUM_VERSION);
1358
1505
  if (key === "supportedArchitectures" && isArray(value)) return !supportsV12_5;
@@ -1370,19 +1517,42 @@ function hasIncompatibleSettingValue(key, value, target) {
1370
1517
  "constraints"
1371
1518
  ].some((field) => Object.hasOwn(value, field));
1372
1519
  }
1373
- if (key === "namedRegistries" && supportsV12_5) return Object.keys(value).some((prefix) => prefix.toLowerCase() === "pkg");
1520
+ if (key === "namedRegistries") return Object.keys(value).some((prefix) => isReservedRegistryPrefix(prefix, target));
1374
1521
  if (key === "registries") return Object.values(value).some((entry) => {
1375
1522
  if (!entry || typeof entry !== "object") return false;
1376
1523
  if (Object.hasOwn(entry, "ecosystem") && !supportsV12_5) return true;
1377
1524
  if (Object.hasOwn(entry, "packages") && !supportsMinimumVersion(target.version, PNPM_V12_5_1_MINIMUM_VERSION)) return true;
1378
1525
  const prefix = Reflect.get(entry, "prefix");
1379
- return supportsV12_5 && isString(prefix) && prefix.toLowerCase() === "pkg";
1526
+ return isString(prefix) && isReservedRegistryPrefix(prefix, target);
1380
1527
  });
1381
1528
  return false;
1382
1529
  }
1383
1530
  //#endregion
1384
1531
  //#region src/features/settings/schema.ts
1385
1532
  /**
1533
+ * Detect legacy runtime fields without a destination in pnpm v11 and newer.
1534
+ *
1535
+ * @param key - Canonical setting name
1536
+ * @param value - Untrusted legacy runtime declaration
1537
+ * @param compatibility - Target major controlling runtime conversion
1538
+ *
1539
+ * @returns Whether converting the runtime would discard sibling fields
1540
+ */
1541
+ function hasUnmigratableRuntimeFields(key, value, compatibility) {
1542
+ return Boolean(key === "executionEnv" && compatibility !== "v10" && value && typeof value === "object" && Object.keys(value).some((field) => field !== "nodeVersion"));
1543
+ }
1544
+ /**
1545
+ * Check task declarations against the fields consumed by the target release.
1546
+ *
1547
+ * @param value - Untrusted task declarations
1548
+ * @param taskSettings - Supported task field names
1549
+ *
1550
+ * @returns Whether any declared task field is unsupported
1551
+ */
1552
+ function hasUnsupportedTaskFields(value, taskSettings) {
1553
+ return Boolean(value && typeof value === "object" && Object.values(value).some((task) => task && typeof task === "object" && Object.keys(task).some((field) => !taskSettings.has(field))));
1554
+ }
1555
+ /**
1386
1556
  * Create an empty collection of schema selection issues.
1387
1557
  *
1388
1558
  * @returns Empty issue lists for every supported issue category
@@ -1487,8 +1657,8 @@ function resolveSettingIssue({ allowedFields, target, key, npmrc, value }) {
1487
1657
  if (compatibility !== "v10" && PROJECT_REFUSED_SETTINGS.has(key)) return "refused";
1488
1658
  if (REGISTRY_SETTINGS.has(key) && containsUnsafeRegistryValue(value)) return "unsafe";
1489
1659
  if (PROXY_SETTINGS.has(key) && isString(value) && value.includes("${")) return "unsafe";
1490
- if (hasIncompatibleSettingValue(key, value, target)) return "incompatible";
1491
- if (key === "tasks" && value && typeof value === "object" && Object.values(value).some((task) => task && typeof task === "object" && Object.keys(task).some((field) => !taskSettings.has(field)))) return "incompatible";
1660
+ if (hasInvalidRegistryLocations(key, value) || hasUnmigratableRuntimeFields(key, value, compatibility) || hasIncompatibleSettingValue(key, value, target)) return "incompatible";
1661
+ if (key === "tasks" && hasUnsupportedTaskFields(value, taskSettings)) return "incompatible";
1492
1662
  if (!workspaceSettings.has(key)) return isSettingFromAnotherVersion(key, compatibility) ? "incompatible" : "unknown";
1493
1663
  if (allowedFields && !allowedFields.has(key)) return "unsupported";
1494
1664
  }
@@ -1897,8 +2067,17 @@ function getNpmrcLineKey(line) {
1897
2067
  const trimmed = line.trim();
1898
2068
  if (!trimmed || trimmed.startsWith(";") || trimmed.startsWith("#")) return;
1899
2069
  const index = trimmed.indexOf("=");
1900
- if (index <= 0) return;
1901
- return normalizeNpmrcKey(trimmed.slice(0, index));
2070
+ if (index === 0) return;
2071
+ let key = (index < 0 ? trimmed : trimmed.slice(0, index)).trim();
2072
+ if (key.startsWith("\"") && key.endsWith("\"") || key.startsWith("'") && key.endsWith("'")) {
2073
+ if (key.startsWith("'")) key = key.slice(1, -1);
2074
+ try {
2075
+ const parsed = JSON.parse(key);
2076
+ if (!isString(parsed)) return;
2077
+ key = parsed;
2078
+ } catch {}
2079
+ } else key = key.replace(/\\(?<escaped>[\\;#])|[;#].*$/gu, (_, escaped) => escaped ?? "");
2080
+ return normalizeNpmrcKey(key);
1902
2081
  }
1903
2082
  /**
1904
2083
  * Check whether a `.npmrc` key is auth/registry-related and should stay in
@@ -1925,7 +2104,7 @@ function isNpmrcAuthOrRegistryKey(key) {
1925
2104
  * @param compatibility - Concrete pnpm compatibility target
1926
2105
  * @param migratedKeys - Original `.npmrc` keys that were migrated
1927
2106
  *
1928
- * @returns A promise that resolves when the file has been pruned
2107
+ * @returns Whether the file was changed or removed
1929
2108
  *
1930
2109
  * @throws {Error} When file read/write operations fail
1931
2110
  *
@@ -1938,18 +2117,25 @@ async function pruneNpmrc(path, compatibility, migratedKeys) {
1938
2117
  const migratedKeySet = new Set(migratedKeys.map(normalizeNpmrcKey));
1939
2118
  const content = await fsReadFile(path);
1940
2119
  let inSection = false;
1941
- const updatedContent = content.split(/\r\n|[\r\n]/u).filter((line) => {
2120
+ let removed = false;
2121
+ const lines = content.split(/\r\n|[\r\n]/u).filter((line) => {
1942
2122
  if (/^\[[^\]]*\]\s*$/u.test(line.replace(/^\uFEFF/u, ""))) inSection = true;
1943
2123
  if (inSection) return true;
1944
2124
  const key = getNpmrcLineKey(line);
1945
2125
  if (!key) return true;
1946
- return !migratedKeySet.has(key);
1947
- }).join("\n").trimEnd();
2126
+ if (migratedKeySet.has(key)) {
2127
+ removed = true;
2128
+ return false;
2129
+ }
2130
+ return true;
2131
+ });
2132
+ if (!removed) return false;
2133
+ const updatedContent = lines.join("\n").trimEnd();
1948
2134
  if (compatibility !== "v10" && !updatedContent.trim()) {
1949
2135
  await fsRemoveFile(path);
1950
- return;
2136
+ return true;
1951
2137
  }
1952
- await fsWriteFile(path, updatedContent);
2138
+ return fsWriteFileIfChanged(path, updatedContent);
1953
2139
  }
1954
2140
  /**
1955
2141
  * Read `.npmrc` and return settings that should be migrated into workspace config.
@@ -2488,10 +2674,10 @@ async function persistMigration(options) {
2488
2674
  if (!cleanNpmrc) return result;
2489
2675
  const pruneTasks = projectNpmrcs.projects.flatMap((project) => {
2490
2676
  const appliedKeys = selectAppliedProjectKeys(project, finalSettings);
2491
- return appliedKeys.length ? [pruneNpmrc(project.npmrcPath, compatibility, appliedKeys).then(() => project.npmrcPath)] : [];
2677
+ return appliedKeys.length ? [pruneNpmrc(project.npmrcPath, compatibility, appliedKeys).then((changed) => changed ? project.npmrcPath : void 0)] : [];
2492
2678
  });
2493
- if (npmrcExists && appliedNpmrcKeys.length) pruneTasks.push(pruneNpmrc(npmrcPath, compatibility, appliedNpmrcKeys).then(() => npmrcPath));
2494
- const cleanedNpmrcPaths = await Promise.all(pruneTasks);
2679
+ if (npmrcExists && appliedNpmrcKeys.length) pruneTasks.push(pruneNpmrc(npmrcPath, compatibility, appliedNpmrcKeys).then((changed) => changed ? npmrcPath : void 0));
2680
+ const cleanedNpmrcPaths = (await Promise.all(pruneTasks)).filter(isString);
2495
2681
  result.changedFiles.push(...cleanedNpmrcPaths);
2496
2682
  result.sourceSettingsCleaned ||= cleanedNpmrcPaths.length > 0;
2497
2683
  return result;
@@ -2587,9 +2773,9 @@ function normalizeSideEffectsCache(settings) {
2587
2773
  * @returns Nothing; the URL's declaration is created or updated in place
2588
2774
  */
2589
2775
  function addRegistryScope(declarations, url, scope) {
2590
- declarations[url] ??= {};
2591
- const declaration = declarations[url];
2776
+ const declaration = declarations.get(url) ?? {};
2592
2777
  declaration.scopes = unique([...declaration.scopes ?? [], scope]);
2778
+ declarations.set(url, declaration);
2593
2779
  }
2594
2780
  /**
2595
2781
  * Find source URLs with multiple active aliases across the merged settings.
@@ -2618,9 +2804,9 @@ function findRegistryAliasConflicts(prefixesByUrl, namedRegistries, canonicalPre
2618
2804
  function normalizeNamedRegistries(settings, warnings, canonicalSettings) {
2619
2805
  const { namedRegistries } = settings;
2620
2806
  if (!namedRegistries) return;
2621
- const declarations = {};
2807
+ const declarations = /* @__PURE__ */ new Map();
2622
2808
  for (const [key, value] of Object.entries(settings.registries ?? {})) if (isString(value)) addRegistryScope(declarations, value, key === "default" ? "@" : key);
2623
- else declarations[key] = { ...value };
2809
+ else declarations.set(key, { ...value });
2624
2810
  const canonicalPrefixes = new Set(Object.values(canonicalSettings.registries ?? {}).flatMap((value) => !isString(value) && isString(value?.prefix) ? [value.prefix] : []));
2625
2811
  const prefixesByUrl = /* @__PURE__ */ new Map();
2626
2812
  const registryAliases = Object.entries(namedRegistries).filter(([prefix]) => !canonicalPrefixes.has(prefix));
@@ -2635,17 +2821,17 @@ function normalizeNamedRegistries(settings, warnings, canonicalSettings) {
2635
2821
  return;
2636
2822
  }
2637
2823
  for (const [url, [prefix]] of prefixesByUrl) {
2638
- declarations[url] ??= {};
2639
- const declaration = declarations[url];
2640
- const canonicalDeclaration = canonicalSettings.registries?.[url];
2824
+ const declaration = declarations.get(url) ?? {};
2825
+ const canonicalDeclaration = canonicalSettings.registries && Object.hasOwn(canonicalSettings.registries, url) ? canonicalSettings.registries[url] : void 0;
2641
2826
  const declaredPrefix = (isString(canonicalDeclaration) ? void 0 : canonicalDeclaration?.prefix) ?? declaration.prefix;
2642
2827
  if (isString(declaredPrefix) && declaredPrefix !== prefix) {
2643
2828
  warnings.push(`namedRegistries was kept because ${url} already declares prefix ${declaredPrefix}.`);
2644
2829
  return;
2645
2830
  }
2646
2831
  declaration.prefix = prefix;
2832
+ declarations.set(url, declaration);
2647
2833
  }
2648
- settings.registries = declarations;
2834
+ settings.registries = Object.fromEntries(declarations);
2649
2835
  Reflect.deleteProperty(settings, "namedRegistries");
2650
2836
  }
2651
2837
  /**
@@ -3207,7 +3393,7 @@ const bold = getColor("bold");
3207
3393
  //#endregion
3208
3394
  //#region package.json
3209
3395
  var name = "pnpm-settings-migrator";
3210
- var version = "0.16.0";
3396
+ var version = "0.17.0";
3211
3397
  //#endregion
3212
3398
  //#region src/cli/options.ts
3213
3399
  /**
package/dist/index.d.mts CHANGED
@@ -259,6 +259,15 @@ interface PnpmSkillsSettings {
259
259
  * Settings supported by pnpm v12, subject to the resolved version capabilities.
260
260
  */
261
261
  interface PnpmSettingsV12 {
262
+ /**
263
+ * Link available companion @types packages as optional peers (pnpm 12.12+).
264
+ * Null leaves pnpm's default unchanged.
265
+ */
266
+ addMissingPeerTypes?: boolean | null;
267
+ /**
268
+ * Project-owned remote cache configuration (pnpm 12.12+).
269
+ */
270
+ remoteCache?: PnpmRemoteCacheSettings | null;
262
271
  /**
263
272
  * Per-dependency build and agent skill permissions (pnpm 12.11+).
264
273
  */
@@ -353,6 +362,16 @@ interface PnpmSettingsV12 {
353
362
  */
354
363
  tasks?: Record<string, PnpmTaskSettings>;
355
364
  }
365
+ /**
366
+ * Remote cache options allowed in project YAML. Server and signing settings
367
+ * belong in trusted global configuration or environment variables.
368
+ */
369
+ interface PnpmRemoteCacheSettings {
370
+ /**
371
+ * Organization owning shared artifacts; null leaves it unset.
372
+ */
373
+ org?: string | null;
374
+ }
356
375
  //#endregion
357
376
  //#region src/types/workspace.d.ts
358
377
  /**
@@ -730,4 +749,4 @@ export declare function migratePnpmSettings(rawOptions?: Options): Promise<Migra
730
749
  */
731
750
  export declare function resolveOptions(options?: Options): ResolvedOptions;
732
751
  //#endregion
733
- export type { CompatibilityTarget, GlobalShimPolicy, MergeStrategy, MigrationResult, NpmRC, Options, PackageJson, PackageManagerEngine, PnpmCargoSettings, PnpmLoadedNodeLinkerSettings, PnpmLockfileSettings, PnpmPackagePermissions, PnpmPythonSettings, PnpmRegistryDeclaration, PnpmSettingsDeprecated, PnpmSettingsV11, PnpmSettingsV12, PnpmSkillsSettings, PnpmTaskSettings, PnpmWorkspace, PnpmWorkspaceLegacy, ResolvedOptions, RuntimeEngine, SettingsChange };
752
+ export type { CompatibilityTarget, GlobalShimPolicy, MergeStrategy, MigrationResult, NpmRC, Options, PackageJson, PackageManagerEngine, PnpmCargoSettings, PnpmLoadedNodeLinkerSettings, PnpmLockfileSettings, PnpmPackagePermissions, PnpmPythonSettings, PnpmRegistryDeclaration, PnpmRemoteCacheSettings, PnpmSettingsDeprecated, PnpmSettingsV11, PnpmSettingsV12, PnpmSkillsSettings, PnpmTaskSettings, PnpmWorkspace, PnpmWorkspaceLegacy, ResolvedOptions, RuntimeEngine, SettingsChange };
package/dist/index.mjs CHANGED
@@ -625,6 +625,22 @@ const PNPM_V12_11_MINIMUM_VERSION = [
625
625
  0
626
626
  ];
627
627
  /**
628
+ * Release introducing remote cache configuration and missing peer types.
629
+ */
630
+ const PNPM_V12_12_MINIMUM_VERSION = [
631
+ 12,
632
+ 12,
633
+ 0
634
+ ];
635
+ /**
636
+ * Release reserving the tool registry prefix.
637
+ */
638
+ const PNPM_V12_12_1_MINIMUM_VERSION = [
639
+ 12,
640
+ 12,
641
+ 1
642
+ ];
643
+ /**
628
644
  * Workspace settings first consumed by pnpm v12 in 12.11.0.
629
645
  */
630
646
  const PNPM_V12_11_WORKSPACE_SETTINGS = [
@@ -650,6 +666,11 @@ const PNPM_V12_7_WORKSPACE_SETTINGS = [
650
666
  * @see https://github.com/pnpm/pnpm/blob/v12.4.0/pnpm/crates/config/src/workspace_yaml.rs
651
667
  */
652
668
  const PNPM_SETTINGS_CAPABILITIES = [
669
+ {
670
+ minimumVersion: PNPM_V12_12_MINIMUM_VERSION,
671
+ workspaceFields: ["addMissingPeerTypes", "remoteCache"],
672
+ taskFields: []
673
+ },
653
674
  {
654
675
  minimumVersion: PNPM_V12_11_MINIMUM_VERSION,
655
676
  workspaceFields: PNPM_V12_11_WORKSPACE_SETTINGS,
@@ -731,9 +752,82 @@ const PNPM_V12_ONLY_WORKSPACE_SETTINGS = [
731
752
  "globalShims"
732
753
  ];
733
754
  /**
755
+ * Legacy fields absent from pnpm v12's workspace reader through 12.12.1.
756
+ * Removed aliases with explicit migration replacements are kept separately.
757
+ *
758
+ * @see https://github.com/pnpm/pnpm/blob/v12.12.1/pnpm/crates/config/src/workspace_yaml/settings.rs
759
+ */
760
+ const PNPM_V12_IGNORED_WORKSPACE_SETTINGS = [
761
+ "access",
762
+ "allowNew",
763
+ "allowSameVersion",
764
+ "auditIgnorePrune",
765
+ "autoConfirmAllPrompts",
766
+ "binLinks",
767
+ "commitHooks",
768
+ "cpu",
769
+ "depth",
770
+ "description",
771
+ "dev",
772
+ "dryRun",
773
+ "enablePnp",
774
+ "extraBinPaths",
775
+ "extraEnv",
776
+ "fetchingConcurrency",
777
+ "force",
778
+ "git",
779
+ "gitTagVersion",
780
+ "global",
781
+ "globalPath",
782
+ "globalPrefix",
783
+ "ignoreCurrentSpecifiers",
784
+ "json",
785
+ "libc",
786
+ "localAddress",
787
+ "lockfileOnly",
788
+ "long",
789
+ "message",
790
+ "npmPath",
791
+ "only",
792
+ "os",
793
+ "packDestination",
794
+ "packGzipLevel",
795
+ "parseable",
796
+ "pnpmExecPath",
797
+ "preserveAbsolutePaths",
798
+ "production",
799
+ "publishBranch",
800
+ "recursive",
801
+ "registriesByPrefix",
802
+ "registriesByScope",
803
+ "registryOptionsByUrl",
804
+ "requiredScripts",
805
+ "runtime",
806
+ "save",
807
+ "saveDev",
808
+ "saveOptional",
809
+ "saveProd",
810
+ "sideEffectsCacheRead",
811
+ "sideEffectsCacheWrite",
812
+ "signGitTag",
813
+ "strictSsl",
814
+ "tag",
815
+ "tryLoadDefaultPnpmfile",
816
+ "umask",
817
+ "useGitBranchLockfile",
818
+ "useLockfile",
819
+ "useRunningStoreServer",
820
+ "version",
821
+ "workspacePackagePatterns",
822
+ "workspacePackages",
823
+ "workspacePrefix",
824
+ "workspaceRoot",
825
+ "yes"
826
+ ];
827
+ /**
734
828
  * Lookup used to remove v11-only fields from the v12 workspace allowlist.
735
829
  */
736
- const v11OnlyWorkspaceSettings = new Set(PNPM_V11_ONLY_WORKSPACE_SETTINGS);
830
+ const v11OnlyWorkspaceSettings = /* @__PURE__ */ new Set([...PNPM_V11_ONLY_WORKSPACE_SETTINGS, ...PNPM_V12_IGNORED_WORKSPACE_SETTINGS]);
737
831
  /**
738
832
  * Fields accepted by registry declarations with pnpm 12.9 concurrency limits.
739
833
  */
@@ -768,7 +862,7 @@ const PROJECT_REFUSED_SETTINGS = new Set(PNPM_PROJECT_REFUSED_SETTINGS);
768
862
  /**
769
863
  * Fields reported as incompatible when selecting settings for v12.
770
864
  */
771
- const PNPM_V11_ONLY_SETTINGS = new Set(PNPM_V11_ONLY_WORKSPACE_SETTINGS);
865
+ const PNPM_V11_ONLY_SETTINGS = /* @__PURE__ */ new Set([...PNPM_V11_ONLY_WORKSPACE_SETTINGS, ...PNPM_V12_IGNORED_WORKSPACE_SETTINGS]);
772
866
  /**
773
867
  * Allowed workspace fields for the v11 target.
774
868
  */
@@ -1112,6 +1206,32 @@ function formatSettingsIssues(options) {
1112
1206
  return warnings;
1113
1207
  }
1114
1208
  //#endregion
1209
+ //#region src/features/settings/registries.ts
1210
+ /**
1211
+ * Check that a registry location is a URL rather than an object property name.
1212
+ *
1213
+ * @param value - Untrusted registry location
1214
+ *
1215
+ * @returns Whether the location is an absolute or scheme-relative URL
1216
+ */
1217
+ function isRegistryUrl(value) {
1218
+ return isString(value) && URL.canParse(value.startsWith("//") ? `https:${value}` : value);
1219
+ }
1220
+ /**
1221
+ * Validate locations in legacy registry maps and URL-keyed declarations.
1222
+ *
1223
+ * @param key - Canonical workspace setting name
1224
+ * @param value - Untrusted registry configuration
1225
+ *
1226
+ * @returns Whether the complete setting must be retained in its source
1227
+ */
1228
+ function hasInvalidRegistryLocations(key, value) {
1229
+ if (!["namedRegistries", "registries"].includes(key) || value === null) return false;
1230
+ if (!isPlainObject(value)) return true;
1231
+ if (key === "namedRegistries") return Object.values(value).some((url) => !isRegistryUrl(url));
1232
+ return Object.entries(value).some(([url, entry]) => isString(entry) ? !isRegistryUrl(entry) : !isRegistryUrl(url));
1233
+ }
1234
+ //#endregion
1115
1235
  //#region src/features/settings/permissions.ts
1116
1236
  /**
1117
1237
  * Validate a dependency's capability map without dropping unknown permissions.
@@ -1256,6 +1376,22 @@ function hasIncompatibleV12SettingValue(key, value, target) {
1256
1376
  return false;
1257
1377
  }
1258
1378
  //#endregion
1379
+ //#region src/features/settings/remote-cache.ts
1380
+ /**
1381
+ * Accept only the project-owned remote cache organization.
1382
+ * Keep machine configuration and unknown fields intact in their source.
1383
+ *
1384
+ * @param key - Canonical workspace setting name
1385
+ * @param value - Untrusted incoming or existing workspace value
1386
+ *
1387
+ * @returns Whether the remote cache setting cannot migrate to project YAML
1388
+ */
1389
+ function hasInvalidRemoteCacheValue(key, value) {
1390
+ if (key !== "remoteCache" || value === null) return false;
1391
+ if (!value || typeof value !== "object" || isArray(value)) return true;
1392
+ return Object.entries(value).some(([field, entry]) => field !== "org" || entry !== null && !isString(entry));
1393
+ }
1394
+ //#endregion
1259
1395
  //#region src/features/settings/versioned-values.ts
1260
1396
  /**
1261
1397
  * Bounds of the signed 32-bit integer used by pnpm's task priority.
@@ -1304,7 +1440,7 @@ function hasInvalidScalarValue(key, value, target) {
1304
1440
  "ndjson",
1305
1441
  "silent"
1306
1442
  ].includes(value);
1307
- if (key === "provenance") return value !== null && !isBoolean(value);
1443
+ if (key === "provenance" || key === "addMissingPeerTypes") return value !== null && !isBoolean(value);
1308
1444
  if (key === "progress") return !isBoolean(value);
1309
1445
  if (key === "tagVersionPrefix") return !isString(value);
1310
1446
  if (key === "loglevel") return !isString(value) || !value.includes("${") && ![
@@ -1334,6 +1470,17 @@ function hasInvalidReleaseValue(key, value, target) {
1334
1470
  return false;
1335
1471
  }
1336
1472
  /**
1473
+ * Check release-specific reserved aliases without broadening case sensitivity.
1474
+ *
1475
+ * @param prefix - Registry alias as declared
1476
+ * @param target - Confirmed target version
1477
+ *
1478
+ * @returns Whether the alias shadows a protocol introduced by these releases
1479
+ */
1480
+ function isReservedRegistryPrefix(prefix, target) {
1481
+ return prefix.toLowerCase() === "pkg" && supportsMinimumVersion(target.version, PNPM_V12_5_MINIMUM_VERSION) || prefix === "tool" && supportsMinimumVersion(target.version, PNPM_V12_12_1_MINIMUM_VERSION);
1482
+ }
1483
+ /**
1337
1484
  * Check value shapes that changed within a pnpm major release.
1338
1485
  *
1339
1486
  * Reject the whole setting so cleanup cannot discard unsupported nested values.
@@ -1345,7 +1492,7 @@ function hasInvalidReleaseValue(key, value, target) {
1345
1492
  * @returns Whether the value uses a shape unsupported by the selected release
1346
1493
  */
1347
1494
  function hasIncompatibleSettingValue(key, value, target) {
1348
- if (hasInvalidPermissionSettings(key, value) || hasInvalidReleaseValue(key, value, target) || hasInvalidV11PatchValue(key, value, target) || hasIncompatibleV12SettingValue(key, value, target)) return true;
1495
+ if (hasInvalidPermissionSettings(key, value) || hasInvalidRemoteCacheValue(key, value) || hasInvalidReleaseValue(key, value, target) || hasInvalidV11PatchValue(key, value, target) || hasIncompatibleV12SettingValue(key, value, target)) return true;
1349
1496
  if (hasInvalidScalarValue(key, value, target)) return true;
1350
1497
  const supportsV12_5 = supportsMinimumVersion(target.version, PNPM_V12_5_MINIMUM_VERSION);
1351
1498
  if (key === "supportedArchitectures" && isArray(value)) return !supportsV12_5;
@@ -1363,19 +1510,42 @@ function hasIncompatibleSettingValue(key, value, target) {
1363
1510
  "constraints"
1364
1511
  ].some((field) => Object.hasOwn(value, field));
1365
1512
  }
1366
- if (key === "namedRegistries" && supportsV12_5) return Object.keys(value).some((prefix) => prefix.toLowerCase() === "pkg");
1513
+ if (key === "namedRegistries") return Object.keys(value).some((prefix) => isReservedRegistryPrefix(prefix, target));
1367
1514
  if (key === "registries") return Object.values(value).some((entry) => {
1368
1515
  if (!entry || typeof entry !== "object") return false;
1369
1516
  if (Object.hasOwn(entry, "ecosystem") && !supportsV12_5) return true;
1370
1517
  if (Object.hasOwn(entry, "packages") && !supportsMinimumVersion(target.version, PNPM_V12_5_1_MINIMUM_VERSION)) return true;
1371
1518
  const prefix = Reflect.get(entry, "prefix");
1372
- return supportsV12_5 && isString(prefix) && prefix.toLowerCase() === "pkg";
1519
+ return isString(prefix) && isReservedRegistryPrefix(prefix, target);
1373
1520
  });
1374
1521
  return false;
1375
1522
  }
1376
1523
  //#endregion
1377
1524
  //#region src/features/settings/schema.ts
1378
1525
  /**
1526
+ * Detect legacy runtime fields without a destination in pnpm v11 and newer.
1527
+ *
1528
+ * @param key - Canonical setting name
1529
+ * @param value - Untrusted legacy runtime declaration
1530
+ * @param compatibility - Target major controlling runtime conversion
1531
+ *
1532
+ * @returns Whether converting the runtime would discard sibling fields
1533
+ */
1534
+ function hasUnmigratableRuntimeFields(key, value, compatibility) {
1535
+ return Boolean(key === "executionEnv" && compatibility !== "v10" && value && typeof value === "object" && Object.keys(value).some((field) => field !== "nodeVersion"));
1536
+ }
1537
+ /**
1538
+ * Check task declarations against the fields consumed by the target release.
1539
+ *
1540
+ * @param value - Untrusted task declarations
1541
+ * @param taskSettings - Supported task field names
1542
+ *
1543
+ * @returns Whether any declared task field is unsupported
1544
+ */
1545
+ function hasUnsupportedTaskFields(value, taskSettings) {
1546
+ return Boolean(value && typeof value === "object" && Object.values(value).some((task) => task && typeof task === "object" && Object.keys(task).some((field) => !taskSettings.has(field))));
1547
+ }
1548
+ /**
1379
1549
  * Create an empty collection of schema selection issues.
1380
1550
  *
1381
1551
  * @returns Empty issue lists for every supported issue category
@@ -1480,8 +1650,8 @@ function resolveSettingIssue({ allowedFields, target, key, npmrc, value }) {
1480
1650
  if (compatibility !== "v10" && PROJECT_REFUSED_SETTINGS.has(key)) return "refused";
1481
1651
  if (REGISTRY_SETTINGS.has(key) && containsUnsafeRegistryValue(value)) return "unsafe";
1482
1652
  if (PROXY_SETTINGS.has(key) && isString(value) && value.includes("${")) return "unsafe";
1483
- if (hasIncompatibleSettingValue(key, value, target)) return "incompatible";
1484
- if (key === "tasks" && value && typeof value === "object" && Object.values(value).some((task) => task && typeof task === "object" && Object.keys(task).some((field) => !taskSettings.has(field)))) return "incompatible";
1653
+ if (hasInvalidRegistryLocations(key, value) || hasUnmigratableRuntimeFields(key, value, compatibility) || hasIncompatibleSettingValue(key, value, target)) return "incompatible";
1654
+ if (key === "tasks" && hasUnsupportedTaskFields(value, taskSettings)) return "incompatible";
1485
1655
  if (!workspaceSettings.has(key)) return isSettingFromAnotherVersion(key, compatibility) ? "incompatible" : "unknown";
1486
1656
  if (allowedFields && !allowedFields.has(key)) return "unsupported";
1487
1657
  }
@@ -1890,8 +2060,17 @@ function getNpmrcLineKey(line) {
1890
2060
  const trimmed = line.trim();
1891
2061
  if (!trimmed || trimmed.startsWith(";") || trimmed.startsWith("#")) return;
1892
2062
  const index = trimmed.indexOf("=");
1893
- if (index <= 0) return;
1894
- return normalizeNpmrcKey(trimmed.slice(0, index));
2063
+ if (index === 0) return;
2064
+ let key = (index < 0 ? trimmed : trimmed.slice(0, index)).trim();
2065
+ if (key.startsWith("\"") && key.endsWith("\"") || key.startsWith("'") && key.endsWith("'")) {
2066
+ if (key.startsWith("'")) key = key.slice(1, -1);
2067
+ try {
2068
+ const parsed = JSON.parse(key);
2069
+ if (!isString(parsed)) return;
2070
+ key = parsed;
2071
+ } catch {}
2072
+ } else key = key.replace(/\\(?<escaped>[\\;#])|[;#].*$/gu, (_, escaped) => escaped ?? "");
2073
+ return normalizeNpmrcKey(key);
1895
2074
  }
1896
2075
  /**
1897
2076
  * Check whether a `.npmrc` key is auth/registry-related and should stay in
@@ -1918,7 +2097,7 @@ function isNpmrcAuthOrRegistryKey(key) {
1918
2097
  * @param compatibility - Concrete pnpm compatibility target
1919
2098
  * @param migratedKeys - Original `.npmrc` keys that were migrated
1920
2099
  *
1921
- * @returns A promise that resolves when the file has been pruned
2100
+ * @returns Whether the file was changed or removed
1922
2101
  *
1923
2102
  * @throws {Error} When file read/write operations fail
1924
2103
  *
@@ -1931,18 +2110,25 @@ async function pruneNpmrc(path, compatibility, migratedKeys) {
1931
2110
  const migratedKeySet = new Set(migratedKeys.map(normalizeNpmrcKey));
1932
2111
  const content = await fsReadFile(path);
1933
2112
  let inSection = false;
1934
- const updatedContent = content.split(/\r\n|[\r\n]/u).filter((line) => {
2113
+ let removed = false;
2114
+ const lines = content.split(/\r\n|[\r\n]/u).filter((line) => {
1935
2115
  if (/^\[[^\]]*\]\s*$/u.test(line.replace(/^\uFEFF/u, ""))) inSection = true;
1936
2116
  if (inSection) return true;
1937
2117
  const key = getNpmrcLineKey(line);
1938
2118
  if (!key) return true;
1939
- return !migratedKeySet.has(key);
1940
- }).join("\n").trimEnd();
2119
+ if (migratedKeySet.has(key)) {
2120
+ removed = true;
2121
+ return false;
2122
+ }
2123
+ return true;
2124
+ });
2125
+ if (!removed) return false;
2126
+ const updatedContent = lines.join("\n").trimEnd();
1941
2127
  if (compatibility !== "v10" && !updatedContent.trim()) {
1942
2128
  await fsRemoveFile(path);
1943
- return;
2129
+ return true;
1944
2130
  }
1945
- await fsWriteFile(path, updatedContent);
2131
+ return fsWriteFileIfChanged(path, updatedContent);
1946
2132
  }
1947
2133
  /**
1948
2134
  * Read `.npmrc` and return settings that should be migrated into workspace config.
@@ -2481,10 +2667,10 @@ async function persistMigration(options) {
2481
2667
  if (!cleanNpmrc) return result;
2482
2668
  const pruneTasks = projectNpmrcs.projects.flatMap((project) => {
2483
2669
  const appliedKeys = selectAppliedProjectKeys(project, finalSettings);
2484
- return appliedKeys.length ? [pruneNpmrc(project.npmrcPath, compatibility, appliedKeys).then(() => project.npmrcPath)] : [];
2670
+ return appliedKeys.length ? [pruneNpmrc(project.npmrcPath, compatibility, appliedKeys).then((changed) => changed ? project.npmrcPath : void 0)] : [];
2485
2671
  });
2486
- if (npmrcExists && appliedNpmrcKeys.length) pruneTasks.push(pruneNpmrc(npmrcPath, compatibility, appliedNpmrcKeys).then(() => npmrcPath));
2487
- const cleanedNpmrcPaths = await Promise.all(pruneTasks);
2672
+ if (npmrcExists && appliedNpmrcKeys.length) pruneTasks.push(pruneNpmrc(npmrcPath, compatibility, appliedNpmrcKeys).then((changed) => changed ? npmrcPath : void 0));
2673
+ const cleanedNpmrcPaths = (await Promise.all(pruneTasks)).filter(isString);
2488
2674
  result.changedFiles.push(...cleanedNpmrcPaths);
2489
2675
  result.sourceSettingsCleaned ||= cleanedNpmrcPaths.length > 0;
2490
2676
  return result;
@@ -2580,9 +2766,9 @@ function normalizeSideEffectsCache(settings) {
2580
2766
  * @returns Nothing; the URL's declaration is created or updated in place
2581
2767
  */
2582
2768
  function addRegistryScope(declarations, url, scope) {
2583
- declarations[url] ??= {};
2584
- const declaration = declarations[url];
2769
+ const declaration = declarations.get(url) ?? {};
2585
2770
  declaration.scopes = unique([...declaration.scopes ?? [], scope]);
2771
+ declarations.set(url, declaration);
2586
2772
  }
2587
2773
  /**
2588
2774
  * Find source URLs with multiple active aliases across the merged settings.
@@ -2611,9 +2797,9 @@ function findRegistryAliasConflicts(prefixesByUrl, namedRegistries, canonicalPre
2611
2797
  function normalizeNamedRegistries(settings, warnings, canonicalSettings) {
2612
2798
  const { namedRegistries } = settings;
2613
2799
  if (!namedRegistries) return;
2614
- const declarations = {};
2800
+ const declarations = /* @__PURE__ */ new Map();
2615
2801
  for (const [key, value] of Object.entries(settings.registries ?? {})) if (isString(value)) addRegistryScope(declarations, value, key === "default" ? "@" : key);
2616
- else declarations[key] = { ...value };
2802
+ else declarations.set(key, { ...value });
2617
2803
  const canonicalPrefixes = new Set(Object.values(canonicalSettings.registries ?? {}).flatMap((value) => !isString(value) && isString(value?.prefix) ? [value.prefix] : []));
2618
2804
  const prefixesByUrl = /* @__PURE__ */ new Map();
2619
2805
  const registryAliases = Object.entries(namedRegistries).filter(([prefix]) => !canonicalPrefixes.has(prefix));
@@ -2628,17 +2814,17 @@ function normalizeNamedRegistries(settings, warnings, canonicalSettings) {
2628
2814
  return;
2629
2815
  }
2630
2816
  for (const [url, [prefix]] of prefixesByUrl) {
2631
- declarations[url] ??= {};
2632
- const declaration = declarations[url];
2633
- const canonicalDeclaration = canonicalSettings.registries?.[url];
2817
+ const declaration = declarations.get(url) ?? {};
2818
+ const canonicalDeclaration = canonicalSettings.registries && Object.hasOwn(canonicalSettings.registries, url) ? canonicalSettings.registries[url] : void 0;
2634
2819
  const declaredPrefix = (isString(canonicalDeclaration) ? void 0 : canonicalDeclaration?.prefix) ?? declaration.prefix;
2635
2820
  if (isString(declaredPrefix) && declaredPrefix !== prefix) {
2636
2821
  warnings.push(`namedRegistries was kept because ${url} already declares prefix ${declaredPrefix}.`);
2637
2822
  return;
2638
2823
  }
2639
2824
  declaration.prefix = prefix;
2825
+ declarations.set(url, declaration);
2640
2826
  }
2641
- settings.registries = declarations;
2827
+ settings.registries = Object.fromEntries(declarations);
2642
2828
  Reflect.deleteProperty(settings, "namedRegistries");
2643
2829
  }
2644
2830
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pnpm-settings-migrator",
3
- "version": "0.16.0",
3
+ "version": "0.17.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",
@@ -44,7 +44,7 @@
44
44
  "dependencies": {
45
45
  "@ntnyq/utils": "^0.23.0",
46
46
  "@pnpm/types": "^1102.1.1",
47
- "cac": "^7.0.0",
47
+ "cac": "^7.1.0",
48
48
  "camelcase-keys": "^10.0.3",
49
49
  "consola": "^3.4.2",
50
50
  "defu": "^6.1.7",
@@ -56,17 +56,17 @@
56
56
  "yaml": "^2.9.1"
57
57
  },
58
58
  "devDependencies": {
59
- "@types/node": "^26.6.4",
59
+ "@types/node": "^26.6.5",
60
60
  "bumpp": "^12.3.0",
61
61
  "husky": "^9.1.7",
62
62
  "nano-staged": "^1.0.2",
63
63
  "npm-run-all2": "^9.0.3",
64
- "oxfmt": "^0.72.0",
65
- "oxlint": "^1.87.0",
66
- "tsdown": "^0.23.0",
64
+ "oxfmt": "^0.73.0",
65
+ "oxlint": "^1.88.0",
66
+ "tsdown": "^0.23.1",
67
67
  "tsx": "^4.23.15",
68
68
  "typescript": "^7.0.2",
69
- "vitest": "^5.0.3"
69
+ "vitest": "^5.0.4"
70
70
  },
71
71
  "nano-staged": {
72
72
  "*.{js,ts,mjs,tsx}": "oxlint --fix",