From 509190f54221b680bfec84272466f6005fb1642c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:01:00 +0000 Subject: [PATCH 1/4] pm: honour --dry-run in link, unlink, pm version, pm cache rm, pm trust, pm pkg, and pm migrate --dry-run was parsed by each of these commands but never read. link created the global symlink, unlink deleted it, pm version wrote package.json and ran the version scripts and git commit/tag, pm cache rm deleted the cache, pm trust ran the blocked scripts and wrote package.json and bun.lock, pm pkg set/delete/fix wrote package.json, and pm migrate wrote bun.lock. Each command now stops before its first write and prints what it would have done. --- docs/pm/cli/pm.mdx | 10 ++- src/runtime/cli/link_command.rs | 19 ++++- src/runtime/cli/package_manager_command.rs | 83 ++++++++++++------- src/runtime/cli/pm_pkg_command.rs | 22 +++-- src/runtime/cli/pm_trusted_command.rs | 47 +++++++++++ src/runtime/cli/pm_version_command.rs | 10 ++- src/runtime/cli/unlink_command.rs | 19 ++++- .../bun-install-lifecycle-scripts.test.ts | 55 ++++++++++++ test/cli/install/bun-link.test.ts | 48 ++++++++++- test/cli/install/bun-pm-pkg.test.ts | 22 +++++ test/cli/install/bun-pm-version.test.ts | 52 ++++++++++++ test/cli/install/bun-pm.test.ts | 33 ++++++++ test/cli/install/migration/migrate.test.ts | 25 ++++++ 13 files changed, 404 insertions(+), 41 deletions(-) diff --git a/docs/pm/cli/pm.mdx b/docs/pm/cli/pm.mdx index 94b615753b85..d695267e4930 100644 --- a/docs/pm/cli/pm.mdx +++ b/docs/pm/cli/pm.mdx @@ -332,6 +332,8 @@ To clear Bun's global module cache: bun pm cache rm ``` +Pass `--dry-run` to print the directories that would be deleted without deleting them. + ## migrate To migrate another package manager's lockfile without installing anything: @@ -340,6 +342,8 @@ To migrate another package manager's lockfile without installing anything: bun pm migrate ``` +Pass `--dry-run` to run the migration without writing `bun.lock`. + ## untrusted To print current untrusted dependencies with scripts: @@ -366,6 +370,7 @@ bun pm trust Options for the `trust` command: - `--all`: Trust all untrusted dependencies. +- `--dry-run`: Print the scripts that would run and the packages that would be trusted, without running scripts or writing `package.json` and `bun.lock`. ## default-trusted @@ -406,6 +411,7 @@ Options: --message=, -m Custom commit message, use %s for version substitution --preid= Prerelease identifier (i.e beta → 1.0.1-beta.0) --force, -f Bypass dirty git history check + --dry-run Print the new version without writing, running scripts, or tagging Examples: bun pm version patch @@ -423,7 +429,7 @@ bun pm version patch v1.0.1 ``` -Supports `patch`, `minor`, `major`, `premajor`, `preminor`, `prepatch`, `prerelease`, `from-git`, or specific versions like `1.2.3`. By default it creates a git commit and tag; pass `--no-git-tag-version` to skip them. +Supports `patch`, `minor`, `major`, `premajor`, `preminor`, `prepatch`, `prerelease`, `from-git`, or specific versions like `1.2.3`. By default it creates a git commit and tag; pass `--no-git-tag-version` to skip them. Pass `--dry-run` to print the new version without writing `package.json`, running lifecycle scripts, or touching git. ## pkg @@ -459,3 +465,5 @@ bun pm pkg delete scripts.test contributors[0] # multiple/nested # fix bun pm pkg fix # auto-fix common issues ``` + +`set`, `delete`, and `fix` accept `--dry-run`, which prints the resulting `package.json` instead of writing it. diff --git a/src/runtime/cli/link_command.rs b/src/runtime/cli/link_command.rs index 37e454a1ce58..f987ce3023ee 100644 --- a/src/runtime/cli/link_command.rs +++ b/src/runtime/cli/link_command.rs @@ -2,7 +2,7 @@ use bstr::BStr; use bun_core::strings; use bun_core::{Global, Output}; -use bun_paths::AbsPath; +use bun_paths::{AbsPath, platform, resolve_path}; use bun_resolver::fs::FileSystem; use bun_sys::{Dir, Fd, FdDirExt}; @@ -112,6 +112,22 @@ fn link(ctx: command::Context) -> crate::Result<()> { // from `package_json_source` (dropped above). let name = lockfile.str(&package.name); + if manager.options.dry_run { + if manager.options.log_level != LogLevel::Silent { + let link_path = resolve_path::join_abs_string_z::( + pm::global_link_dir_path(manager), + &[name], + ); + bun_core::prettyln!( + "dry run: would link \"{}\" at {}", + BStr::new(name), + BStr::new(link_path.as_bytes()), + ); + } + Output::flush(); + Global::exit(0); + } + // Step 2. Setup the global directory let node_modules: Dir = 'brk: { bin::Linker::ensure_umask(); @@ -169,7 +185,6 @@ fn link(ctx: command::Context) -> crate::Result<()> { #[cfg(windows)] { use bun_core::ZStr; - use bun_paths::{platform, resolve_path}; // create the junction let top_level = FileSystem::instance().top_level_dir_without_trailing_slash(); let mut link_path_buf = bun_paths::path_buffer_pool::get(); diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 4dbcc945eea9..cd64d55f8a3b 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -436,38 +436,47 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; && strings::eql_comptime(pm.options.positionals[1], b"rm") { let mut had_err = false; + let dry_run = pm.options.dry_run; let mut process_env = bun_dotenv::Loader::init(); process_env.load_process()?; let cache_dir = fetch_cache_directory_path(&mut process_env, None); - let mut rm_buf = bun_paths::path_buffer_pool::get(); - let rm_dir = match Dir::cwd().make_open_path(&cache_dir.path, Default::default()) { - Ok(d) => d, - Err(err) => { - bun_core::pretty_errorln!( - "{} getting cache directory", - crate::Error::from(err).name(), - ); - Global::crash(); - } - }; - let rm_path = match rm_dir.get_fd_path(&mut rm_buf) { - Ok(p) => &p[..], - Err(err) => { - bun_core::pretty_errorln!( - "{} getting cache directory", - crate::Error::from(err).name(), - ); - Global::crash(); - } - }; - rm_dir.close(); + if dry_run { + bun_core::prettyln!( + "dry run: would delete 'bun install' cache at {}", + bstr::BStr::new(&cache_dir.path), + ); + } else { + let mut rm_buf = bun_paths::path_buffer_pool::get(); + let rm_dir = + match Dir::cwd().make_open_path(&cache_dir.path, Default::default()) { + Ok(d) => d, + Err(err) => { + bun_core::pretty_errorln!( + "{} getting cache directory", + crate::Error::from(err).name(), + ); + Global::crash(); + } + }; + let rm_path = match rm_dir.get_fd_path(&mut rm_buf) { + Ok(p) => &p[..], + Err(err) => { + bun_core::pretty_errorln!( + "{} getting cache directory", + crate::Error::from(err).name(), + ); + Global::crash(); + } + }; + rm_dir.close(); - if let Err(err) = bun_sys::delete_tree_absolute(rm_path) { - Output::err(err, "Could not delete {s}", (bstr::BStr::new(rm_path),)); - had_err = true; + if let Err(err) = bun_sys::delete_tree_absolute(rm_path) { + Output::err(err, "Could not delete {s}", (bstr::BStr::new(rm_path),)); + had_err = true; + } + bun_core::prettyln!("Cleared 'bun install' cache"); } - bun_core::prettyln!("Cleared 'bun install' cache"); 'bunx: { let tmp = Fs::RealFS::platform_temp_dir(); @@ -516,7 +525,14 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; }; let name = entry.name.slice_u8(); if name.starts_with(prefix.as_slice()) { - if let Err(err) = tmp_dir.delete_tree(name) { + if dry_run { + bun_core::prettyln!( + "dry run: would delete {}{}{}", + bstr::BStr::new(strings::without_trailing_slash(tmp)), + std::path::MAIN_SEPARATOR, + bstr::BStr::new(name), + ); + } else if let Err(err) = tmp_dir.delete_tree(name) { Output::err(err, "Could not delete {s}", (bstr::BStr::new(name),)); had_err = true; continue; @@ -526,7 +542,11 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; } } - bun_core::prettyln!("Cleared {} cached 'bunx' packages", deleted); + if dry_run { + bun_core::prettyln!("Would clear {} cached 'bunx' packages", deleted); + } else { + bun_core::prettyln!("Cleared {} cached 'bunx' packages", deleted); + } } Global::exit(if had_err { 1 } else { 0 }); @@ -742,6 +762,13 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; Global::exit(1); } Self::handle_load_lockfile_errors(&load_lockfile, log_level); + if pm.options.dry_run { + if log_level != LogLevel::Silent { + bun_core::prettyln!("dry run: would write bun.lock"); + } + Output::flush(); + Global::exit(0); + } // Reshaped for borrowck — `save_to_disk` needs // `&mut Lockfile` (self) and `&LoadResult` simultaneously, but // `LoadResultOk.lockfile` already holds the only `&mut` into the diff --git a/src/runtime/cli/pm_pkg_command.rs b/src/runtime/cli/pm_pkg_command.rs index 9c4700446669..79623f03c91d 100644 --- a/src/runtime/cli/pm_pkg_command.rs +++ b/src/runtime/cli/pm_pkg_command.rs @@ -326,14 +326,14 @@ impl PmPkgCommand { } if modified { - Self::save_package_json(&path, root, &pkg)?; + Self::save_package_json(&path, root, &pkg, pm.options.dry_run)?; } Ok(()) } fn exec_delete( ctx: &Context, - _pm: &mut PackageManager, + pm: &mut PackageManager, args: &[&[u8]], cwd: &[u8], ) -> Result<(), Error> { @@ -369,12 +369,12 @@ impl PmPkgCommand { } if modified { - Self::save_package_json(&path, root, &pkg)?; + Self::save_package_json(&path, root, &pkg, pm.options.dry_run)?; } Ok(()) } - fn exec_fix(ctx: &Context, _pm: &mut PackageManager, cwd: &[u8]) -> Result<(), Error> { + fn exec_fix(ctx: &Context, pm: &mut PackageManager, cwd: &[u8]) -> Result<(), Error> { let path = Self::find_package_json(cwd)?; let pkg = Self::load_package_json(ctx, &path)?; @@ -426,7 +426,7 @@ impl PmPkgCommand { } if modified { - Self::save_package_json(&path, root, &pkg)?; + Self::save_package_json(&path, root, &pkg, pm.options.dry_run)?; } Ok(()) } @@ -825,7 +825,12 @@ impl PmPkgCommand { Ok(true) } - fn save_package_json(path: &[u8], root: Expr, pkg: &PackageJson) -> Result<(), Error> { + fn save_package_json( + path: &[u8], + root: Expr, + pkg: &PackageJson, + dry_run: bool, + ) -> Result<(), Error> { let preserve_newline = !pkg.contents.is_empty() && pkg.contents[pkg.contents.len() - 1] == b'\n'; @@ -853,6 +858,11 @@ impl PmPkgCommand { } let content = writer.ctx.written_without_trailing_zero(); + if dry_run { + let _ = Output::writer().write_all(content); + Output::flush(); + return Ok(()); + } let path_z = bun_core::ZBox::from_bytes(path); if let Err(e) = bun_sys::File::write_file(bun_sys::Fd::cwd(), path_z.as_zstr(), content) { Output::err_generic( diff --git a/src/runtime/cli/pm_trusted_command.rs b/src/runtime/cli/pm_trusted_command.rs index d03bd50c0d34..368ece9ef74f 100644 --- a/src/runtime/cli/pm_trusted_command.rs +++ b/src/runtime/cli/pm_trusted_command.rs @@ -238,6 +238,47 @@ impl TrustCommand { } } + fn print_dry_run( + lockfile: &Lockfile, + scripts_at_depth: &ArrayHashMap>, + package_names_to_add: &StringArrayHashMap<()>, + ) { + let buf = lockfile.buffers.string_bytes.as_slice(); + let resolutions = lockfile.packages.items_resolution(); + let mut total_scripts: usize = 0; + let mut total_packages: usize = 0; + + Output::print(format_args!("\n")); + for entry in scripts_at_depth.values().iter().rev() { + for info in entry.iter() { + if info.skip { + continue; + } + total_packages += 1; + total_scripts += info.scripts_list.total as usize; + info.scripts_list.print_scripts( + &resolutions[info.package_id as usize], + buf, + PrintFormat::Untrusted, + ); + Output::print(format_args!("\n")); + } + } + + bun_core::prettyln!( + "dry run: would run {} script{} across {} package{}", + total_scripts, + if total_scripts != 1 { "s" } else { "" }, + total_packages, + if total_packages != 1 { "s" } else { "" }, + ); + bun_core::prettyln!("dry run: would add to trustedDependencies:"); + for name in package_names_to_add.keys() { + bun_core::pretty!(" - {}\n", bstr::BStr::new(name)); + } + Output::flush(); + } + pub(crate) fn exec( ctx: Command::Context, pm: &mut PackageManager, @@ -439,6 +480,12 @@ impl TrustCommand { Global::crash(); } + // SAFETY: `pm_raw` singleton; `options` is CLI config set at init. + if unsafe { (*pm_raw).options.dry_run } { + Self::print_dry_run(lockfile, &scripts_at_depth, &package_names_to_add); + return Ok(()); + } + let mut scripts_node: Progress::Node; // SAFETY: `pm_raw` singleton; `progress` is owned inline. let show_progress = unsafe { (*pm_raw).options.log_level.show_progress() }; diff --git a/src/runtime/cli/pm_version_command.rs b/src/runtime/cli/pm_version_command.rs index caa1e9e1ece2..4f0498a8726f 100644 --- a/src/runtime/cli/pm_version_command.rs +++ b/src/runtime/cli/pm_version_command.rs @@ -133,7 +133,8 @@ impl PmVersionCommand { Global::exit(1); } - let scripts = if pm.options.do_.run_scripts() { + let dry_run = pm.options.dry_run; + let scripts = if pm.options.do_.run_scripts() && !dry_run { json.as_property(b"scripts") } else { None @@ -193,6 +194,12 @@ impl PmVersionCommand { } } + if dry_run { + Output::print(format_args!("v{}\n", BStr::new(&new_version_str))); + Output::flush(); + return Ok(()); + } + { json.data .e_object_mut() @@ -469,6 +476,7 @@ impl PmVersionCommand { \x20 --message=\\, -m Custom commit message, use %s for version substitution\n\ \x20 --preid=\\ Prerelease identifier (i.e beta → {})\n\ \x20 --force, -f Bypass dirty git history check\n\ + \x20 --dry-run Print the new version without writing, running scripts, or tagging\n\ \n\ Examples:\n\ \x20 $ bun pm version patch\n\ diff --git a/src/runtime/cli/unlink_command.rs b/src/runtime/cli/unlink_command.rs index bac275fdb361..fa4510777136 100644 --- a/src/runtime/cli/unlink_command.rs +++ b/src/runtime/cli/unlink_command.rs @@ -112,10 +112,13 @@ fn unlink(ctx: &mut ContextData) -> crate::Result<()> { // `lockfile.buffers.string_bytes`. let name = lockfile.str(&package.name); - match sys::lstat(resolve_path::join_abs_string_z::( + let dry_run = manager.options.dry_run; + let log_level = manager.options.log_level; + let link_path = resolve_path::join_abs_string_z::( global_link_dir_path(manager), &[name], - )) { + ); + match sys::lstat(link_path) { Ok(stat) => { if !sys::S::ISLNK(stat.st_mode as _) { bun_core::pretty_errorln!( @@ -134,6 +137,18 @@ fn unlink(ctx: &mut ContextData) -> crate::Result<()> { } } + if dry_run { + if log_level != LogLevel::Silent { + bun_core::prettyln!( + "dry run: would unlink \"{}\" ({})", + BStr::new(name), + BStr::new(link_path.as_bytes()), + ); + } + Output::flush(); + Global::exit(0); + } + // Step 2. Setup the global directory let node_modules: Dir = 'brk: { bin::Linker::ensure_umask(); diff --git a/test/cli/install/bun-install-lifecycle-scripts.test.ts b/test/cli/install/bun-install-lifecycle-scripts.test.ts index 3ccbfb3b4c3f..53a9d8a505a7 100644 --- a/test/cli/install/bun-install-lifecycle-scripts.test.ts +++ b/test/cli/install/bun-install-lifecycle-scripts.test.ts @@ -3761,6 +3761,61 @@ for (const forceWaiterThread of isLinux ? [false, true] : [false]) { assertManifestsPopulated(join(packageDir, ".bun-cache"), verdaccio.registryUrl()); }); + test("bun pm trust --dry-run runs no scripts and writes nothing", async () => { + using ctx = await setupTest(); + const { packageDir, packageJson, env } = ctx; + const testEnv = forceWaiterThread ? { ...env, BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" } : env; + + await writeFile( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { + "uses-what-bin": "1.5.0", + }, + }), + ); + + let { stderr, exited } = spawn({ + cmd: [bunExe(), "i"], + cwd: packageDir, + stdout: "pipe", + stderr: "pipe", + env: testEnv, + }); + + let err = await stderr.text(); + expect(err).toContain("Saved lockfile"); + expect(err).not.toContain("error:"); + expect(await exited).toBe(0); + expect(await exists(join(packageDir, "node_modules", "uses-what-bin", "what-bin.txt"))).toBeFalse(); + + const packageJsonBefore = await file(packageJson).text(); + const lockfileBefore = await file(join(packageDir, "bun.lock")).text(); + + const proc = spawn({ + cmd: [bunExe(), "pm", "trust", "uses-what-bin", "--dry-run"], + cwd: packageDir, + stdout: "pipe", + stderr: "pipe", + env: testEnv, + }); + + const [out, dryErr, dryExit] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(dryErr).toContain("bun pm trust"); + expect(dryErr).not.toContain("error:"); + expect(out).toContain("[install]: what-bin"); + expect(out).toContain("dry run: would run 1 script across 1 package"); + expect(out).toContain("dry run: would add to trustedDependencies:"); + expect(out).toContain(" - uses-what-bin"); + expect(out).not.toContain("script ran"); + expect(dryExit).toBe(0); + + expect(await exists(join(packageDir, "node_modules", "uses-what-bin", "what-bin.txt"))).toBeFalse(); + expect(await file(packageJson).text()).toBe(packageJsonBefore); + expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfileBefore); + }); + test("bun pm trust and untrusted on missing package", async () => { using ctx = await setupTest(); const { packageDir, packageJson, env } = ctx; diff --git a/test/cli/install/bun-link.test.ts b/test/cli/install/bun-link.test.ts index 8a937dad63fd..5a7e51526720 100644 --- a/test/cli/install/bun-link.test.ts +++ b/test/cli/install/bun-link.test.ts @@ -1,12 +1,13 @@ import { file, spawn } from "bun"; import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "bun:test"; -import { access, mkdir, writeFile } from "fs/promises"; +import { access, exists, mkdir, writeFile } from "fs/promises"; import { bunExe, bunEnv as env, isWindows, readdirSorted, runBunInstall, + tempDir, tmpdirSync, toBeValidBin, toHaveBins, @@ -471,3 +472,48 @@ it("should link dependency without crashing", async () => { // This should fail with a non-zero exit code. expect(await exited4).toBe(1); }); + +it("link and unlink --dry-run do not touch the global link directory", async () => { + using dir = tempDir("link-dry-run", { + "package.json": JSON.stringify({ name: "linkme-dry", version: "1.0.0" }), + }); + const bunInstall = join(String(dir), "bun-install"); + const globalLink = join(bunInstall, "install", "global", "node_modules", "linkme-dry"); + const testEnv = { ...env, BUN_INSTALL: bunInstall }; + + const run = async (...cmd: string[]) => { + await using proc = spawn({ + cmd: [bunExe(), ...cmd], + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + env: testEnv, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + }; + + let res = await run("link", "--dry-run"); + expect(res.stderr).not.toContain("error"); + expect(res.stdout).toContain(`dry run: would link "linkme-dry" at ${globalLink}`); + expect(res.stdout).not.toContain("Registered"); + expect(res.exitCode).toBe(0); + expect(await exists(globalLink)).toBeFalse(); + + res = await run("link"); + expect(res.stdout).toContain(`Success! Registered "linkme-dry"`); + expect(res.exitCode).toBe(0); + expect(await exists(globalLink)).toBeTrue(); + + res = await run("unlink", "--dry-run"); + expect(res.stderr).not.toContain("error"); + expect(res.stdout).toContain(`dry run: would unlink "linkme-dry"`); + expect(res.stdout).not.toContain("unlinked package"); + expect(res.exitCode).toBe(0); + expect(await exists(globalLink)).toBeTrue(); + + res = await run("unlink"); + expect(res.stdout).toContain(`success: unlinked package "linkme-dry"`); + expect(res.exitCode).toBe(0); + expect(await exists(globalLink)).toBeFalse(); +}); diff --git a/test/cli/install/bun-pm-pkg.test.ts b/test/cli/install/bun-pm-pkg.test.ts index 42f573f655d4..af4bc0b075af 100644 --- a/test/cli/install/bun-pm-pkg.test.ts +++ b/test/cli/install/bun-pm-pkg.test.ts @@ -372,6 +372,28 @@ describe.concurrent("bun pm pkg", () => { }); }); + describe("--dry-run", () => { + it("set prints the result without writing package.json", async () => { + using dir = makeTestDir(); + const before = await readPkg(dir); + const { output, error, code } = await runPmPkg(["set", "description=New description", "--dry-run"], dir); + expect(error).toBe(""); + expect(JSON.parse(output)).toMatchObject({ name: "test-package", description: "New description" }); + expect(code).toBe(0); + expect(await readPkg(dir)).toEqual(before); + }); + + it("delete prints the result without writing package.json", async () => { + using dir = makeTestDir(); + const before = await readPkg(dir); + const { output, error, code } = await runPmPkg(["delete", "description", "--dry-run"], dir); + expect(error).toBe(""); + expect(JSON.parse(output).description).toBeUndefined(); + expect(code).toBe(0); + expect(await readPkg(dir)).toEqual(before); + }); + }); + describe("delete command", () => { it("should delete a property", async () => { using dir = makeTestDir(); diff --git a/test/cli/install/bun-pm-version.test.ts b/test/cli/install/bun-pm-version.test.ts index a89ea0f84ed9..03d36ab48a37 100644 --- a/test/cli/install/bun-pm-version.test.ts +++ b/test/cli/install/bun-pm-version.test.ts @@ -487,6 +487,58 @@ describe.concurrent("bun pm version", () => { }); }); + describe("--dry-run", () => { + it("prints the new version without writing package.json or running scripts", async () => { + await using testDir = tempDir(`version-${i++}`, { + "package.json": JSON.stringify( + { + name: "test", + version: "1.0.0", + scripts: { + preversion: "echo 'step1' >> lifecycle.log", + version: "echo 'step2' >> lifecycle.log", + postversion: "echo 'step3' >> lifecycle.log", + }, + }, + null, + 2, + ), + }); + + const { output, error, code } = await runCommand( + [bunExe(), "pm", "version", "minor", "--dry-run", "--no-git-tag-version"], + testDir, + ); + + expect(error.trim()).toBe(""); + expect(output.trim()).toBe("v1.1.0"); + expect(code).toBe(0); + + const pkg = await Bun.file(join(testDir, "package.json")).json(); + expect(pkg.version).toBe("1.0.0"); + expect(await Bun.file(join(testDir, "lifecycle.log")).exists()).toBe(false); + }); + + it("does not commit or tag in a git repository", async () => { + const testDir = await setupGitTest(); + + const { output, error, code } = await runCommand([bunExe(), "pm", "version", "patch", "--dry-run"], testDir); + + expect(error.trim()).toBe(""); + expect(output.trim()).toBe("v1.0.1"); + expect(code).toBe(0); + + const pkg = await Bun.file(join(testDir, "package.json")).json(); + expect(pkg.version).toBe("1.0.0"); + + const { output: tagOutput } = await runCommand(["git", "tag", "-l"], testDir); + expect(tagOutput.trim()).toBe(""); + + const { output: logOutput } = await runCommand(["git", "log", "--oneline"], testDir); + expect(logOutput).not.toContain("v1.0.1"); + }); + }); + describe("JSON formatting preservation", () => { it("preserves JSON formatting correctly", async () => { const originalJson1 = `{ diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index 8ab93b18ba94..345d117e6106 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -1084,3 +1084,36 @@ test("bun pm cache rm does not create the directory named by a project-local .en expect(stderr).not.toContain("error"); expect(exitCode).toBe(0); }); + +test("bun pm cache rm --dry-run prints the cache directory and deletes nothing", async () => { + using dir = tempDir("pm-cache-rm-dry-run", { + "package.json": JSON.stringify({ name: "cache-rm-dry-run", version: "1.0.0" }), + "bun-install/install/cache/cached-package.txt": "cached artifact", + }); + const dirStr = String(dir); + const bunInstallDir = join(dirStr, "bun-install"); + const realCacheDir = join(bunInstallDir, "install", "cache"); + + const spawnEnv: NodeJS.Dict = { + ...env, + BUN_INSTALL: bunInstallDir, + XDG_CACHE_HOME: join(dirStr, "xdg-cache"), + HOME: dirStr, + }; + delete spawnEnv.BUN_INSTALL_CACHE_DIR; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "cache", "rm", "--dry-run"], + cwd: dirStr, + stdout: "pipe", + stderr: "pipe", + env: spawnEnv, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("error"); + expect(stdout).toInclude(`dry run: would delete 'bun install' cache at ${realCacheDir}`); + expect(stdout).not.toInclude("Cleared"); + expect(exitCode).toBe(0); + expect(await exists(join(realCacheDir, "cached-package.txt"))).toBeTrue(); +}); diff --git a/test/cli/install/migration/migrate.test.ts b/test/cli/install/migration/migrate.test.ts index 096021f8ecf3..b24250b4c02a 100644 --- a/test/cli/install/migration/migrate.test.ts +++ b/test/cli/install/migration/migrate.test.ts @@ -823,6 +823,31 @@ describe("package-lock.json migration fixes", () => { const sha = (n: number) => Buffer.alloc(39, "0").toString() + n; + test.concurrent("pm migrate --dry-run does not write bun.lock", async () => { + const dependencies = { a: "github:user/a" }; + using dir = synthetic("npm-migrate-dry-run", { + "package.json": JSON.stringify({ name: "dry-run", dependencies }), + "package-lock.json": JSON.stringify({ + name: "dry-run", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "dry-run", dependencies }, + "node_modules/a": { version: "1.0.0", resolved: `git+ssh://git@github.com/user/a.git#${sha(1)}` }, + }, + }), + }); + + const { stdout, stderr, exitCode } = await run(dir, "pm", "migrate", "--dry-run"); + expect(stderr).toContain("migrated lockfile from package-lock.json"); + expect(stdout).toContain("dry run: would write bun.lock"); + expect(exitCode).toBe(0); + expect(await Bun.file(join(String(dir), "bun.lock")).exists()).toBe(false); + + const { text } = await migrate(dir); + expect(text).toContain(`a@github:user/a#${sha(1)}`); + }); + test.concurrent("git hosts round-trip (B1, github: parity)", async () => { const dependencies = { a: "github:user/a", From 75a7a3372c6b726d16309a3ece3fbf78573e1066 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:45:49 +0000 Subject: [PATCH 2/4] pm trust: add --ignore-scripts, which records the trust without running the scripts `bun pm trust --ignore-scripts` adds the names to trustedDependencies in package.json and skips the lifecycle scripts. The lockfile is left alone, so the next `bun install` sees a package that package.json trusts and the lockfile does not, runs its scripts, and saves the lockfile. Only the flag skips the scripts. `ignoreScripts` in bunfig.toml or .npmrc does not, because `bun pm trust ` is the explicit request to run those scripts. pm migrate --dry-run now names the lockfile it would write from LoadResult::save_format, so it prints bun.lockb when saveTextLockfile is false. LockfileFormat::filename and LoadResult::save_format become pub for that. pm pkg --dry-run ends its output with a newline when the package.json has no trailing newline. Folds the part of #41686 that #41690 did not cover. --- docs/pm/cli/pm.mdx | 1 + src/install/lockfile.rs | 4 +- src/install/lockfile/Package/Scripts.rs | 7 + src/runtime/cli/package_manager_command.rs | 13 +- src/runtime/cli/pm_pkg_command.rs | 3 + src/runtime/cli/pm_trusted_command.rs | 133 +++++++++++------- .../bun-install-lifecycle-scripts.test.ts | 45 ++++++ test/cli/install/bun-pm.test.ts | 43 ++++++ 8 files changed, 194 insertions(+), 55 deletions(-) diff --git a/docs/pm/cli/pm.mdx b/docs/pm/cli/pm.mdx index d695267e4930..c48db4bbb818 100644 --- a/docs/pm/cli/pm.mdx +++ b/docs/pm/cli/pm.mdx @@ -371,6 +371,7 @@ Options for the `trust` command: - `--all`: Trust all untrusted dependencies. - `--dry-run`: Print the scripts that would run and the packages that would be trusted, without running scripts or writing `package.json` and `bun.lock`. +- `--ignore-scripts`: Add the packages to `trustedDependencies` in `package.json` without running their scripts. The next `bun install` runs them and records the trust in `bun.lock`. Only the flag does this: `ignoreScripts` in `bunfig.toml` or `.npmrc` does not stop `bun pm trust` from running the scripts you name. ## default-trusted diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index 3e473b67b8c3..30bdef15522a 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -309,7 +309,7 @@ pub enum LockfileFormat { } impl LockfileFormat { - pub(crate) fn filename(self) -> &'static ZStr { + pub fn filename(self) -> &'static ZStr { match self { LockfileFormat::Text => zstr!("bun.lock"), LockfileFormat::Binary => zstr!("bun.lockb"), @@ -396,7 +396,7 @@ impl<'a> LoadResult<'a> { } } - pub(crate) fn save_format(&self, options: &PackageManagerOptions) -> LockfileFormat { + pub fn save_format(&self, options: &PackageManagerOptions) -> LockfileFormat { match self { LoadResult::NotFound => { // saving a lockfile for a new project. default to text lockfile diff --git a/src/install/lockfile/Package/Scripts.rs b/src/install/lockfile/Package/Scripts.rs index c228a9d9b723..7245f95202ed 100644 --- a/src/install/lockfile/Package/Scripts.rs +++ b/src/install/lockfile/Package/Scripts.rs @@ -403,6 +403,8 @@ impl Scripts { pub enum PrintFormat { Completed, Untrusted, + /// Trusted by `bun pm trust --ignore-scripts` but not run. + Skipped, } // `Clone` — `List` owns `cwd`/`package_name`/`items`, but @@ -458,6 +460,11 @@ impl List { BStr::new(name), BStr::new(script), ), + PrintFormat::Skipped => bun_core::pretty!( + " - [{s}]: {s}\n", + BStr::new(name), + BStr::new(script), + ), } } } diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index cd64d55f8a3b..128162d9433f 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -220,7 +220,9 @@ impl PackageManagerCommand { bun pm migrate migrate another package manager's lockfile without installing anything\n\ bun pm untrusted print current untrusted dependencies with scripts\n\ bun pm trust names ... run scripts for untrusted dependencies and add to `trustedDependencies`\n\ - └ --all trust all untrusted dependencies\n\ + ├ --all trust all untrusted dependencies\n\ + ├ --dry-run print the scripts that would run, without running them or saving\n\ + └ --ignore-scripts add to `trustedDependencies` without running the scripts\n\ bun pm default-trusted print the default trusted dependencies list\n\ \n\ Learn more about these at https://bun.com/docs/cli/pm.\n"; @@ -762,9 +764,14 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; Global::exit(1); } Self::handle_load_lockfile_errors(&load_lockfile, log_level); - if pm.options.dry_run { + // SAFETY: `pm_raw` singleton; `options` is CLI config set at init. + let options = unsafe { &(*pm_raw).options }; + if options.dry_run { if log_level != LogLevel::Silent { - bun_core::prettyln!("dry run: would write bun.lock"); + bun_core::prettyln!( + "dry run: would write {}", + bstr::BStr::new(load_lockfile.save_format(options).filename().as_bytes()), + ); } Output::flush(); Global::exit(0); diff --git a/src/runtime/cli/pm_pkg_command.rs b/src/runtime/cli/pm_pkg_command.rs index 79623f03c91d..ab8d71a7bed1 100644 --- a/src/runtime/cli/pm_pkg_command.rs +++ b/src/runtime/cli/pm_pkg_command.rs @@ -860,6 +860,9 @@ impl PmPkgCommand { let content = writer.ctx.written_without_trailing_zero(); if dry_run { let _ = Output::writer().write_all(content); + if !preserve_newline { + let _ = Output::writer().write_all(b"\n"); + } Output::flush(); return Ok(()); } diff --git a/src/runtime/cli/pm_trusted_command.rs b/src/runtime/cli/pm_trusted_command.rs index 368ece9ef74f..08f41d80047c 100644 --- a/src/runtime/cli/pm_trusted_command.rs +++ b/src/runtime/cli/pm_trusted_command.rs @@ -486,9 +486,13 @@ impl TrustCommand { return Ok(()); } + // Only the flag: `ignoreScripts` from bunfig or .npmrc must not stop + // `bun pm trust`, which is the explicit request to run these scripts. + let run_scripts = !strings::left_has_any_in_right(args, &[b"--ignore-scripts"]); + let mut scripts_node: Progress::Node; // SAFETY: `pm_raw` singleton; `progress` is owned inline. - let show_progress = unsafe { (*pm_raw).options.log_level.show_progress() }; + let show_progress = run_scripts && unsafe { (*pm_raw).options.log_level.show_progress() }; if show_progress { // SAFETY: see above; `progress.start()` returns `&mut root` which is @@ -509,6 +513,9 @@ impl TrustCommand { // `spawn_package_lifecycle_scripts` and still print it later, so clone // the `List` per spawn. for entry in scripts_at_depth.values().iter().rev() { + if !run_scripts { + break; + } for info in entry.iter() { if info.skip { continue; @@ -623,15 +630,7 @@ impl TrustCommand { // now add the package names to lockfile.trustedDependencies and package.json `trustedDependencies` debug_assert!(!package_names_to_add.keys().is_empty()); - // could be null if these are the first packages to be trusted - // SAFETY: `pm_raw` singleton; mutates `lockfile.trusted_dependencies`. - unsafe { - if (*pm_raw).lockfile.trusted_dependencies.is_none() { - (*pm_raw).lockfile.trusted_dependencies = Some(Default::default()); - } - } - - let mut total_scripts_ran: usize = 0; + let mut total_scripts: usize = 0; let mut total_packages_with_scripts: usize = 0; let mut total_skipped_packages: usize = 0; @@ -649,9 +648,16 @@ impl TrustCommand { total_skipped_packages += 1; } else { total_packages_with_scripts += 1; - total_scripts_ran += info.scripts_list.total as usize; - info.scripts_list - .print_scripts(resolution, buf, PrintFormat::Completed); + total_scripts += info.scripts_list.total as usize; + info.scripts_list.print_scripts( + resolution, + buf, + if run_scripts { + PrintFormat::Completed + } else { + PrintFormat::Skipped + }, + ); } Output::print(format_args!("\n")); } @@ -662,32 +668,45 @@ impl TrustCommand { package_names_to_add.keys_mut(), )?; - for name in package_names_to_add.keys() { - // SAFETY: `pm_raw` singleton; `trusted_dependencies` set Some above. + // With `--ignore-scripts` the lockfile is left alone: the next install + // finds the names in package.json but not in the lockfile + // (`added_trusted_dependencies`) and runs the scripts then. + if run_scripts { + // could be null if these are the first packages to be trusted + // SAFETY: `pm_raw` singleton; mutates `lockfile.trusted_dependencies`. unsafe { - (*pm_raw) - .lockfile - .trusted_dependencies - .as_mut() - .unwrap() - .put( - bun_semver::string::Builder::string_hash(name) - as install::TruncatedPackageNameHash, - Box::<[u8]>::from(&**name), - )?; + if (*pm_raw).lockfile.trusted_dependencies.is_none() { + (*pm_raw).lockfile.trusted_dependencies = Some(Default::default()); + } } - } - // Reshaped for borrowck — `save_to_disk` needs `&mut Lockfile` - // and `&LoadResult` simultaneously, but `LoadResultOk.lockfile` already - // holds the only `&mut`. Same projection pattern as `migrate` in - // `package_manager_command.rs`. - // SAFETY: `load_lockfile` is `Ok` (errors exited in - // `handle_load_lockfile_errors`). `save_to_disk` reads `load_result` - // only for `save_format()` (scalar `format`/`migrated` fields). - unsafe { - let lf: *mut Lockfile = &raw mut *(*pm_raw).lockfile; - (*lf).save_to_disk(&load_lockfile, &(*pm_raw).options); + for name in package_names_to_add.keys() { + // SAFETY: `pm_raw` singleton; `trusted_dependencies` set Some above. + unsafe { + (*pm_raw) + .lockfile + .trusted_dependencies + .as_mut() + .unwrap() + .put( + bun_semver::string::Builder::string_hash(name) + as install::TruncatedPackageNameHash, + Box::<[u8]>::from(&**name), + )?; + } + } + + // Reshaped for borrowck — `save_to_disk` needs `&mut Lockfile` + // and `&LoadResult` simultaneously, but `LoadResultOk.lockfile` already + // holds the only `&mut`. Same projection pattern as `migrate` in + // `package_manager_command.rs`. + // SAFETY: `load_lockfile` is `Ok` (errors exited in + // `handle_load_lockfile_errors`). `save_to_disk` reads `load_result` + // only for `save_format()` (scalar `format`/`migrated` fields). + unsafe { + let lf: *mut Lockfile = &raw mut *(*pm_raw).lockfile; + (*lf).save_to_disk(&load_lockfile, &(*pm_raw).options); + } } let mut buffer_writer = bun_js_printer::BufferWriter::init(); @@ -722,21 +741,35 @@ impl TrustCommand { let _ = bun_sys::ftruncate(root_file.handle, new_package_json_contents.len() as i64); let _ = root_file.close(); - debug_assert!(total_scripts_ran > 0); - - bun_core::pretty!( - " {} script{} ran across {} package{} ", - total_scripts_ran, - if total_scripts_ran > 1 { "s" } else { "" }, - total_packages_with_scripts, - if total_packages_with_scripts > 1 { - "s" - } else { - "" - }, - ); + debug_assert!(total_scripts > 0); - Output::print_start_end_stdout(bun_core::start_time(), bun_core::time::nano_timestamp()); + let scripts_plural = if total_scripts > 1 { "s" } else { "" }; + let packages_plural = if total_packages_with_scripts > 1 { + "s" + } else { + "" + }; + if run_scripts { + bun_core::pretty!( + " {} script{} ran across {} package{} ", + total_scripts, + scripts_plural, + total_packages_with_scripts, + packages_plural, + ); + Output::print_start_end_stdout( + bun_core::start_time(), + bun_core::time::nano_timestamp(), + ); + } else { + bun_core::pretty!( + " {} script{} skipped across {} package{} (--ignore-scripts)", + total_scripts, + scripts_plural, + total_packages_with_scripts, + packages_plural, + ); + } Output::print(format_args!("\n")); if total_skipped_packages > 0 { diff --git a/test/cli/install/bun-install-lifecycle-scripts.test.ts b/test/cli/install/bun-install-lifecycle-scripts.test.ts index 53a9d8a505a7..b8a39215c545 100644 --- a/test/cli/install/bun-install-lifecycle-scripts.test.ts +++ b/test/cli/install/bun-install-lifecycle-scripts.test.ts @@ -3816,6 +3816,51 @@ for (const forceWaiterThread of isLinux ? [false, true] : [false]) { expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfileBefore); }); + test("bun pm trust --ignore-scripts records the trust and the next install runs the scripts", async () => { + using ctx = await setupTest(); + const { packageDir, packageJson, env } = ctx; + const testEnv = forceWaiterThread ? { ...env, BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" } : env; + + await writeFile( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { + "uses-what-bin": "1.0.0", + }, + }), + ); + const whatBinTxt = join(packageDir, "node_modules", "uses-what-bin", "what-bin.txt"); + + await runBunInstall(testEnv, packageDir); + expect(await exists(whatBinTxt)).toBeFalse(); + + { + await using proc = spawn({ + cmd: [bunExe(), "pm", "trust", "uses-what-bin", "--ignore-scripts"], + cwd: packageDir, + stdout: "pipe", + stderr: "pipe", + env: testEnv, + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(err).not.toContain("error:"); + expect(out).toContain("- [install]: what-bin"); + expect(out).toContain("1 script skipped across 1 package (--ignore-scripts)"); + expect(out).not.toContain("ran across"); + expect(exitCode).toBe(0); + } + expect(await exists(whatBinTxt)).toBeFalse(); + expect((await file(packageJson).json()).trustedDependencies).toEqual(["uses-what-bin"]); + // Only package.json records the trust, so that the next install sees a + // newly trusted package and runs its scripts. + expect(await file(join(packageDir, "bun.lock")).text()).not.toContain("trustedDependencies"); + + await runBunInstall(testEnv, packageDir); + expect(await exists(whatBinTxt)).toBeTrue(); + expect(await file(join(packageDir, "bun.lock")).text()).toContain("trustedDependencies"); + }); + test("bun pm trust and untrusted on missing package", async () => { using ctx = await setupTest(); const { packageDir, packageJson, env } = ctx; diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index 345d117e6106..55f6d8332fb6 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -1117,3 +1117,46 @@ test("bun pm cache rm --dry-run prints the cache directory and deletes nothing", expect(exitCode).toBe(0); expect(await exists(join(realCacheDir, "cached-package.txt"))).toBeTrue(); }); + +test("bun pm trust runs the scripts when only .npmrc sets ignore-scripts", async () => { + using dir = tempDir("pm-trust-npmrc-ignore-scripts", { + "package.json": JSON.stringify({ name: "app", dependencies: { dep: "file:./dep" } }), + "dep/package.json": JSON.stringify({ + name: "dep", + version: "1.0.0", + scripts: { postinstall: "echo ran > postinstall-ran.txt" }, + }), + ".npmrc": "ignore-scripts=true\n", + }); + const dirStr = String(dir); + const ranTxt = join(dirStr, "node_modules", "dep", "postinstall-ran.txt"); + + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: dirStr, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(stdout).toContain("Blocked 1 postinstall"); + expect(exitCode).toBe(0); + expect(await exists(ranTxt)).toBeFalse(); + } + + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "trust", "dep"], + cwd: dirStr, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("error:"); + expect(stdout).toContain("1 script ran across 1 package"); + expect(await exists(ranTxt)).toBeTrue(); + expect(exitCode).toBe(0); +}); From 0e64c7794ccd432898a99469bdd0518eacdd488c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:29:55 +0000 Subject: [PATCH 3/4] pm pkg: test fix --dry-run, shorten two comments in pm trust --- src/runtime/cli/pm_trusted_command.rs | 8 +++----- test/cli/install/bun-pm-pkg.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/runtime/cli/pm_trusted_command.rs b/src/runtime/cli/pm_trusted_command.rs index 08f41d80047c..d3f1f145a448 100644 --- a/src/runtime/cli/pm_trusted_command.rs +++ b/src/runtime/cli/pm_trusted_command.rs @@ -486,8 +486,7 @@ impl TrustCommand { return Ok(()); } - // Only the flag: `ignoreScripts` from bunfig or .npmrc must not stop - // `bun pm trust`, which is the explicit request to run these scripts. + // The CLI flag only: `ignoreScripts` in bunfig.toml or .npmrc does not apply here. let run_scripts = !strings::left_has_any_in_right(args, &[b"--ignore-scripts"]); let mut scripts_node: Progress::Node; @@ -668,9 +667,8 @@ impl TrustCommand { package_names_to_add.keys_mut(), )?; - // With `--ignore-scripts` the lockfile is left alone: the next install - // finds the names in package.json but not in the lockfile - // (`added_trusted_dependencies`) and runs the scripts then. + // Under `--ignore-scripts` the lockfile keeps the old list, so the next + // install sees the names as newly trusted and runs their scripts. if run_scripts { // could be null if these are the first packages to be trusted // SAFETY: `pm_raw` singleton; mutates `lockfile.trusted_dependencies`. diff --git a/test/cli/install/bun-pm-pkg.test.ts b/test/cli/install/bun-pm-pkg.test.ts index af4bc0b075af..8ff5507a50f0 100644 --- a/test/cli/install/bun-pm-pkg.test.ts +++ b/test/cli/install/bun-pm-pkg.test.ts @@ -392,6 +392,18 @@ describe.concurrent("bun pm pkg", () => { expect(code).toBe(0); expect(await readPkg(dir)).toEqual(before); }); + + it("fix prints the result without writing package.json", async () => { + using dir = tempDir("pm-pkg-fix-dry-run", { + "package.json": JSON.stringify({ name: "TEST-PACKAGE", version: "1.0.0" }, null, 2), + }); + const before = await readPkg(String(dir)); + const { output, error, code } = await runPmPkg(["fix", "--dry-run"], String(dir)); + expect(error).toBe(""); + expect(JSON.parse(output)).toEqual({ name: "test-package", version: "1.0.0" }); + expect(code).toBe(0); + expect(await readPkg(String(dir))).toEqual(before); + }); }); describe("delete command", () => { From 05a3a29c4ae5e8d3a65d5b296b137540bf77c037 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:24:42 +0000 Subject: [PATCH 4/4] pm trust: back out --ignore-scripts, keep this PR to --dry-run The record-only --ignore-scripts mode relied on the next `bun install` running the scripts of a package that package.json newly trusts. That holds under the hoisted linker (PackageInstaller.rs checks summary.added_trusted_dependencies for already-installed packages), but not under the isolated linker: an entry that is already in node_modules/.bun takes the relink path, and Installer::next_step goes from SymlinkDependencyBinaries straight to Done when relinking, so RunPreinstall never runs. The install then saves the trust to bun.lock and the scripts never run until a forced or clean install. The same happens when trustedDependencies is edited by hand, so it is a separate isolated-linker bug, and --ignore-scripts on pm trust stays with #41686 until that is sorted out. Kept from the fold: pm migrate --dry-run names the lockfile from LoadResult::save_format, pm pkg --dry-run ends with a newline, the --dry-run line under trust in bun pm --help, and the pm pkg fix --dry-run test. --- docs/pm/cli/pm.mdx | 1 - src/install/lockfile/Package/Scripts.rs | 7 - src/runtime/cli/package_manager_command.rs | 3 +- src/runtime/cli/pm_trusted_command.rs | 131 +++++++----------- .../bun-install-lifecycle-scripts.test.ts | 45 ------ test/cli/install/bun-pm.test.ts | 43 ------ 6 files changed, 51 insertions(+), 179 deletions(-) diff --git a/docs/pm/cli/pm.mdx b/docs/pm/cli/pm.mdx index c48db4bbb818..d695267e4930 100644 --- a/docs/pm/cli/pm.mdx +++ b/docs/pm/cli/pm.mdx @@ -371,7 +371,6 @@ Options for the `trust` command: - `--all`: Trust all untrusted dependencies. - `--dry-run`: Print the scripts that would run and the packages that would be trusted, without running scripts or writing `package.json` and `bun.lock`. -- `--ignore-scripts`: Add the packages to `trustedDependencies` in `package.json` without running their scripts. The next `bun install` runs them and records the trust in `bun.lock`. Only the flag does this: `ignoreScripts` in `bunfig.toml` or `.npmrc` does not stop `bun pm trust` from running the scripts you name. ## default-trusted diff --git a/src/install/lockfile/Package/Scripts.rs b/src/install/lockfile/Package/Scripts.rs index 7245f95202ed..c228a9d9b723 100644 --- a/src/install/lockfile/Package/Scripts.rs +++ b/src/install/lockfile/Package/Scripts.rs @@ -403,8 +403,6 @@ impl Scripts { pub enum PrintFormat { Completed, Untrusted, - /// Trusted by `bun pm trust --ignore-scripts` but not run. - Skipped, } // `Clone` — `List` owns `cwd`/`package_name`/`items`, but @@ -460,11 +458,6 @@ impl List { BStr::new(name), BStr::new(script), ), - PrintFormat::Skipped => bun_core::pretty!( - " - [{s}]: {s}\n", - BStr::new(name), - BStr::new(script), - ), } } } diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 128162d9433f..cbc065f7a298 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -221,8 +221,7 @@ impl PackageManagerCommand { bun pm untrusted print current untrusted dependencies with scripts\n\ bun pm trust names ... run scripts for untrusted dependencies and add to `trustedDependencies`\n\ ├ --all trust all untrusted dependencies\n\ - ├ --dry-run print the scripts that would run, without running them or saving\n\ - └ --ignore-scripts add to `trustedDependencies` without running the scripts\n\ + └ --dry-run print the scripts that would run, without running them or saving\n\ bun pm default-trusted print the default trusted dependencies list\n\ \n\ Learn more about these at https://bun.com/docs/cli/pm.\n"; diff --git a/src/runtime/cli/pm_trusted_command.rs b/src/runtime/cli/pm_trusted_command.rs index d3f1f145a448..368ece9ef74f 100644 --- a/src/runtime/cli/pm_trusted_command.rs +++ b/src/runtime/cli/pm_trusted_command.rs @@ -486,12 +486,9 @@ impl TrustCommand { return Ok(()); } - // The CLI flag only: `ignoreScripts` in bunfig.toml or .npmrc does not apply here. - let run_scripts = !strings::left_has_any_in_right(args, &[b"--ignore-scripts"]); - let mut scripts_node: Progress::Node; // SAFETY: `pm_raw` singleton; `progress` is owned inline. - let show_progress = run_scripts && unsafe { (*pm_raw).options.log_level.show_progress() }; + let show_progress = unsafe { (*pm_raw).options.log_level.show_progress() }; if show_progress { // SAFETY: see above; `progress.start()` returns `&mut root` which is @@ -512,9 +509,6 @@ impl TrustCommand { // `spawn_package_lifecycle_scripts` and still print it later, so clone // the `List` per spawn. for entry in scripts_at_depth.values().iter().rev() { - if !run_scripts { - break; - } for info in entry.iter() { if info.skip { continue; @@ -629,7 +623,15 @@ impl TrustCommand { // now add the package names to lockfile.trustedDependencies and package.json `trustedDependencies` debug_assert!(!package_names_to_add.keys().is_empty()); - let mut total_scripts: usize = 0; + // could be null if these are the first packages to be trusted + // SAFETY: `pm_raw` singleton; mutates `lockfile.trusted_dependencies`. + unsafe { + if (*pm_raw).lockfile.trusted_dependencies.is_none() { + (*pm_raw).lockfile.trusted_dependencies = Some(Default::default()); + } + } + + let mut total_scripts_ran: usize = 0; let mut total_packages_with_scripts: usize = 0; let mut total_skipped_packages: usize = 0; @@ -647,16 +649,9 @@ impl TrustCommand { total_skipped_packages += 1; } else { total_packages_with_scripts += 1; - total_scripts += info.scripts_list.total as usize; - info.scripts_list.print_scripts( - resolution, - buf, - if run_scripts { - PrintFormat::Completed - } else { - PrintFormat::Skipped - }, - ); + total_scripts_ran += info.scripts_list.total as usize; + info.scripts_list + .print_scripts(resolution, buf, PrintFormat::Completed); } Output::print(format_args!("\n")); } @@ -667,44 +662,32 @@ impl TrustCommand { package_names_to_add.keys_mut(), )?; - // Under `--ignore-scripts` the lockfile keeps the old list, so the next - // install sees the names as newly trusted and runs their scripts. - if run_scripts { - // could be null if these are the first packages to be trusted - // SAFETY: `pm_raw` singleton; mutates `lockfile.trusted_dependencies`. + for name in package_names_to_add.keys() { + // SAFETY: `pm_raw` singleton; `trusted_dependencies` set Some above. unsafe { - if (*pm_raw).lockfile.trusted_dependencies.is_none() { - (*pm_raw).lockfile.trusted_dependencies = Some(Default::default()); - } - } - - for name in package_names_to_add.keys() { - // SAFETY: `pm_raw` singleton; `trusted_dependencies` set Some above. - unsafe { - (*pm_raw) - .lockfile - .trusted_dependencies - .as_mut() - .unwrap() - .put( - bun_semver::string::Builder::string_hash(name) - as install::TruncatedPackageNameHash, - Box::<[u8]>::from(&**name), - )?; - } + (*pm_raw) + .lockfile + .trusted_dependencies + .as_mut() + .unwrap() + .put( + bun_semver::string::Builder::string_hash(name) + as install::TruncatedPackageNameHash, + Box::<[u8]>::from(&**name), + )?; } + } - // Reshaped for borrowck — `save_to_disk` needs `&mut Lockfile` - // and `&LoadResult` simultaneously, but `LoadResultOk.lockfile` already - // holds the only `&mut`. Same projection pattern as `migrate` in - // `package_manager_command.rs`. - // SAFETY: `load_lockfile` is `Ok` (errors exited in - // `handle_load_lockfile_errors`). `save_to_disk` reads `load_result` - // only for `save_format()` (scalar `format`/`migrated` fields). - unsafe { - let lf: *mut Lockfile = &raw mut *(*pm_raw).lockfile; - (*lf).save_to_disk(&load_lockfile, &(*pm_raw).options); - } + // Reshaped for borrowck — `save_to_disk` needs `&mut Lockfile` + // and `&LoadResult` simultaneously, but `LoadResultOk.lockfile` already + // holds the only `&mut`. Same projection pattern as `migrate` in + // `package_manager_command.rs`. + // SAFETY: `load_lockfile` is `Ok` (errors exited in + // `handle_load_lockfile_errors`). `save_to_disk` reads `load_result` + // only for `save_format()` (scalar `format`/`migrated` fields). + unsafe { + let lf: *mut Lockfile = &raw mut *(*pm_raw).lockfile; + (*lf).save_to_disk(&load_lockfile, &(*pm_raw).options); } let mut buffer_writer = bun_js_printer::BufferWriter::init(); @@ -739,35 +722,21 @@ impl TrustCommand { let _ = bun_sys::ftruncate(root_file.handle, new_package_json_contents.len() as i64); let _ = root_file.close(); - debug_assert!(total_scripts > 0); + debug_assert!(total_scripts_ran > 0); - let scripts_plural = if total_scripts > 1 { "s" } else { "" }; - let packages_plural = if total_packages_with_scripts > 1 { - "s" - } else { - "" - }; - if run_scripts { - bun_core::pretty!( - " {} script{} ran across {} package{} ", - total_scripts, - scripts_plural, - total_packages_with_scripts, - packages_plural, - ); - Output::print_start_end_stdout( - bun_core::start_time(), - bun_core::time::nano_timestamp(), - ); - } else { - bun_core::pretty!( - " {} script{} skipped across {} package{} (--ignore-scripts)", - total_scripts, - scripts_plural, - total_packages_with_scripts, - packages_plural, - ); - } + bun_core::pretty!( + " {} script{} ran across {} package{} ", + total_scripts_ran, + if total_scripts_ran > 1 { "s" } else { "" }, + total_packages_with_scripts, + if total_packages_with_scripts > 1 { + "s" + } else { + "" + }, + ); + + Output::print_start_end_stdout(bun_core::start_time(), bun_core::time::nano_timestamp()); Output::print(format_args!("\n")); if total_skipped_packages > 0 { diff --git a/test/cli/install/bun-install-lifecycle-scripts.test.ts b/test/cli/install/bun-install-lifecycle-scripts.test.ts index b8a39215c545..53a9d8a505a7 100644 --- a/test/cli/install/bun-install-lifecycle-scripts.test.ts +++ b/test/cli/install/bun-install-lifecycle-scripts.test.ts @@ -3816,51 +3816,6 @@ for (const forceWaiterThread of isLinux ? [false, true] : [false]) { expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfileBefore); }); - test("bun pm trust --ignore-scripts records the trust and the next install runs the scripts", async () => { - using ctx = await setupTest(); - const { packageDir, packageJson, env } = ctx; - const testEnv = forceWaiterThread ? { ...env, BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" } : env; - - await writeFile( - packageJson, - JSON.stringify({ - name: "foo", - dependencies: { - "uses-what-bin": "1.0.0", - }, - }), - ); - const whatBinTxt = join(packageDir, "node_modules", "uses-what-bin", "what-bin.txt"); - - await runBunInstall(testEnv, packageDir); - expect(await exists(whatBinTxt)).toBeFalse(); - - { - await using proc = spawn({ - cmd: [bunExe(), "pm", "trust", "uses-what-bin", "--ignore-scripts"], - cwd: packageDir, - stdout: "pipe", - stderr: "pipe", - env: testEnv, - }); - const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(err).not.toContain("error:"); - expect(out).toContain("- [install]: what-bin"); - expect(out).toContain("1 script skipped across 1 package (--ignore-scripts)"); - expect(out).not.toContain("ran across"); - expect(exitCode).toBe(0); - } - expect(await exists(whatBinTxt)).toBeFalse(); - expect((await file(packageJson).json()).trustedDependencies).toEqual(["uses-what-bin"]); - // Only package.json records the trust, so that the next install sees a - // newly trusted package and runs its scripts. - expect(await file(join(packageDir, "bun.lock")).text()).not.toContain("trustedDependencies"); - - await runBunInstall(testEnv, packageDir); - expect(await exists(whatBinTxt)).toBeTrue(); - expect(await file(join(packageDir, "bun.lock")).text()).toContain("trustedDependencies"); - }); - test("bun pm trust and untrusted on missing package", async () => { using ctx = await setupTest(); const { packageDir, packageJson, env } = ctx; diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index 55f6d8332fb6..345d117e6106 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -1117,46 +1117,3 @@ test("bun pm cache rm --dry-run prints the cache directory and deletes nothing", expect(exitCode).toBe(0); expect(await exists(join(realCacheDir, "cached-package.txt"))).toBeTrue(); }); - -test("bun pm trust runs the scripts when only .npmrc sets ignore-scripts", async () => { - using dir = tempDir("pm-trust-npmrc-ignore-scripts", { - "package.json": JSON.stringify({ name: "app", dependencies: { dep: "file:./dep" } }), - "dep/package.json": JSON.stringify({ - name: "dep", - version: "1.0.0", - scripts: { postinstall: "echo ran > postinstall-ran.txt" }, - }), - ".npmrc": "ignore-scripts=true\n", - }); - const dirStr = String(dir); - const ranTxt = join(dirStr, "node_modules", "dep", "postinstall-ran.txt"); - - { - await using proc = Bun.spawn({ - cmd: [bunExe(), "install"], - cwd: dirStr, - stdout: "pipe", - stderr: "pipe", - env, - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).not.toContain("error:"); - expect(stdout).toContain("Blocked 1 postinstall"); - expect(exitCode).toBe(0); - expect(await exists(ranTxt)).toBeFalse(); - } - - await using proc = Bun.spawn({ - cmd: [bunExe(), "pm", "trust", "dep"], - cwd: dirStr, - stdout: "pipe", - stderr: "pipe", - env, - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - expect(stderr).not.toContain("error:"); - expect(stdout).toContain("1 script ran across 1 package"); - expect(await exists(ranTxt)).toBeTrue(); - expect(exitCode).toBe(0); -});