pnpm-settings-migrator 0.19.0 → 0.20.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
@@ -333,6 +333,26 @@ destination write fails. Credentials and trusted global settings keep their
333
333
  existing protections. Earlier or unconfirmed targets retain incoming include
334
334
  indexes with diagnostics and reject existing included workspaces.
335
335
 
336
+ From pnpm 12.16.0, `strictSsl` and `runtime` accept booleans or null,
337
+ `localAddress` and `tag` accept strings or null, and `requiredScripts` accepts
338
+ string arrays or null. These settings remain in their sources for earlier or
339
+ unconfirmed v12 targets. `runtime: false` disables installing declared runtimes;
340
+ `tag: next` selects that distribution tag when adding a package without a version.
341
+ Invalid IP address strings stay unchanged because pnpm treats them as unset.
342
+ String placeholders are preserved without expansion. Invalid types keep the
343
+ complete setting in its source. For example, `required-scripts[]=build` becomes
344
+ `requiredScripts: [build]` at 12.16.0 and stays in `.npmrc` at 12.15.0.
345
+
346
+ From pnpm 11.28.9, v11 registry declarations also accept `serverType: pnpr`.
347
+ Earlier or unconfirmed v11 targets retain the complete registry map. The setting
348
+ does not enable v12-only registry fields such as `networkConcurrency` or `ecosystem`.
349
+
350
+ pnpm 12.16 no longer consumes `scriptsPrependNodePath`, so the migrator keeps
351
+ incoming values in their sources and rejects existing workspace values before
352
+ writes. Project `ci` values and the global-only `symlinkedLockfileTargetDirs`
353
+ also stay in their sources. Configure the latter in trusted global configuration
354
+ or through `PNPM_CONFIG_SYMLINKED_LOCKFILE_TARGET_DIRS`.
355
+
336
356
  pnpm v12 reads `filter` and `filterProd` from CLI arguments or `updateConfig`
337
357
  hooks, not workspace YAML. These settings remain in their source with
338
358
  diagnostics for v12 targets; existing YAML containing them blocks migration.
@@ -385,6 +405,24 @@ Notes:
385
405
  - Invalid registry locations, including relative paths and object property names
386
406
  such as `__proto__`, remain in their sources with diagnostics. Registry
387
407
  conversion never uses inherited object properties as declarations.
408
+ - Registry maps are validated as a whole, including declarations without
409
+ versioned fields. An invalid sibling
410
+ declaration keeps every registry in its source under all merge strategies;
411
+ existing incompatible maps block migration before any writes.
412
+ - A URL key must name a declaration object. String values are accepted only in
413
+ the legacy scope map representation, whose keys cannot look like URLs.
414
+ Invalid registry settings remain intact in `package.json` and `.npmrc`, while
415
+ unrelated valid settings can still migrate and be cleaned from their sources.
416
+ - Legacy `<scope>: <url>` routes and URL-keyed declarations cannot be mixed,
417
+ including when `replaceDeprecated` introduces the mixture. The structure is
418
+ checked independently of versioned registry fields.
419
+ - Registry prefixes must be unique. A scope may be repeated only when it routes
420
+ to the same registry after the target pnpm version's URL normalization. From
421
+ 12.11.1, the exact HTTPS and protocol-relative npm `.com` registry roots are
422
+ equivalent to `.org`; earlier or unconfirmed v12 targets compare them literally.
423
+ Routing conflicts introduced by merging or deprecated-setting conversion stop
424
+ migration before any destination writes
425
+ or source cleanup, including when registries belong to an included YAML file.
388
426
  - Dynamic `${...}` values in `httpProxy`, `httpsProxy`, `noProxy`, `proxy`, and
389
427
  `noproxy` stay in their source for every target. Project YAML does not expand
390
428
  these placeholders; configure them globally in trusted configuration or use
@@ -429,21 +467,29 @@ Notes:
429
467
  - The migrator does not update the `packageManager` version, CI environment variables,
430
468
  shell setup, or pnpm commands in scripts. When `packageManager` still pins pnpm 10,
431
469
  pass `--compatibility v11` explicitly and update the pin separately.
432
- - Compatibility checks cover pnpm 11.28.7 and 12.15.0, including 11.28.0,
470
+ - Compatibility checks cover pnpm 11.28.9 and 12.16.1, including 11.28.0,
433
471
  11.28.1, 11.28.2, 11.28.3, 11.28.4, 11.28.5, 11.28.6, 12.7.0, 12.8.0,
434
472
  12.8.1, 12.8.2, 12.9.0, 12.9.1, 12.10.0, 12.10.1, 12.11.0, 12.11.1,
435
- 12.11.2, 12.12.0, 12.12.1, 12.13.0, and 12.14.0.
473
+ 12.11.2, 12.12.0, 12.12.1, 12.13.0, 12.14.0, 11.28.7, 11.28.8,
474
+ 12.15.0, and 12.16.0.
436
475
  Earlier regressions remain at 11.25.0,
437
476
  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.
438
- As [audited on 2026-10-22](docs/research/pnpm-settings-bump-2026-10-22.md),
439
- npm `latest` and the highest confirmed stable release are 12.15.0, also on
440
- `latest-12` and `next-12`. The highest stable v11 release, 11.28.7, is on
441
- `next-11`; `latest-11` still points to 11.28.6. GitHub marks both targets as
442
- non-prereleases. The project is pinned to 12.15.0. These checks verify the
477
+ As [audited on 2026-10-29](docs/research/pnpm-settings-bump-2026-10-29.md),
478
+ npm `latest` and the highest confirmed stable release are 12.16.1, also on
479
+ `latest-12` and `next-12`. The highest stable v11 release, 11.28.9, is on
480
+ `latest-11` and `next-11`. GitHub marks both targets as non-prereleases.
481
+ The project is pinned to 12.16.1. These checks verify the
443
482
  documented fixtures, not every setting in every release. Local and config
444
483
  dependency `include` consumption is verified on 12.15.0, with 12.14.0 as the
445
484
  preceding boundary. v10 has unit regressions and source review
