pnpm-settings-migrator 0.8.0 → 0.8.1

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
@@ -57,8 +57,8 @@ Compatibility target for migrated settings:
57
57
  - `v10`: keep legacy settings as-is and migrate schema-aligned pnpm config keys from `.npmrc`
58
58
  - `v11`: normalize to v11-compatible settings (`allowBuilds`, `allowUnusedPatches`, etc.)
59
59
  and migrate all non auth/registry `.npmrc` entries to `pnpm-workspace.yaml`
60
- - `v12`: apply the same settings migration as `v11`. pnpm 12 keeps pnpm 11's
61
- settings and lockfile format.
60
+ - `v12`: use the same settings-key migration as `v11`. Lockfile migration and
61
+ validation are outside this tool's scope.
62
62
 
63
63
  In `v11` and `v12` modes, normalization is applied both to settings read from
64
64
  legacy files and to deprecated settings already present in
package/dist/cli.mjs CHANGED
@@ -1,87 +1,133 @@
1
1
  import process from "node:process";
2
- import { cac } from "cac";
3
2
  import consola$1, { consola } from "consola";
4
- import { resolve } from "pathe";
5
- import { Document, parse, stringify } from "yaml";
6
- import { access, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { cac } from "cac";
4
+ import { basename, dirname, join, resolve } from "pathe";
5
+ import { randomUUID } from "node:crypto";
6
+ import { access, chmod, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
7
+ import { fileURLToPath } from "node:url";
7
8
  import { getColor } from "consola/utils";
8
- import { isPlainObject, isUndefined, pick } from "@ntnyq/utils";
9
+ import { isPlainObject, isUndefined } from "@ntnyq/utils";
9
10
  import { defu } from "defu";
10
11
  import camelcaseKeys from "camelcase-keys";
11
12
  import { readIniFile } from "read-ini-file";
12
13
  import { kebabCase } from "uncase";
13
14
  import { isDeepStrictEqual } from "node:util";
15
+ import { Document, isMap, isNode, isScalar, isSeq, parseDocument, stringify } from "yaml";
14
16
  import detectIndent from "detect-indent";
15
17
  //#region package.json
16
18
  var name = "pnpm-settings-migrator";
17
- var version = "0.8.0";
19
+ var version = "0.8.1";
20
+ //#endregion
21
+ //#region src/cli-options.ts
22
+ /**
23
+ * Create the command-line parser shared by the executable and parser tests.
24
+ */
25
+ function createCli() {
26
+ const cli = cac(name).version(version).option("--cwd <cwd>", "Current working directory").option("--sort-keys", "Sort keys when write pnpm-workspace.yaml").option("--compatibility <compatibility>", "Compatibility target (auto, v10, v11, v12)").option("--replace-deprecated", "Replace deprecated pnpm settings with new ones and remove old keys").option("--strategy <strategy>", "Strategy to handle conflicts (discard, merge, overwrite)").option("--no-yarn-resolutions", "Disable migrating resolutions field in package.json").option("--no-newline-between", "Disable adding newlines between each root keys").option("--no-show-changes", "Disable showing settings changes").option("--no-clean-npmrc", "Disable removing pnpm settings in .npmrc file").option("--no-clean-package-json", "Disable removing pnpm fields in package.json");
27
+ cli.help((sections) => {
28
+ if (!(cli.commands.length > 0 && cli.commands.every((command) => command.isDefaultCommand))) return sections;
29
+ return sections.filter((section) => section.title !== "Commands" && !section.title?.startsWith("For more info"));
30
+ });
31
+ return cli;
32
+ }
18
33
  //#endregion
19
34
  //#region src/constants.ts
20
35
  const NPMRC = ".npmrc";
21
36
  const PACKAGE_JSON = "package.json";
22
37
  const PNPM_WORKSPACE_YAML = "pnpm-workspace.yaml";
23
38
  /**
24
- * @see {@link https://github.com/pnpm/pnpm/blob/main/packages/types/src/package.ts}
25
- * Schema-aligned pnpm config fields that can be migrated from `.npmrc`.
39
+ * @see {@link https://pnpm.io/10.x/settings}
40
+ * Documented pnpm v10 config fields that can be migrated from `.npmrc`.
26
41
  *
27
42
  * Intentionally excluded workspace manifest-only fields:
28
43
  * - `catalog`
29
- * - `catalogMode`
30
44
  * - `catalogs`
31
- * - `cleanupUnusedCatalogs`
32
45
  * - `packages`
33
46
  */
34
- const PNPM_SETTINGS_FIELDS = [
47
+ const PNPM_V10_NPMRC_SETTINGS_FIELDS = [
35
48
  "allowBuilds",
36
49
  "allowedDeprecatedVersions",
37
50
  "allowNonAppliedPatches",
38
51
  "allowUnusedPatches",
39
52
  "auditConfig",
53
+ "auditLevel",
54
+ "autoInstallPeers",
55
+ "blockExoticSubdeps",
56
+ "cacheDir",
57
+ "catalogMode",
58
+ "childConcurrency",
59
+ "ci",
60
+ "cleanupUnusedCatalogs",
61
+ "color",
40
62
  "configDependencies",
63
+ "dangerouslyAllowAllBuilds",
41
64
  "dedupeDirectDeps",
42
65
  "dedupeInjectedDeps",
43
66
  "dedupePeerDependents",
44
67
  "deployAllFiles",
68
+ "disallowWorkspaceCycles",
69
+ "dlxCacheMaxAge",
70
+ "enableGlobalVirtualStore",
71
+ "enableModulesDir",
72
+ "enablePrePostScripts",
45
73
  "engineStrict",
46
74
  "executionEnv",
75
+ "extendNodePath",
76
+ "failIfNoMatch",
47
77
  "fetchMinSpeedKiBps",
78
+ "fetchRetries",
48
79
  "fetchRetryFactor",
49
80
  "fetchRetryMaxtimeout",
50
81
  "fetchRetryMintimeout",
51
- "fetchRetryTimeout",
82
+ "fetchTimeout",
52
83
  "fetchWarnTimeoutMs",
53
84
  "forceLegacyDeploy",
85
+ "frozenLockfile",
86
+ "gitBranchLockfile",
54
87
  "gitChecks",
55
88
  "gitShallowHosts",
56
- "globalVirtualStore",
89
+ "globalBinDir",
90
+ "globalDir",
91
+ "globalPnpmfile",
57
92
  "globalVirtualStoreDir",
58
93
  "hoist",
59
94
  "hoistPattern",
60
95
  "hoistWorkspacePackages",
61
96
  "httpProxy",
62
97
  "httpsProxy",
98
+ "ignoreCompatibilityDb",
63
99
  "ignoredBuiltDependencies",
64
100
  "ignoreDepScripts",
65
- "ignoreDeps",
66
101
  "ignoredOptionalDependencies",
67
102
  "ignorePatchFailures",
103
+ "ignorePnpmfile",
104
+ "ignoreScripts",
68
105
  "ignoreWorkspaceCycles",
106
+ "ignoreWorkspaceRootCheck",
107
+ "includeWorkspaceRoot",
69
108
  "injectWorkspacePackages",
70
109
  "linkWorkspacePackages",
110
+ "localAddress",
71
111
  "lockfile",
72
112
  "lockfileDir",
113
+ "lockfileIncludeTarballUrl",
114
+ "loglevel",
73
115
  "managePackageManagerVersions",
116
+ "maxsockets",
74
117
  "mergeGitBranchLockfiles",
75
118
  "mergeGitBranchLockfilesBranchPattern",
119
+ "minimumReleaseAge",
120
+ "minimumReleaseAgeExclude",
76
121
  "modulesCacheMaxAge",
122
+ "modulesDir",
77
123
  "networkConcurrency",
78
124
  "neverBuiltDependencies",
79
- "nodeDownloadMirrors",
80
125
  "nodeLinker",
81
- "noproxy",
126
+ "nodeOptions",
127
+ "nodeVersion",
82
128
  "noProxy",
129
+ "noproxy",
83
130
  "npmPath",
84
- "npmrcAuthFile",
85
131
  "onlyBuiltDependencies",
86
132
  "onlyBuiltDependenciesFile",
87
133
  "optimisticRepeatInstall",
@@ -95,38 +141,46 @@ const PNPM_SETTINGS_FIELDS = [
95
141
  "peerDependencyRules",
96
142
  "peersSuffixMaxLength",
97
143
  "pnpmfile",
98
- "pnpmfileIgnorePkgManifest",
99
- "pmOnFail",
100
144
  "preferFrozenLockfile",
145
+ "preferSymlinkedExecutables",
101
146
  "preferWorkspacePackages",
102
147
  "production",
103
148
  "proxy",
104
149
  "publicHoistPattern",
105
150
  "recursiveInstall",
106
- "registries",
151
+ "registrySupportsTimeField",
107
152
  "requiredScripts",
108
153
  "resolutionMode",
109
154
  "resolvePeersFromWorkspaceRoot",
110
155
  "saveExact",
111
156
  "savePrefix",
157
+ "saveWorkspaceProtocol",
112
158
  "scriptShell",
113
159
  "shamefullyHoist",
114
160
  "sharedWorkspaceLockfile",
115
161
  "shellEmulator",
116
162
  "sideEffectsCache",
117
163
  "sideEffectsCacheReadonly",
164
+ "stateDir",
118
165
  "storeDir",
119
166
  "strictDepBuilds",
120
167
  "strictPeerDependencies",
121
168
  "strictSsl",
169
+ "strictStorePkgContentCheck",
122
170
  "supportedArchitectures",
171
+ "symlink",
123
172
  "syncInjectedDepsAfterScripts",
124
173
  "tag",
125
- "targetDependenciesField",
126
- "timeout",
174
+ "trustPolicy",
175
+ "trustPolicyExclude",
176
+ "trustPolicyIgnoreAfter",
177
+ "unsafePerm",
127
178
  "updateConfig",
179
+ "updateNotifier",
128
180
  "useBetaCli",
129
181
  "useNodeVersion",
182
+ "useRunningStoreServer",
183
+ "useStderr",
130
184
  "verifyDepsBeforeRun",
131
185
  "verifyStoreIntegrity",
132
186
  "virtualStoreDir",
@@ -156,7 +210,6 @@ const DEFAULT_OPTIONS = {
156
210
  cleanNpmrc: true,
157
211
  cleanPackageJson: true,
158
212
  compatibility: "auto",
159
- cwd: process.cwd(),
160
213
  newlineBetween: true,
161
214
  replaceDeprecated: false,
162
215
  showChanges: true,
@@ -210,7 +263,7 @@ function resolveOptions(options = {}) {
210
263
  return {
211
264
  cleanNpmrc: options.cleanNpmrc ?? DEFAULT_OPTIONS.cleanNpmrc,
212
265
  compatibility: resolveCompatibility(options.compatibility),
213
- cwd: options.cwd ?? DEFAULT_OPTIONS.cwd,
266
+ cwd: options.cwd ?? process.cwd(),
214
267
  newlineBetween: options.newlineBetween ?? DEFAULT_OPTIONS.newlineBetween,
215
268
  showChanges: options.showChanges ?? DEFAULT_OPTIONS.showChanges,
216
269
  sortKeys: options.sortKeys ?? DEFAULT_OPTIONS.sortKeys,
@@ -223,6 +276,28 @@ function resolveOptions(options = {}) {
223
276
  //#endregion
224
277
  //#region src/utils/fs.ts
225
278
  /**
279
+ * Modulus that isolates Unix permission bits from a stat mode.
280
+ */
281
+ const FILE_PERMISSION_MODULUS = 512;
282
+ /**
283
+ * Resolve a filesystem path to a string suitable for a sibling temp file.
284
+ */
285
+ function resolvePathString(path) {
286
+ if (path instanceof URL) return fileURLToPath(path);
287
+ return typeof path === "string" ? path : path.toString();
288
+ }
289
+ /**
290
+ * Read an existing file's permission bits, if the file exists.
291
+ */
292
+ async function resolveExistingMode(path) {
293
+ try {
294
+ return (await stat(path)).mode % FILE_PERMISSION_MODULUS;
295
+ } catch (error) {
296
+ if (error.code === "ENOENT") return;
297
+ throw error;
298
+ }
299
+ }
300
+ /**
226
301
  * Check whether a filesystem path exists.
227
302
  *
228
303
  * @param path - Filesystem path to check
@@ -261,10 +336,22 @@ async function fsRemoveFile(path) {
261
336
  * @returns A promise that resolves after the file is written
262
337
  */
263
338
  async function fsWriteFile(path, content) {
264
- await writeFile(path, `${content.trimEnd()}\n`, "utf-8");
339
+ const pathString = resolvePathString(path);
340
+ const temporaryPath = join(dirname(pathString), `.${basename(pathString)}.${process.pid}.${randomUUID()}.tmp`);
341
+ const mode = await resolveExistingMode(path);
342
+ try {
343
+ await writeFile(temporaryPath, `${content.trimEnd()}\n`, {
344
+ encoding: "utf-8",
345
+ mode
346
+ });
347
+ if (mode !== void 0) await chmod(temporaryPath, mode);
348
+ await rename(temporaryPath, pathString);
349
+ } finally {
350
+ await rm(temporaryPath, { force: true });
351
+ }
265
352
  }
266
- getColor("cyan");
267
- getColor("yellow");
353
+ //#endregion
354
+ //#region src/utils/color.ts
268
355
  /**
269
356
  * Apply dim terminal styling to a value.
270
357
  */
@@ -286,27 +373,9 @@ const bold = getColor("bold");
286
373
  */
287
374
  const magenta = getColor("magenta");
288
375
  //#endregion
289
- //#region src/utils/compatibility.ts
290
- /**
291
- * Legacy build settings that can be replaced for pnpm v10.
292
- */
293
- const PNPM_REPLACEABLE_IN_V10_SETTINGS = [
294
- "allowNonAppliedPatches",
295
- "ignoredBuiltDependencies",
296
- "neverBuiltDependencies",
297
- "onlyBuiltDependencies",
298
- "onlyBuiltDependenciesFile"
299
- ];
376
+ //#region src/utils/legacy-build-settings.ts
300
377
  /**
301
378
  * Read packages listed by the legacy `onlyBuiltDependenciesFile` setting.
302
- *
303
- * @param cwd - Working directory used to resolve the referenced file
304
- * @param file - Relative path stored in `onlyBuiltDependenciesFile`
305
- *
306
- * @returns Package names read from the referenced JSON file
307
- *
308
- * @throws {TypeError} When the referenced JSON value is not a string array
309
- * @throws {SyntaxError} When the referenced file contains invalid JSON
310
379
  */
311
380
  async function readOnlyBuiltDependenciesFile(cwd, file) {
312
381
  if (!file) return [];
@@ -316,21 +385,44 @@ async function readOnlyBuiltDependenciesFile(cwd, file) {
316
385
  return value;
317
386
  }
318
387
  /**
388
+ * Read and validate one legacy build dependency list. Configuration files are
389
+ * external input, so their runtime shape can differ from its declared type.
390
+ */
391
+ function readLegacyBuildDependencyList(settings, key) {
392
+ const value = settings[key];
393
+ if (value === void 0) return [];
394
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
395
+ const npmrcKey = key.replace(/[A-Z]/gu, (character) => `-${character.toLowerCase()}`);
396
+ throw new TypeError(`Invalid ${key}: expected an array of package names. In .npmrc, use ${npmrcKey}[]=<package>.`);
397
+ }
398
+ return value;
399
+ }
400
+ /**
319
401
  * Build an `allowBuilds` map from legacy build-script settings.
320
- *
321
- * @param incomingSettings - Settings containing legacy build declarations
322
- * @param cwd - Working directory used to resolve referenced files
323
- *
324
- * @returns Package-to-permission map, or `undefined` when no entries exist
325
402
  */
326
403
  async function collectAllowBuildsFromLegacy(incomingSettings, cwd) {
327
404
  const allowBuilds = {};
405
+ const allowed = readLegacyBuildDependencyList(incomingSettings, "onlyBuiltDependencies");
406
+ const ignored = readLegacyBuildDependencyList(incomingSettings, "ignoredBuiltDependencies");
407
+ const neverBuilt = readLegacyBuildDependencyList(incomingSettings, "neverBuiltDependencies");
328
408
  const allowedFromFile = await readOnlyBuiltDependenciesFile(cwd, incomingSettings.onlyBuiltDependenciesFile);
329
- for (const name of [...incomingSettings.onlyBuiltDependencies || [], ...allowedFromFile]) allowBuilds[name] = true;
330
- for (const name of incomingSettings.ignoredBuiltDependencies || []) allowBuilds[name] = false;
331
- for (const name of incomingSettings.neverBuiltDependencies || []) allowBuilds[name] = false;
409
+ for (const name of [...allowed, ...allowedFromFile]) allowBuilds[name] = true;
410
+ for (const name of ignored) allowBuilds[name] = false;
411
+ for (const name of neverBuilt) allowBuilds[name] = false;
332
412
  return Object.keys(allowBuilds).length ? allowBuilds : void 0;
333
413
  }
414
+ //#endregion
415
+ //#region src/utils/compatibility.ts
416
+ /**
417
+ * Legacy build settings that can be replaced for pnpm v10.
418
+ */
419
+ const PNPM_REPLACEABLE_IN_V10_SETTINGS = [
420
+ "allowNonAppliedPatches",
421
+ "ignoredBuiltDependencies",
422
+ "neverBuiltDependencies",
423
+ "onlyBuiltDependencies",
424
+ "onlyBuiltDependenciesFile"
425
+ ];
334
426
  /**
335
427
  * Resolve the pnpm v11 replacement for legacy package-manager settings.
336
428
  *
@@ -466,6 +558,20 @@ const PNPM_V11_MAJOR = 11;
466
558
  */
467
559
  const PNPM_V12_MAJOR = 12;
468
560
  /**
561
+ * Resolve a pnpm major from a package manager declaration.
562
+ */
563
+ function resolvePnpmMajor(hint) {
564
+ const match = hint?.match(/^pnpm@[\s<=>^~]*(?<major>\d+)(?:\.|\s|$)/u);
565
+ return match?.groups?.major ? Number(match.groups.major) : void 0;
566
+ }
567
+ /**
568
+ * Normalize the single-or-array devEngines declaration.
569
+ */
570
+ function normalizePackageManagers(value) {
571
+ if (Array.isArray(value)) return value;
572
+ return value ? [value] : [];
573
+ }
574
+ /**
469
575
  * Resolve final compatibility target from user option and package manager hint.
470
576
  *
471
577
  * @param compatibility - Explicit or automatically detected compatibility target
@@ -476,10 +582,11 @@ const PNPM_V12_MAJOR = 12;
476
582
  */
477
583
  function resolveCompatibilityTarget(compatibility, packageManager, devPackageManager) {
478
584
  if (compatibility !== "auto") return compatibility;
479
- const match = (packageManager || (devPackageManager?.name === "pnpm" ? `pnpm@${devPackageManager.version}` : void 0))?.match(/^pnpm@[\s<=>^~]*(?<major>\d+)(?:\.|\s|$)/u);
480
- const major = Number(match?.groups?.major);
481
- if (major >= PNPM_V12_MAJOR) return "v12";
482
- return major >= PNPM_V11_MAJOR ? "v11" : "v10";
585
+ const packageManagerMajor = resolvePnpmMajor(packageManager);
586
+ const devPackageManagerMajors = normalizePackageManagers(devPackageManager).filter((item) => item.name === "pnpm").map((item) => resolvePnpmMajor(`pnpm@${item.version ?? ""}`)).filter((major) => major !== void 0);
587
+ const major = packageManagerMajor ?? (devPackageManagerMajors.length ? Math.max(...devPackageManagerMajors) : void 0);
588
+ if (major !== void 0 && major >= PNPM_V12_MAJOR) return "v12";
589
+ return major !== void 0 && major >= PNPM_V11_MAJOR ? "v11" : "v10";
483
590
  }
484
591
  //#endregion
485
592
  //#region src/utils/merge.ts
@@ -622,9 +729,8 @@ function isNpmrcAuthOrRegistryKey(key) {
622
729
  /**
623
730
  * Remove pnpm-related settings from `.npmrc` file.
624
731
  *
625
- * This function reads the `.npmrc` file, filters out all lines that start with
626
- * pnpm-specific configuration keys (as defined in PNPM_SETTINGS_FIELDS), and
627
- * writes the cleaned content back to the file.
732
+ * This function reads the `.npmrc` file, removes lines whose exact normalized
733
+ * keys were migrated, and writes the cleaned content back to the file.
628
734
  *
629
735
  * @param path - Absolute path to the `.npmrc` file
630
736
  * @param compatibility - Concrete pnpm compatibility target
@@ -636,17 +742,15 @@ function isNpmrcAuthOrRegistryKey(key) {
636
742
  *
637
743
  * @example
638
744
  * ```ts
639
- * await pruneNpmrc('/path/to/.npmrc')
745
+ * await pruneNpmrc('/path/to/.npmrc', 'v10', ['node-linker'])
640
746
  * ```
641
747
  */
642
- async function pruneNpmrc(path, compatibility = "v10", migratedKeys = []) {
643
- const pnpmSettingsFields = PNPM_SETTINGS_FIELDS.map((field) => kebabCase(field));
748
+ async function pruneNpmrc(path, compatibility, migratedKeys) {
644
749
  const migratedKeySet = new Set(migratedKeys.map(normalizeNpmrcKey));
645
750
  const updatedContent = (await fsReadFile(path)).split(/\r?\n/u).filter((line) => {
646
751
  const key = getNpmrcLineKey(line);
647
752
  if (!key) return true;
648
- if (compatibility !== "v10") return !migratedKeySet.has(normalizeNpmrcKey(key));
649
- return !pnpmSettingsFields.some((field) => key.startsWith(field));
753
+ return !migratedKeySet.has(key);
650
754
  }).join("\n").trimEnd();
651
755
  if (compatibility !== "v10" && !updatedContent.trim()) {
652
756
  await fsRemoveFile(path);
@@ -671,10 +775,12 @@ async function pruneNpmrc(path, compatibility = "v10", migratedKeys = []) {
671
775
  async function readMigratableNpmrc(path, compatibility) {
672
776
  const raw = await readIniFile(path);
673
777
  if (compatibility === "v10") {
674
- const settings = camelcaseKeys(raw);
778
+ const pnpmSettingsFields = new Set(PNPM_V10_NPMRC_SETTINGS_FIELDS.map((field) => normalizeNpmrcKey(kebabCase(field))));
779
+ const keys = Object.keys(raw).filter((key) => pnpmSettingsFields.has(normalizeNpmrcKey(key)));
780
+ const migratable = Object.fromEntries(keys.map((key) => [key, raw[key]]));
675
781
  return {
676
- keys: PNPM_SETTINGS_FIELDS,
677
- settings: pick(settings, PNPM_SETTINGS_FIELDS)
782
+ keys,
783
+ settings: camelcaseKeys(migratable)
678
784
  };
679
785
  }
680
786
  const migratable = {};
@@ -696,6 +802,15 @@ async function readMigratableNpmrc(path, compatibility) {
696
802
  //#endregion
697
803
  //#region src/utils/settings-change.ts
698
804
  /**
805
+ * Maximum dynamic-programming cells used for an exact line diff.
806
+ */
807
+ const MAX_LCS_CELLS = 1e6;
808
+ /**
809
+ * URL userinfo can contain proxy usernames and passwords. It must never be
810
+ * printed in a migration report.
811
+ */
812
+ const URL_USERINFO_PATTERN = /(?<scheme>[a-z][a-z\d+.-]*:\/\/)[^/\s@]+@/giu;
813
+ /**
699
814
  * Collect changed root settings between two workspace configurations.
700
815
  *
701
816
  * @param before - Workspace settings before migration
@@ -714,6 +829,38 @@ function collectSettingsChanges(before, after) {
714
829
  }));
715
830
  }
716
831
  /**
832
+ * Create a bounded-memory diff that retains shared prefixes and suffixes.
833
+ *
834
+ * This fallback deliberately gives up the exact LCS for unusually large
835
+ * settings to keep reporting from exhausting memory after migration writes.
836
+ */
837
+ function createLinearLineDiff(beforeLines, afterLines) {
838
+ let prefixLength = 0;
839
+ const maximumPrefixLength = Math.min(beforeLines.length, afterLines.length);
840
+ while (prefixLength < maximumPrefixLength && beforeLines[prefixLength] === afterLines[prefixLength]) prefixLength++;
841
+ let suffixLength = 0;
842
+ const maximumSuffixLength = maximumPrefixLength - prefixLength;
843
+ while (suffixLength < maximumSuffixLength && beforeLines[beforeLines.length - suffixLength - 1] === afterLines[afterLines.length - suffixLength - 1]) suffixLength++;
844
+ return [
845
+ ...beforeLines.slice(0, prefixLength).map((value) => ({
846
+ kind: "unchanged",
847
+ value
848
+ })),
849
+ ...beforeLines.slice(prefixLength, beforeLines.length - suffixLength).map((value) => ({
850
+ kind: "removed",
851
+ value
852
+ })),
853
+ ...afterLines.slice(prefixLength, afterLines.length - suffixLength).map((value) => ({
854
+ kind: "added",
855
+ value
856
+ })),
857
+ ...beforeLines.slice(beforeLines.length - suffixLength).map((value) => ({
858
+ kind: "unchanged",
859
+ value
860
+ }))
861
+ ];
862
+ }
863
+ /**
717
864
  * Create a line-based diff using the longest common subsequence.
718
865
  *
719
866
  * @param beforeLines - YAML lines before migration
@@ -722,6 +869,7 @@ function collectSettingsChanges(before, after) {
722
869
  * @returns Classified diff lines in display order
723
870
  */
724
871
  function createLineDiff(beforeLines, afterLines) {
872
+ if (beforeLines.length * afterLines.length > MAX_LCS_CELLS) return createLinearLineDiff(beforeLines, afterLines);
725
873
  const lengths = Array.from({ length: beforeLines.length + 1 }, () => Array(afterLines.length + 1).fill(0));
726
874
  for (let beforeIndex = beforeLines.length - 1; beforeIndex >= 0; beforeIndex--) for (let afterIndex = afterLines.length - 1; afterIndex >= 0; afterIndex--) lengths[beforeIndex][afterIndex] = beforeLines[beforeIndex] === afterLines[afterIndex] ? lengths[beforeIndex + 1][afterIndex + 1] + 1 : Math.max(lengths[beforeIndex + 1][afterIndex], lengths[beforeIndex][afterIndex + 1]);
727
875
  const diff = [];
@@ -776,6 +924,17 @@ function formatSettingLines(key, value) {
776
924
  return stringify({ [key]: value }).trimEnd().split("\n");
777
925
  }
778
926
  /**
927
+ * Remove credentials embedded in URLs while preserving enough context to
928
+ * identify the changed setting.
929
+ *
930
+ * @param value - Rendered YAML diff line
931
+ *
932
+ * @returns Diff line safe to print to a terminal or CI log
933
+ */
934
+ function redactUrlCredentials(value) {
935
+ return value.replace(URL_USERINFO_PATTERN, "$<scheme>***@");
936
+ }
937
+ /**
779
938
  * Format a setting change as a GitHub-style YAML diff.
780
939
  *
781
940
  * @param change - Root setting change to format
@@ -783,7 +942,10 @@ function formatSettingLines(key, value) {
783
942
  * @returns Classified YAML diff lines
784
943
  */
785
944
  function createSettingsDiffLines(change) {
786
- return createLineDiff(formatSettingLines(change.key, change.before), formatSettingLines(change.key, change.after));
945
+ return createLineDiff(formatSettingLines(change.key, change.before), formatSettingLines(change.key, change.after)).map((line) => ({
946
+ kind: line.kind,
947
+ value: redactUrlCredentials(line.value)
948
+ }));
787
949
  }
788
950
  /**
789
951
  * Print a summary and GitHub-style YAML diff for every changed setting.
@@ -802,6 +964,39 @@ function reportSettingsChanges(changes) {
802
964
  }
803
965
  }
804
966
  //#endregion
967
+ //#region src/utils/yaml-document.ts
968
+ /**
969
+ * Sort every mapping in a YAML node without replacing its comments or anchors.
970
+ */
971
+ function sortYamlMappings(node) {
972
+ if (isMap(node)) {
973
+ node.items.sort((left, right) => {
974
+ const leftKey = isScalar(left.key) ? String(left.key.value) : "";
975
+ const rightKey = isScalar(right.key) ? String(right.key.value) : "";
976
+ return leftKey.localeCompare(rightKey);
977
+ });
978
+ for (const pair of node.items) if (isNode(pair.value)) sortYamlMappings(pair.value);
979
+ } else if (isSeq(node)) {
980
+ for (const item of node.items) if (isNode(item)) sortYamlMappings(item);
981
+ }
982
+ }
983
+ /**
984
+ * Apply semantic root changes to the parsed document while retaining untouched
985
+ * YAML nodes and their source metadata.
986
+ */
987
+ function updateYamlDocument(document, options) {
988
+ const { after, before, sortKeys } = options;
989
+ for (const key of Object.keys(before)) if (!Object.hasOwn(after, key)) document.delete(key);
990
+ for (const [key, value] of Object.entries(after)) if (!Object.hasOwn(before, key) || !isDeepStrictEqual(Reflect.get(before, key), value)) document.set(key, value);
991
+ if (sortKeys) sortYamlMappings(document.contents);
992
+ }
993
+ /**
994
+ * Normalize blank lines immediately before root mapping keys.
995
+ */
996
+ function formatRootSpacing(content, newlineBetween) {
997
+ return content.replace(/\n+(?=[^\s#][^:\n]*:)/gu, newlineBetween ? "\n\n" : "\n");
998
+ }
999
+ //#endregion
805
1000
  //#region src/utils/config.ts
806
1001
  /**
807
1002
  * Resolve the indentation width for a YAML document.
@@ -847,13 +1042,21 @@ async function readPackageJson(path, exists) {
847
1042
  */
848
1043
  async function readPnpmWorkspace(path, exists) {
849
1044
  if (!exists) return {
1045
+ document: new Document({}),
850
1046
  indent: 2,
851
1047
  value: {}
852
1048
  };
853
1049
  const content = await fsReadFile(path);
1050
+ const document = parseDocument(content);
1051
+ const [parseError] = document.errors;
1052
+ if (parseError) throw parseError;
1053
+ const parsedValue = document.toJS();
1054
+ if (parsedValue !== null && !isMap(document.contents)) throw new TypeError("pnpm-workspace.yaml must contain a root mapping.");
1055
+ if (parsedValue === null) document.contents = document.createNode({});
854
1056
  return {
1057
+ document,
855
1058
  indent: resolveYamlIndent(content),
856
- value: parse(content) ?? {}
1059
+ value: parsedValue ?? {}
857
1060
  };
858
1061
  }
859
1062
  /**
@@ -954,11 +1157,7 @@ async function migratePnpmSettings(rawOptions = {}) {
954
1157
  settings: {}
955
1158
  };
956
1159
  const pnpmSettingsInNpmrc = npmrcMigratable.settings;
957
- const pnpmSettingsInPackageJson = resolvePackageJsonSettings(packageJson.value, options.yarnResolutions);
958
- const incomingSettings = {
959
- ...pnpmSettingsInNpmrc,
960
- ...pnpmSettingsInPackageJson
961
- };
1160
+ const incomingSettings = mergeByStrategy(resolvePackageJsonSettings(packageJson.value, options.yarnResolutions), pnpmSettingsInNpmrc, "merge");
962
1161
  const [existingNormalization, incomingNormalization] = await Promise.all([normalizeIncomingSettings(pnpmWorkspace.value, {
963
1162
  compatibility,
964
1163
  cwd: options.cwd,
@@ -984,16 +1183,12 @@ async function migratePnpmSettings(rawOptions = {}) {
984
1183
  return;
985
1184
  }
986
1185
  const pnpmWorkspaceResult = mergeByStrategy(pnpmWorkspace.value, incomingSettings, options.strategy);
987
- const yamlDocument = new Document({}, { sortMapEntries: options.sortKeys });
988
- Object.entries(pnpmWorkspaceResult).forEach(([key, value]) => {
989
- yamlDocument.add({
990
- key,
991
- value
992
- });
1186
+ updateYamlDocument(pnpmWorkspace.document, {
1187
+ after: pnpmWorkspaceResult,
1188
+ before: pnpmWorkspaceBefore,
1189
+ sortKeys: options.sortKeys
993
1190
  });
994
- const yamlContent = yamlDocument.toString({ indent: pnpmWorkspace.indent });
995
- await fsWriteFile(pnpmWorkspaceYamlPath, options.newlineBetween ? yamlContent.replace(/\n(?=[^\s#][^:\n]*:)/gu, "\n\n") : yamlContent);
996
- if (npmrcExists && options.cleanNpmrc) await pruneNpmrc(npmrcPath, compatibility, npmrcMigratable.keys);
1191
+ const finalYamlContent = formatRootSpacing(pnpmWorkspace.document.toString({ indent: pnpmWorkspace.indent }), options.newlineBetween);
997
1192
  let packageJsonChanged = runtimeMigration.changed;
998
1193
  if (packageJsonExists && options.cleanPackageJson) {
999
1194
  if (packageJson.value.pnpm) packageJsonChanged = true;
@@ -1003,7 +1198,9 @@ async function migratePnpmSettings(rawOptions = {}) {
1003
1198
  delete packageJson.value.resolutions;
1004
1199
  }
1005
1200
  }
1201
+ await fsWriteFile(pnpmWorkspaceYamlPath, finalYamlContent);
1006
1202
  if (packageJsonExists && packageJsonChanged) await fsWriteFile(packageJsonPath, JSON.stringify(packageJson.value, null, packageJson.indent));
1203
+ if (npmrcExists && options.cleanNpmrc) await pruneNpmrc(npmrcPath, compatibility, npmrcMigratable.keys);
1007
1204
  reportMigrationChanges(options.showChanges, pnpmWorkspaceBefore, pnpmWorkspaceResult);
1008
1205
  } catch (err) {
1009
1206
  consola$1.error("Failed to migrate pnpm settings:", err);
@@ -1012,9 +1209,8 @@ async function migratePnpmSettings(rawOptions = {}) {
1012
1209
  }
1013
1210
  //#endregion
1014
1211
  //#region src/cli.ts
1015
- const cli = cac(name);
1016
- cli.version(version).option("--cwd [cwd]", "Current working directory").option("--sort-keys", "Sort keys when write pnpm-workspace.yaml").option("--compatibility <compatibility>", "Compatibility target (auto, v10, v11, v12)").option("--replace-deprecated", "Replace deprecated pnpm settings with new ones and remove old keys").option("--strategy <strategy>", "Strategy to handle conflicts (discard, merge, overwrite)").option("--no-yarn-resolutions", "Disable migrating resolutions field in package.json").option("--no-newline-between", "Disable adding newlines between each root keys").option("--no-show-changes", "Disable showing settings changes").option("--no-clean-npmrc", "Disable removing pnpm settings in .npmrc file").option("--no-clean-package-json", "Disable removing pnpm fields in package.json").help();
1017
- cli.command("").action(async (options) => {
1212
+ const cli = createCli();
1213
+ cli.command("").usage("[options]").action(async (options) => {
1018
1214
  try {
1019
1215
  consola.log(`\n${bold(magenta(name))} ${dim(`v${version}`)}`);
1020
1216
  consola.log(dim("\n--------------\n"));
package/dist/index.d.mts CHANGED
@@ -91,7 +91,7 @@ interface PackageJson {
91
91
  * Development tool declarations.
92
92
  */
93
93
  devEngines?: {
94
- packageManager?: PackageManagerEngine;
94
+ packageManager?: PackageManagerEngine | PackageManagerEngine[];
95
95
  runtime?: RuntimeEngine | RuntimeEngine[];
96
96
  [key: string]: unknown;
97
97
  };
@@ -128,7 +128,7 @@ type NpmRC = Record<string, any>;
128
128
  interface PackageManagerEngine {
129
129
  name: string;
130
130
  onFail?: 'download' | 'error' | 'ignore' | 'warn';
131
- version: string;
131
+ version?: string;
132
132
  }
133
133
  /**
134
134
  * Runtime declaration stored in `package.json#devEngines.runtime`.
package/dist/index.mjs CHANGED
@@ -1,82 +1,115 @@
1
1
  import consola from "consola";
2
- import { resolve } from "pathe";
3
- import { Document, parse, stringify } from "yaml";
2
+ import { basename, dirname, join, resolve } from "pathe";
4
3
  import process from "node:process";
5
- import { access, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { randomUUID } from "node:crypto";
5
+ import { access, chmod, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { fileURLToPath } from "node:url";
6
7
  import { getColor } from "consola/utils";
7
- import { isPlainObject, isUndefined, pick } from "@ntnyq/utils";
8
+ import { isPlainObject, isUndefined } from "@ntnyq/utils";
8
9
  import { defu } from "defu";
9
10
  import camelcaseKeys from "camelcase-keys";
10
11
  import { readIniFile } from "read-ini-file";
11
12
  import { kebabCase } from "uncase";
12
13
  import { isDeepStrictEqual } from "node:util";
14
+ import { Document, isMap, isNode, isScalar, isSeq, parseDocument, stringify } from "yaml";
13
15
  import detectIndent from "detect-indent";
14
16
  //#region src/constants.ts
15
17
  const NPMRC = ".npmrc";
16
18
  const PACKAGE_JSON = "package.json";
17
19
  const PNPM_WORKSPACE_YAML = "pnpm-workspace.yaml";
18
20
  /**
19
- * @see {@link https://github.com/pnpm/pnpm/blob/main/packages/types/src/package.ts}
20
- * Schema-aligned pnpm config fields that can be migrated from `.npmrc`.
21
+ * @see {@link https://pnpm.io/10.x/settings}
22
+ * Documented pnpm v10 config fields that can be migrated from `.npmrc`.
21
23
  *
22
24
  * Intentionally excluded workspace manifest-only fields:
23
25
  * - `catalog`
24
- * - `catalogMode`
25
26
  * - `catalogs`
26
- * - `cleanupUnusedCatalogs`
27
27
  * - `packages`
28
28
  */
29
- const PNPM_SETTINGS_FIELDS = [
29
+ const PNPM_V10_NPMRC_SETTINGS_FIELDS = [
30
30
  "allowBuilds",
31
31
  "allowedDeprecatedVersions",
32
32
  "allowNonAppliedPatches",
33
33
  "allowUnusedPatches",
34
34
  "auditConfig",
35
+ "auditLevel",
36
+ "autoInstallPeers",
37
+ "blockExoticSubdeps",
38
+ "cacheDir",
39
+ "catalogMode",
40
+ "childConcurrency",
41
+ "ci",
42
+ "cleanupUnusedCatalogs",
43
+ "color",
35
44
  "configDependencies",
45
+ "dangerouslyAllowAllBuilds",
36
46
  "dedupeDirectDeps",
37
47
  "dedupeInjectedDeps",
38
48
  "dedupePeerDependents",
39
49
  "deployAllFiles",
50
+ "disallowWorkspaceCycles",
51
+ "dlxCacheMaxAge",
52
+ "enableGlobalVirtualStore",
53
+ "enableModulesDir",
54
+ "enablePrePostScripts",
40
55
  "engineStrict",
41
56
  "executionEnv",
57
+ "extendNodePath",
58
+ "failIfNoMatch",
42
59
  "fetchMinSpeedKiBps",
60
+ "fetchRetries",
43
61
  "fetchRetryFactor",
44
62
  "fetchRetryMaxtimeout",
45
63
  "fetchRetryMintimeout",
46
- "fetchRetryTimeout",
64
+ "fetchTimeout",
47
65
  "fetchWarnTimeoutMs",
48
66
  "forceLegacyDeploy",
67
+ "frozenLockfile",
68
+ "gitBranchLockfile",
49
69
  "gitChecks",
50
70
  "gitShallowHosts",
51
- "globalVirtualStore",
71
+ "globalBinDir",
72
+ "globalDir",
73
+ "globalPnpmfile",
52
74
  "globalVirtualStoreDir",
53
75
  "hoist",
54
76
  "hoistPattern",
55
77
  "hoistWorkspacePackages",
56
78
  "httpProxy",
57
79
  "httpsProxy",
80
+ "ignoreCompatibilityDb",
58
81
  "ignoredBuiltDependencies",
59
82
  "ignoreDepScripts",
60
- "ignoreDeps",
61
83
  "ignoredOptionalDependencies",
62
84
  "ignorePatchFailures",
85
+ "ignorePnpmfile",
86
+ "ignoreScripts",
63
87
  "ignoreWorkspaceCycles",
88
+ "ignoreWorkspaceRootCheck",
89
+ "includeWorkspaceRoot",
64
90
  "injectWorkspacePackages",
65
91
  "linkWorkspacePackages",
92
+ "localAddress",
66
93
  "lockfile",
67
94
  "lockfileDir",
95
+ "lockfileIncludeTarballUrl",
96
+ "loglevel",
68
97
  "managePackageManagerVersions",
98
+ "maxsockets",
69
99
  "mergeGitBranchLockfiles",
70
100
  "mergeGitBranchLockfilesBranchPattern",
101
+ "minimumReleaseAge",
102
+ "minimumReleaseAgeExclude",
71
103
  "modulesCacheMaxAge",
104
+ "modulesDir",
72
105
  "networkConcurrency",
73
106
  "neverBuiltDependencies",
74
- "nodeDownloadMirrors",
75
107
  "nodeLinker",
76
- "noproxy",
108
+ "nodeOptions",
109
+ "nodeVersion",
77
110
  "noProxy",
111
+ "noproxy",
78
112
  "npmPath",
79
- "npmrcAuthFile",
80
113
  "onlyBuiltDependencies",
81
114
  "onlyBuiltDependenciesFile",
82
115
  "optimisticRepeatInstall",
@@ -90,38 +123,46 @@ const PNPM_SETTINGS_FIELDS = [
90
123
  "peerDependencyRules",
91
124
  "peersSuffixMaxLength",
92
125
  "pnpmfile",
93
- "pnpmfileIgnorePkgManifest",
94
- "pmOnFail",
95
126
  "preferFrozenLockfile",
127
+ "preferSymlinkedExecutables",
96
128
  "preferWorkspacePackages",
97
129
  "production",
98
130
  "proxy",
99
131
  "publicHoistPattern",
100
132
  "recursiveInstall",
101
- "registries",
133
+ "registrySupportsTimeField",
102
134
  "requiredScripts",
103
135
  "resolutionMode",
104
136
  "resolvePeersFromWorkspaceRoot",
105
137
  "saveExact",
106
138
  "savePrefix",
139
+ "saveWorkspaceProtocol",
107
140
  "scriptShell",
108
141
  "shamefullyHoist",
109
142
  "sharedWorkspaceLockfile",
110
143
  "shellEmulator",
111
144
  "sideEffectsCache",
112
145
  "sideEffectsCacheReadonly",
146
+ "stateDir",
113
147
  "storeDir",
114
148
  "strictDepBuilds",
115
149
  "strictPeerDependencies",
116
150
  "strictSsl",
151
+ "strictStorePkgContentCheck",
117
152
  "supportedArchitectures",
153
+ "symlink",
118
154
  "syncInjectedDepsAfterScripts",
119
155
  "tag",
120
- "targetDependenciesField",
121
- "timeout",
156
+ "trustPolicy",
157
+ "trustPolicyExclude",
158
+ "trustPolicyIgnoreAfter",
159
+ "unsafePerm",
122
160
  "updateConfig",
161
+ "updateNotifier",
123
162
  "useBetaCli",
124
163
  "useNodeVersion",
164
+ "useRunningStoreServer",
165
+ "useStderr",
125
166
  "verifyDepsBeforeRun",
126
167
  "verifyStoreIntegrity",
127
168
  "virtualStoreDir",
@@ -151,7 +192,6 @@ const DEFAULT_OPTIONS = {
151
192
  cleanNpmrc: true,
152
193
  cleanPackageJson: true,
153
194
  compatibility: "auto",
154
- cwd: process.cwd(),
155
195
  newlineBetween: true,
156
196
  replaceDeprecated: false,
157
197
  showChanges: true,
@@ -205,7 +245,7 @@ function resolveOptions(options = {}) {
205
245
  return {
206
246
  cleanNpmrc: options.cleanNpmrc ?? DEFAULT_OPTIONS.cleanNpmrc,
207
247
  compatibility: resolveCompatibility(options.compatibility),
208
- cwd: options.cwd ?? DEFAULT_OPTIONS.cwd,
248
+ cwd: options.cwd ?? process.cwd(),
209
249
  newlineBetween: options.newlineBetween ?? DEFAULT_OPTIONS.newlineBetween,
210
250
  showChanges: options.showChanges ?? DEFAULT_OPTIONS.showChanges,
211
251
  sortKeys: options.sortKeys ?? DEFAULT_OPTIONS.sortKeys,
@@ -218,6 +258,28 @@ function resolveOptions(options = {}) {
218
258
  //#endregion
219
259
  //#region src/utils/fs.ts
220
260
  /**
261
+ * Modulus that isolates Unix permission bits from a stat mode.
262
+ */
263
+ const FILE_PERMISSION_MODULUS = 512;
264
+ /**
265
+ * Resolve a filesystem path to a string suitable for a sibling temp file.
266
+ */
267
+ function resolvePathString(path) {
268
+ if (path instanceof URL) return fileURLToPath(path);
269
+ return typeof path === "string" ? path : path.toString();
270
+ }
271
+ /**
272
+ * Read an existing file's permission bits, if the file exists.
273
+ */
274
+ async function resolveExistingMode(path) {
275
+ try {
276
+ return (await stat(path)).mode % FILE_PERMISSION_MODULUS;
277
+ } catch (error) {
278
+ if (error.code === "ENOENT") return;
279
+ throw error;
280
+ }
281
+ }
282
+ /**
221
283
  * Check whether a filesystem path exists.
222
284
  *
223
285
  * @param path - Filesystem path to check
@@ -256,10 +318,22 @@ async function fsRemoveFile(path) {
256
318
  * @returns A promise that resolves after the file is written
257
319
  */
258
320
  async function fsWriteFile(path, content) {
259
- await writeFile(path, `${content.trimEnd()}\n`, "utf-8");
321
+ const pathString = resolvePathString(path);
322
+ const temporaryPath = join(dirname(pathString), `.${basename(pathString)}.${process.pid}.${randomUUID()}.tmp`);
323
+ const mode = await resolveExistingMode(path);
324
+ try {
325
+ await writeFile(temporaryPath, `${content.trimEnd()}\n`, {
326
+ encoding: "utf-8",
327
+ mode
328
+ });
329
+ if (mode !== void 0) await chmod(temporaryPath, mode);
330
+ await rename(temporaryPath, pathString);
331
+ } finally {
332
+ await rm(temporaryPath, { force: true });
333
+ }
260
334
  }
261
- getColor("cyan");
262
- getColor("yellow");
335
+ //#endregion
336
+ //#region src/utils/color.ts
263
337
  /**
264
338
  * Apply dim terminal styling to a value.
265
339
  */
@@ -275,27 +349,9 @@ const red = getColor("red");
275
349
  getColor("bold");
276
350
  getColor("magenta");
277
351
  //#endregion
278
- //#region src/utils/compatibility.ts
279
- /**
280
- * Legacy build settings that can be replaced for pnpm v10.
281
- */
282
- const PNPM_REPLACEABLE_IN_V10_SETTINGS = [
283
- "allowNonAppliedPatches",
284
- "ignoredBuiltDependencies",
285
- "neverBuiltDependencies",
286
- "onlyBuiltDependencies",
287
- "onlyBuiltDependenciesFile"
288
- ];
352
+ //#region src/utils/legacy-build-settings.ts
289
353
  /**
290
354
  * Read packages listed by the legacy `onlyBuiltDependenciesFile` setting.
291
- *
292
- * @param cwd - Working directory used to resolve the referenced file
293
- * @param file - Relative path stored in `onlyBuiltDependenciesFile`
294
- *
295
- * @returns Package names read from the referenced JSON file
296
- *
297
- * @throws {TypeError} When the referenced JSON value is not a string array
298
- * @throws {SyntaxError} When the referenced file contains invalid JSON
299
355
  */
300
356
  async function readOnlyBuiltDependenciesFile(cwd, file) {
301
357
  if (!file) return [];
@@ -305,21 +361,44 @@ async function readOnlyBuiltDependenciesFile(cwd, file) {
305
361
  return value;
306
362
  }
307
363
  /**
364
+ * Read and validate one legacy build dependency list. Configuration files are
365
+ * external input, so their runtime shape can differ from its declared type.
366
+ */
367
+ function readLegacyBuildDependencyList(settings, key) {
368
+ const value = settings[key];
369
+ if (value === void 0) return [];
370
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
371
+ const npmrcKey = key.replace(/[A-Z]/gu, (character) => `-${character.toLowerCase()}`);
372
+ throw new TypeError(`Invalid ${key}: expected an array of package names. In .npmrc, use ${npmrcKey}[]=<package>.`);
373
+ }
374
+ return value;
375
+ }
376
+ /**
308
377
  * Build an `allowBuilds` map from legacy build-script settings.
309
- *
310
- * @param incomingSettings - Settings containing legacy build declarations
311
- * @param cwd - Working directory used to resolve referenced files
312
- *
313
- * @returns Package-to-permission map, or `undefined` when no entries exist
314
378
  */
315
379
  async function collectAllowBuildsFromLegacy(incomingSettings, cwd) {
316
380
  const allowBuilds = {};
381
+ const allowed = readLegacyBuildDependencyList(incomingSettings, "onlyBuiltDependencies");
382
+ const ignored = readLegacyBuildDependencyList(incomingSettings, "ignoredBuiltDependencies");
383
+ const neverBuilt = readLegacyBuildDependencyList(incomingSettings, "neverBuiltDependencies");
317
384
  const allowedFromFile = await readOnlyBuiltDependenciesFile(cwd, incomingSettings.onlyBuiltDependenciesFile);
318
- for (const name of [...incomingSettings.onlyBuiltDependencies || [], ...allowedFromFile]) allowBuilds[name] = true;
319
- for (const name of incomingSettings.ignoredBuiltDependencies || []) allowBuilds[name] = false;
320
- for (const name of incomingSettings.neverBuiltDependencies || []) allowBuilds[name] = false;
385
+ for (const name of [...allowed, ...allowedFromFile]) allowBuilds[name] = true;
386
+ for (const name of ignored) allowBuilds[name] = false;
387
+ for (const name of neverBuilt) allowBuilds[name] = false;
321
388
  return Object.keys(allowBuilds).length ? allowBuilds : void 0;
322
389
  }
390
+ //#endregion
391
+ //#region src/utils/compatibility.ts
392
+ /**
393
+ * Legacy build settings that can be replaced for pnpm v10.
394
+ */
395
+ const PNPM_REPLACEABLE_IN_V10_SETTINGS = [
396
+ "allowNonAppliedPatches",
397
+ "ignoredBuiltDependencies",
398
+ "neverBuiltDependencies",
399
+ "onlyBuiltDependencies",
400
+ "onlyBuiltDependenciesFile"
401
+ ];
323
402
  /**
324
403
  * Resolve the pnpm v11 replacement for legacy package-manager settings.
325
404
  *
@@ -455,6 +534,20 @@ const PNPM_V11_MAJOR = 11;
455
534
  */
456
535
  const PNPM_V12_MAJOR = 12;
457
536
  /**
537
+ * Resolve a pnpm major from a package manager declaration.
538
+ */
539
+ function resolvePnpmMajor(hint) {
540
+ const match = hint?.match(/^pnpm@[\s<=>^~]*(?<major>\d+)(?:\.|\s|$)/u);
541
+ return match?.groups?.major ? Number(match.groups.major) : void 0;
542
+ }
543
+ /**
544
+ * Normalize the single-or-array devEngines declaration.
545
+ */
546
+ function normalizePackageManagers(value) {
547
+ if (Array.isArray(value)) return value;
548
+ return value ? [value] : [];
549
+ }
550
+ /**
458
551
  * Resolve final compatibility target from user option and package manager hint.
459
552
  *
460
553
  * @param compatibility - Explicit or automatically detected compatibility target
@@ -465,10 +558,11 @@ const PNPM_V12_MAJOR = 12;
465
558
  */
466
559
  function resolveCompatibilityTarget(compatibility, packageManager, devPackageManager) {
467
560
  if (compatibility !== "auto") return compatibility;
468
- const match = (packageManager || (devPackageManager?.name === "pnpm" ? `pnpm@${devPackageManager.version}` : void 0))?.match(/^pnpm@[\s<=>^~]*(?<major>\d+)(?:\.|\s|$)/u);
469
- const major = Number(match?.groups?.major);
470
- if (major >= PNPM_V12_MAJOR) return "v12";
471
- return major >= PNPM_V11_MAJOR ? "v11" : "v10";
561
+ const packageManagerMajor = resolvePnpmMajor(packageManager);
562
+ const devPackageManagerMajors = normalizePackageManagers(devPackageManager).filter((item) => item.name === "pnpm").map((item) => resolvePnpmMajor(`pnpm@${item.version ?? ""}`)).filter((major) => major !== void 0);
563
+ const major = packageManagerMajor ?? (devPackageManagerMajors.length ? Math.max(...devPackageManagerMajors) : void 0);
564
+ if (major !== void 0 && major >= PNPM_V12_MAJOR) return "v12";
565
+ return major !== void 0 && major >= PNPM_V11_MAJOR ? "v11" : "v10";
472
566
  }
473
567
  //#endregion
474
568
  //#region src/utils/merge.ts
@@ -611,9 +705,8 @@ function isNpmrcAuthOrRegistryKey(key) {
611
705
  /**
612
706
  * Remove pnpm-related settings from `.npmrc` file.
613
707
  *
614
- * This function reads the `.npmrc` file, filters out all lines that start with
615
- * pnpm-specific configuration keys (as defined in PNPM_SETTINGS_FIELDS), and
616
- * writes the cleaned content back to the file.
708
+ * This function reads the `.npmrc` file, removes lines whose exact normalized
709
+ * keys were migrated, and writes the cleaned content back to the file.
617
710
  *
618
711
  * @param path - Absolute path to the `.npmrc` file
619
712
  * @param compatibility - Concrete pnpm compatibility target
@@ -625,17 +718,15 @@ function isNpmrcAuthOrRegistryKey(key) {
625
718
  *
626
719
  * @example
627
720
  * ```ts
628
- * await pruneNpmrc('/path/to/.npmrc')
721
+ * await pruneNpmrc('/path/to/.npmrc', 'v10', ['node-linker'])
629
722
  * ```
630
723
  */
631
- async function pruneNpmrc(path, compatibility = "v10", migratedKeys = []) {
632
- const pnpmSettingsFields = PNPM_SETTINGS_FIELDS.map((field) => kebabCase(field));
724
+ async function pruneNpmrc(path, compatibility, migratedKeys) {
633
725
  const migratedKeySet = new Set(migratedKeys.map(normalizeNpmrcKey));
634
726
  const updatedContent = (await fsReadFile(path)).split(/\r?\n/u).filter((line) => {
635
727
  const key = getNpmrcLineKey(line);
636
728
  if (!key) return true;
637
- if (compatibility !== "v10") return !migratedKeySet.has(normalizeNpmrcKey(key));
638
- return !pnpmSettingsFields.some((field) => key.startsWith(field));
729
+ return !migratedKeySet.has(key);
639
730
  }).join("\n").trimEnd();
640
731
  if (compatibility !== "v10" && !updatedContent.trim()) {
641
732
  await fsRemoveFile(path);
@@ -660,10 +751,12 @@ async function pruneNpmrc(path, compatibility = "v10", migratedKeys = []) {
660
751
  async function readMigratableNpmrc(path, compatibility) {
661
752
  const raw = await readIniFile(path);
662
753
  if (compatibility === "v10") {
663
- const settings = camelcaseKeys(raw);
754
+ const pnpmSettingsFields = new Set(PNPM_V10_NPMRC_SETTINGS_FIELDS.map((field) => normalizeNpmrcKey(kebabCase(field))));
755
+ const keys = Object.keys(raw).filter((key) => pnpmSettingsFields.has(normalizeNpmrcKey(key)));
756
+ const migratable = Object.fromEntries(keys.map((key) => [key, raw[key]]));
664
757
  return {
665
- keys: PNPM_SETTINGS_FIELDS,
666
- settings: pick(settings, PNPM_SETTINGS_FIELDS)
758
+ keys,
759
+ settings: camelcaseKeys(migratable)
667
760
  };
668
761
  }
669
762
  const migratable = {};
@@ -685,6 +778,15 @@ async function readMigratableNpmrc(path, compatibility) {
685
778
  //#endregion
686
779
  //#region src/utils/settings-change.ts
687
780
  /**
781
+ * Maximum dynamic-programming cells used for an exact line diff.
782
+ */
783
+ const MAX_LCS_CELLS = 1e6;
784
+ /**
785
+ * URL userinfo can contain proxy usernames and passwords. It must never be
786
+ * printed in a migration report.
787
+ */
788
+ const URL_USERINFO_PATTERN = /(?<scheme>[a-z][a-z\d+.-]*:\/\/)[^/\s@]+@/giu;
789
+ /**
688
790
  * Collect changed root settings between two workspace configurations.
689
791
  *
690
792
  * @param before - Workspace settings before migration
@@ -703,6 +805,38 @@ function collectSettingsChanges(before, after) {
703
805
  }));
704
806
  }
705
807
  /**
808
+ * Create a bounded-memory diff that retains shared prefixes and suffixes.
809
+ *
810
+ * This fallback deliberately gives up the exact LCS for unusually large
811
+ * settings to keep reporting from exhausting memory after migration writes.
812
+ */
813
+ function createLinearLineDiff(beforeLines, afterLines) {
814
+ let prefixLength = 0;
815
+ const maximumPrefixLength = Math.min(beforeLines.length, afterLines.length);
816
+ while (prefixLength < maximumPrefixLength && beforeLines[prefixLength] === afterLines[prefixLength]) prefixLength++;
817
+ let suffixLength = 0;
818
+ const maximumSuffixLength = maximumPrefixLength - prefixLength;
819
+ while (suffixLength < maximumSuffixLength && beforeLines[beforeLines.length - suffixLength - 1] === afterLines[afterLines.length - suffixLength - 1]) suffixLength++;
820
+ return [
821
+ ...beforeLines.slice(0, prefixLength).map((value) => ({
822
+ kind: "unchanged",
823
+ value
824
+ })),
825
+ ...beforeLines.slice(prefixLength, beforeLines.length - suffixLength).map((value) => ({
826
+ kind: "removed",
827
+ value
828
+ })),
829
+ ...afterLines.slice(prefixLength, afterLines.length - suffixLength).map((value) => ({
830
+ kind: "added",
831
+ value
832
+ })),
833
+ ...beforeLines.slice(beforeLines.length - suffixLength).map((value) => ({
834
+ kind: "unchanged",
835
+ value
836
+ }))
837
+ ];
838
+ }
839
+ /**
706
840
  * Create a line-based diff using the longest common subsequence.
707
841
  *
708
842
  * @param beforeLines - YAML lines before migration
@@ -711,6 +845,7 @@ function collectSettingsChanges(before, after) {
711
845
  * @returns Classified diff lines in display order
712
846
  */
713
847
  function createLineDiff(beforeLines, afterLines) {
848
+ if (beforeLines.length * afterLines.length > MAX_LCS_CELLS) return createLinearLineDiff(beforeLines, afterLines);
714
849
  const lengths = Array.from({ length: beforeLines.length + 1 }, () => Array(afterLines.length + 1).fill(0));
715
850
  for (let beforeIndex = beforeLines.length - 1; beforeIndex >= 0; beforeIndex--) for (let afterIndex = afterLines.length - 1; afterIndex >= 0; afterIndex--) lengths[beforeIndex][afterIndex] = beforeLines[beforeIndex] === afterLines[afterIndex] ? lengths[beforeIndex + 1][afterIndex + 1] + 1 : Math.max(lengths[beforeIndex + 1][afterIndex], lengths[beforeIndex][afterIndex + 1]);
716
851
  const diff = [];
@@ -765,6 +900,17 @@ function formatSettingLines(key, value) {
765
900
  return stringify({ [key]: value }).trimEnd().split("\n");
766
901
  }
767
902
  /**
903
+ * Remove credentials embedded in URLs while preserving enough context to
904
+ * identify the changed setting.
905
+ *
906
+ * @param value - Rendered YAML diff line
907
+ *
908
+ * @returns Diff line safe to print to a terminal or CI log
909
+ */
910
+ function redactUrlCredentials(value) {
911
+ return value.replace(URL_USERINFO_PATTERN, "$<scheme>***@");
912
+ }
913
+ /**
768
914
  * Format a setting change as a GitHub-style YAML diff.
769
915
  *
770
916
  * @param change - Root setting change to format
@@ -772,7 +918,10 @@ function formatSettingLines(key, value) {
772
918
  * @returns Classified YAML diff lines
773
919
  */
774
920
  function createSettingsDiffLines(change) {
775
- return createLineDiff(formatSettingLines(change.key, change.before), formatSettingLines(change.key, change.after));
921
+ return createLineDiff(formatSettingLines(change.key, change.before), formatSettingLines(change.key, change.after)).map((line) => ({
922
+ kind: line.kind,
923
+ value: redactUrlCredentials(line.value)
924
+ }));
776
925
  }
777
926
  /**
778
927
  * Print a summary and GitHub-style YAML diff for every changed setting.
@@ -791,6 +940,39 @@ function reportSettingsChanges(changes) {
791
940
  }
792
941
  }
793
942
  //#endregion
943
+ //#region src/utils/yaml-document.ts
944
+ /**
945
+ * Sort every mapping in a YAML node without replacing its comments or anchors.
946
+ */
947
+ function sortYamlMappings(node) {
948
+ if (isMap(node)) {
949
+ node.items.sort((left, right) => {
950
+ const leftKey = isScalar(left.key) ? String(left.key.value) : "";
951
+ const rightKey = isScalar(right.key) ? String(right.key.value) : "";
952
+ return leftKey.localeCompare(rightKey);
953
+ });
954
+ for (const pair of node.items) if (isNode(pair.value)) sortYamlMappings(pair.value);
955
+ } else if (isSeq(node)) {
956
+ for (const item of node.items) if (isNode(item)) sortYamlMappings(item);
957
+ }
958
+ }
959
+ /**
960
+ * Apply semantic root changes to the parsed document while retaining untouched
961
+ * YAML nodes and their source metadata.
962
+ */
963
+ function updateYamlDocument(document, options) {
964
+ const { after, before, sortKeys } = options;
965
+ for (const key of Object.keys(before)) if (!Object.hasOwn(after, key)) document.delete(key);
966
+ for (const [key, value] of Object.entries(after)) if (!Object.hasOwn(before, key) || !isDeepStrictEqual(Reflect.get(before, key), value)) document.set(key, value);
967
+ if (sortKeys) sortYamlMappings(document.contents);
968
+ }
969
+ /**
970
+ * Normalize blank lines immediately before root mapping keys.
971
+ */
972
+ function formatRootSpacing(content, newlineBetween) {
973
+ return content.replace(/\n+(?=[^\s#][^:\n]*:)/gu, newlineBetween ? "\n\n" : "\n");
974
+ }
975
+ //#endregion
794
976
  //#region src/utils/config.ts
795
977
  /**
796
978
  * Resolve the indentation width for a YAML document.
@@ -836,13 +1018,21 @@ async function readPackageJson(path, exists) {
836
1018
  */
837
1019
  async function readPnpmWorkspace(path, exists) {
838
1020
  if (!exists) return {
1021
+ document: new Document({}),
839
1022
  indent: 2,
840
1023
  value: {}
841
1024
  };
842
1025
  const content = await fsReadFile(path);
1026
+ const document = parseDocument(content);
1027
+ const [parseError] = document.errors;
1028
+ if (parseError) throw parseError;
1029
+ const parsedValue = document.toJS();
1030
+ if (parsedValue !== null && !isMap(document.contents)) throw new TypeError("pnpm-workspace.yaml must contain a root mapping.");
1031
+ if (parsedValue === null) document.contents = document.createNode({});
843
1032
  return {
1033
+ document,
844
1034
  indent: resolveYamlIndent(content),
845
- value: parse(content) ?? {}
1035
+ value: parsedValue ?? {}
846
1036
  };
847
1037
  }
848
1038
  /**
@@ -943,11 +1133,7 @@ async function migratePnpmSettings(rawOptions = {}) {
943
1133
  settings: {}
944
1134
  };
945
1135
  const pnpmSettingsInNpmrc = npmrcMigratable.settings;
946
- const pnpmSettingsInPackageJson = resolvePackageJsonSettings(packageJson.value, options.yarnResolutions);
947
- const incomingSettings = {
948
- ...pnpmSettingsInNpmrc,
949
- ...pnpmSettingsInPackageJson
950
- };
1136
+ const incomingSettings = mergeByStrategy(resolvePackageJsonSettings(packageJson.value, options.yarnResolutions), pnpmSettingsInNpmrc, "merge");
951
1137
  const [existingNormalization, incomingNormalization] = await Promise.all([normalizeIncomingSettings(pnpmWorkspace.value, {
952
1138
  compatibility,
953
1139
  cwd: options.cwd,
@@ -973,16 +1159,12 @@ async function migratePnpmSettings(rawOptions = {}) {
973
1159
  return;
974
1160
  }
975
1161
  const pnpmWorkspaceResult = mergeByStrategy(pnpmWorkspace.value, incomingSettings, options.strategy);
976
- const yamlDocument = new Document({}, { sortMapEntries: options.sortKeys });
977
- Object.entries(pnpmWorkspaceResult).forEach(([key, value]) => {
978
- yamlDocument.add({
979
- key,
980
- value
981
- });
1162
+ updateYamlDocument(pnpmWorkspace.document, {
1163
+ after: pnpmWorkspaceResult,
1164
+ before: pnpmWorkspaceBefore,
1165
+ sortKeys: options.sortKeys
982
1166
  });
983
- const yamlContent = yamlDocument.toString({ indent: pnpmWorkspace.indent });
984
- await fsWriteFile(pnpmWorkspaceYamlPath, options.newlineBetween ? yamlContent.replace(/\n(?=[^\s#][^:\n]*:)/gu, "\n\n") : yamlContent);
985
- if (npmrcExists && options.cleanNpmrc) await pruneNpmrc(npmrcPath, compatibility, npmrcMigratable.keys);
1167
+ const finalYamlContent = formatRootSpacing(pnpmWorkspace.document.toString({ indent: pnpmWorkspace.indent }), options.newlineBetween);
986
1168
  let packageJsonChanged = runtimeMigration.changed;
987
1169
  if (packageJsonExists && options.cleanPackageJson) {
988
1170
  if (packageJson.value.pnpm) packageJsonChanged = true;
@@ -992,7 +1174,9 @@ async function migratePnpmSettings(rawOptions = {}) {
992
1174
  delete packageJson.value.resolutions;
993
1175
  }
994
1176
  }
1177
+ await fsWriteFile(pnpmWorkspaceYamlPath, finalYamlContent);
995
1178
  if (packageJsonExists && packageJsonChanged) await fsWriteFile(packageJsonPath, JSON.stringify(packageJson.value, null, packageJson.indent));
1179
+ if (npmrcExists && options.cleanNpmrc) await pruneNpmrc(npmrcPath, compatibility, npmrcMigratable.keys);
996
1180
  reportMigrationChanges(options.showChanges, pnpmWorkspaceBefore, pnpmWorkspaceResult);
997
1181
  } catch (err) {
998
1182
  consola.error("Failed to migrate pnpm settings:", err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pnpm-settings-migrator",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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",
@@ -67,6 +67,9 @@
67
67
  "*.{js,ts,mjs,tsx}": "oxlint --fix",
68
68
  "*": "oxfmt --no-error-on-unmatched-pattern"
69
69
  },
70
+ "engines": {
71
+ "node": "^22.19.0 || >=24.11.0"
72
+ },
70
73
  "scripts": {
71
74
  "build": "tsdown",
72
75
  "dev": "tsdown --watch",