pnpm-settings-migrator 0.15.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
@@ -217,6 +217,52 @@ the lockfile. Existing linker strings and boolean `lockfile` values still work.
217
217
  it from workspace configuration. Unsupported or invalid nested settings stay
218
218
  intact in their source; the migrator does not add pnpm's defaults.
219
219
 
220
+ For pnpm 12.11.0 and later stable v12 releases, `permissions` maps dependency
221
+ names or package IDs to `build` and `skills` decisions. Boolean `build`
222
+ decisions override matching `allowBuilds` entries; strings are undecided prompts
223
+ and null leaves a capability unset. Both settings are preserved for pnpm to
224
+ apply its precedence. Unknown capabilities or invalid nested values keep the
225
+ entire source object intact.
226
+
227
+ `skills.dirs` accepts an array of workspace-relative agent skill directories;
228
+ an empty array disables linking. Workspace `provenance` also requires 12.11.0
229
+ for v12 targets and accepts boolean or null values. Its v11 support is unchanged.
230
+ For example, targeting 12.11.0 migrates
231
+ `{ "pnpm": { "permissions": { "example": { "build": false, "skills": true } }, "skills": { "dirs": [".agents/skills"] } } }`
232
+ to equivalent workspace YAML. Targeting 12.10.1 retains these settings with
233
+ diagnostics.
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
+
253
+ pnpm v12 reads `filter` and `filterProd` from CLI arguments or `updateConfig`
254
+ hooks, not workspace YAML. These settings remain in their source with
255
+ diagnostics for v12 targets; existing YAML containing them blocks migration.
256
+ The 12.11.2 hook fix does not add workspace support.
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
+
220
266
  </details>
221
267
 
222
268
  <details>
@@ -253,6 +299,9 @@ Notes:
253
299
  such as `globalDir`, `stateDir`, `configDir`, and `scope` stay in `.npmrc`.
254
300
  - Registry declarations containing embedded credentials or dynamic `${...}`
255
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.
256
305
  - Dynamic `${...}` values in `httpProxy`, `httpsProxy`, `noProxy`, `proxy`, and
257
306
  `noproxy` stay in their source for every target. Project YAML does not expand
258
307
  these placeholders; configure them globally in trusted configuration or use
@@ -277,10 +326,16 @@ Notes:
277
326
  `package.json#pnpm` child keys remain in `package.json`.
278
327
  INI sections in `.npmrc` are retained conservatively; their child names are
279
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.
280
332
  - Runtime migration writes `devEngines.runtime` before removing a workspace
281
333
  runtime declaration. A failed write preserves source settings for retry.
282
334
  If an existing runtime declaration conflicts with a workspace runtime, migration
283
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.
284
339
  - YAML aliases remain when their anchors are unchanged and precede them.
285
340
  If replacement, removal, or sorting would invalidate an alias, its original
286
341
  value is written explicitly so unrelated settings keep their values.
@@ -291,16 +346,17 @@ Notes:
291
346
  - The migrator does not update the `packageManager` version, CI environment variables,
292
347
  shell setup, or pnpm commands in scripts. When `packageManager` still pins pnpm 10,
293
348
  pass `--compatibility v11` explicitly and update the pin separately.
294
- - Compatibility checks cover pnpm 11.28.5 and 12.10.0, including 11.28.0,
349
+ - Compatibility checks cover pnpm 11.28.5 and 12.12.1, including 11.28.0,
295
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,
296
- 12.9.0, and 12.9.1. Earlier regressions remain at 11.25.0,
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.
352
+ Earlier regressions remain at 11.25.0,
297
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.
298
- As [audited on 2026-10-06](docs/research/pnpm-settings-bump-2026-10-06.md),
299
- npm `latest` is 12.9.1; the highest confirmed stable release is 12.10.0 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
300
356
  `next-12`. The v11 target is 11.28.5 on `next-11`, while `latest-11` is
301
- 11.28.2. The project is pinned to 12.9.1. These checks verify the documented
357
+ 11.28.2. The project is pinned to 12.11.2. These checks verify the documented
302
358
  fixtures, not every setting in every release. v10 has unit regressions and
303
- source review through 10.34.6; real v10 consumption remains unverified.
359
+ source review through 10.34.7; real v10 consumption remains unverified.
304
360
  The removed `pnpm install --resolution-only` CLI flag is outside this settings
305
361
  migrator's scope; replace it with `pnpm peers check` in scripts before upgrading.
306
362
 
