From 19bf2cf821222eeeb6f2c4e26f0d9d5058d33a63 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:47:12 +0000 Subject: [PATCH 1/5] install: keep the pnpm block in package.json when migrating from pnpm The pnpm-lock.yaml migration copied pnpm.overrides and pnpm.patchedDependencies to the root of package.json and then deleted them from the pnpm block (deleting the block itself when nothing else was in it). pnpm only reads its configuration from that block, so after one bun install, pnpm install --frozen-lockfile failed for everyone still on pnpm with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH, and a plain pnpm install dropped the overrides from pnpm-lock.yaml. The entries are now copied and the block is left as it was. The copy written to the root is a separate object, so rewriting bare patch keys to name@version and merging in pnpm-workspace.yaml entries no longer shows up inside the block. The four merge-into-root blocks share one helper, and the message says "copied" instead of "moved". The rewritten package.json now goes through print_package_json_into_cache_entry and reparse_root like the other package.json editors. Before, the cache entry kept the edited tree, which pointed into the contents buffer that had just been freed and into Store nodes that the next initialize_store() resets, so bun update run directly on a pnpm project read freed memory when it edited the root package.json after the migration. --- docs/pm/cli/install.mdx | 12 +- src/install/pnpm.rs | 293 +++++------------- .../install/migration/pnpm-lock-v9.test.ts | 129 +++++++- test/cli/install/nested-overrides.test.ts | 20 +- 4 files changed, 225 insertions(+), 229 deletions(-) diff --git a/docs/pm/cli/install.mdx b/docs/pm/cli/install.mdx index 0b219f2b285e..fbf73124eef1 100644 --- a/docs/pm/cli/install.mdx +++ b/docs/pm/cli/install.mdx @@ -583,11 +583,13 @@ Dependencies using pnpm's `catalog:` protocol are preserved: ### Configuration Migration -Bun migrates the following pnpm configuration from both `pnpm-lock.yaml` and `pnpm-workspace.yaml`: +Bun copies the following pnpm configuration into the root `package.json`: -- **Overrides**: Moved from `pnpm.overrides` to root-level `overrides` in `package.json` -- **Patched Dependencies**: Moved from `pnpm.patchedDependencies` to root-level `patchedDependencies` in `package.json` -- **Workspace Overrides**: Applied from `pnpm-workspace.yaml` to root `package.json` +- **Overrides**: Copied from `pnpm.overrides` to root-level `overrides` in `package.json` +- **Patched Dependencies**: Copied from `pnpm.patchedDependencies` to root-level `patchedDependencies` in `package.json` +- **Workspace Overrides**: Copied from `overrides` and `patchedDependencies` in `pnpm-workspace.yaml` to the same root-level fields + +Bun leaves the `pnpm` field of `package.json` as it is. pnpm reads its configuration from that field and ignores the root-level fields, so `pnpm install --frozen-lockfile` keeps working for teammates who still use pnpm. Bun reads the root-level fields and ignores the `pnpm` field. ### Requirements and limitations @@ -598,7 +600,7 @@ Bun migrates the following pnpm configuration from both `pnpm-lock.yaml` and `pn - Relative `link:` dependencies and git dependencies with a sub-directory (`resolution.path`) are not supported - If migration fails for any of these reasons, Bun prints why and resolves from scratch instead -After migration, you can safely remove `pnpm-lock.yaml` and `pnpm-workspace.yaml` files. +Once nobody on the repository uses pnpm anymore, you can remove `pnpm-lock.yaml`, `pnpm-workspace.yaml`, and the `pnpm` field of `package.json`. --- diff --git a/src/install/pnpm.rs b/src/install/pnpm.rs index abf7916c98f8..70faa416fb8e 100644 --- a/src/install/pnpm.rs +++ b/src/install/pnpm.rs @@ -17,6 +17,7 @@ use crate::external_slice::ExternalSlice; use crate::integrity::Integrity; use crate::lockfile::{self, LoadResult, LoadResultOk, Lockfile}; use crate::npm::{self}; +use crate::package_manager_real::update_package_json_and_install::print_package_json_into_cache_entry; use crate::repository::Repository; use crate::resolution::{self, Resolution, TaggedValue}; use crate::{DependencyID, INVALID_PACKAGE_ID, PackageID, PackageManager}; @@ -2368,140 +2369,29 @@ fn update_package_json_after_migration( return Ok(()); } - let mut needs_update = false; - let mut moved_overrides = false; - let mut moved_patched_deps = false; - let mut moved: Vec<&'static str> = Vec::new(); + let mut copied: Vec<&'static str> = Vec::new(); - if let Some(mut pnpm_prop) = json.as_property(b"pnpm") { + // The `pnpm` block is left as it is: pnpm still reads its config from there, and bun + // only reads the root-level fields, so the same package.json keeps working for both. + if let Some(pnpm_prop) = json.as_property(b"pnpm") { if pnpm_prop.expr.is_object() { - let pnpm_obj = e_object_mut(&mut pnpm_prop.expr); - - if let Some(overrides_field) = pnpm_obj.get(b"overrides") { - if is_non_empty_object(&overrides_field) { - if let Some(mut existing_prop) = json.as_property(b"overrides") { - if existing_prop.expr.is_object() { - let existing_overrides = e_object_mut(&mut existing_prop.expr); - for prop in e_object(&overrides_field).properties.slice() { - let Some(key) = - as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - continue; - }; - existing_overrides.put( - &bump, - key, - prop.value.expect("infallible: prop has value"), - )?; - } - } - } else { - e_object_mut(&mut json).put(&bump, b"overrides", overrides_field)?; - } - moved_overrides = true; - needs_update = true; - moved.push("pnpm.overrides to overrides"); - } - } + let pnpm_obj = e_object(&pnpm_prop.expr); - if let Some(mut patched_field) = pnpm_obj.get(b"patchedDependencies") { - if is_non_empty_object(&patched_field) { - rewrite_bare_patch_keys(&mut patched_field, patches)?; - if let Some(mut existing_prop) = json.as_property(b"patchedDependencies") { - if existing_prop.expr.is_object() { - let existing_patches = e_object_mut(&mut existing_prop.expr); - for prop in e_object(&patched_field).properties.slice() { - let Some(key) = - as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - continue; - }; - existing_patches.put( - &bump, - key, - prop.value.expect("infallible: prop has value"), - )?; - } - } - } else { - e_object_mut(&mut json).put( - &bump, - b"patchedDependencies", - patched_field, - )?; - } - moved_patched_deps = true; - needs_update = true; - moved.push("pnpm.patchedDependencies to patchedDependencies"); + if let Some(overrides) = pnpm_obj.get(b"overrides").filter(is_non_empty_object) { + if copy_into_root(&mut json, &bump, b"overrides", copy_object(&overrides))? { + copied.push("pnpm.overrides to overrides"); } } - if moved_overrides || moved_patched_deps { - let mut remaining_count: usize = 0; - for prop in pnpm_obj.properties.slice() { - let Some(key) = as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - remaining_count += 1; - continue; - }; - if moved_overrides && key == b"overrides" { - continue; - } - if moved_patched_deps && key == b"patchedDependencies" { - continue; - } - remaining_count += 1; - } - - if remaining_count == 0 { - let mut new_root_count: usize = 0; - for prop in e_object(&json).properties.slice() { - let Some(key) = - as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - new_root_count += 1; - continue; - }; - if key != b"pnpm" { - new_root_count += 1; - } - } - - let mut new_root_props = G::PropertyList::init_capacity(new_root_count); - for prop in e_object(&json).properties.slice() { - let Some(key) = - as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - VecExt::append(&mut new_root_props, shallow_clone_prop(prop)); - continue; - }; - if key != b"pnpm" { - VecExt::append(&mut new_root_props, shallow_clone_prop(prop)); - } - } - - e_object_mut(&mut json).properties = new_root_props; - } else { - let mut new_pnpm_props = G::PropertyList::init_capacity(remaining_count); - for prop in pnpm_obj.properties.slice() { - let Some(key) = - as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - VecExt::append(&mut new_pnpm_props, shallow_clone_prop(prop)); - continue; - }; - if moved_overrides && key == b"overrides" { - continue; - } - if moved_patched_deps && key == b"patchedDependencies" { - continue; - } - VecExt::append(&mut new_pnpm_props, shallow_clone_prop(prop)); - } - - pnpm_obj.properties = new_pnpm_props; + if let Some(patched) = pnpm_obj + .get(b"patchedDependencies") + .filter(is_non_empty_object) + { + let mut patched = copy_object(&patched); + rewrite_bare_patch_keys(&mut patched, patches)?; + if copy_into_root(&mut json, &bump, b"patchedDependencies", patched)? { + copied.push("pnpm.patchedDependencies to patchedDependencies"); } - needs_update = true; } } } @@ -2659,108 +2549,44 @@ fn update_package_json_after_migration( } } if wrote_workspaces { - needs_update = true; - moved.push("pnpm-workspace.yaml to workspaces"); - } - - // Handle overrides from pnpm-workspace.yaml - if let Some(ws_overrides) = &workspace_overrides_obj { - if ws_overrides.is_object() { - if let Some(mut existing_prop) = json.as_property(b"overrides") { - if existing_prop.expr.is_object() { - let existing_overrides = e_object_mut(&mut existing_prop.expr); - for prop in e_object(ws_overrides).properties.slice() { - let Some(key) = - as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - continue; - }; - existing_overrides.put( - &bump, - key, - prop.value.expect("infallible: prop has value"), - )?; - } - } - } else { - e_object_mut(&mut json).put(&bump, b"overrides", *ws_overrides)?; - } - needs_update = true; - moved.push("pnpm-workspace.yaml overrides to overrides"); - } + copied.push("pnpm-workspace.yaml to workspaces"); } - // Handle patchedDependencies from pnpm-workspace.yaml - if let Some(ws_patched) = &mut workspace_patched_deps_obj { - if ws_patched.is_object() { - rewrite_bare_patch_keys(ws_patched, patches)?; - if let Some(mut existing_prop) = json.as_property(b"patchedDependencies") { - if existing_prop.expr.is_object() { - let existing_patches = e_object_mut(&mut existing_prop.expr); - for prop in e_object(ws_patched).properties.slice() { - let Some(key) = - as_string(prop.key.as_ref().expect("infallible: prop has key")) - else { - continue; - }; - existing_patches.put( - &bump, - key, - prop.value.expect("infallible: prop has value"), - )?; - } - } - } else { - e_object_mut(&mut json).put(&bump, b"patchedDependencies", *ws_patched)?; - } - needs_update = true; - moved.push("pnpm-workspace.yaml patchedDependencies to patchedDependencies"); + if let Some(ws_overrides) = workspace_overrides_obj { + if copy_into_root(&mut json, &bump, b"overrides", ws_overrides)? { + copied.push("pnpm-workspace.yaml overrides to overrides"); } } - if needs_update { - let mut buffer_writer = bun_js_printer::BufferWriter::init(); - buffer_writer.append_newline = !root_pkg_json.source.contents().is_empty() - && root_pkg_json.source.contents()[root_pkg_json.source.contents().len() - 1] == b'\n'; - let mut package_json_writer = bun_js_printer::BufferPrinter::init(buffer_writer); - - if bun_js_printer::print_json( - &mut package_json_writer, - json, - &root_pkg_json.source, - bun_js_printer::PrintJsonOptions { - indent: root_pkg_json.indentation, - mangled_props: None, - ..Default::default() - }, - ) - .is_err() - { - return Ok(()); + if let Some(mut ws_patched) = workspace_patched_deps_obj { + rewrite_bare_patch_keys(&mut ws_patched, patches)?; + if copy_into_root(&mut json, &bump, b"patchedDependencies", ws_patched)? { + copied.push("pnpm-workspace.yaml patchedDependencies to patchedDependencies"); } + } - if package_json_writer.flush().is_err() { - return Err(AllocError); + if !copied.is_empty() { + print_package_json_into_cache_entry(root_pkg_json, json); + // The edited tree still points into the previous contents and into Store nodes that + // the next `initialize_store()` resets, and the install that follows reads this cache + // entry again (`bun update` edits it, `--frozen-lockfile` messages are derived from it). + if let Err(err) = root_pkg_json.reparse_root(log) { + bun_core::pretty_errorln!("package.json failed to parse due to error {}", err.name()); + bun_core::Global::crash(); } - root_pkg_json.source.contents = std::borrow::Cow::Owned( - package_json_writer - .ctx - .written_without_trailing_zero() - .to_vec(), - ); - - // Write the updated package.json if sys::File::write_file( dir, bun_core::zstr!("package.json"), root_pkg_json.source.contents(), ) .is_ok() - && !moved.is_empty() && !silent { - bun_core::pretty_errorln!("moved {} in package.json", moved.join(", ")); + bun_core::pretty_errorln!( + "copied {} in package.json", + copied.join(", ") + ); } } @@ -2771,6 +2597,49 @@ fn is_non_empty_object(expr: &Expr) -> bool { matches!(&expr.data, ExprData::EObject(o) if !o.properties.is_empty()) } +/// A separate object with the same entries. The root-level copy is edited afterwards +/// (`rewrite_bare_patch_keys`, entries merged in from pnpm-workspace.yaml), and none of +/// that may show up in the `pnpm` block it was copied from. +fn copy_object(src: &Expr) -> Expr { + let src_props = e_object(src).properties.slice(); + let mut properties = G::PropertyList::init_capacity(src_props.len()); + for prop in src_props { + VecExt::append(&mut properties, shallow_clone_prop(prop)); + } + Expr::init( + E::Object { + properties, + ..Default::default() + }, + bun_ast::Loc::EMPTY, + ) +} + +/// Adds the entries of `src` to the root-level `field` object, creating it when absent. +/// Returns `false` when `field` exists but is not an object, in which case nothing is written. +fn copy_into_root( + json: &mut Expr, + bump: &bun_alloc::Arena, + field: &[u8], + src: Expr, +) -> Result { + let Some(mut existing) = json.as_property(field) else { + e_object_mut(json).put(bump, field, src)?; + return Ok(true); + }; + if !existing.expr.is_object() { + return Ok(false); + } + let existing_obj = e_object_mut(&mut existing.expr); + for prop in e_object(&src).properties.slice() { + let Some(key) = as_string(prop.key.as_ref().expect("infallible: prop has key")) else { + continue; + }; + existing_obj.put(bump, key, prop.value.expect("infallible: prop has value"))?; + } + Ok(true) +} + fn paths_array(paths: &[&'static [u8]]) -> Expr { let mut items = js_ast::ExprNodeList::init_capacity(paths.len()); for path in paths { diff --git a/test/cli/install/migration/pnpm-lock-v9.test.ts b/test/cli/install/migration/pnpm-lock-v9.test.ts index 9fe4c0434e27..d4dcedfba52f 100644 --- a/test/cli/install/migration/pnpm-lock-v9.test.ts +++ b/test/cli/install/migration/pnpm-lock-v9.test.ts @@ -785,16 +785,19 @@ snapshots: const { stderr, exitCode } = await migrate(packageDir); expect(stderr).not.toContain("is not in patchedDependencies"); + expect(stderr).toContain("copied pnpm.patchedDependencies to patchedDependencies in package.json"); expect(stderr).toContain("migrated lockfile from pnpm-lock.yaml"); expect(exitCode).toBe(0); const bunLock = await bunLockOf(packageDir); expect(bunLock).toContain(`"patchedDependencies": {\n "no-deps@1.0.1": "patches/no-deps.patch",\n }`); + // The versioned key only goes to the root; pnpm keeps reading the bare key from its own block. const packageJson = await Bun.file(join(packageDir, "package.json")).json(); expect(packageJson).toStrictEqual({ name: "patch-path-in-package-json", dependencies: { "no-deps": "^1.0.0" }, + pnpm: { patchedDependencies: { "no-deps": "patches/no-deps.patch" } }, patchedDependencies: { "no-deps@1.0.1": "patches/no-deps.patch" }, }); @@ -807,6 +810,55 @@ snapshots: ); }); + // `bun update` migrates, then edits the cached root package.json that the migration rewrote, so the cached + // copy has to be re-read from the rewritten contents (a stale copy pointed into the freed previous contents). + test("bun update straight from pnpm-lock.yaml edits the package.json the migration rewrote", async () => { + const pnpm = { + patchedDependencies: { "no-deps": "patches/no-deps.patch" }, + overrides: { "a-dep": "1.0.1" }, + }; + const { packageDir } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "hoisted" }, + files: { + "package.json": JSON.stringify({ + name: "update-after-migration", + dependencies: { "no-deps": "^1.0.0" }, + pnpm, + }), + "patches/no-deps.patch": NO_DEPS_INDEX_PATCH, + "pnpm-lock.yaml": bareHashNoDepsLockfile("no-deps").replace( + "importers:", + "overrides:\n a-dep: 1.0.1\n\nimporters:", + ), + }, + }); + + const update = await run(packageDir, "update"); + + expect(update.stderr).toContain( + "copied pnpm.overrides to overrides, pnpm.patchedDependencies to patchedDependencies in package.json", + ); + expect(update.stderr).toContain("migrated lockfile from pnpm-lock.yaml"); + expect(update.stderr).not.toContain("error:"); + expect(update.stdout).toContain("no-deps@1.0.1"); + expect(update.exitCode).toBe(0); + expect(await Bun.file(join(packageDir, "package.json")).json()).toStrictEqual({ + name: "update-after-migration", + dependencies: { "no-deps": "^1.0.1" }, + pnpm, + overrides: pnpm.overrides, + patchedDependencies: { "no-deps@1.0.1": "patches/no-deps.patch" }, + }); + expect(await Bun.file(join(packageDir, "node_modules/no-deps/index.js")).text()).toStartWith( + "globalThis.patchedByMigration = true;\n", + ); + + const frozen = await run(packageDir, "install", "--frozen-lockfile"); + + expect(frozen.stderr).not.toContain("error:"); + expect(frozen.exitCode).toBe(0); + }); + test("versioned lockfile key falls back to the bare config key", async () => { const { packageDir } = await verdaccio.createTestDir({ bunfigOpts: { linker: "hoisted" }, @@ -2440,7 +2492,7 @@ importers: return bunLock.slice(start, end + "\n },".length); } - // pnpm/pnpm#5928 (`-` removes the dependency) is warned once, with a location, when bun install reads the moved package.json overrides; pnpm/pnpm#6774 (`name@range` keys) migrates as ranged rules + // pnpm/pnpm#5928 (`-` removes the dependency) is warned once, with a location, when bun install reads the copied package.json overrides; pnpm/pnpm#6774 (`name@range` keys) migrates as ranged rules test.concurrent("removal values are dropped; name@range keys migrate as ranged rules", async () => { const overrides = { "left-pad": "-", @@ -2463,12 +2515,13 @@ importers: const { stderr, exitCode } = await migrate(String(dir)); expect(stderr).not.toContain("warn:"); - expect(stderr).toContain("moved pnpm.overrides to overrides in package.json"); + expect(stderr).toContain("copied pnpm.overrides to overrides in package.json"); expect(stderr).toContain("migrated lockfile from pnpm-lock.yaml"); expect(exitCode).toBe(0); expect(await Bun.file(join(String(dir), "package.json")).json()).toStrictEqual({ name: "overrides-unsupported", + pnpm: { overrides }, overrides, }); @@ -2499,6 +2552,76 @@ importers: expect(install.exitCode).toBe(0); }); + // pnpm reads `pnpm.overrides` and `pnpm.patchedDependencies` from package.json and ignores the root-level + // fields, so removing them from the `pnpm` block breaks `pnpm install --frozen-lockfile` for everyone still on + // pnpm (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH). The block has to survive the migration as it was. + test.concurrent("the pnpm block in package.json is left as it was", async () => { + const pnpm = { + overrides: { "no-deps": "1.0.0" }, + patchedDependencies: { "one-dep@1.0.0": "patches/one-dep.patch" }, + onlyBuiltDependencies: ["one-dep"], + }; + using dir = tempDir("pnpm-v9-pnpm-block-kept", { + "package.json": JSON.stringify({ name: "pnpm-block-kept", private: true, pnpm }), + "pnpm-lock.yaml": overridesLockfile(" no-deps: 1.0.0"), + }); + using onlyMigratedKeys = tempDir("pnpm-v9-pnpm-block-only-migrated-keys", { + "package.json": JSON.stringify({ name: "pnpm-block-only-migrated-keys", pnpm: { overrides: pnpm.overrides } }), + "pnpm-lock.yaml": overridesLockfile(" no-deps: 1.0.0"), + }); + + const { stderr, exitCode } = await migrate(String(dir)); + + expect(stderr).not.toContain("warn:"); + expect(stderr).toContain( + "copied pnpm.overrides to overrides, pnpm.patchedDependencies to patchedDependencies in package.json", + ); + expect(exitCode).toBe(0); + expect(await Bun.file(join(String(dir), "package.json")).json()).toStrictEqual({ + name: "pnpm-block-kept", + private: true, + pnpm, + overrides: pnpm.overrides, + patchedDependencies: pnpm.patchedDependencies, + }); + + const onlyMigrated = await migrate(String(onlyMigratedKeys)); + + expect(onlyMigrated.stderr).toContain("copied pnpm.overrides to overrides in package.json"); + expect(onlyMigrated.exitCode).toBe(0); + expect(await Bun.file(join(String(onlyMigratedKeys), "package.json")).json()).toStrictEqual({ + name: "pnpm-block-only-migrated-keys", + pnpm: { overrides: pnpm.overrides }, + overrides: pnpm.overrides, + }); + }); + + // pnpm 10 merges `pnpm.overrides` with the `overrides` of pnpm-workspace.yaml, and the lockfile records the + // merged set. The yaml entries belong in the root-level copy only. + test.concurrent("pnpm-workspace.yaml overrides go into the root copy, not into the pnpm block", async () => { + using dir = tempDir("pnpm-v9-overrides-package-json-and-workspace-yaml", { + "package.json": JSON.stringify({ + name: "overrides-package-json-and-workspace-yaml", + pnpm: { overrides: { "no-deps": "1.0.0" } }, + }), + "pnpm-workspace.yaml": "overrides:\n a-dep: 1.0.1\n", + "pnpm-lock.yaml": overridesLockfile(" no-deps: 1.0.0\n a-dep: 1.0.1"), + }); + + const { stderr, exitCode } = await migrate(String(dir)); + + expect(stderr).not.toContain("warn:"); + expect(stderr).toContain( + "copied pnpm.overrides to overrides, pnpm-workspace.yaml overrides to overrides in package.json", + ); + expect(exitCode).toBe(0); + expect(await Bun.file(join(String(dir), "package.json")).json()).toStrictEqual({ + name: "overrides-package-json-and-workspace-yaml", + pnpm: { overrides: { "no-deps": "1.0.0" } }, + overrides: { "no-deps": "1.0.0", "a-dep": "1.0.1" }, + }); + }); + test.concurrent("parent selectors become nested rules", async () => { using dir = tempDir("pnpm-v9-overrides-nested", { "package.json": JSON.stringify({ name: "overrides-nested" }), @@ -2536,7 +2659,7 @@ importers: const deep = await migrate(String(tooDeep)); expect(deep.stderr).not.toContain("warn:"); - expect(deep.stderr).toContain("moved pnpm.overrides to overrides in package.json"); + expect(deep.stderr).toContain("copied pnpm.overrides to overrides in package.json"); expect(deep.stderr).toContain("migrated lockfile from pnpm-lock.yaml"); expect(deep.exitCode).toBe(0); expect(await bunLockOf(String(tooDeep))).not.toContain("a>b"); diff --git a/test/cli/install/nested-overrides.test.ts b/test/cli/install/nested-overrides.test.ts index 9308eb302438..e70335cd2780 100644 --- a/test/cli/install/nested-overrides.test.ts +++ b/test/cli/install/nested-overrides.test.ts @@ -1553,7 +1553,7 @@ snapshots: } const migratedLine = /\[[\d.]+m?s\] migrated lockfile from pnpm-lock\.yaml\n/; - const movedOverridesLine = "moved pnpm.overrides to overrides in package.json"; + const copiedOverridesLine = "copied pnpm.overrides to overrides in package.json"; test("pnpm-lock.yaml parent>child overrides become nested rules that package.json agrees with", async () => { const dir = await project( @@ -1565,7 +1565,7 @@ snapshots: expect(migrated.err).not.toContain("warn:"); expect(migrated.err).not.toContain("error:"); expect(migrated.err).toMatch(migratedLine); - expect(occurrences(migrated.err, movedOverridesLine)).toBe(1); + expect(occurrences(migrated.err, copiedOverridesLine)).toBe(1); expect(migrated.exitCode).toBe(0); const text = await lock(dir); expect(text).toContain('"one-dep": {'); @@ -1575,6 +1575,7 @@ snapshots: expect(JSON.parse(packageJson)).toStrictEqual({ name: "nested-overrides", dependencies: { "one-dep": "1.0.0" }, + pnpm: { overrides: { "one-dep>no-deps": "2.0.0" } }, overrides: { "one-dep>no-deps": "2.0.0" }, }); await installOk(dir, "--frozen-lockfile"); @@ -1589,7 +1590,7 @@ snapshots: const migrated = await migrate(dir); expect(migrated.err).not.toContain("warn:"); expect(migrated.err).not.toContain("error:"); - expect(occurrences(migrated.err, movedOverridesLine)).toBe(1); + expect(occurrences(migrated.err, copiedOverridesLine)).toBe(1); expect(migrated.exitCode).toBe(0); const text = await lock(dir); expect(text).toContain('"one-dep": {'); @@ -1607,7 +1608,7 @@ snapshots: const migrated = await migrate(dir); expect(migrated.err).not.toContain("warn:"); expect(migrated.err).not.toContain("error:"); - expect(occurrences(migrated.err, movedOverridesLine)).toBe(1); + expect(occurrences(migrated.err, copiedOverridesLine)).toBe(1); expect(migrated.exitCode).toBe(0); const text = await lock(dir); expect(text).toContain('"lockfileVersion": 3'); @@ -1638,7 +1639,7 @@ snapshots: }) .then(({ packageDir }) => packageDir); const migrated = await migrate(dir); - expect(occurrences(migrated.err, movedOverridesLine)).toBe(1); + expect(occurrences(migrated.err, copiedOverridesLine)).toBe(1); expect(migrated.err).not.toContain("error:"); expect(migrated.exitCode).toBe(0); const packageJson = await packageJsonText(dir); @@ -1649,10 +1650,11 @@ snapshots: name: "nested-overrides", dependencies: { "one-dep": "1.0.0" }, overrides: { "a-dep": "1.0.1", "one-dep>no-deps": "2.0.0" }, + pnpm: { overrides: { "one-dep>no-deps": "2.0.0" } }, }); }); - test("an empty pnpm.overrides is not moved and package.json is not announced as modified", async () => { + test("an empty pnpm.overrides is not copied and package.json is not announced as modified", async () => { const dir = await project({ dependencies: { "one-dep": "1.0.0" }, pnpm: { overrides: {} } }, "hoisted", { "pnpm-lock.yaml": await pnpmLock({ noDepsVersion: "1.0.1" }), }); @@ -1660,7 +1662,7 @@ snapshots: const migrated = await migrate(dir); expect(migrated.err).not.toContain("warn:"); expect(migrated.err).not.toContain("error:"); - expect(migrated.err).not.toContain(movedOverridesLine); + expect(migrated.err).not.toContain(copiedOverridesLine); expect(migrated.err).toMatch(migratedLine); expect(migrated.exitCode).toBe(0); expect(await packageJsonText(dir)).toBe(before); @@ -1696,14 +1698,14 @@ snapshots: ), ); - test("bun install warns once per rejected rule that the migration moved into package.json", async () => { + test("bun install warns once per rejected rule that the migration copied into package.json", async () => { const dir = await rejectedRulesProject(); const { err, exitCode } = await install(dir); expect(occurrences(err, 'warn: Removing "left-pad" with "-" is not supported')).toBe(1); expect(occurrences(err, 'warn: Bun currently only supports one level of nested "overrides"')).toBe(1); expect(occurrences(err, "warn:")).toBe(2); expect(err).toMatch(migratedLine); - expect(occurrences(err, movedOverridesLine)).toBe(1); + expect(occurrences(err, copiedOverridesLine)).toBe(1); expect(err).not.toContain("error:"); expect(exitCode).toBe(0); expect((await file(join(dir, "package.json")).json()).overrides).toStrictEqual({ From f133b4ad6b8654e70bfe87a42e6325b2113f7e61 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:10:21 +0000 Subject: [PATCH 2/5] test: cover bun update -i migrating a pnpm workspace twice (#23694) --- .../install/migration/pnpm-lock-v9.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/test/cli/install/migration/pnpm-lock-v9.test.ts b/test/cli/install/migration/pnpm-lock-v9.test.ts index d4dcedfba52f..906ad2a665ed 100644 --- a/test/cli/install/migration/pnpm-lock-v9.test.ts +++ b/test/cli/install/migration/pnpm-lock-v9.test.ts @@ -2471,6 +2471,77 @@ snapshots: }); }); + // #23694: `bun update -i` migrates once to list the outdated packages, edits the root package.json through the + // cache, then installs, which migrates again because bun.lock is still not on disk. The editor and the second + // migration both used the tree the first migration had edited, which pointed into the contents it had freed. + test("bun update -i in a pnpm workspace migrates twice and keeps package.json intact", async () => { + const { packageDir } = await verdaccio.createTestDir({ + bunfigOpts: { linker: "hoisted" }, + files: { + "package.json": JSON.stringify({ + name: "update-interactive", + dependencies: { "no-deps": "^1.0.0" }, + pnpm: { overrides: { "a-dep": "1.0.1" } }, + }), + "packages/a/package.json": JSON.stringify({ name: "a" }), + "pnpm-workspace.yaml": "packages:\n - packages/*\n", + "pnpm-lock.yaml": `lockfileVersion: '9.0' + +overrides: + a-dep: 1.0.1 + +importers: + + .: + dependencies: + no-deps: + specifier: ^1.0.0 + version: 1.0.0 + + packages/a: {} + +packages: + + no-deps@1.0.0: + resolution: {integrity: ${NO_DEPS_1_0_0_INTEGRITY}} + +snapshots: + + no-deps@1.0.0: {} +`, + }, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "update", "-i"], + cwd: packageDir, + env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(packageDir, ".bun-cache") }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + proc.stdin.write("a\r"); + proc.stdin.end(); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("error:"); + expect(stdout).toContain("no-deps"); + expect(exitCode).toBe(0); + expect(await Bun.file(join(packageDir, "package.json")).json()).toStrictEqual({ + name: "update-interactive", + dependencies: { "no-deps": "^1.1.0" }, + pnpm: { overrides: { "a-dep": "1.0.1" } }, + overrides: { "a-dep": "1.0.1" }, + workspaces: ["packages/*"], + }); + expect(existsSync(join(packageDir, "bun.lock"))).toBe(true); + + const frozen = await run(packageDir, "install", "--frozen-lockfile"); + + expect(frozen.stderr).not.toContain("error:"); + expect(frozen.exitCode).toBe(0); + }); + describe("overrides", () => { function overridesLockfile(overrides: string) { return `lockfileVersion: '9.0' From 99b6cc66ef8c3189e948d59450d2a68ed9f48da1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:13:50 +0000 Subject: [PATCH 3/5] install: shorten comments in update_package_json_after_migration --- src/install/pnpm.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/install/pnpm.rs b/src/install/pnpm.rs index 70faa416fb8e..bd59b3b96943 100644 --- a/src/install/pnpm.rs +++ b/src/install/pnpm.rs @@ -2371,8 +2371,7 @@ fn update_package_json_after_migration( let mut copied: Vec<&'static str> = Vec::new(); - // The `pnpm` block is left as it is: pnpm still reads its config from there, and bun - // only reads the root-level fields, so the same package.json keeps working for both. + // Copied, not moved: pnpm keeps reading this block, bun only reads the root-level fields. if let Some(pnpm_prop) = json.as_property(b"pnpm") { if pnpm_prop.expr.is_object() { let pnpm_obj = e_object(&pnpm_prop.expr); @@ -2567,9 +2566,8 @@ fn update_package_json_after_migration( if !copied.is_empty() { print_package_json_into_cache_entry(root_pkg_json, json); - // The edited tree still points into the previous contents and into Store nodes that - // the next `initialize_store()` resets, and the install that follows reads this cache - // entry again (`bun update` edits it, `--frozen-lockfile` messages are derived from it). + // `bun update` goes on to edit this entry; the tree printed above borrows from the + // contents that were just replaced. if let Err(err) = root_pkg_json.reparse_root(log) { bun_core::pretty_errorln!("package.json failed to parse due to error {}", err.name()); bun_core::Global::crash(); @@ -2597,9 +2595,7 @@ fn is_non_empty_object(expr: &Expr) -> bool { matches!(&expr.data, ExprData::EObject(o) if !o.properties.is_empty()) } -/// A separate object with the same entries. The root-level copy is edited afterwards -/// (`rewrite_bare_patch_keys`, entries merged in from pnpm-workspace.yaml), and none of -/// that may show up in the `pnpm` block it was copied from. +/// The root-level copy gets edited further; an `Expr` from `get` would alias the `pnpm` block. fn copy_object(src: &Expr) -> Expr { let src_props = e_object(src).properties.slice(); let mut properties = G::PropertyList::init_capacity(src_props.len()); @@ -2615,8 +2611,8 @@ fn copy_object(src: &Expr) -> Expr { ) } -/// Adds the entries of `src` to the root-level `field` object, creating it when absent. -/// Returns `false` when `field` exists but is not an object, in which case nothing is written. +/// Merges `src` into the root-level `field` object, creating it when absent. +/// `false` when `field` exists but is not an object; nothing is written then. fn copy_into_root( json: &mut Expr, bump: &bun_alloc::Arena, From d40cca50aceb0251719846edb0a5617c49661773 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:15:56 +0000 Subject: [PATCH 4/5] install: single-line comments in update_package_json_after_migration --- src/install/pnpm.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/install/pnpm.rs b/src/install/pnpm.rs index bd59b3b96943..cf6ac65fefd7 100644 --- a/src/install/pnpm.rs +++ b/src/install/pnpm.rs @@ -2566,8 +2566,7 @@ fn update_package_json_after_migration( if !copied.is_empty() { print_package_json_into_cache_entry(root_pkg_json, json); - // `bun update` goes on to edit this entry; the tree printed above borrows from the - // contents that were just replaced. + // The printed tree borrows from the replaced contents; `bun update` edits this entry next. if let Err(err) = root_pkg_json.reparse_root(log) { bun_core::pretty_errorln!("package.json failed to parse due to error {}", err.name()); bun_core::Global::crash(); @@ -2611,8 +2610,7 @@ fn copy_object(src: &Expr) -> Expr { ) } -/// Merges `src` into the root-level `field` object, creating it when absent. -/// `false` when `field` exists but is not an object; nothing is written then. +/// Merges `src` into the root-level `field` (created when absent); `false` if it is not an object. fn copy_into_root( json: &mut Expr, bump: &bun_alloc::Arena, From cb7af7477b7de75f7f629998dff2a034dbcee1de Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:56:25 +0000 Subject: [PATCH 5/5] install: parse pnpm-workspace.yaml into the arena that outlives the package.json print Quoted yaml scalars are copied into the arena the yaml is parsed with. That arena was local to the block that read the file, while the catalog, overrides and patchedDependencies objects taken from the tree are printed into package.json after the block ends. --- src/install/pnpm.rs | 4 +-- .../install/migration/pnpm-lock-v9.test.ts | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/install/pnpm.rs b/src/install/pnpm.rs index cf6ac65fefd7..e3d7e5252407 100644 --- a/src/install/pnpm.rs +++ b/src/install/pnpm.rs @@ -2415,11 +2415,11 @@ fn update_package_json_after_migration( // `Expr::data_store_reset`). let contents: &'static [u8] = js_ast::data_store_dupe_str(&contents); let yaml_source = bun_ast::Source::init_path_string(b"pnpm-workspace.yaml", contents); - let arena = bun_alloc::Arena::new(); + // Quoted scalars are copied into the arena; `bump` lives until the print below. let Ok(ws_root) = bun_parsers::yaml::YAML::parse( &yaml_source, log, - &arena, + &bump, bun_parsers::yaml::CyclicAliases::Reject, ) else { break 'read_pnpm_workspace_yaml; diff --git a/test/cli/install/migration/pnpm-lock-v9.test.ts b/test/cli/install/migration/pnpm-lock-v9.test.ts index 906ad2a665ed..e5d76d805ed9 100644 --- a/test/cli/install/migration/pnpm-lock-v9.test.ts +++ b/test/cli/install/migration/pnpm-lock-v9.test.ts @@ -2693,6 +2693,33 @@ importers: }); }); + // Unquoted yaml scalars point into the file contents; quoted ones (the usual spelling for scoped names) are + // copied into the arena the yaml was parsed with, which has to stay alive until package.json is printed. + test.concurrent("quoted pnpm-workspace.yaml scalars are written to package.json", async () => { + using dir = tempDir("pnpm-v9-workspace-yaml-quoted", { + "package.json": JSON.stringify({ name: "workspace-yaml-quoted" }), + "pnpm-workspace.yaml": `overrides: + '@scope/pkg': "1.0.0" +catalog: + '@scope/other': '^2.0.0' +`, + "pnpm-lock.yaml": overridesLockfile(" '@scope/pkg': 1.0.0"), + }); + + const { stderr, exitCode } = await migrate(String(dir)); + + expect(stderr).not.toContain("warn:"); + expect(stderr).toContain( + "copied pnpm-workspace.yaml to workspaces, pnpm-workspace.yaml overrides to overrides in package.json", + ); + expect(exitCode).toBe(0); + expect(await Bun.file(join(String(dir), "package.json")).json()).toStrictEqual({ + name: "workspace-yaml-quoted", + workspaces: { catalog: { "@scope/other": "^2.0.0" } }, + overrides: { "@scope/pkg": "1.0.0" }, + }); + }); + test.concurrent("parent selectors become nested rules", async () => { using dir = tempDir("pnpm-v9-overrides-nested", { "package.json": JSON.stringify({ name: "overrides-nested" }),