446
- through 10.34.7; real v10 consumption remains unverified.
485
+ through 10.34.9; real v10 consumption remains unverified. The 12.16 fixtures
486
+ verify TLS certificate handling against a temporary HTTPS registry, dist-tag
487
+ selection, runtime suppression, required script enforcement, and frozen
488
+ installs. They use OpenSSL to create a disposable certificate.
489
+ Routing fixtures at 11.28.8, 11.28.9, 12.9.0, 12.10.0, 12.11.0, 12.11.1,
490
+ 12.11.2, 12.15.0, and 12.16.1 verify prefix/scope conflicts, mixed registry
491
+ representations after conversion, and rejection before writes under all three
492
+ strategies. They compare native URL normalization across the 12.11.1 boundary.
447
493
  The removed `pnpm install --resolution-only` CLI flag is outside this settings
448
494
  migrator's scope; replace it with `pnpm peers check` in scripts before upgrading.
449
495
 
package/dist/cli.mjs CHANGED
@@ -335,6 +335,7 @@ const PNPM_PROJECT_REFUSED_SETTINGS = [
335
335
  "scope",
336
336
  "selectedProjectsGraph",
337
337
  "stateDir",
338
+ "symlinkedLockfileTargetDirs",
338
339
  "tools",
339
340
  "userConfig",
340
341
  "userconfig",
@@ -635,6 +636,14 @@ const PNPM_V12_11_MINIMUM_VERSION = [
635
636
  0
636
637
  ];
637
638
  /**
639
+ * Release canonicalizing npm's HTTPS and protocol-relative registry aliases.
640
+ */
641
+ const PNPM_V12_11_1_MINIMUM_VERSION = [
642
+ 12,
643
+ 11,
644
+ 1
645
+ ];
646
+ /**
638
647
  * Release introducing remote cache configuration and missing peer types.
639
648
  */
640
649
  const PNPM_V12_12_MINIMUM_VERSION = [
@@ -683,6 +692,32 @@ const PNPM_V12_15_MINIMUM_VERSION = [
683
692
  0
684
693
  ];
685
694
  /**
695
+ * Release introducing pnpr registry declarations in v11.
696
+ */
697
+ const PNPM_V11_28_9_MINIMUM_VERSION = [
698
+ 11,
699
+ 28,
700
+ 9
701
+ ];
702
+ /**
703
+ * Release consuming TLS, runtime, script requirements, and dist-tag settings.
704
+ */
705
+ const PNPM_V12_16_MINIMUM_VERSION = [
706
+ 12,
707
+ 16,
708
+ 0
709
+ ];
710
+ /**
711
+ * Workspace settings first consumed by pnpm v12 in 12.16.0.
712
+ */
713
+ const PNPM_V12_16_WORKSPACE_SETTINGS = [
714
+ "localAddress",
715
+ "requiredScripts",
716
+ "runtime",
717
+ "strictSsl",
718
+ "tag"
719
+ ];
720
+ /**
686
721
  * Workspace settings first consumed by pnpm v12 in 12.11.0.
687
722
  */
688
723
  const PNPM_V12_11_WORKSPACE_SETTINGS = [
@@ -708,6 +743,11 @@ const PNPM_V12_7_WORKSPACE_SETTINGS = [
708
743
  * @see https://github.com/pnpm/pnpm/blob/v12.4.0/pnpm/crates/config/src/workspace_yaml.rs
709
744
  */
710
745
  const PNPM_SETTINGS_CAPABILITIES = [
746
+ {
747
+ minimumVersion: PNPM_V12_16_MINIMUM_VERSION,
748
+ workspaceFields: PNPM_V12_16_WORKSPACE_SETTINGS,
749
+ taskFields: []
750
+ },
711
751
  {
712
752
  minimumVersion: PNPM_V11_28_7_MINIMUM_VERSION,
713
753
  workspaceFields: [
@@ -817,7 +857,8 @@ const PNPM_V12_ONLY_WORKSPACE_SETTINGS = [
817
857
  "globalShims"
818
858
  ];
819
859
  /**
820
- * Legacy fields absent from pnpm v12's workspace reader through 12.15.0.
860
+ * Legacy fields absent from pnpm v12's base workspace reader.
861
+ * Version capabilities restore fields consumed by later releases.
821
862
  * Removed aliases with explicit migration replacements are kept separately.
822
863
  *
823
864
  * @see https://github.com/pnpm/pnpm/blob/v12.15.0/pnpm/crates/config/src/workspace_yaml/settings.rs
@@ -829,6 +870,7 @@ const PNPM_V12_IGNORED_WORKSPACE_SETTINGS = [
829
870
  "auditIgnorePrune",
830
871
  "autoConfirmAllPrompts",
831
872
  "binLinks",
873
+ "ci",
832
874
  "commitHooks",
833
875
  "cpu",
834
876
  "depth",
@@ -1304,6 +1346,52 @@ function isRegistryUrl(value) {
1304
1346
  return isString(value) && URL.canParse(value.startsWith("//") ? `https:${value}` : value);
1305
1347
  }
1306
1348
  /**
1349
+ * Normalize a registry only for comparing scope routes, as the target does.
1350
+ * Prefix uniqueness deliberately uses the original declarations instead.
1351
+ *
1352
+ * @param registry - Registry URL already checked for valid syntax
1353
+ * @param target - Confirmed target controlling URL normalization
1354
+ *
1355
+ * @returns Registry identity used by the target's scope lookup
1356
+ */
1357
+ function normalizeScopeRegistry(registry, target) {
1358
+ let normalized = registry;
1359
+ if (target.compatibility === "v11" && URL.canParse(registry)) normalized = new URL(registry).toString();
1360
+ else if (supportsMinimumVersion(target.version, PNPM_V12_11_1_MINIMUM_VERSION)) normalized = registry.replace(/^(?<protocol>https:\/\/|\/\/)registry\.npmjs\.com\/?$/u, "$<protocol>registry.npmjs.org/");
1361
+ return normalized.endsWith("/") ? normalized : `${normalized}/`;
1362
+ }
1363
+ /**
1364
+ * Detect duplicate prefixes and scope routes pointing at different registries.
1365
+ * Called during source selection and final workspace validation before writes.
1366
+ *
1367
+ * @param key - Canonical setting name
1368
+ * @param value - Untrusted registry map
1369
+ * @param target - Release whose declaration routing rules apply
1370
+ *
1371
+ * @returns Whether declarations conflict with another route in the same map
1372
+ */
1373
+ function hasConflictingRegistryRoutes(key, value, target) {
1374
+ if (key !== "registries" || target.compatibility === "v10" || !isPlainObject(value)) return false;
1375
+ const prefixes = /* @__PURE__ */ new Set();
1376
+ const scopes = /* @__PURE__ */ new Map();
1377
+ return Object.entries(value).some(([registry, declaration]) => {
1378
+ if (!isPlainObject(declaration)) return false;
1379
+ const { prefix } = declaration;
1380
+ if (isString(prefix)) {
1381
+ if (prefixes.has(prefix)) return true;
1382
+ prefixes.add(prefix);
1383
+ }
1384
+ if (!isArray(declaration.scopes)) return false;
1385
+ const normalized = normalizeScopeRegistry(registry, target);
1386
+ return declaration.scopes.filter(isString).some((scope) => {
1387
+ const existing = scopes.get(scope);
1388
+ if (existing !== void 0 && existing !== normalized) return true;
1389
+ scopes.set(scope, normalized);
1390
+ return false;
1391
+ });
1392
+ });
1393
+ }
1394
+ /**
1307
1395
  * Validate locations in legacy registry maps and URL-keyed declarations.
1308
1396
  *
1309
1397
  * @param key - Canonical workspace setting name
@@ -1317,6 +1405,73 @@ function hasInvalidRegistryLocations(key, value) {
1317
1405
  if (key === "namedRegistries") return Object.values(value).some((url) => !isRegistryUrl(url));
1318
1406
  return Object.entries(value).some(([url, entry]) => isString(entry) ? !isRegistryUrl(entry) : !isRegistryUrl(url));
1319
1407
  }
1408
+ /**
1409
+ * Check the declared type of a non-null registry option.
1410
+ *
1411
+ * @param field - Registry declaration field name
1412
+ * @param value - Untrusted option value
1413
+ * @param target - Release controlling registry server types
1414
+ *
1415
+ * @returns Whether a nested option is invalid for pnpm 12.9
1416
+ */
1417
+ function hasInvalidRegistryOptionValue(field, value, target) {
1418
+ switch (field) {
1419
+ case "networkConcurrency": return !isNumber(value) || !Number.isSafeInteger(value) || value <= 0;
1420
+ case "ecosystem": return !isString(value) || ![
1421
+ "npm",
1422
+ "cargo",
1423
+ "pypi"
1424
+ ].includes(value);
1425
+ case "packages": return !isArray(value) || !value.every(isString);
1426
+ case "scopes": return !isArray(value) || value.some((scope) => !isString(scope) || !scope.startsWith("@"));
1427
+ case "prefix": return !isString(value);
1428
+ case "serverType": return !isString(value) || ![
1429
+ "npm",
1430
+ "artifactory",
1431
+ ...supportsMinimumVersion(target.version, PNPM_V12_15_MINIMUM_VERSION) || supportsMinimumVersion(target.version, PNPM_V11_28_9_MINIMUM_VERSION) ? ["pnpr"] : []
1432
+ ].includes(value);
1433
+ case "supportsTimeField": return !isBoolean(value);
1434
+ default: return true;
1435
+ }
1436
+ }
1437
+ /**
1438
+ * Validate every field in a registry declaration for the selected release.
1439
+ *
1440
+ * @param declaration - Registry options with or without versioned fields
1441
+ * @param target - Release controlling registry server types
1442
+ *
1443
+ * @returns Whether a nested option is unknown or invalid for the target
1444
+ */
1445
+ function hasInvalidRegistryDeclaration(declaration, target) {
1446
+ const ecosystem = Reflect.get(declaration, "ecosystem") ?? "npm";
1447
+ return Object.entries(declaration).some(([field, value]) => {
1448
+ if (!PNPM_V12_REGISTRY_DECLARATION_FIELDS.includes(field)) return true;
1449
+ if (value === null) return false;
1450
+ if (field === "packages" && ecosystem !== "pypi") return true;
1451
+ if ([
1452
+ "scopes",
1453
+ "prefix",
1454
+ "serverType",
1455
+ "supportsTimeField"
1456
+ ].includes(field) && ecosystem !== "npm") return true;
1457
+ return hasInvalidRegistryOptionValue(field, value, target);
1458
+ });
1459
+ }
1460
+ /**
1461
+ * Validate registry representation before release-specific declaration fields.
1462
+ * Pure legacy scope maps must use scope keys rather than registry URLs.
1463
+ *
1464
+ * @param value - Registry map already checked for valid locations
1465
+ * @param target - Release controlling declaration fields and enum values
1466
+ *
1467
+ * @returns Whether any entry prevents migration of the complete map
1468
+ */
1469
+ function hasIncompatibleRegistryValue(value, target) {
1470
+ const entries = Object.values(value);
1471
+ if (entries.every(isString)) return Object.keys(value).some((key) => key.includes("://") || key.startsWith("//"));
1472
+ if (!entries.every(isPlainObject)) return true;
1473
+ return entries.some((entry) => Object.hasOwn(entry, "networkConcurrency") && !supportsMinimumVersion(target.version, PNPM_V12_9_MINIMUM_VERSION) || hasInvalidRegistryDeclaration(entry, target));
1474
+ }
1320
1475
  //#endregion
1321
1476
  //#region src/features/settings/permissions.ts
1322
1477
  /**
@@ -1630,58 +1785,6 @@ function hasInvalidNodeLinkerOptions(value) {
1630
1785
  return Object.hasOwn(value, "excluded") && (!isArray(excluded) || !excluded.every(isString));
1631
1786
  }
1632
1787
  /**
1633
- * Check the declared type of a non-null registry option.
1634
- *
1635
- * @param field - Registry declaration field name
1636
- * @param value - Untrusted option value
1637
- * @param target - Release controlling registry server types
1638
- *
1639
- * @returns Whether a nested option is invalid for pnpm 12.9
1640
- */
1641
- function hasInvalidRegistryOptionValue(field, value, target) {
1642
- switch (field) {
1643
- case "networkConcurrency": return !isNumber(value) || !Number.isSafeInteger(value) || value <= 0;
1644
- case "ecosystem": return !isString(value) || ![
1645
- "npm",
1646
- "cargo",
1647
- "pypi"
1648
- ].includes(value);
1649
- case "packages": return !isArray(value) || !value.every(isString);
1650
- case "scopes": return !isArray(value) || value.some((scope) => !isString(scope) || !scope.startsWith("@"));
1651
- case "prefix": return !isString(value);
1652
- case "serverType": return !isString(value) || ![
1653
- "npm",
1654
- "artifactory",
1655
- ...supportsMinimumVersion(target.version, PNPM_V12_15_MINIMUM_VERSION) ? ["pnpr"] : []
1656
- ].includes(value);
1657
- case "supportsTimeField": return !isBoolean(value);
1658
- default: return true;
1659
- }
1660
- }
1661
- /**
1662
- * Validate a registry declaration that opts into per-registry concurrency.
1663
- *
1664
- * @param declaration - Registry options containing versioned fields
1665
- * @param target - Release controlling registry server types
1666
- *
1667
- * @returns Whether a nested option is invalid for pnpm 12.9
1668
- */
1669
- function hasInvalidRegistryConcurrencyDeclaration(declaration, target) {
1670
- const ecosystem = Reflect.get(declaration, "ecosystem") ?? "npm";
1671
- return Object.entries(declaration).some(([field, value]) => {
1672
- if (!PNPM_V12_REGISTRY_DECLARATION_FIELDS.includes(field)) return true;
1673
- if (value === null) return false;
1674
- if (field === "packages" && ecosystem !== "pypi") return true;
1675
- if ([
1676
- "scopes",
1677
- "prefix",
1678
- "serverType",
1679
- "supportsTimeField"
1680
- ].includes(field) && ecosystem !== "npm") return true;
1681
- return hasInvalidRegistryOptionValue(field, value, target);
1682
- });
1683
- }
1684
- /**
1685
1788
  * Validate scalar alternatives of the pnpm 12.10 linker and lockfile unions.
1686
1789
  *
1687
1790
  * @param key - Canonical setting name
@@ -1737,14 +1840,7 @@ function hasIncompatibleV12SettingValue(key, value, target) {
1737
1840
  if (!value || typeof value !== "object") return false;
1738
1841
  if (key === "nodeLinker") return !supportsV12_10 || hasInvalidNodeLinkerOptions(value);
1739
1842
  if (key === "lockfile") return hasInvalidLockfileOptions(value, target);
1740
- if (key === "registries") {
1741
- const entries = Object.values(value);
1742
- return entries.some((entry) => {
1743
- if (!entry || typeof entry !== "object" || !Object.hasOwn(entry, "networkConcurrency") && !(Object.hasOwn(entry, "serverType") && (Reflect.get(entry, "serverType") === "pnpr" || supportsMinimumVersion(target.version, PNPM_V12_15_MINIMUM_VERSION)))) return false;
1744
- if (Object.hasOwn(entry, "networkConcurrency") && !supportsMinimumVersion(target.version, PNPM_V12_9_MINIMUM_VERSION)) return true;
1745
- return entries.some(isString) || hasInvalidRegistryConcurrencyDeclaration(entry, target);
1746
- });
1747
- }
1843
+ if (key === "registries") return hasIncompatibleRegistryValue(value, target);
1748
1844
  return false;
1749
1845
  }
1750
1846
  //#endregion
@@ -1793,6 +1889,23 @@ function hasInvalidV11PatchValue(key, value, target) {
1793
1889
  return false;
1794
1890
  }
1795
1891
  /**
1892
+ * Validate the fields newly consumed or ignored by pnpm 12.16.
1893
+ *
1894
+ * @param key - Canonical setting name
1895
+ * @param value - Untrusted source value
1896
+ * @param target - Release controlling the workspace reader
1897
+ *
1898
+ * @returns Whether the setting cannot be represented for this target
1899
+ */
1900
+ function hasInvalidV12_16Value(key, value, target) {
1901
+ if (!supportsMinimumVersion(target.version, PNPM_V12_16_MINIMUM_VERSION)) return false;
1902
+ if (key === "scriptsPrependNodePath") return true;
1903
+ if (["strictSsl", "runtime"].includes(key)) return value !== null && !isBoolean(value);
1904
+ if (["localAddress", "tag"].includes(key)) return value !== null && !isString(value);
1905
+ if (key === "requiredScripts") return value !== null && (!isArray(value) || !value.every(isString));
1906
+ return false;
1907
+ }
1908
+ /**
1796
1909
  * Validate scalar settings introduced or first consumed in pnpm 12.6.
1797
1910
  *
1798
1911
  * @param key - Canonical setting name
@@ -1838,6 +1951,7 @@ function hasInvalidScalarValue(key, value, target) {
1838
1951
  * @returns Whether the value cannot be used by the target release
1839
1952
  */
1840
1953
  function hasInvalidReleaseValue(key, value, target) {
1954
+ if (hasInvalidV12_16Value(key, value, target)) return true;
1841
1955
  if (key === "globalShims" && supportsMinimumVersion(target.version, PNPM_V12_8_2_MINIMUM_VERSION)) return true;
1842
1956
  if (key === "userAgent" && isString(value) && value.includes("${") && (supportsMinimumVersion(target.version, PNPM_V11_28_MINIMUM_VERSION) || supportsMinimumVersion(target.version, PNPM_V12_7_MINIMUM_VERSION))) return true;
1843
1957
  if (key === "packages" && value !== null && value !== void 0 && supportsMinimumVersion(target.version, PNPM_V11_28_1_MINIMUM_VERSION)) return !isArray(value) || value.some((entry) => !isString(entry) || !entry);
@@ -2025,7 +2139,7 @@ function resolveSettingIssue({ allowedFields, target, key, npmrc, value }) {
2025
2139
  if (compatibility !== "v10" && PROJECT_REFUSED_SETTINGS.has(key)) return "refused";
2026
2140
  if (REGISTRY_SETTINGS.has(key) && containsUnsafeRegistryValue(value)) return "unsafe";
2027
2141
  if (PROXY_SETTINGS.has(key) && isString(value) && value.includes("${")) return "unsafe";
2028
- if (hasInvalidRegistryLocations(key, value) || hasUnmigratableRuntimeFields(key, value, compatibility) || hasIncompatibleSettingValue(key, value, target)) return "incompatible";
2142
+ if (hasInvalidRegistryLocations(key, value) || hasConflictingRegistryRoutes(key, value, target) || hasUnmigratableRuntimeFields(key, value, compatibility) || hasIncompatibleSettingValue(key, value, target)) return "incompatible";
2029
2143
  if (key === "tasks" && hasUnsupportedTaskFields(value, taskSettings)) return "incompatible";
2030
2144
  if (!workspaceSettings.has(key)) return isSettingFromAnotherVersion(key, compatibility) ? "incompatible" : "unknown";
2031
2145
  if (allowedFields && !allowedFields.has(key)) return "unsupported";
@@ -4288,6 +4402,7 @@ async function migratePnpmSettings(rawOptions = {}) {
4288
4402
  yarnResolutions: sources.packageJson.yarnResolutions
4289
4403
  })) return result;
4290
4404
  const pnpmWorkspaceResult = mergeIncludedWorkspace(workspace, incomingSettings, options.strategy);
4405
+ assertCompatibleWorkspaceSettings(pnpmWorkspaceResult, target);
4291
4406
  const workspaceFiles = workspace.include === void 0 ? [] : renderIncludedWorkspace(workspace, pnpmWorkspaceResult, {
4292
4407
  ...options,
4293
4408
  target
@@ -4368,7 +4483,7 @@ const bold = getColor("bold");
4368
4483
  //#endregion
4369
4484
  //#region package.json
4370
4485
  var name = "pnpm-settings-migrator";
4371
- var version = "0.19.0";
4486
+ var version = "0.20.0";
4372
4487
  //#endregion
4373
4488
  //#region src/cli/options.ts
4374
4489
  /**
package/dist/index.d.mts CHANGED
@@ -193,7 +193,7 @@ interface PnpmCargoSettings {
193
193
  */
194
194
  interface PnpmRegistryDeclaration extends Omit<RegistryDeclaration, 'serverType'> {
195
195
  /**
196
- * Registry protocol; pnpr tar registries require pnpm 12.15+.
196
+ * Registry protocol; pnpr requires pnpm 11.28.9+ or 12.15+.
197
197
  */
198
198
  serverType?: 'npm' | 'artifactory' | 'pnpr' | null;
199
199
  /**
@@ -268,6 +268,28 @@ interface PnpmSkillsSettings {
268
268
  * Settings supported by pnpm v12, subject to the resolved version capabilities.
269
269
  */
270
270
  interface PnpmSettingsV12 {
271
+ /**
272
+ * Verify registry TLS certificates (consumed by v12 from 12.16).
273
+ * Null leaves the upstream default effective.
274
+ */
275
+ strictSsl?: boolean | null;
276
+ /**
277
+ * Source IP for registry connections (consumed by v12 from 12.16).
278
+ * Invalid addresses are treated as unset by pnpm.
279
+ */
280
+ localAddress?: string | null;
281
+ /**
282
+ * Install declared runtimes unless false (consumed by v12 from 12.16).
283
+ */
284
+ runtime?: boolean | null;
285
+ /**
286
+ * Scripts every selected workspace project must define (v12.16+).
287
+ */
288
+ requiredScripts?: string[] | null;
289
+ /**
290
+ * Default distribution tag used when adding packages (v12.16+).
291
+ */
292
+ tag?: string | null;
271
293
  /**
272
294
  * Settings delegated to local YAML files or config dependencies (12.15+).
273
295
  * Wildcards are allowed only for local files.
@@ -648,7 +670,7 @@ interface PnpmSettingsV11 {
648
670
  /**
649
671
  * `pnpm-workspace.yaml` types.
650
672
  */
651
- type PnpmWorkspace = Omit<PnpmSettings, 'autoInstallPeersFromHighestMatch' | 'externalDependencies' | 'httpProxy' | 'httpsProxy' | 'lockfile' | 'nodeLinker' | 'provenance' | 'registries' | 'sideEffectsCache' | 'supportedArchitectures' | 'tasks'> & PnpmSettingsDeprecated & PnpmSettingsV11 & PnpmSettingsV12 & PnpmWorkspaceLegacy;
673
+ type PnpmWorkspace = Omit<PnpmSettings, 'autoInstallPeersFromHighestMatch' | 'externalDependencies' | 'httpProxy' | 'httpsProxy' | 'lockfile' | 'nodeLinker' | 'provenance' | 'registries' | 'requiredScripts' | 'sideEffectsCache' | 'supportedArchitectures' | 'tasks'> & PnpmSettingsDeprecated & PnpmSettingsV11 & PnpmSettingsV12 & PnpmWorkspaceLegacy;
652
674
  //#endregion
653
675
  //#region src/types/package-json.d.ts
654
676
  /**
package/dist/index.mjs CHANGED
@@ -333,6 +333,7 @@ const PNPM_PROJECT_REFUSED_SETTINGS = [
333
333
  "scope",
334
334
  "selectedProjectsGraph",
335
335
  "stateDir",
336
+ "symlinkedLockfileTargetDirs",
336
337
  "tools",
337
338
  "userConfig",
338
339
  "userconfig",
@@ -633,6 +634,14 @@ const PNPM_V12_11_MINIMUM_VERSION = [
633
634
  0
634
635
  ];
635
636
  /**
637
+ * Release canonicalizing npm's HTTPS and protocol-relative registry aliases.
638
+ */
639
+ const PNPM_V12_11_1_MINIMUM_VERSION = [
640
+ 12,
641
+ 11,
642
+ 1
643
+ ];
644
+ /**
636
645
  * Release introducing remote cache configuration and missing peer types.
637
646
  */
638
647
  const PNPM_V12_12_MINIMUM_VERSION = [
@@ -681,6 +690,32 @@ const PNPM_V12_15_MINIMUM_VERSION = [
681
690
  0
682
691
  ];
683
692
  /**
693
+ * Release introducing pnpr registry declarations in v11.
694
+ */
695
+ const PNPM_V11_28_9_MINIMUM_VERSION = [
696
+ 11,
697
+ 28,
698
+ 9
699
+ ];
700
+ /**
701
+ * Release consuming TLS, runtime, script requirements, and dist-tag settings.
702
+ */
703
+ const PNPM_V12_16_MINIMUM_VERSION = [
704
+ 12,
705
+ 16,
706
+ 0
707
+ ];
708
+ /**
709
+ * Workspace settings first consumed by pnpm v12 in 12.16.0.
710
+ */
711
+ const PNPM_V12_16_WORKSPACE_SETTINGS = [
712
+ "localAddress",
713
+ "requiredScripts",
714
+ "runtime",
715
+ "strictSsl",
716
+ "tag"
717
+ ];
718
+ /**
684
719
  * Workspace settings first consumed by pnpm v12 in 12.11.0.
685
720
  */
686
721
  const PNPM_V12_11_WORKSPACE_SETTINGS = [
@@ -706,6 +741,11 @@ const PNPM_V12_7_WORKSPACE_SETTINGS = [
706
741
  * @see https://github.com/pnpm/pnpm/blob/v12.4.0/pnpm/crates/config/src/workspace_yaml.rs
707
742
  */
708
743
  const PNPM_SETTINGS_CAPABILITIES = [
744
+ {
745
+ minimumVersion: PNPM_V12_16_MINIMUM_VERSION,
746
+ workspaceFields: PNPM_V12_16_WORKSPACE_SETTINGS,
747
+ taskFields: []
748
+ },
709
749
  {
710
750
  minimumVersion: PNPM_V11_28_7_MINIMUM_VERSION,
711
751
  workspaceFields: [
@@ -815,7 +855,8 @@ const PNPM_V12_ONLY_WORKSPACE_SETTINGS = [
815
855
  "globalShims"
816
856
  ];
817
857
  /**
818
- * Legacy fields absent from pnpm v12's workspace reader through 12.15.0.
858
+ * Legacy fields absent from pnpm v12's base workspace reader.
859
+ * Version capabilities restore fields consumed by later releases.
819
860
  * Removed aliases with explicit migration replacements are kept separately.
820
861
  *
821
862
  * @see https://github.com/pnpm/pnpm/blob/v12.15.0/pnpm/crates/config/src/workspace_yaml/settings.rs
@@ -827,6 +868,7 @@ const PNPM_V12_IGNORED_WORKSPACE_SETTINGS = [
827
868
  "auditIgnorePrune",
828
869
  "autoConfirmAllPrompts",
829
870
  "binLinks",
871
+ "ci",
830
872
  "commitHooks",
831
873
  "cpu",
832
874
  "depth",
@@ -1297,6 +1339,52 @@ function isRegistryUrl(value) {
1297
1339
  return isString(value) && URL.canParse(value.startsWith("//") ? `https:${value}` : value);
1298
1340
  }
1299
1341
  /**
1342
+ * Normalize a registry only for comparing scope routes, as the target does.
1343
+ * Prefix uniqueness deliberately uses the original declarations instead.
1344
+ *
1345
+ * @param registry - Registry URL already checked for valid syntax
1346
+ * @param target - Confirmed target controlling URL normalization
1347
+ *
1348
+ * @returns Registry identity used by the target's scope lookup
1349
+ */
1350
+ function normalizeScopeRegistry(registry, target) {
1351
+ let normalized = registry;
1352
+ if (target.compatibility === "v11" && URL.canParse(registry)) normalized = new URL(registry).toString();
1353
+ else if (supportsMinimumVersion(target.version, PNPM_V12_11_1_MINIMUM_VERSION)) normalized = registry.replace(/^(?<protocol>https:\/\/|\/\/)registry\.npmjs\.com\/?$/u, "$<protocol>registry.npmjs.org/");
1354
+ return normalized.endsWith("/") ? normalized : `${normalized}/`;
1355
+ }
1356
+ /**
1357
+ * Detect duplicate prefixes and scope routes pointing at different registries.
1358
+ * Called during source selection and final workspace validation before writes.
1359
+ *
1360
+ * @param key - Canonical setting name
1361
+ * @param value - Untrusted registry map
1362
+ * @param target - Release whose declaration routing rules apply
1363
+ *
1364
+ * @returns Whether declarations conflict with another route in the same map
1365
+ */
1366
+ function hasConflictingRegistryRoutes(key, value, target) {
1367
+ if (key !== "registries" || target.compatibility === "v10" || !isPlainObject(value)) return false;
1368
+ const prefixes = /* @__PURE__ */ new Set();
1369
+ const scopes = /* @__PURE__ */ new Map();
1370
+ return Object.entries(value).some(([registry, declaration]) => {
1371
+ if (!isPlainObject(declaration)) return false;
1372
+ const { prefix } = declaration;
1373
+ if (isString(prefix)) {
1374
+ if (prefixes.has(prefix)) return true;
1375
+ prefixes.add(prefix);
1376
+ }
1377
+ if (!isArray(declaration.scopes)) return false;
1378
+ const normalized = normalizeScopeRegistry(registry, target);
1379
+ return declaration.scopes.filter(isString).some((scope) => {
1380
+ const existing = scopes.get(scope);
1381
+ if (existing !== void 0 && existing !== normalized) return true;
1382
+ scopes.set(scope, normalized);
1383
+ return false;
1384
+ });
1385
+ });
1386
+ }
1387
+ /**
1300
1388
  * Validate locations in legacy registry maps and URL-keyed declarations.
1301
1389
  *
1302
1390
  * @param key - Canonical workspace setting name
@@ -1310,6 +1398,73 @@ function hasInvalidRegistryLocations(key, value) {
1310
1398
  if (key === "namedRegistries") return Object.values(value).some((url) => !isRegistryUrl(url));
1311
1399
  return Object.entries(value).some(([url, entry]) => isString(entry) ? !isRegistryUrl(entry) : !isRegistryUrl(url));
1312
1400
  }
1401
+ /**
1402
+ * Check the declared type of a non-null registry option.
1403
+ *
1404
+ * @param field - Registry declaration field name
1405
+ * @param value - Untrusted option value
1406
+ * @param target - Release controlling registry server types
1407
+ *
1408
+ * @returns Whether a nested option is invalid for pnpm 12.9
1409
+ */
1410
+ function hasInvalidRegistryOptionValue(field, value, target) {
1411
+ switch (field) {
1412
+ case "networkConcurrency": return !isNumber(value) || !Number.isSafeInteger(value) || value <= 0;
1413
+ case "ecosystem": return !isString(value) || ![
1414
+ "npm",
1415
+ "cargo",
1416
+ "pypi"
1417
+ ].includes(value);
1418
+ case "packages": return !isArray(value) || !value.every(isString);
1419
+ case "scopes": return !isArray(value) || value.some((scope) => !isString(scope) || !scope.startsWith("@"));
1420
+ case "prefix": return !isString(value);
1421
+ case "serverType": return !isString(value) || ![
1422
+ "npm",
1423
+ "artifactory",
1424
+ ...supportsMinimumVersion(target.version, PNPM_V12_15_MINIMUM_VERSION) || supportsMinimumVersion(target.version, PNPM_V11_28_9_MINIMUM_VERSION) ? ["pnpr"] : []
1425
+ ].includes(value);
1426
+ case "supportsTimeField": return !isBoolean(value);
1427
+ default: return true;
1428
+ }
1429
+ }
1430
+ /**
1431
+ * Validate every field in a registry declaration for the selected release.
1432
+ *
1433
+ * @param declaration - Registry options with or without versioned fields
1434
+ * @param target - Release controlling registry server types
1435
+ *
1436
+ * @returns Whether a nested option is unknown or invalid for the target
1437
+ */
1438
+ function hasInvalidRegistryDeclaration(declaration, target) {
1439
+ const ecosystem = Reflect.get(declaration, "ecosystem") ?? "npm";
1440
+ return Object.entries(declaration).some(([field, value]) => {
1441
+ if (!PNPM_V12_REGISTRY_DECLARATION_FIELDS.includes(field)) return true;
1442
+ if (value === null) return false;
1443
+ if (field === "packages" && ecosystem !== "pypi") return true;
1444
+ if ([
1445
+ "scopes",
1446
+ "prefix",
1447
+ "serverType",
1448
+ "supportsTimeField"
1449
+ ].includes(field) && ecosystem !== "npm") return true;
1450
+ return hasInvalidRegistryOptionValue(field, value, target);
1451
+ });
1452
+ }
1453
+ /**
1454
+ * Validate registry representation before release-specific declaration fields.
1455
+ * Pure legacy scope maps must use scope keys rather than registry URLs.
1456
+ *
1457
+ * @param value - Registry map already checked for valid locations
1458
+ * @param target - Release controlling declaration fields and enum values
1459
+ *
1460
+ * @returns Whether any entry prevents migration of the complete map
1461
+ */
1462
+ function hasIncompatibleRegistryValue(value, target) {
1463
+ const entries = Object.values(value);
1464
+ if (entries.every(isString)) return Object.keys(value).some((key) => key.includes("://") || key.startsWith("//"));
1465
+ if (!entries.every(isPlainObject)) return true;
1466
+ return entries.some((entry) => Object.hasOwn(entry, "networkConcurrency") && !supportsMinimumVersion(target.version, PNPM_V12_9_MINIMUM_VERSION) || hasInvalidRegistryDeclaration(entry, target));
1467
+ }
1313
1468
  //#endregion
1314
1469
  //#region src/features/settings/permissions.ts
1315
1470
  /**
@@ -1623,58 +1778,6 @@ function hasInvalidNodeLinkerOptions(value) {
1623
1778
  return Object.hasOwn(value, "excluded") && (!isArray(excluded) || !excluded.every(isString));
1624
1779
  }
1625
1780
  /**
1626
- * Check the declared type of a non-null registry option.
1627
- *
1628
- * @param field - Registry declaration field name
1629
- * @param value - Untrusted option value
1630
- * @param target - Release controlling registry server types
1631
- *
1632
- * @returns Whether a nested option is invalid for pnpm 12.9
1633
- */
1634
- function hasInvalidRegistryOptionValue(field, value, target) {
1635
- switch (field) {
1636
- case "networkConcurrency": return !isNumber(value) || !Number.isSafeInteger(value) || value <= 0;
1637
- case "ecosystem": return !isString(value) || ![
1638
- "npm",
1639
- "cargo",
1640
- "pypi"
1641
- ].includes(value);
1642
- case "packages": return !isArray(value) || !value.every(isString);
1643
- case "scopes": return !isArray(value) || value.some((scope) => !isString(scope) || !scope.startsWith("@"));
1644
- case "prefix": return !isString(value);
1645
- case "serverType": return !isString(value) || ![
1646
- "npm",
1647
- "artifactory",
1648
- ...supportsMinimumVersion(target.version, PNPM_V12_15_MINIMUM_VERSION) ? ["pnpr"] : []
1649
- ].includes(value);
1650
- case "supportsTimeField": return !isBoolean(value);
1651
- default: return true;
1652
- }
1653
- }
1654
- /**
1655
- * Validate a registry declaration that opts into per-registry concurrency.
1656
- *
1657
- * @param declaration - Registry options containing versioned fields
1658
- * @param target - Release controlling registry server types
1659
- *
1660
- * @returns Whether a nested option is invalid for pnpm 12.9
1661
- */
1662
- function hasInvalidRegistryConcurrencyDeclaration(declaration, target) {
1663
- const ecosystem = Reflect.get(declaration, "ecosystem") ?? "npm";
1664
- return Object.entries(declaration).some(([field, value]) => {
1665
- if (!PNPM_V12_REGISTRY_DECLARATION_FIELDS.includes(field)) return true;
1666
- if (value === null) return false;
1667
- if (field === "packages" && ecosystem !== "pypi") return true;
1668
- if ([
1669
- "scopes",
1670
- "prefix",
1671
- "serverType",
1672
- "supportsTimeField"
1673
- ].includes(field) && ecosystem !== "npm") return true;
1674
- return hasInvalidRegistryOptionValue(field, value, target);
1675
- });
1676
- }
1677
- /**
1678
1781
  * Validate scalar alternatives of the pnpm 12.10 linker and lockfile unions.
1679
1782
  *
1680
1783
  * @param key - Canonical setting name
@@ -1730,14 +1833,7 @@ function hasIncompatibleV12SettingValue(key, value, target) {
1730
1833
  if (!value || typeof value !== "object") return false;
1731
1834
  if (key === "nodeLinker") return !supportsV12_10 || hasInvalidNodeLinkerOptions(value);
1732
1835
  if (key === "lockfile") return hasInvalidLockfileOptions(value, target);
1733
- if (key === "registries") {
1734
- const entries = Object.values(value);
1735
- return entries.some((entry) => {
1736
- if (!entry || typeof entry !== "object" || !Object.hasOwn(entry, "networkConcurrency") && !(Object.hasOwn(entry, "serverType") && (Reflect.get(entry, "serverType") === "pnpr" || supportsMinimumVersion(target.version, PNPM_V12_15_MINIMUM_VERSION)))) return false;
1737
- if (Object.hasOwn(entry, "networkConcurrency") && !supportsMinimumVersion(target.version, PNPM_V12_9_MINIMUM_VERSION)) return true;
1738
- return entries.some(isString) || hasInvalidRegistryConcurrencyDeclaration(entry, target);
1739
- });
1740
- }
1836
+ if (key === "registries") return hasIncompatibleRegistryValue(value, target);
1741
1837
  return false;
1742
1838
  }
1743
1839
  //#endregion
@@ -1786,6 +1882,23 @@ function hasInvalidV11PatchValue(key, value, target) {
1786
1882
  return false;
1787
1883
  }
1788
1884
  /**
1885
+ * Validate the fields newly consumed or ignored by pnpm 12.16.
1886
+ *
1887
+ * @param key - Canonical setting name
1888
+ * @param value - Untrusted source value
1889
+ * @param target - Release controlling the workspace reader
1890
+ *
1891
+ * @returns Whether the setting cannot be represented for this target
1892
+ */
1893
+ function hasInvalidV12_16Value(key, value, target) {
1894
+ if (!supportsMinimumVersion(target.version, PNPM_V12_16_MINIMUM_VERSION)) return false;
1895
+ if (key === "scriptsPrependNodePath") return true;
1896
+ if (["strictSsl", "runtime"].includes(key)) return value !== null && !isBoolean(value);
1897
+ if (["localAddress", "tag"].includes(key)) return value !== null && !isString(value);
1898
+ if (key === "requiredScripts") return value !== null && (!isArray(value) || !value.every(isString));
1899
+ return false;
1900
+ }
1901
+ /**
1789
1902
  * Validate scalar settings introduced or first consumed in pnpm 12.6.
1790
1903
  *
1791
1904
  * @param key - Canonical setting name
@@ -1831,6 +1944,7 @@ function hasInvalidScalarValue(key, value, target) {
1831
1944
  * @returns Whether the value cannot be used by the target release
1832
1945
  */
1833
1946
  function hasInvalidReleaseValue(key, value, target) {
1947
+ if (hasInvalidV12_16Value(key, value, target)) return true;
1834
1948
  if (key === "globalShims" && supportsMinimumVersion(target.version, PNPM_V12_8_2_MINIMUM_VERSION)) return true;
1835
1949
  if (key === "userAgent" && isString(value) && value.includes("${") && (supportsMinimumVersion(target.version, PNPM_V11_28_MINIMUM_VERSION) || supportsMinimumVersion(target.version, PNPM_V12_7_MINIMUM_VERSION))) return true;
1836
1950
  if (key === "packages" && value !== null && value !== void 0 && supportsMinimumVersion(target.version, PNPM_V11_28_1_MINIMUM_VERSION)) return !isArray(value) || value.some((entry) => !isString(entry) || !entry);
@@ -2018,7 +2132,7 @@ function resolveSettingIssue({ allowedFields, target, key, npmrc, value }) {
2018
2132
  if (compatibility !== "v10" && PROJECT_REFUSED_SETTINGS.has(key)) return "refused";
2019
2133
  if (REGISTRY_SETTINGS.has(key) && containsUnsafeRegistryValue(value)) return "unsafe";
2020
2134
  if (PROXY_SETTINGS.has(key) && isString(value) && value.includes("${")) return "unsafe";
2021
- if (hasInvalidRegistryLocations(key, value) || hasUnmigratableRuntimeFields(key, value, compatibility) || hasIncompatibleSettingValue(key, value, target)) return "incompatible";
2135
+ if (hasInvalidRegistryLocations(key, value) || hasConflictingRegistryRoutes(key, value, target) || hasUnmigratableRuntimeFields(key, value, compatibility) || hasIncompatibleSettingValue(key, value, target)) return "incompatible";
2022
2136
  if (key === "tasks" && hasUnsupportedTaskFields(value, taskSettings)) return "incompatible";
2023
2137
  if (!workspaceSettings.has(key)) return isSettingFromAnotherVersion(key, compatibility) ? "incompatible" : "unknown";
2024
2138
  if (allowedFields && !allowedFields.has(key)) return "unsupported";
@@ -4281,6 +4395,7 @@ async function migratePnpmSettings(rawOptions = {}) {
4281
4395
  yarnResolutions: sources.packageJson.yarnResolutions
4282
4396
  })) return result;
4283
4397
  const pnpmWorkspaceResult = mergeIncludedWorkspace(workspace, incomingSettings, options.strategy);
4398
+ assertCompatibleWorkspaceSettings(pnpmWorkspaceResult, target);
4284
4399
  const workspaceFiles = workspace.include === void 0 ? [] : renderIncludedWorkspace(workspace, pnpmWorkspaceResult, {
4285
4400
  ...options,
4286
4401
  target
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pnpm-settings-migrator",
3
- "version": "0.19.0",
3
+ "version": "0.20.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",