@@ -373,7 +429,7 @@ Strategy to handle conflicts when merging settings with existing `pnpm-workspace
373
429
  `packageConfigs` matcher arrays preserve order and repeated entries because the
374
430
  last matching entry wins. With `merge`, incoming matchers follow existing ones;
375
431
  an overlapping end/start sequence is reused to keep repeated migrations stable.
376
- `filter` and `filterProd` use the same ordered merging: a repeated inclusion can
432
+ For v11 targets, `filter` and `filterProd` use the same ordered merging: a repeated inclusion can
377
433
  restore projects removed by an earlier exclusion. Cleanup verifies the combined
378
434
  selector sequence. If package cleanup is disabled, later `.npmrc` selectors are
379
435
  retained when needed to keep repeated migrations stable.
package/dist/cli.mjs CHANGED
@@ -502,7 +502,12 @@ const PNPM_V11_WORKSPACE_SETTINGS_FIELDS = unique([...PNPM_V10_NPMRC_SETTINGS_FI
502
502
  /**
503
503
  * Settings that are supported by v11 but not by pnpm v12.
504
504
  */
505
- const PNPM_V11_ONLY_WORKSPACE_SETTINGS = ["confirmModulesPurge", "packageConfigs"];
505
+ const PNPM_V11_ONLY_WORKSPACE_SETTINGS = [
506
+ "confirmModulesPurge",
507
+ "filter",
508
+ "filterProd",
509
+ "packageConfigs"
510
+ ];
506
511
  /**
507
512
  * Fields pnpm v11 accepts inside a `packageConfigs` entry.
508
513
  */
@@ -614,6 +619,38 @@ const PNPM_V12_10_MINIMUM_VERSION = [
614
619
  0
615
620
  ];
616
621
  /**
622
+ * Release introducing package permissions, agent skills, and workspace provenance.
623
+ */
624
+ const PNPM_V12_11_MINIMUM_VERSION = [
625
+ 12,
626
+ 11,
627
+ 0
628
+ ];
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
+ /**
646
+ * Workspace settings first consumed by pnpm v12 in 12.11.0.
647
+ */
648
+ const PNPM_V12_11_WORKSPACE_SETTINGS = [
649
+ "permissions",
650
+ "skills",
651
+ "provenance"
652
+ ];
653
+ /**
617
654
  * Workspace settings first consumed by pnpm v12 in 12.10.0.
618
655
  */
619
656
  const PNPM_V12_10_WORKSPACE_SETTINGS = ["failIfNoMatch"];
@@ -631,6 +668,16 @@ const PNPM_V12_7_WORKSPACE_SETTINGS = [
631
668
  * @see https://github.com/pnpm/pnpm/blob/v12.4.0/pnpm/crates/config/src/workspace_yaml.rs
632
669
  */
633
670
  const PNPM_SETTINGS_CAPABILITIES = [
671
+ {
672
+ minimumVersion: PNPM_V12_12_MINIMUM_VERSION,
673
+ workspaceFields: ["addMissingPeerTypes", "remoteCache"],
674
+ taskFields: []
675
+ },
676
+ {
677
+ minimumVersion: PNPM_V12_11_MINIMUM_VERSION,
678
+ workspaceFields: PNPM_V12_11_WORKSPACE_SETTINGS,
679
+ taskFields: []
680
+ },
634
681
  {
635
682
  minimumVersion: PNPM_V11_28_MINIMUM_VERSION,
636
683
  workspaceFields: ["forceIgnoresPlatform"],
@@ -707,9 +754,82 @@ const PNPM_V12_ONLY_WORKSPACE_SETTINGS = [
707
754
  "globalShims"
708
755
  ];
709
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
+ /**
710
830
  * Lookup used to remove v11-only fields from the v12 workspace allowlist.
711
831
  */
712
- 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]);
713
833
  /**
714
834
  * Fields accepted by registry declarations with pnpm 12.9 concurrency limits.
715
835
  */
@@ -725,7 +845,7 @@ const PNPM_V12_REGISTRY_DECLARATION_FIELDS = [
725
845
  /**
726
846
  * Settings accepted by a pnpm v12 project workspace manifest.
727
847
  */
728
- const PNPM_V12_WORKSPACE_SETTINGS_FIELDS = [...PNPM_V11_WORKSPACE_SETTINGS_FIELDS.filter((field) => !v11OnlyWorkspaceSettings.has(field) && !PNPM_V12_6_WORKSPACE_SETTINGS.includes(field) && !PNPM_V12_7_WORKSPACE_SETTINGS.includes(field) && !PNPM_V12_10_WORKSPACE_SETTINGS.includes(field)), ...PNPM_V12_ONLY_WORKSPACE_SETTINGS];
848
+ const PNPM_V12_WORKSPACE_SETTINGS_FIELDS = [...PNPM_V11_WORKSPACE_SETTINGS_FIELDS.filter((field) => !v11OnlyWorkspaceSettings.has(field) && !PNPM_V12_6_WORKSPACE_SETTINGS.includes(field) && !PNPM_V12_7_WORKSPACE_SETTINGS.includes(field) && !PNPM_V12_10_WORKSPACE_SETTINGS.includes(field) && !PNPM_V12_11_WORKSPACE_SETTINGS.includes(field)), ...PNPM_V12_ONLY_WORKSPACE_SETTINGS];
729
849
  //#endregion
730
850
  //#region src/constants/settings-schema.ts
731
851
  /**
@@ -744,7 +864,7 @@ const PROJECT_REFUSED_SETTINGS = new Set(PNPM_PROJECT_REFUSED_SETTINGS);
744
864
  /**
745
865
  * Fields reported as incompatible when selecting settings for v12.
746
866
  */
747
- 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]);
748
868
  /**
749
869
  * Allowed workspace fields for the v11 target.
750
870
  */
@@ -1093,6 +1213,63 @@ function formatSettingsIssues(options) {
1093
1213
  return warnings;
1094
1214
  }
1095
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
1242
+ //#region src/features/settings/permissions.ts
1243
+ /**
1244
+ * Validate a dependency's capability map without dropping unknown permissions.
1245
+ *
1246
+ * @param value - Untrusted per-package permission entry
1247
+ *
1248
+ * @returns Whether pnpm cannot consume every capability in the entry
1249
+ */
1250
+ function hasInvalidPackagePermissions(value) {
1251
+ if (value === null) return false;
1252
+ if (!value || typeof value !== "object" || isArray(value)) return true;
1253
+ return Object.entries(value).some(([capability, decision]) => !["build", "skills"].includes(capability) || decision !== null && !isBoolean(decision) && !isString(decision));
1254
+ }
1255
+ /**
1256
+ * Validate permissions and skill directories introduced in pnpm 12.11.
1257
+ * Preserve an entire object when a nested value is invalid or ignored upstream.
1258
+ *
1259
+ * @param key - Canonical workspace setting name
1260
+ * @param value - Untrusted source or existing workspace value
1261
+ *
1262
+ * @returns Whether the setting contains unsupported nested values
1263
+ */
1264
+ function hasInvalidPermissionSettings(key, value) {
1265
+ if (!["permissions", "skills"].includes(key) || value === null) return false;
1266
+ if (!value || typeof value !== "object" || isArray(value)) return true;
1267
+ if (key === "permissions") return Object.values(value).some(hasInvalidPackagePermissions);
1268
+ if (Object.keys(value).some((field) => field !== "dirs")) return true;
1269
+ const dirs = Reflect.get(value, "dirs");
1270
+ return Object.hasOwn(value, "dirs") && dirs !== null && (!isArray(dirs) || !dirs.every(isString));
1271
+ }
1272
+ //#endregion
1096
1273
  //#region src/features/settings/pnpm-v12-values.ts
1097
1274
  /**
1098
1275
  * Check a loaded linker object without discarding unsupported options.
@@ -1206,6 +1383,22 @@ function hasIncompatibleV12SettingValue(key, value, target) {
1206
1383
  return false;
1207
1384
  }
1208
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
1209
1402
  //#region src/features/settings/versioned-values.ts
1210
1403
  /**
1211
1404
  * Bounds of the signed 32-bit integer used by pnpm's task priority.
@@ -1254,6 +1447,7 @@ function hasInvalidScalarValue(key, value, target) {
1254
1447
  "ndjson",
1255
1448
  "silent"
1256
1449
  ].includes(value);
1450
+ if (key === "provenance" || key === "addMissingPeerTypes") return value !== null && !isBoolean(value);
1257
1451
  if (key === "progress") return !isBoolean(value);
1258
1452
  if (key === "tagVersionPrefix") return !isString(value);
1259
1453
  if (key === "loglevel") return !isString(value) || !value.includes("${") && ![
@@ -1283,6 +1477,17 @@ function hasInvalidReleaseValue(key, value, target) {
1283
1477
  return false;
1284
1478
  }
1285
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
+ /**
1286
1491
  * Check value shapes that changed within a pnpm major release.
1287
1492
  *
1288
1493
  * Reject the whole setting so cleanup cannot discard unsupported nested values.
@@ -1294,7 +1499,7 @@ function hasInvalidReleaseValue(key, value, target) {
1294
1499
  * @returns Whether the value uses a shape unsupported by the selected release
1295
1500
  */
1296
1501
  function hasIncompatibleSettingValue(key, value, target) {
1297
- if (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;
1298
1503
  if (hasInvalidScalarValue(key, value, target)) return true;
1299
1504
  const supportsV12_5 = supportsMinimumVersion(target.version, PNPM_V12_5_MINIMUM_VERSION);
1300
1505
  if (key === "supportedArchitectures" && isArray(value)) return !supportsV12_5;
@@ -1312,19 +1517,42 @@ function hasIncompatibleSettingValue(key, value, target) {
1312
1517
  "constraints"
1313
1518
  ].some((field) => Object.hasOwn(value, field));
1314
1519
  }
1315
- 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));
1316
1521
  if (key === "registries") return Object.values(value).some((entry) => {
1317
1522
  if (!entry || typeof entry !== "object") return false;
1318
1523
  if (Object.hasOwn(entry, "ecosystem") && !supportsV12_5) return true;
1319
1524
  if (Object.hasOwn(entry, "packages") && !supportsMinimumVersion(target.version, PNPM_V12_5_1_MINIMUM_VERSION)) return true;
1320
1525
  const prefix = Reflect.get(entry, "prefix");
1321
- return supportsV12_5 && isString(prefix) && prefix.toLowerCase() === "pkg";
1526
+ return isString(prefix) && isReservedRegistryPrefix(prefix, target);
1322
1527
  });
1323
1528
  return false;
1324
1529
  }
1325
1530
  //#endregion
1326
1531
  //#region src/features/settings/schema.ts
1327
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
+ /**
1328
1556
  * Create an empty collection of schema selection issues.
1329
1557
  *
1330
1558
  * @returns Empty issue lists for every supported issue category
@@ -1429,8 +1657,8 @@ function resolveSettingIssue({ allowedFields, target, key, npmrc, value }) {
1429
1657
  if (compatibility !== "v10" && PROJECT_REFUSED_SETTINGS.has(key)) return "refused";
1430
1658
  if (REGISTRY_SETTINGS.has(key) && containsUnsafeRegistryValue(value)) return "unsafe";
1431
1659
  if (PROXY_SETTINGS.has(key) && isString(value) && value.includes("${")) return "unsafe";
1432
- if (hasIncompatibleSettingValue(key, value, target)) return "incompatible";
1433
- 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";
1434
1662
  if (!workspaceSettings.has(key)) return isSettingFromAnotherVersion(key, compatibility) ? "incompatible" : "unknown";
1435
1663
  if (allowedFields && !allowedFields.has(key)) return "unsupported";
1436
1664
  }
@@ -1839,8 +2067,17 @@ function getNpmrcLineKey(line) {
1839
2067
  const trimmed = line.trim();
1840
2068
  if (!trimmed || trimmed.startsWith(";") || trimmed.startsWith("#")) return;
1841
2069
  const index = trimmed.indexOf("=");
1842
- if (index <= 0) return;
1843
- 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);
1844
2081
  }
1845
2082
  /**
1846
2083
  * Check whether a `.npmrc` key is auth/registry-related and should stay in
@@ -1867,7 +2104,7 @@ function isNpmrcAuthOrRegistryKey(key) {
1867
2104
  * @param compatibility - Concrete pnpm compatibility target
1868
2105
  * @param migratedKeys - Original `.npmrc` keys that were migrated
1869
2106
  *
1870
- * @returns A promise that resolves when the file has been pruned
2107
+ * @returns Whether the file was changed or removed
1871
2108
  *
1872
2109
  * @throws {Error} When file read/write operations fail
1873
2110
  *
@@ -1880,18 +2117,25 @@ async function pruneNpmrc(path, compatibility, migratedKeys) {
1880
2117
  const migratedKeySet = new Set(migratedKeys.map(normalizeNpmrcKey));
1881
2118
  const content = await fsReadFile(path);
1882
2119
  let inSection = false;
1883
- 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) => {
1884
2122
  if (/^\[[^\]]*\]\s*$/u.test(line.replace(/^\uFEFF/u, ""))) inSection = true;
1885
2123
  if (inSection) return true;
1886
2124
  const key = getNpmrcLineKey(line);
1887
2125
  if (!key) return true;
1888
- return !migratedKeySet.has(key);
1889
- }).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();
1890
2134
  if (compatibility !== "v10" && !updatedContent.trim()) {
1891
2135
  await fsRemoveFile(path);
1892
- return;
2136
+ return true;
1893
2137
  }
1894
- await fsWriteFile(path, updatedContent);
2138
+ return fsWriteFileIfChanged(path, updatedContent);
1895
2139
  }
1896
2140
  /**
1897
2141
  * Read `.npmrc` and return settings that should be migrated into workspace config.
@@ -2321,6 +2565,7 @@ function resolveReplacementSettingKey(sourceKey, targetKey, npmrc) {
2321
2565
  * @returns Original source keys that can be pruned
2322
2566
  */
2323
2567
  function selectAppliedRootKeys({ appliedOrderedFilterKeys, finalSettings, normalizedSettings, keys, npmrc = false, runtimeApplied, runtimeVersion, sourceSettings }) {
2568
+ const hasUnappliedSkillsDisable = normalizedSettings.skills?.dirs?.length === 0 && finalSettings.skills?.dirs?.length !== 0;
2324
2569
  return keys.filter((sourceKey) => {
2325
2570
  const targetKey = npmrc ? Object.keys(camelcaseKeys({ [sourceKey]: true }))[0] ?? sourceKey : sourceKey;
2326
2571
  if (!Object.hasOwn(normalizedSettings, targetKey)) {
@@ -2343,6 +2588,7 @@ function selectAppliedRootKeys({ appliedOrderedFilterKeys, finalSettings, normal
2343
2588
  const expected = Reflect.get(normalizedSettings, targetKey);
2344
2589
  return appliedOrderedFilterKeys.has(targetKey) && containsOrderedFilterValue(actual, expected);
2345
2590
  }
2591
+ if (targetKey === "skills" && hasUnappliedSkillsDisable) return false;
2346
2592
  return containsMigratedValue(Reflect.get(finalSettings, targetKey), Reflect.get(normalizedSettings, targetKey));
2347
2593
  });
2348
2594
  }
@@ -2428,10 +2674,10 @@ async function persistMigration(options) {
2428
2674
  if (!cleanNpmrc) return result;
2429
2675
  const pruneTasks = projectNpmrcs.projects.flatMap((project) => {
2430
2676
  const appliedKeys = selectAppliedProjectKeys(project, finalSettings);
2431
- 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)] : [];
2432
2678
  });
2433
- if (npmrcExists && appliedNpmrcKeys.length) pruneTasks.push(pruneNpmrc(npmrcPath, compatibility, appliedNpmrcKeys).then(() => npmrcPath));
2434
- 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);
2435
2681
  result.changedFiles.push(...cleanedNpmrcPaths);
2436
2682
  result.sourceSettingsCleaned ||= cleanedNpmrcPaths.length > 0;
2437
2683
  return result;
@@ -2527,9 +2773,9 @@ function normalizeSideEffectsCache(settings) {
2527
2773
  * @returns Nothing; the URL's declaration is created or updated in place
2528
2774
  */
2529
2775
  function addRegistryScope(declarations, url, scope) {
2530
- declarations[url] ??= {};
2531
- const declaration = declarations[url];
2776
+ const declaration = declarations.get(url) ?? {};
2532
2777
  declaration.scopes = unique([...declaration.scopes ?? [], scope]);
2778
+ declarations.set(url, declaration);
2533
2779
  }
2534
2780
  /**
2535
2781
  * Find source URLs with multiple active aliases across the merged settings.
@@ -2558,9 +2804,9 @@ function findRegistryAliasConflicts(prefixesByUrl, namedRegistries, canonicalPre
2558
2804
  function normalizeNamedRegistries(settings, warnings, canonicalSettings) {
2559
2805
  const { namedRegistries } = settings;
2560
2806
  if (!namedRegistries) return;
2561
- const declarations = {};
2807
+ const declarations = /* @__PURE__ */ new Map();
2562
2808
  for (const [key, value] of Object.entries(settings.registries ?? {})) if (isString(value)) addRegistryScope(declarations, value, key === "default" ? "@" : key);
2563
- else declarations[key] = { ...value };
2809
+ else declarations.set(key, { ...value });
2564
2810
  const canonicalPrefixes = new Set(Object.values(canonicalSettings.registries ?? {}).flatMap((value) => !isString(value) && isString(value?.prefix) ? [value.prefix] : []));
2565
2811
  const prefixesByUrl = /* @__PURE__ */ new Map();
2566
2812
  const registryAliases = Object.entries(namedRegistries).filter(([prefix]) => !canonicalPrefixes.has(prefix));
@@ -2575,17 +2821,17 @@ function normalizeNamedRegistries(settings, warnings, canonicalSettings) {
2575
2821
  return;
2576
2822
  }
2577
2823
  for (const [url, [prefix]] of prefixesByUrl) {
2578
- declarations[url] ??= {};
2579
- const declaration = declarations[url];
2580
- 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;
2581
2826
  const declaredPrefix = (isString(canonicalDeclaration) ? void 0 : canonicalDeclaration?.prefix) ?? declaration.prefix;
2582
2827
  if (isString(declaredPrefix) && declaredPrefix !== prefix) {
2583
2828
  warnings.push(`namedRegistries was kept because ${url} already declares prefix ${declaredPrefix}.`);
2584
2829
  return;
2585
2830
  }
2586
2831
  declaration.prefix = prefix;
2832
+ declarations.set(url, declaration);
2587
2833
  }
2588
- settings.registries = declarations;
2834
+ settings.registries = Object.fromEntries(declarations);
2589
2835
  Reflect.deleteProperty(settings, "namedRegistries");
2590
2836
  }
2591
2837
  /**
@@ -3147,7 +3393,7 @@ const bold = getColor("bold");
3147
3393
  //#endregion
3148
3394
  //#region package.json
3149
3395
  var name = "pnpm-settings-migrator";
3150
- var version = "0.15.0";
3396
+ var version = "0.17.0";
3151
3397
  //#endregion
3152
3398
  //#region src/cli/options.ts
3153
3399
  /**
package/dist/index.d.mts CHANGED
@@ -230,10 +230,52 @@ interface PnpmLockfileSettings {
230
230
  */
231
231
  includeResolutionSettings?: boolean | null;
232
232
  }
233
+ /**
234
+ * Dependency capabilities introduced in pnpm 12.11.0. Strings are undecided
235
+ * prompts; null leaves a capability unset.
236
+ */
237
+ interface PnpmPackagePermissions {
238
+ /**
239
+ * Lifecycle script decision, taking precedence over the matching allowBuilds
240
+ * entry when boolean. An undecided string leaves that entry effective.
241
+ */
242
+ build?: boolean | string | null;
243
+ /**
244
+ * Whether pnpm may link the agent skills shipped by the dependency.
245
+ */
246
+ skills?: boolean | string | null;
247
+ }
248
+ /**
249
+ * Agent skill linking configuration introduced in pnpm 12.11.0.
250
+ */
251
+ interface PnpmSkillsSettings {
252
+ /**
253
+ * Workspace-relative agent skill directories, replacing detected directories.
254
+ * An empty list disables linking; null leaves directory detection enabled.
255
+ */
256
+ dirs?: string[] | null;
257
+ }
233
258
  /**
234
259
  * Settings supported by pnpm v12, subject to the resolved version capabilities.
235
260
  */
236
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;
271
+ /**
272
+ * Per-dependency build and agent skill permissions (pnpm 12.11+).
273
+ */
274
+ permissions?: Record<string, PnpmPackagePermissions | null> | null;
275
+ /**
276
+ * Agent skill linking options (pnpm 12.11+).
277
+ */
278
+ skills?: PnpmSkillsSettings | null;
237
279
  /**
238
280
  * Dependency linking mode. Loaded linking and its options require pnpm 12.10+.
239
281
  */
@@ -320,6 +362,16 @@ interface PnpmSettingsV12 {
320
362
  */
321
363
  tasks?: Record<string, PnpmTaskSettings>;
322
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
+ }
323
375
  //#endregion
324
376
  //#region src/types/workspace.d.ts
325
377
  /**
@@ -440,6 +492,11 @@ interface PnpmSettingsDeprecated {
440
492
  * Settings introduced in pnpm v11.
441
493
  */
442
494
  interface PnpmSettingsV11 {
495
+ /**
496
+ * Publish provenance preference. Consumed from workspace configuration by
497
+ * pnpm v11 and pnpm v12 starting with 12.11. Null leaves it unset.
498
+ */
499
+ provenance?: boolean | null;
443
500
  /**
444
501
  * Fail when no workspace project matches a filter. Accepted by pnpm v11 and
445
502
  * consumed from workspace configuration by pnpm v12 starting with 12.10.
@@ -484,7 +541,7 @@ interface PnpmSettingsV11 {
484
541
  /**
485
542
  * `pnpm-workspace.yaml` types.
486
543
  */
487
- type PnpmWorkspace = Omit<PnpmSettings, 'lockfile' | 'nodeLinker' | 'registries' | 'supportedArchitectures' | 'tasks'> & PnpmSettingsDeprecated & PnpmSettingsV11 & PnpmSettingsV12 & PnpmWorkspaceLegacy;
544
+ type PnpmWorkspace = Omit<PnpmSettings, 'lockfile' | 'nodeLinker' | 'provenance' | 'registries' | 'supportedArchitectures' | 'tasks'> & PnpmSettingsDeprecated & PnpmSettingsV11 & PnpmSettingsV12 & PnpmWorkspaceLegacy;
488
545
  //#endregion
489
546
  //#region src/types/package-json.d.ts
490
547
  /**
@@ -692,4 +749,4 @@ export declare function migratePnpmSettings(rawOptions?: Options): Promise<Migra
692
749
  */
693
750
  export declare function resolveOptions(options?: Options): ResolvedOptions;
694
751
  //#endregion
695
- export type { CompatibilityTarget, GlobalShimPolicy, MergeStrategy, MigrationResult, NpmRC, Options, PackageJson, PackageManagerEngine, PnpmCargoSettings, PnpmLoadedNodeLinkerSettings, PnpmLockfileSettings, PnpmPythonSettings, PnpmRegistryDeclaration, PnpmSettingsDeprecated, PnpmSettingsV11, PnpmSettingsV12, 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
@@ -500,7 +500,12 @@ const PNPM_V11_WORKSPACE_SETTINGS_FIELDS = unique([...PNPM_V10_NPMRC_SETTINGS_FI
500
500
  /**
501
501
  * Settings that are supported by v11 but not by pnpm v12.
502
502
  */
503
- const PNPM_V11_ONLY_WORKSPACE_SETTINGS = ["confirmModulesPurge", "packageConfigs"];
503
+ const PNPM_V11_ONLY_WORKSPACE_SETTINGS = [
504
+ "confirmModulesPurge",
505
+ "filter",
506
+ "filterProd",
507
+ "packageConfigs"
508
+ ];
504
509
  /**
505
510
  * Fields pnpm v11 accepts inside a `packageConfigs` entry.
506
511
  */
@@ -612,6 +617,38 @@ const PNPM_V12_10_MINIMUM_VERSION = [
612
617
  0
613
618
  ];
614
619
  /**
620
+ * Release introducing package permissions, agent skills, and workspace provenance.
621
+ */
622
+ const PNPM_V12_11_MINIMUM_VERSION = [
623
+ 12,
624
+ 11,
625
+ 0
626
+ ];
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
+ /**
644
+ * Workspace settings first consumed by pnpm v12 in 12.11.0.
645
+ */
646
+ const PNPM_V12_11_WORKSPACE_SETTINGS = [
647
+ "permissions",
648
+ "skills",
649
+ "provenance"
650
+ ];
651
+ /**
615
652
  * Workspace settings first consumed by pnpm v12 in 12.10.0.
616
653
  */
617
654
  const PNPM_V12_10_WORKSPACE_SETTINGS = ["failIfNoMatch"];
@@ -629,6 +666,16 @@ const PNPM_V12_7_WORKSPACE_SETTINGS = [
629
666
  * @see https://github.com/pnpm/pnpm/blob/v12.4.0/pnpm/crates/config/src/workspace_yaml.rs
630
667
  */
631
668
  const PNPM_SETTINGS_CAPABILITIES = [
669
+ {
670
+ minimumVersion: PNPM_V12_12_MINIMUM_VERSION,
671
+ workspaceFields: ["addMissingPeerTypes", "remoteCache"],
672
+ taskFields: []
673
+ },
674
+ {
675
+ minimumVersion: PNPM_V12_11_MINIMUM_VERSION,
676
+ workspaceFields: PNPM_V12_11_WORKSPACE_SETTINGS,
677
+ taskFields: []
678
+ },
632
679
  {
633
680
  minimumVersion: PNPM_V11_28_MINIMUM_VERSION,
634
681
  workspaceFields: ["forceIgnoresPlatform"],
@@ -705,9 +752,82 @@ const PNPM_V12_ONLY_WORKSPACE_SETTINGS = [
705
752
  "globalShims"
706
753
  ];
707
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
+ /**
708
828
  * Lookup used to remove v11-only fields from the v12 workspace allowlist.
709
829
  */
710
- 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]);
711
831
  /**
712
832
  * Fields accepted by registry declarations with pnpm 12.9 concurrency limits.
713
833
  */
@@ -723,7 +843,7 @@ const PNPM_V12_REGISTRY_DECLARATION_FIELDS = [
723
843
  /**
724
844
  * Settings accepted by a pnpm v12 project workspace manifest.
725
845
  */
726
- const PNPM_V12_WORKSPACE_SETTINGS_FIELDS = [...PNPM_V11_WORKSPACE_SETTINGS_FIELDS.filter((field) => !v11OnlyWorkspaceSettings.has(field) && !PNPM_V12_6_WORKSPACE_SETTINGS.includes(field) && !PNPM_V12_7_WORKSPACE_SETTINGS.includes(field) && !PNPM_V12_10_WORKSPACE_SETTINGS.includes(field)), ...PNPM_V12_ONLY_WORKSPACE_SETTINGS];
846
+ const PNPM_V12_WORKSPACE_SETTINGS_FIELDS = [...PNPM_V11_WORKSPACE_SETTINGS_FIELDS.filter((field) => !v11OnlyWorkspaceSettings.has(field) && !PNPM_V12_6_WORKSPACE_SETTINGS.includes(field) && !PNPM_V12_7_WORKSPACE_SETTINGS.includes(field) && !PNPM_V12_10_WORKSPACE_SETTINGS.includes(field) && !PNPM_V12_11_WORKSPACE_SETTINGS.includes(field)), ...PNPM_V12_ONLY_WORKSPACE_SETTINGS];
727
847
  //#endregion
728
848
  //#region src/constants/settings-schema.ts
729
849
  /**
@@ -742,7 +862,7 @@ const PROJECT_REFUSED_SETTINGS = new Set(PNPM_PROJECT_REFUSED_SETTINGS);
742
862
  /**
743
863
  * Fields reported as incompatible when selecting settings for v12.
744
864
  */
745
- 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]);
746
866
  /**
747
867
  * Allowed workspace fields for the v11 target.
748
868
  */
@@ -1086,6 +1206,63 @@ function formatSettingsIssues(options) {
1086
1206
  return warnings;
1087
1207
  }
1088
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
1235
+ //#region src/features/settings/permissions.ts
1236
+ /**
1237
+ * Validate a dependency's capability map without dropping unknown permissions.
1238
+ *
1239
+ * @param value - Untrusted per-package permission entry
1240
+ *
1241
+ * @returns Whether pnpm cannot consume every capability in the entry
1242
+ */
1243
+ function hasInvalidPackagePermissions(value) {
1244
+ if (value === null) return false;
1245
+ if (!value || typeof value !== "object" || isArray(value)) return true;
1246
+ return Object.entries(value).some(([capability, decision]) => !["build", "skills"].includes(capability) || decision !== null && !isBoolean(decision) && !isString(decision));
1247
+ }
1248
+ /**
1249
+ * Validate permissions and skill directories introduced in pnpm 12.11.
1250
+ * Preserve an entire object when a nested value is invalid or ignored upstream.
1251
+ *
1252
+ * @param key - Canonical workspace setting name
1253
+ * @param value - Untrusted source or existing workspace value
1254
+ *
1255
+ * @returns Whether the setting contains unsupported nested values
1256
+ */
1257
+ function hasInvalidPermissionSettings(key, value) {
1258
+ if (!["permissions", "skills"].includes(key) || value === null) return false;
1259
+ if (!value || typeof value !== "object" || isArray(value)) return true;
1260
+ if (key === "permissions") return Object.values(value).some(hasInvalidPackagePermissions);
1261
+ if (Object.keys(value).some((field) => field !== "dirs")) return true;
1262
+ const dirs = Reflect.get(value, "dirs");
1263
+ return Object.hasOwn(value, "dirs") && dirs !== null && (!isArray(dirs) || !dirs.every(isString));
1264
+ }
1265
+ //#endregion
1089
1266
  //#region src/features/settings/pnpm-v12-values.ts
1090
1267
  /**
1091
1268
  * Check a loaded linker object without discarding unsupported options.
@@ -1199,6 +1376,22 @@ function hasIncompatibleV12SettingValue(key, value, target) {
1199
1376
  return false;
1200
1377
  }
1201
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
1202
1395
  //#region src/features/settings/versioned-values.ts
1203
1396
  /**
1204
1397
  * Bounds of the signed 32-bit integer used by pnpm's task priority.
@@ -1247,6 +1440,7 @@ function hasInvalidScalarValue(key, value, target) {
1247
1440
  "ndjson",
1248
1441
  "silent"
1249
1442
  ].includes(value);
1443
+ if (key === "provenance" || key === "addMissingPeerTypes") return value !== null && !isBoolean(value);
1250
1444
  if (key === "progress") return !isBoolean(value);
1251
1445
  if (key === "tagVersionPrefix") return !isString(value);
1252
1446
  if (key === "loglevel") return !isString(value) || !value.includes("${") && ![
@@ -1276,6 +1470,17 @@ function hasInvalidReleaseValue(key, value, target) {
1276
1470
  return false;
1277
1471
  }
1278
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
+ /**
1279
1484
  * Check value shapes that changed within a pnpm major release.
1280
1485
  *
1281
1486
  * Reject the whole setting so cleanup cannot discard unsupported nested values.
@@ -1287,7 +1492,7 @@ function hasInvalidReleaseValue(key, value, target) {
1287
1492
  * @returns Whether the value uses a shape unsupported by the selected release
1288
1493
  */
1289
1494
  function hasIncompatibleSettingValue(key, value, target) {
1290
- if (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;
1291
1496
  if (hasInvalidScalarValue(key, value, target)) return true;
1292
1497
  const supportsV12_5 = supportsMinimumVersion(target.version, PNPM_V12_5_MINIMUM_VERSION);
1293
1498
  if (key === "supportedArchitectures" && isArray(value)) return !supportsV12_5;
@@ -1305,19 +1510,42 @@ function hasIncompatibleSettingValue(key, value, target) {
1305
1510
  "constraints"
1306
1511
  ].some((field) => Object.hasOwn(value, field));
1307
1512
  }
1308
- 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));
1309
1514
  if (key === "registries") return Object.values(value).some((entry) => {
1310
1515
  if (!entry || typeof entry !== "object") return false;
1311
1516
  if (Object.hasOwn(entry, "ecosystem") && !supportsV12_5) return true;
1312
1517
  if (Object.hasOwn(entry, "packages") && !supportsMinimumVersion(target.version, PNPM_V12_5_1_MINIMUM_VERSION)) return true;
1313
1518
  const prefix = Reflect.get(entry, "prefix");
1314
- return supportsV12_5 && isString(prefix) && prefix.toLowerCase() === "pkg";
1519
+ return isString(prefix) && isReservedRegistryPrefix(prefix, target);
1315
1520
  });
1316
1521
  return false;
1317
1522
  }
1318
1523
  //#endregion
1319
1524
  //#region src/features/settings/schema.ts
1320
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
+ /**
1321
1549
  * Create an empty collection of schema selection issues.
1322
1550
  *
1323
1551
  * @returns Empty issue lists for every supported issue category
@@ -1422,8 +1650,8 @@ function resolveSettingIssue({ allowedFields, target, key, npmrc, value }) {
1422
1650
  if (compatibility !== "v10" && PROJECT_REFUSED_SETTINGS.has(key)) return "refused";
1423
1651
  if (REGISTRY_SETTINGS.has(key) && containsUnsafeRegistryValue(value)) return "unsafe";
1424
1652
  if (PROXY_SETTINGS.has(key) && isString(value) && value.includes("${")) return "unsafe";
1425
- if (hasIncompatibleSettingValue(key, value, target)) return "incompatible";
1426
- 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";
1427
1655
  if (!workspaceSettings.has(key)) return isSettingFromAnotherVersion(key, compatibility) ? "incompatible" : "unknown";
1428
1656
  if (allowedFields && !allowedFields.has(key)) return "unsupported";
1429
1657
  }
@@ -1832,8 +2060,17 @@ function getNpmrcLineKey(line) {
1832
2060
  const trimmed = line.trim();
1833
2061
  if (!trimmed || trimmed.startsWith(";") || trimmed.startsWith("#")) return;
1834
2062
  const index = trimmed.indexOf("=");
1835
- if (index <= 0) return;
1836
- 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);
1837
2074
  }
1838
2075
  /**
1839
2076
  * Check whether a `.npmrc` key is auth/registry-related and should stay in
@@ -1860,7 +2097,7 @@ function isNpmrcAuthOrRegistryKey(key) {
1860
2097
  * @param compatibility - Concrete pnpm compatibility target
1861
2098
  * @param migratedKeys - Original `.npmrc` keys that were migrated
1862
2099
  *
1863
- * @returns A promise that resolves when the file has been pruned
2100
+ * @returns Whether the file was changed or removed
1864
2101
  *
1865
2102
  * @throws {Error} When file read/write operations fail
1866
2103
  *
@@ -1873,18 +2110,25 @@ async function pruneNpmrc(path, compatibility, migratedKeys) {
1873
2110
  const migratedKeySet = new Set(migratedKeys.map(normalizeNpmrcKey));
1874
2111
  const content = await fsReadFile(path);
1875
2112
  let inSection = false;
1876
- 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) => {
1877
2115
  if (/^\[[^\]]*\]\s*$/u.test(line.replace(/^\uFEFF/u, ""))) inSection = true;
1878
2116
  if (inSection) return true;
1879
2117
  const key = getNpmrcLineKey(line);
1880
2118
  if (!key) return true;
1881
- return !migratedKeySet.has(key);
1882
- }).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();
1883
2127
  if (compatibility !== "v10" && !updatedContent.trim()) {
1884
2128
  await fsRemoveFile(path);
1885
- return;
2129
+ return true;
1886
2130
  }
1887
- await fsWriteFile(path, updatedContent);
2131
+ return fsWriteFileIfChanged(path, updatedContent);
1888
2132
  }
1889
2133
  /**
1890
2134
  * Read `.npmrc` and return settings that should be migrated into workspace config.
@@ -2314,6 +2558,7 @@ function resolveReplacementSettingKey(sourceKey, targetKey, npmrc) {
2314
2558
  * @returns Original source keys that can be pruned
2315
2559
  */
2316
2560
  function selectAppliedRootKeys({ appliedOrderedFilterKeys, finalSettings, normalizedSettings, keys, npmrc = false, runtimeApplied, runtimeVersion, sourceSettings }) {
2561
+ const hasUnappliedSkillsDisable = normalizedSettings.skills?.dirs?.length === 0 && finalSettings.skills?.dirs?.length !== 0;
2317
2562
  return keys.filter((sourceKey) => {
2318
2563
  const targetKey = npmrc ? Object.keys(camelcaseKeys({ [sourceKey]: true }))[0] ?? sourceKey : sourceKey;
2319
2564
  if (!Object.hasOwn(normalizedSettings, targetKey)) {
@@ -2336,6 +2581,7 @@ function selectAppliedRootKeys({ appliedOrderedFilterKeys, finalSettings, normal
2336
2581
  const expected = Reflect.get(normalizedSettings, targetKey);
2337
2582
  return appliedOrderedFilterKeys.has(targetKey) && containsOrderedFilterValue(actual, expected);
2338
2583
  }
2584
+ if (targetKey === "skills" && hasUnappliedSkillsDisable) return false;
2339
2585
  return containsMigratedValue(Reflect.get(finalSettings, targetKey), Reflect.get(normalizedSettings, targetKey));
2340
2586
  });
2341
2587
  }
@@ -2421,10 +2667,10 @@ async function persistMigration(options) {
2421
2667
  if (!cleanNpmrc) return result;
2422
2668
  const pruneTasks = projectNpmrcs.projects.flatMap((project) => {
2423
2669
  const appliedKeys = selectAppliedProjectKeys(project, finalSettings);
2424
- 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)] : [];
2425
2671
  });
2426
- if (npmrcExists && appliedNpmrcKeys.length) pruneTasks.push(pruneNpmrc(npmrcPath, compatibility, appliedNpmrcKeys).then(() => npmrcPath));
2427
- 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);
2428
2674
  result.changedFiles.push(...cleanedNpmrcPaths);
2429
2675
  result.sourceSettingsCleaned ||= cleanedNpmrcPaths.length > 0;
2430
2676
  return result;
@@ -2520,9 +2766,9 @@ function normalizeSideEffectsCache(settings) {
2520
2766
  * @returns Nothing; the URL's declaration is created or updated in place
2521
2767
  */
2522
2768
  function addRegistryScope(declarations, url, scope) {
2523
- declarations[url] ??= {};
2524
- const declaration = declarations[url];
2769
+ const declaration = declarations.get(url) ?? {};
2525
2770
  declaration.scopes = unique([...declaration.scopes ?? [], scope]);
2771
+ declarations.set(url, declaration);
2526
2772
  }
2527
2773
  /**
2528
2774
  * Find source URLs with multiple active aliases across the merged settings.
@@ -2551,9 +2797,9 @@ function findRegistryAliasConflicts(prefixesByUrl, namedRegistries, canonicalPre
2551
2797
  function normalizeNamedRegistries(settings, warnings, canonicalSettings) {
2552
2798
  const { namedRegistries } = settings;
2553
2799
  if (!namedRegistries) return;
2554
- const declarations = {};
2800
+ const declarations = /* @__PURE__ */ new Map();
2555
2801
  for (const [key, value] of Object.entries(settings.registries ?? {})) if (isString(value)) addRegistryScope(declarations, value, key === "default" ? "@" : key);
2556
- else declarations[key] = { ...value };
2802
+ else declarations.set(key, { ...value });
2557
2803
  const canonicalPrefixes = new Set(Object.values(canonicalSettings.registries ?? {}).flatMap((value) => !isString(value) && isString(value?.prefix) ? [value.prefix] : []));
2558
2804
  const prefixesByUrl = /* @__PURE__ */ new Map();
2559
2805
  const registryAliases = Object.entries(namedRegistries).filter(([prefix]) => !canonicalPrefixes.has(prefix));
@@ -2568,17 +2814,17 @@ function normalizeNamedRegistries(settings, warnings, canonicalSettings) {
2568
2814
  return;
2569
2815
  }
2570
2816
  for (const [url, [prefix]] of prefixesByUrl) {
2571
- declarations[url] ??= {};
2572
- const declaration = declarations[url];
2573
- 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;
2574
2819
  const declaredPrefix = (isString(canonicalDeclaration) ? void 0 : canonicalDeclaration?.prefix) ?? declaration.prefix;
2575
2820
  if (isString(declaredPrefix) && declaredPrefix !== prefix) {
2576
2821
  warnings.push(`namedRegistries was kept because ${url} already declares prefix ${declaredPrefix}.`);
2577
2822
  return;
2578
2823
  }
2579
2824
  declaration.prefix = prefix;
2825
+ declarations.set(url, declaration);
2580
2826
  }
2581
- settings.registries = declarations;
2827
+ settings.registries = Object.fromEntries(declarations);
2582
2828
  Reflect.deleteProperty(settings, "namedRegistries");
2583
2829
  }
2584
2830
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pnpm-settings-migrator",
3
- "version": "0.15.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",