@openrewrite/recipes-nodejs 0.37.0-20260106-083133 → 0.37.0-20260106-170728

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.
Files changed (36) hide show
  1. package/dist/security/dependency-vulnerability-check.d.ts +8 -54
  2. package/dist/security/dependency-vulnerability-check.d.ts.map +1 -1
  3. package/dist/security/dependency-vulnerability-check.js +176 -287
  4. package/dist/security/dependency-vulnerability-check.js.map +1 -1
  5. package/dist/security/index.d.ts +3 -0
  6. package/dist/security/index.d.ts.map +1 -1
  7. package/dist/security/index.js +3 -0
  8. package/dist/security/index.js.map +1 -1
  9. package/dist/security/npm-utils.d.ts +8 -2
  10. package/dist/security/npm-utils.d.ts.map +1 -1
  11. package/dist/security/npm-utils.js +114 -14
  12. package/dist/security/npm-utils.js.map +1 -1
  13. package/dist/security/override-utils.d.ts +23 -0
  14. package/dist/security/override-utils.d.ts.map +1 -0
  15. package/dist/security/override-utils.js +169 -0
  16. package/dist/security/override-utils.js.map +1 -0
  17. package/dist/security/remove-redundant-overrides.d.ts +1 -10
  18. package/dist/security/remove-redundant-overrides.d.ts.map +1 -1
  19. package/dist/security/remove-redundant-overrides.js +4 -152
  20. package/dist/security/remove-redundant-overrides.js.map +1 -1
  21. package/dist/security/types.d.ts +42 -0
  22. package/dist/security/types.d.ts.map +1 -0
  23. package/dist/security/types.js +7 -0
  24. package/dist/security/types.js.map +1 -0
  25. package/dist/security/version-utils.d.ts +13 -0
  26. package/dist/security/version-utils.d.ts.map +1 -0
  27. package/dist/security/version-utils.js +173 -0
  28. package/dist/security/version-utils.js.map +1 -0
  29. package/package.json +1 -1
  30. package/src/security/dependency-vulnerability-check.ts +300 -525
  31. package/src/security/index.ts +3 -0
  32. package/src/security/npm-utils.ts +172 -37
  33. package/src/security/override-utils.ts +253 -0
  34. package/src/security/remove-redundant-overrides.ts +9 -211
  35. package/src/security/types.ts +115 -0
  36. package/src/security/version-utils.ts +198 -0
@@ -5,5 +5,8 @@
5
5
  */
6
6
 
7
7
  export * from "./vulnerability";
8
+ export * from "./version-utils";
9
+ export * from "./types";
10
+ export * from "./override-utils";
8
11
  export * from "./dependency-vulnerability-check";
9
12
  export * from "./remove-redundant-overrides";
@@ -107,7 +107,7 @@ export async function fetchNpmPackageInfo(
107
107
 
108
108
  if (Array.isArray(versions)) {
109
109
  for (const v of versions) {
110
- versionMap[v] = { version: v };
110
+ versionMap[v] = {version: v};
111
111
  }
112
112
  }
113
113
 
@@ -251,42 +251,41 @@ export interface DirectUpgradeCheckResult {
251
251
  export type DependencyScope = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies';
252
252
 
253
253
  /**
254
- * Checks if upgrading a direct dependency to a specific version fixes a transitive vulnerability.
255
- * Does this by running npm install in a temp directory and checking the resolved transitive version.
254
+ * Checks if a direct dependency at a given version, when installed alone (in isolation),
255
+ * brings in a safe version of a transitive dependency. This is useful when multiple
256
+ * direct dependencies bring in the same vulnerable transitive - we need to check each
257
+ * in isolation to find upgrade candidates.
256
258
  *
257
259
  * @param pm The package manager to use
258
- * @param directDepName The direct dependency to upgrade
259
- * @param directDepVersion The version to upgrade to
260
- * @param transitiveDepName The transitive dependency that has a vulnerability
261
- * @param isVulnerable Function that checks if a version is vulnerable
262
- * @param originalPackageJson The original package.json content
263
- * @param scope The dependency scope of the direct dependency
260
+ * @param directDepName The direct dependency name
261
+ * @param directDepVersion The version to test
262
+ * @param transitiveDepName The transitive dependency to check
263
+ * @param isVulnerable Function that checks if a transitive version is vulnerable
264
+ * @param scope The dependency scope
264
265
  * @param configFiles Optional config files (e.g., .npmrc)
265
- * @returns Result indicating if the upgrade fixes the vulnerability
266
+ * @returns Result indicating if the upgrade is safe and what transitive version it brings
266
267
  */
267
- export async function checkDirectUpgradeFixesTransitive(
268
+ export async function checkDirectDepBringsSafeTransitiveInIsolation(
268
269
  pm: PackageManager,
269
270
  directDepName: string,
270
271
  directDepVersion: string,
271
272
  transitiveDepName: string,
272
273
  isVulnerable: (version: string) => boolean,
273
- originalPackageJson: string,
274
274
  scope: DependencyScope,
275
275
  configFiles?: Record<string, string>
276
276
  ): Promise<DirectUpgradeCheckResult> {
277
277
  try {
278
- // Parse and modify package.json with upgraded direct dependency
279
- const pkgJson = JSON.parse(originalPackageJson);
280
- if (!pkgJson[scope]?.[directDepName]) {
281
- return {fixed: false, directDepVersion};
282
- }
283
-
284
- // Upgrade the direct dependency
285
- pkgJson[scope][directDepName] = directDepVersion;
286
- const modifiedPackageJson = JSON.stringify(pkgJson, null, 2);
278
+ // Create a minimal package.json with ONLY this direct dependency
279
+ const minimalPackageJson = JSON.stringify({
280
+ name: 'upgrade-check-isolated',
281
+ version: '1.0.0',
282
+ [scope]: {
283
+ [directDepName]: directDepVersion
284
+ }
285
+ }, null, 2);
287
286
 
288
287
  // Run install in temp directory
289
- const result = await runInstallInTempDir(pm, modifiedPackageJson, {
288
+ const result = await runInstallInTempDir(pm, minimalPackageJson, {
290
289
  configFiles,
291
290
  lockOnly: true
292
291
  });
@@ -321,8 +320,9 @@ export async function checkDirectUpgradeFixesTransitive(
321
320
  }
322
321
 
323
322
  /**
324
- * Finds a direct dependency upgrade that fixes a transitive vulnerability.
325
- * Tries versions from newest to oldest within the allowed delta.
323
+ * Finds a direct dependency upgrade that brings in a safe transitive, checking in isolation.
324
+ * This differs from findDirectUpgradeThatFixesTransitive by testing the direct dep alone,
325
+ * not with other dependencies that might also bring in the vulnerable transitive.
326
326
  *
327
327
  * @param pm The package manager to use
328
328
  * @param directDepName The direct dependency to upgrade
@@ -330,19 +330,17 @@ export async function checkDirectUpgradeFixesTransitive(
330
330
  * @param transitiveDepName The transitive dependency that has a vulnerability
331
331
  * @param isVulnerable Function that checks if a version is vulnerable
332
332
  * @param isWithinDelta Function that checks if a version is within the allowed upgrade delta
333
- * @param originalPackageJson The original package.json content
334
- * @param scope The dependency scope of the direct dependency
333
+ * @param scope The dependency scope
335
334
  * @param configFiles Optional config files (e.g., .npmrc)
336
- * @returns The direct dependency version that fixes the transitive, or undefined if none found
335
+ * @returns The direct dependency version that brings in a safe transitive, or undefined
337
336
  */
338
- export async function findDirectUpgradeThatFixesTransitive(
337
+ export async function findDirectUpgradeWithSafeTransitiveInIsolation(
339
338
  pm: PackageManager,
340
339
  directDepName: string,
341
340
  currentDirectVersion: string,
342
341
  transitiveDepName: string,
343
342
  isVulnerable: (version: string) => boolean,
344
343
  isWithinDelta: (fromVersion: string, toVersion: string) => boolean,
345
- originalPackageJson: string,
346
344
  scope: DependencyScope,
347
345
  configFiles?: Record<string, string>
348
346
  ): Promise<string | undefined> {
@@ -355,7 +353,6 @@ export async function findDirectUpgradeThatFixesTransitive(
355
353
  const currentMajor = semver.major(currentDirectVersion);
356
354
 
357
355
  // Filter to versions newer than current and within delta
358
- // Note: getAvailableVersions already filters out prereleases
359
356
  const candidateVersions = availableVersions.filter(v => {
360
357
  try {
361
358
  return semver.gt(v, currentDirectVersion) && isWithinDelta(currentDirectVersion, v);
@@ -369,30 +366,26 @@ export async function findDirectUpgradeThatFixesTransitive(
369
366
  }
370
367
 
371
368
  // Sort candidates: same major version first (descending), then other majors (descending)
372
- // This prioritizes peer-compatible versions while still allowing major upgrades if needed
373
369
  candidateVersions.sort((a, b) => {
374
370
  const aMajor = semver.major(a);
375
371
  const bMajor = semver.major(b);
376
372
  const aIsSameMajor = aMajor === currentMajor;
377
373
  const bIsSameMajor = bMajor === currentMajor;
378
374
 
379
- // Same major versions come first
380
375
  if (aIsSameMajor && !bIsSameMajor) return -1;
381
376
  if (!aIsSameMajor && bIsSameMajor) return 1;
382
377
 
383
- // Within the same priority group, sort by version descending
384
378
  return semver.rcompare(a, b);
385
379
  });
386
380
 
387
381
  // Try candidates in order (same major first, then others)
388
382
  for (const candidate of candidateVersions) {
389
- const result = await checkDirectUpgradeFixesTransitive(
383
+ const result = await checkDirectDepBringsSafeTransitiveInIsolation(
390
384
  pm,
391
385
  directDepName,
392
386
  candidate,
393
387
  transitiveDepName,
394
388
  isVulnerable,
395
- originalPackageJson,
396
389
  scope,
397
390
  configFiles
398
391
  );
@@ -402,13 +395,155 @@ export async function findDirectUpgradeThatFixesTransitive(
402
395
  }
403
396
 
404
397
  // Only try a few candidates to avoid excessive npm installs
405
- // After trying the newest same-major and newest other-major, give up
406
398
  const candidateMajor = semver.major(candidate);
407
399
  if (candidateMajor !== currentMajor) {
408
- // We've tried a different major version and it didn't work, give up
409
400
  break;
410
401
  }
411
402
  }
412
403
 
413
404
  return undefined;
414
405
  }
406
+
407
+ /**
408
+ * Verifies that applying all proposed direct dependency upgrades fixes a transitive vulnerability.
409
+ * This is used after finding individual upgrade candidates to confirm they work together.
410
+ *
411
+ * @param pm The package manager to use
412
+ * @param upgrades Array of direct dependency upgrades to apply
413
+ * @param transitiveDepName The transitive dependency that should be fixed
414
+ * @param isVulnerable Function that checks if a transitive version is vulnerable
415
+ * @param originalPackageJson The original package.json content
416
+ * @param configFiles Optional config files (e.g., .npmrc)
417
+ * @returns True if all upgrades together fix the transitive vulnerability
418
+ */
419
+ export async function verifyAllUpgradesFixTransitive(
420
+ pm: PackageManager,
421
+ upgrades: Array<{ name: string; version: string; scope: DependencyScope }>,
422
+ transitiveDepName: string,
423
+ isVulnerable: (version: string) => boolean,
424
+ originalPackageJson: string,
425
+ configFiles?: Record<string, string>
426
+ ): Promise<boolean> {
427
+ try {
428
+ // Parse and modify package.json with all upgraded dependencies
429
+ const pkgJson = JSON.parse(originalPackageJson);
430
+
431
+ for (const upgrade of upgrades) {
432
+ if (pkgJson[upgrade.scope]?.[upgrade.name]) {
433
+ pkgJson[upgrade.scope][upgrade.name] = upgrade.version;
434
+ }
435
+ }
436
+
437
+ const modifiedPackageJson = JSON.stringify(pkgJson, null, 2);
438
+
439
+ // Run install in temp directory
440
+ const result = await runInstallInTempDir(pm, modifiedPackageJson, {
441
+ configFiles,
442
+ lockOnly: true
443
+ });
444
+
445
+ if (!result.success || !result.lockFileContent) {
446
+ return false;
447
+ }
448
+
449
+ // Check all copies of the transitive in the lock file
450
+ const allVersions = extractAllVersionsFromLockFile(
451
+ result.lockFileContent,
452
+ transitiveDepName,
453
+ pm
454
+ );
455
+
456
+ if (allVersions.length === 0) {
457
+ // Transitive not found - considered fixed
458
+ return true;
459
+ }
460
+
461
+ // ALL copies must be safe
462
+ return allVersions.every(v => !isVulnerable(v));
463
+ } catch {
464
+ return false;
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Extracts ALL versions of a package from a lock file (handles multiple copies).
470
+ * This is important when the same package appears multiple times at different versions
471
+ * due to incompatible peer dependency ranges.
472
+ */
473
+ export function extractAllVersionsFromLockFile(
474
+ lockFileContent: string,
475
+ packageName: string,
476
+ pm: PackageManager
477
+ ): string[] {
478
+ const versions: string[] = [];
479
+ try {
480
+ switch (pm) {
481
+ case PackageManager.Npm: {
482
+ const lockJson = JSON.parse(lockFileContent);
483
+ if (lockJson.packages) {
484
+ for (const [key, value] of Object.entries(lockJson.packages)) {
485
+ // Match both root and nested copies: node_modules/pkg or .../node_modules/pkg
486
+ if (key.endsWith(`node_modules/${packageName}`)) {
487
+ const version = (value as any).version;
488
+ if (version && !versions.includes(version)) {
489
+ versions.push(version);
490
+ }
491
+ }
492
+ }
493
+ }
494
+ // Also check v1 format
495
+ if (lockJson.dependencies?.[packageName]) {
496
+ const version = lockJson.dependencies[packageName].version;
497
+ if (version && !versions.includes(version)) {
498
+ versions.push(version);
499
+ }
500
+ }
501
+ break;
502
+ }
503
+
504
+ case PackageManager.Pnpm: {
505
+ const regex = new RegExp(`['"]?${escapeRegExp(packageName)}@([\\d.]+)['"]?:`, 'g');
506
+ let match;
507
+ while ((match = regex.exec(lockFileContent)) !== null) {
508
+ if (!versions.includes(match[1])) {
509
+ versions.push(match[1]);
510
+ }
511
+ }
512
+ break;
513
+ }
514
+
515
+ case PackageManager.YarnClassic:
516
+ case PackageManager.YarnBerry: {
517
+ const regex = new RegExp(`"?${escapeRegExp(packageName)}@[^":\\n]+[":]*\\s*\\n\\s*version:?\\s*"?([^"\\n]+)"?`, 'g');
518
+ let match;
519
+ while ((match = regex.exec(lockFileContent)) !== null) {
520
+ const version = match[1]?.trim();
521
+ if (version && !versions.includes(version)) {
522
+ versions.push(version);
523
+ }
524
+ }
525
+ break;
526
+ }
527
+
528
+ case PackageManager.Bun: {
529
+ const lockJson = JSON.parse(lockFileContent);
530
+ if (lockJson.packages) {
531
+ for (const [key, value] of Object.entries(lockJson.packages)) {
532
+ if (key === packageName || key.endsWith(`/${packageName}`)) {
533
+ if (Array.isArray(value) && value[0]) {
534
+ const version = String(value[0]);
535
+ if (!versions.includes(version)) {
536
+ versions.push(version);
537
+ }
538
+ }
539
+ }
540
+ }
541
+ }
542
+ break;
543
+ }
544
+ }
545
+ } catch {
546
+ // Return empty array on parse errors
547
+ }
548
+ return versions;
549
+ }
@@ -0,0 +1,253 @@
1
+ /*
2
+ * Copyright 2025 the original author or authors.
3
+ *
4
+ * Moderne Proprietary. Only for use by Moderne customers under the terms of a commercial contract.
5
+ */
6
+
7
+ import {DependencyScope, PackageManager} from "@openrewrite/rewrite/javascript";
8
+ import {ALL_DEPENDENCY_SCOPES} from "./types";
9
+
10
+ /**
11
+ * Finds the dependency scope where a package is declared as a direct dependency.
12
+ * Returns the scope name (dependencies, devDependencies, etc.) or undefined if not found.
13
+ */
14
+ export function findDirectDependencyScope(
15
+ packageJson: Record<string, any>,
16
+ packageName: string
17
+ ): DependencyScope | undefined {
18
+ for (const scope of ALL_DEPENDENCY_SCOPES) {
19
+ if (packageJson[scope]?.[packageName]) {
20
+ return scope;
21
+ }
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ /**
27
+ * Extracts the overrides section from a package.json based on the package manager.
28
+ * Returns the overrides object or undefined if not present.
29
+ */
30
+ export function getOverridesFromPackageJson(
31
+ packageJson: Record<string, any>,
32
+ packageManager: PackageManager
33
+ ): Record<string, string> | undefined {
34
+ switch (packageManager) {
35
+ case PackageManager.Npm:
36
+ case PackageManager.Bun:
37
+ return packageJson.overrides;
38
+ case PackageManager.Pnpm:
39
+ return packageJson.pnpm?.overrides;
40
+ case PackageManager.YarnClassic:
41
+ case PackageManager.YarnBerry:
42
+ return packageJson.resolutions;
43
+ default:
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Gets the field names for overrides and comments based on package manager.
50
+ */
51
+ export function getOverrideFieldNames(packageManager: PackageManager): {
52
+ overrideField: string;
53
+ commentField: string;
54
+ } {
55
+ switch (packageManager) {
56
+ case PackageManager.Npm:
57
+ case PackageManager.Bun:
58
+ return {overrideField: 'overrides', commentField: '//overrides'};
59
+ case PackageManager.Pnpm:
60
+ return {overrideField: 'pnpm', commentField: '//pnpm.overrides'};
61
+ case PackageManager.YarnClassic:
62
+ case PackageManager.YarnBerry:
63
+ return {overrideField: 'resolutions', commentField: '//resolutions'};
64
+ default:
65
+ return {overrideField: 'overrides', commentField: '//overrides'};
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Information about an override entry.
71
+ */
72
+ export interface OverrideInfo {
73
+ /** The full key (e.g., "package" or "package@^1") */
74
+ key: string;
75
+ /** The base package name (without version specifier) */
76
+ packageName: string;
77
+ /** The version the override pins to */
78
+ version: string;
79
+ /** Whether this is a version-specific override (e.g., "package@^1") */
80
+ isVersionSpecific: boolean;
81
+ /** The version range specifier if version-specific (e.g., "^1") */
82
+ versionRange?: string;
83
+ }
84
+
85
+ /**
86
+ * Parses an override key to extract package name and version range.
87
+ * Handles both regular packages (lodash) and scoped packages (@scope/package).
88
+ */
89
+ export function parseOverrideKey(key: string): {
90
+ packageName: string;
91
+ versionRange?: string;
92
+ isVersionSpecific: boolean;
93
+ } {
94
+ const atIndex = key.lastIndexOf('@');
95
+ let packageName: string;
96
+ let versionRange: string | undefined;
97
+ let isVersionSpecific = false;
98
+
99
+ // Check if this is a version-specific override like "package@^1"
100
+ // But be careful with scoped packages like "@scope/package"
101
+ if (atIndex > 0 && !key.startsWith('@')) {
102
+ // Unscoped package with version specifier
103
+ packageName = key.substring(0, atIndex);
104
+ versionRange = key.substring(atIndex + 1);
105
+ isVersionSpecific = true;
106
+ } else if (atIndex > 0 && key.startsWith('@')) {
107
+ // Scoped package - check if there's another @ after the scope
108
+ const secondAtIndex = key.indexOf('@', 1);
109
+ if (secondAtIndex > 0 && secondAtIndex !== atIndex) {
110
+ // Has version specifier: @scope/package@^1
111
+ packageName = key.substring(0, secondAtIndex);
112
+ versionRange = key.substring(secondAtIndex + 1);
113
+ isVersionSpecific = true;
114
+ } else {
115
+ // Just @scope/package
116
+ packageName = key;
117
+ }
118
+ } else {
119
+ packageName = key;
120
+ }
121
+
122
+ return {packageName, versionRange, isVersionSpecific};
123
+ }
124
+
125
+ /**
126
+ * Extracts all override entries from a package.json.
127
+ */
128
+ export function extractOverrides(
129
+ packageJson: Record<string, any>,
130
+ packageManager: PackageManager
131
+ ): OverrideInfo[] {
132
+ const overrides: OverrideInfo[] = [];
133
+ const overrideObj = getOverridesFromPackageJson(packageJson, packageManager);
134
+
135
+ if (!overrideObj) {
136
+ return overrides;
137
+ }
138
+
139
+ for (const [key, value] of Object.entries(overrideObj)) {
140
+ // Skip nested overrides (npm supports objects as values)
141
+ if (typeof value !== 'string') {
142
+ continue;
143
+ }
144
+
145
+ const {packageName, versionRange, isVersionSpecific} = parseOverrideKey(key);
146
+
147
+ overrides.push({
148
+ key,
149
+ packageName,
150
+ version: value,
151
+ isVersionSpecific,
152
+ versionRange
153
+ });
154
+ }
155
+
156
+ return overrides;
157
+ }
158
+
159
+ /**
160
+ * Removes a single override from the package.json object.
161
+ * Mutates the object in place.
162
+ */
163
+ export function removeOverrideFromObject(
164
+ packageJson: Record<string, any>,
165
+ packageManager: PackageManager,
166
+ key: string
167
+ ): void {
168
+ switch (packageManager) {
169
+ case PackageManager.Npm:
170
+ case PackageManager.Bun:
171
+ if (packageJson.overrides) {
172
+ delete packageJson.overrides[key];
173
+ if (Object.keys(packageJson.overrides).length === 0) {
174
+ delete packageJson.overrides;
175
+ }
176
+ }
177
+ break;
178
+ case PackageManager.Pnpm:
179
+ if (packageJson.pnpm?.overrides) {
180
+ delete packageJson.pnpm.overrides[key];
181
+ if (Object.keys(packageJson.pnpm.overrides).length === 0) {
182
+ delete packageJson.pnpm.overrides;
183
+ }
184
+ if (Object.keys(packageJson.pnpm).length === 0) {
185
+ delete packageJson.pnpm;
186
+ }
187
+ }
188
+ break;
189
+ case PackageManager.YarnClassic:
190
+ case PackageManager.YarnBerry:
191
+ if (packageJson.resolutions) {
192
+ delete packageJson.resolutions[key];
193
+ if (Object.keys(packageJson.resolutions).length === 0) {
194
+ delete packageJson.resolutions;
195
+ }
196
+ }
197
+ break;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Removes multiple overrides from package.json content.
203
+ * Also removes associated comments.
204
+ * Returns the modified JSON string.
205
+ */
206
+ export function removeOverridesFromContent(
207
+ originalContent: string,
208
+ packageManager: PackageManager,
209
+ keysToRemove: Set<string>
210
+ ): string {
211
+ const packageJson = JSON.parse(originalContent);
212
+ const {overrideField, commentField} = getOverrideFieldNames(packageManager);
213
+
214
+ // Remove overrides
215
+ if (packageManager === PackageManager.Pnpm) {
216
+ if (packageJson.pnpm?.overrides) {
217
+ for (const key of keysToRemove) {
218
+ delete packageJson.pnpm.overrides[key];
219
+ }
220
+ if (Object.keys(packageJson.pnpm.overrides).length === 0) {
221
+ delete packageJson.pnpm.overrides;
222
+ }
223
+ if (Object.keys(packageJson.pnpm).length === 0) {
224
+ delete packageJson.pnpm;
225
+ }
226
+ }
227
+ } else {
228
+ if (packageJson[overrideField]) {
229
+ for (const key of keysToRemove) {
230
+ delete packageJson[overrideField][key];
231
+ }
232
+ if (Object.keys(packageJson[overrideField]).length === 0) {
233
+ delete packageJson[overrideField];
234
+ }
235
+ }
236
+ }
237
+
238
+ // Remove associated comments
239
+ if (packageJson[commentField]) {
240
+ for (const key of keysToRemove) {
241
+ delete packageJson[commentField][key];
242
+ }
243
+ if (Object.keys(packageJson[commentField]).length === 0) {
244
+ delete packageJson[commentField];
245
+ }
246
+ }
247
+
248
+ // Preserve original indentation
249
+ const indentMatch = originalContent.match(/^(\s+)"/m);
250
+ const indent = indentMatch ? indentMatch[1].length : 2;
251
+
252
+ return JSON.stringify(packageJson, null, indent);
253
+ }