diff --git a/docs/pm/cli/audit.mdx b/docs/pm/cli/audit.mdx index e6da948d760d..059ce7c23407 100644 --- a/docs/pm/cli/audit.mdx +++ b/docs/pm/cli/audit.mdx @@ -11,6 +11,8 @@ bun audit Bun reads the package list from `bun.lock` (no `node_modules` required), sends it to the npm advisory endpoint, and prints a report. Bun sends packages from a scoped registry to that registry instead. If that registry has no advisory endpoint, Bun lists those packages as skipped and they don't affect the exit code. +With no package to audit, `bun audit` sends no request and reports no vulnerabilities. This is also the case if there is no `bun.lock` because the project has no workspaces and no dependencies. + `bun audit` never modifies `package.json`, `bun.lock`, or `node_modules`. To apply fixes, use [`bun audit fix`](#bun-audit-fix). If Bun finds no vulnerabilities, the command prints: diff --git a/docs/pm/cli/dedupe.mdx b/docs/pm/cli/dedupe.mdx index 20b484734721..ea5c4b722ff5 100644 --- a/docs/pm/cli/dedupe.mdx +++ b/docs/pm/cli/dedupe.mdx @@ -53,5 +53,6 @@ bun dedupe v1.4.0 (abc12345) - Bun never removes versions in `patchedDependencies`. If that forces another version to be kept too, Bun prints a `kept …` line explaining why. - Dependencies on a dist-tag, git URL, or tarball keep their resolved version. - Requires a lockfile that matches `package.json`. If dependencies changed since the last install, it exits with `bun.lock does not match package.json`. Run `bun install` first. Bun migrates a `package-lock.json`, `yarn.lock`, or `pnpm-lock.yaml` automatically. +- A project with no workspaces and no dependencies has no lockfile. There `bun dedupe` reports no duplicates and exits `0`. - Cannot be combined with `--frozen-lockfile`, `--production`, or `--no-save`; use `--check` instead. - With the isolated linker, several copies of the _same_ version that differ only in peer dependencies are not duplicates, and Bun does not report them. [`bun prune`](/pm/cli/prune) cleans up stale store entries. diff --git a/docs/pm/cli/pm.mdx b/docs/pm/cli/pm.mdx index c370e3419102..5dd85400f665 100644 --- a/docs/pm/cli/pm.mdx +++ b/docs/pm/cli/pm.mdx @@ -254,7 +254,7 @@ bun pm licenses --json --prod From a workspace root, Bun lists every workspace's dependencies. From inside a workspace package, Bun lists only that package's dependencies. Use [`bun why`](/pm/cli/why) to find out what pulls in an unexpected package. -Requires both `bun.lock` and `node_modules`. Bun skips packages that are in the lockfile but missing from `node_modules` (e.g. after `bun install --production`) and prints a warning. +Requires `node_modules`, and `bun.lock` unless the project has no workspaces and no dependencies. Bun skips packages that are in the lockfile but missing from `node_modules` (e.g. after `bun install --production`) and prints a warning. ## diff diff --git a/docs/pm/cli/prune.mdx b/docs/pm/cli/prune.mdx index 5a57d4d0682b..94eeb285b5aa 100644 --- a/docs/pm/cli/prune.mdx +++ b/docs/pm/cli/prune.mdx @@ -71,6 +71,7 @@ bun prune --production --filter app - Always runs from the workspace root and covers every workspace's `node_modules`, even when invoked inside a workspace package. - Requires `bun.lock` to match `package.json`. If you edited dependencies since the last install, run `bun install` first. +- A project with no workspaces and no dependencies has no `bun.lock`. There `bun prune` removes every package in `node_modules`. - Detects whether `node_modules` was laid out by the hoisted or the isolated linker and prunes it as it is. If the folders hold both layouts (for example a tree left by a switch of linkers under Bun 1.4 or older), Bun uses the linker `bun install` would use. Pass `--linker` to choose. - Matches packages by name. If a package is at the wrong version, Bun leaves it for `bun install` to replace. Bun only removes a nested copy (`node_modules/a/node_modules/b`) once the correct version is installed above it; otherwise Bun keeps it and prints a warning. - Never removes workspace folders, `.bin` entries still in use, dot-directories like `.cache`, plain files, or anything outside `node_modules`. diff --git a/docs/pm/lockfile.mdx b/docs/pm/lockfile.mdx index c9254c266e76..f1279dd0f246 100644 --- a/docs/pm/lockfile.mdx +++ b/docs/pm/lockfile.mdx @@ -48,6 +48,17 @@ print = "yarn" +#### A project with no dependencies + +Bun writes no `bun.lock` for a project that has no workspaces and no dependencies in its `package.json`. Commands that read the lockfile treat that project as one with nothing installed. For example, `bun audit` and `bun outdated` report an empty result and exit `0`. + +These commands decide from the root `package.json` alone. Without a `bun.lock`, they still exit `1` when that file: + +- has a `workspaces` field, even one that matches no folder +- is next to a `pnpm-workspace.yaml` +- is empty (write `{}` to start a project without dependencies) +- is below a folder whose `package.json` has dependencies or workspaces, or has a lockfile next to it + #### Text-based lockfile Bun v1.2 changed the default lockfile format to the text-based `bun.lock`. To migrate an existing binary `bun.lockb`, run `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` and delete `bun.lockb`. diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 9197d5ec109b..5b214a2e5be9 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -221,6 +221,7 @@ pub use self::package_manager_options::Options; pub use self::install_with_manager::install_with_manager; pub use self::package_json_editor as PackageJSONEditor; pub use self::update_request::UpdateRequest; +use self::workspace_manifests::ScratchManifests; pub use self::workspace_package_json_cache::WorkspacePackageJSONCache; pub use super::package_installer::PackageInstaller; @@ -1033,6 +1034,13 @@ pub static ROOT_PACKAGE_JSON_PATH: bun_core::RacyCell<&ZStr> = bun_core::RacyCel // impl PackageManager // ────────────────────────────────────────────────────────────────────────── +/// What `LoadResult::Ok` holds besides the lockfile. +type LoadedParts = ( + lockfile::Migrated, + lockfile::Serializer::SerializerLoadResult, + lockfile::LockfileFormat, +); + impl PackageManager { pub(crate) fn clear_cached_items_depending_on_lockfile_buffer(&mut self) { self.root_package_id.id = None; @@ -1043,7 +1051,7 @@ impl PackageManager { /// is a self-referential split borrow. Encapsulated here so callers stay /// in safe code: the returned `LoadResult` mutably borrows `self` for its /// lifetime, after which `self.lockfile` holds the loaded data. - pub fn load_lockfile_from_cwd( + pub(crate) fn load_lockfile_from_cwd( &mut self, ) -> lockfile::LoadResult<'_> { self.clear_cached_items_depending_on_lockfile_buffer(); @@ -1061,19 +1069,68 @@ impl PackageManager { } } + /// Exits if the lockfile exists and doesn't load. Returns `None` if there is no lockfile. + fn load_lockfile_or_exit_if_broken( + &mut self, + ) -> Option { + // Returning the `Ok` itself would keep `self` borrowed in the other arms of the callers. + match self.load_lockfile_from_cwd::() { + lockfile::LoadResult::Ok(ok) => Some((ok.migrated, ok.serializer_result, ok.format)), + lockfile::LoadResult::NotFound => None, + lockfile::LoadResult::Err(cause) => { + if self.options.log_level != package_manager_options::LogLevel::Silent { + cause.report(self.log_mut()); + } + Global::exit(1); + } + } + } + + fn loaded_lockfile( + &mut self, + (migrated, serializer_result, format): LoadedParts, + ) -> lockfile::LoadResult<'_> { + lockfile::LoadResult::Ok(lockfile::LoadResultOk { + lockfile: &mut self.lockfile, + migrated, + serializer_result, + format, + }) + } + /// `nothing` is the end of the error message for a missing lockfile, e.g. "nothing to update". - pub fn load_lockfile_from_cwd_or_exit(&mut self, nothing: &str) { - let silent = self.options.log_level == package_manager_options::LogLevel::Silent; - match self.load_lockfile_from_cwd::() { - lockfile::LoadResult::Ok(_) => return, - lockfile::LoadResult::NotFound if !silent => { - Output::err_generic("missing lockfile, {s}", (nothing,)); + /// Returns `NotFound`, with the root alone in `self.lockfile`, if package.json has nothing to lock. + pub fn load_lockfile_from_cwd_or_exit( + &mut self, + nothing: &str, + ) -> lockfile::LoadResult<'_> { + let Some(parts) = self.load_lockfile_or_exit_if_broken::() else { + // `bun install` saves no lockfile for a project with nothing to lock. + if let Some(empty) = ScratchManifests::empty_lockfile(self) { + *self.lockfile = empty; + return lockfile::LoadResult::NotFound; + } + if self.options.log_level != package_manager_options::LogLevel::Silent { + if nothing.is_empty() { + Output::err_generic("missing lockfile", ()); + } else { + Output::err_generic("missing lockfile, {s}", (nothing,)); + } bun_core::note!("run 'bun install' first"); } - lockfile::LoadResult::Err(cause) if !silent => cause.report(self.log_mut()), - _ => {} + Global::exit(1); + }; + self.loaded_lockfile(parts) + } + + /// For a command that has its own answer to a missing lockfile, which comes back as `NotFound`. + pub fn load_lockfile_from_cwd_if_any( + &mut self, + ) -> lockfile::LoadResult<'_> { + match self.load_lockfile_or_exit_if_broken::() { + Some(parts) => self.loaded_lockfile(parts), + None => lockfile::LoadResult::NotFound, } - Global::exit(1); } pub(crate) fn crash(&mut self) -> ! { @@ -2003,6 +2060,8 @@ pub fn user_cache_directory(env: &dot_env::Loader, cwd: &[u8]) -> Option { cwd: &'a [u8], next_dir: Option<&'a [u8]>, + /// Passes over a package.json in a shared directory without the warning. + quiet: bool, /// `/package.json` for the directory that `next` returned last. It's a `Vec` because the /// cwd alone can fill a path buffer. path: Vec, @@ -2013,6 +2072,7 @@ impl<'a> PackageJsonWalk<'a> { Self { cwd, next_dir: Some(cwd), + quiet: false, path: Vec::with_capacity(cwd.len() + b"/package.json".len()), } } @@ -2036,10 +2096,12 @@ impl<'a> PackageJsonWalk<'a> { && dir.len() != self.cwd.len() && is_shared_directory(dir) { - bun_core::warn!( - "ignoring package.json in {}: directory is writable by other users", - bstr::BStr::new(dir), - ); + if !self.quiet { + bun_core::warn!( + "ignoring package.json in {}: directory is writable by other users", + bstr::BStr::new(dir), + ); + } continue; } return Some((dir, opened)); @@ -2352,6 +2414,41 @@ pub fn project_directory(cwd: &[u8]) -> Option<&[u8]> { .map(|(project, _)| project) } +/// Returns the nearest directory above `dir` whose project has a lockfile or something to lock. +fn project_above<'a>(dir: &'a [u8], cache: &mut WorkspacePackageJSONCache) -> Option<&'a [u8]> { + const TO_LOCK: [&[u8]; 4] = [ + b"dependencies", + b"devDependencies", + b"optionalDependencies", + b"peerDependencies", + ]; + let mut walk = PackageJsonWalk::new(dir); + walk.next_dir = bun_core::dirname(dir); + // `init` is the one that warns about a package.json in a shared directory. + walk.quiet = true; + while let Some((above, opened)) = walk.next(bun_sys::O::RDONLY) { + if opened.is_err_and(|err| !err.is_unreadable_file(&walk.path) && !err.is_directory()) { + continue; + } + let package_json = cache + .get_with_path(&mut bun_ast::Log::init(), &walk.path, Default::default()) + .unwrap(); + let has_something_to_lock = match package_json { + Ok(json) => { + json.root.get(b"workspaces").is_some() + || (TO_LOCK.iter()) + .any(|key| json.root.get(key).is_some_and(|it| it.property_count() > 0)) + } + // This file could be the project's package.json. + Err(_) => true, + }; + if has_something_to_lock || Lockfile::exists_in(above) { + return Some(above); + } + } + None +} + /// Returns `&'static mut PackageManager` — the process-singleton (held in /// `holder::RAW_PTR`) is leaked for the process lifetime and `init()` is called /// exactly once on the single CLI dispatch thread. Every diff --git a/src/install/PackageManager/patchPackage.rs b/src/install/PackageManager/patchPackage.rs index f8b981c43f40..b5535464c3d6 100644 --- a/src/install/PackageManager/patchPackage.rs +++ b/src/install/PackageManager/patchPackage.rs @@ -27,6 +27,7 @@ use crate::package_manager_real::package_manager_directories::{ compute_cache_dir_and_subpath, get_temporary_directory, is_package_in_cache_at, }; use crate::package_manager_real::populate_package_cache::download_pinned_packages; +use crate::package_manager_real::workspace_manifests::ScratchManifests; use crate::resolution::append_without_url_credentials; use crate::{DependencyID, PackageID, PatchTask, Resolution, ResolutionTag, initialize_store}; @@ -469,11 +470,14 @@ pub fn do_patch_commit( let linker = load_result.node_linker(manager.options.node_linker); match load_result { lockfile::LoadResult::NotFound => { - Output::err_generic( - "Cannot find lockfile. Install packages with `bun install` before patching them.", - (), - ); - Global::crash(); + let Some(empty) = ScratchManifests::empty_lockfile(manager) else { + Output::err_generic( + "Cannot find lockfile. Install packages with `bun install` before patching them.", + (), + ); + Global::crash(); + }; + *lockfile = empty; } lockfile::LoadResult::Err(cause) => { if log_level != LogLevel::Silent { diff --git a/src/install/PackageManager/updatePackageJSONAndInstall.rs b/src/install/PackageManager/updatePackageJSONAndInstall.rs index 56c899dc01a6..f4bcbb21d546 100644 --- a/src/install/PackageManager/updatePackageJSONAndInstall.rs +++ b/src/install/PackageManager/updatePackageJSONAndInstall.rs @@ -183,7 +183,7 @@ fn update_package_json_and_install_with_manager_with_updates( && (manager.options.do_.recursive() || !manager.options.filter_patterns.is_empty()) && manager.options.do_.load_lockfile() { - manager.load_lockfile_from_cwd_or_exit("nothing to update"); + manager.load_lockfile_from_cwd_or_exit::("nothing to update"); let filter_patterns = manager.options.filter_patterns; let workspace_selection::LockfileSelection { ids: selected, diff --git a/src/install/PackageManager/workspace_manifests.rs b/src/install/PackageManager/workspace_manifests.rs index 42ca6baf8e04..9e3644c65b86 100644 --- a/src/install/PackageManager/workspace_manifests.rs +++ b/src/install/PackageManager/workspace_manifests.rs @@ -16,6 +16,8 @@ use crate::{ use super::PackageManager; use super::add_remove_with_filter::{WorkspaceTarget, fetch_entry, root_package_json_path}; +use super::install_with_manager::exit_on_empty_package_json; +use super::package_manager_options::LogLevel; use super::workspace_package_json_cache::GetJSONOptions; use super::workspace_selection::WorkspaceGraph; @@ -61,6 +63,57 @@ impl ScratchManifests { self.fail_on_logged_errors() } + /// Returns the root-only lockfile if the root package.json alone shows there is nothing to lock. + pub(crate) fn empty_lockfile(manager: &mut PackageManager) -> Option { + let path = root_package_json_path(); + let silent = manager.options.log_level == LogLevel::Silent; + // `parse_root` exits if it can't read the file. `bun pm cache pack` runs without one. + let entry = (manager.workspace_package_json_cache) + .get_with_path(&mut bun_ast::Log::init(), &path, Default::default()) + .unwrap() + .ok()?; + // `bun install` reads an empty file as `{}`, but it may be a truncated one. + if entry.source.contents.is_empty() { + if silent { + Global::exit(1); + } + exit_on_empty_package_json(&path); + } + // The workspaces may be missing from disk. A root that is not an object is no manifest. + if !entry.root.is_object() || entry.root.get(b"workspaces").is_some() { + return None; + } + + let mut scratch = ScratchManifests::new(); + scratch.parse_root(manager, Features::main()).ok()?; + // Only an install reads the workspaces that pnpm-workspace.yaml lists. + if scratch.root.dependencies.len > 0 + || bun_sys::exists_z(bun_core::zstr!("pnpm-workspace.yaml")) + { + return None; + } + // A folder below a project may belong to it, and then that project is the one to read. + if !manager.options.global + && let Some(above) = super::project_above( + bun_core::dirname(&path)?, + &mut manager.workspace_package_json_cache, + ) + { + if !silent { + Output::err_generic("missing lockfile", ()); + bun_core::note!( + "{} has no dependencies, and a project is above it in {}", + BStr::new(&path), + BStr::new(above), + ); + } + Global::exit(1); + } + (scratch.lockfile.append_package(&scratch.root)).unwrap_or_oom(); + scratch.lockfile.resolve(&mut scratch.log).unwrap_or_oom(); + Some(scratch.lockfile) + } + /// Doesn't read any workspace. Uses bun.lock's workspace paths, but not its versions, which may /// be stale. fn parse_root_alone(&mut self, manager: &mut PackageManager) -> crate::Result<()> { diff --git a/src/install/dedupe.rs b/src/install/dedupe.rs index 4af631145d05..77229fe171b4 100644 --- a/src/install/dedupe.rs +++ b/src/install/dedupe.rs @@ -11,6 +11,7 @@ use crate::lockfile::package::PackageColumns as _; use crate::lockfile::{LoadResult, Lockfile, Package, PackageIndexEntry, print_log_errors}; use crate::package_manager::Options::{Enable, LogLevel}; use crate::package_manager::ROOT_PACKAGE_JSON_PATH; +use crate::package_manager::workspace_manifests::ScratchManifests; use crate::{ Dependency, DependencyID, DependencyVersionTag, Features, GetJsonResult, PackageID, PackageManager, ResolutionTag, dependency, invalid_package_id, @@ -779,11 +780,14 @@ pub fn dedupe_before_install( match load_result { LoadResult::NotFound => { - if !quiet { - Output::err_generic("missing lockfile, nothing to dedupe", ()); - bun_core::note!("run 'bun install' first"); - } - Global::exit(1); + let Some(empty) = ScratchManifests::empty_lockfile(manager) else { + if !quiet { + Output::err_generic("missing lockfile, nothing to dedupe", ()); + bun_core::note!("run 'bun install' first"); + } + Global::exit(1); + }; + *manager.lockfile = empty; } LoadResult::Err(cause) => { if !quiet { diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index 79ea9ae1c476..5d6a3c0fbcbb 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -459,7 +459,7 @@ impl LoadResultErr { /// Used by the commands that exit on this error. `bun install` continues and prints a different /// message. #[cold] - pub(crate) fn report(&self, log: &bun_ast::Log) { + pub fn report(&self, log: &bun_ast::Log) { if crate::migration::reported_unsupported_lockfile_version(self) { return; } @@ -574,8 +574,7 @@ impl<'a> LoadResult<'a> { ) } - // Callers reach this only after `handleLoadLockfileErrors` has exited on - // the `NotFound`/`Err` arms, so the variant is known-`Ok`. + // Callers rule out `NotFound` and `Err` before they call this. bun_core::enum_unwrap!(pub LoadResult, Ok => fn ok / ok_mut -> LoadResultOk<'a>); } @@ -609,6 +608,23 @@ impl Lockfile { || (self.packages.len() == 1 && self.packages.get(0).resolutions.len == 0) } + /// Returns true if `dir` has a file by the name of a lockfile that `load_from_dir` reads or migrates. + pub(crate) fn exists_in(dir: &[u8]) -> bool { + let mut buf = bun_paths::path_buffer_pool::get(); + [LockfileFormat::Text, LockfileFormat::Binary] + .map(LockfileFormat::filename) + .into_iter() + .chain(migration::OTHER_LOCKFILES.map(|(_, name)| name)) + .any(|name| { + resolve_path::join_abs_string_buf_z_checked::( + dir, + &mut buf, + &[name.as_bytes()], + ) + .is_some_and(sys::exists_z) + }) + } + pub fn load_from_cwd<'a, const ATTEMPT_LOADING_FROM_OTHER_LOCKFILE: bool>( &'a mut self, manager: Option<&mut PackageManager>, diff --git a/src/install/migration.rs b/src/install/migration.rs index f035c0646bee..ed006240c73d 100644 --- a/src/install/migration.rs +++ b/src/install/migration.rs @@ -141,6 +141,14 @@ pub(crate) fn parse_manifest_bin( Ok(()) } +/// The lockfiles a migration tries, in this order. npm reads npm-shrinkwrap.json over package-lock.json. +pub(crate) const OTHER_LOCKFILES: [(&str, &ZStr); 4] = [ + ("npm-shrinkwrap.json", zstr!("npm-shrinkwrap.json")), + ("package-lock.json", zstr!("package-lock.json")), + ("yarn.lock", zstr!("yarn.lock")), + ("pnpm-lock.yaml", zstr!("pnpm-lock.yaml")), +]; + pub fn detect_and_load_other_lockfile<'a>( this: &'a mut Lockfile, dir: Fd, @@ -151,14 +159,8 @@ pub fn detect_and_load_other_lockfile<'a>( // if it exists, do an in-memory migration let timer = std::time::Instant::now(); - // npm itself prefers npm-shrinkwrap.json over package-lock.json. The two have the same format. let mut found = Vec::new(); - for (name, name_z) in [ - ("npm-shrinkwrap.json", zstr!("npm-shrinkwrap.json")), - ("package-lock.json", zstr!("package-lock.json")), - ("yarn.lock", zstr!("yarn.lock")), - ("pnpm-lock.yaml", zstr!("pnpm-lock.yaml")), - ] { + for (name, name_z) in OTHER_LOCKFILES { let Ok(file) = File::openat(dir, name_z, O::RDONLY, 0) else { continue; }; @@ -334,7 +336,7 @@ pub(crate) fn parse_root_overrides( } /// True when the migrator already printed the version warn/error + upgrade note, so lockfile-load reporters must stay quiet. -pub fn reported_unsupported_lockfile_version(err: &LoadResultErr) -> bool { +pub(crate) fn reported_unsupported_lockfile_version(err: &LoadResultErr) -> bool { err.step == LoadStep::Migrating && matches!(err.value, Error::UnexpectedLockfileVersion) } diff --git a/src/install/prune.rs b/src/install/prune.rs index 9f075790882e..131307abfb5b 100644 --- a/src/install/prune.rs +++ b/src/install/prune.rs @@ -19,7 +19,7 @@ use crate::isolated_install::store::{EntryColumns as _, NodeColumns as _, entry use crate::isolated_install::{Store, build_whole_store}; use crate::lockfile::package::PackageColumns as _; use crate::lockfile::tree::is_filtered_dependency_or_workspace; -use crate::lockfile::{LoadResult, Lockfile, print_log_errors, reachable, tree}; +use crate::lockfile::{Lockfile, print_log_errors, reachable, tree}; use crate::lockfile_real::LinkedMembers; use crate::lockfile_real::package::{Diff, DiffSummary, DroppedRow, Package}; use crate::package_install::{StagingLock, StagingPath}; @@ -275,31 +275,9 @@ pub fn prune(manager: &mut PackageManager, original_cwd: &[u8]) -> crate::Result let dry_run = manager.options.dry_run; let configured_linker = manager.options.node_linker; - let loaded = { - let load = manager.load_lockfile_from_cwd::(); - match &load { - LoadResult::NotFound => Err(None), - LoadResult::Err(cause) => Err(Some(cause.value.name())), - LoadResult::Ok(_) => Ok(load.node_linker(configured_linker)), - } - }; - let linker = match loaded { - Ok(linker) => linker, - Err(None) => { - if !quiet { - Output::err_generic("missing lockfile, nothing to prune", ()); - bun_core::note!("run 'bun install' first"); - } - Global::exit(1); - } - Err(Some(name)) => { - if !quiet { - Output::err_generic("failed to load lockfile: {s}", (name,)); - print_log_errors(manager.log_mut()); - } - Global::exit(1); - } - }; + let linker = manager + .load_lockfile_from_cwd_or_exit::("nothing to prune") + .node_linker(configured_linker); manager.options.enable.set(Enable::FROZEN_LOCKFILE, true); manager.summary = exit_unless_lockfile_matches_package_json(manager, "prune")?; diff --git a/src/install/update_scope.rs b/src/install/update_scope.rs index 9fb7fb536746..4a1e148becca 100644 --- a/src/install/update_scope.rs +++ b/src/install/update_scope.rs @@ -13,6 +13,7 @@ use crate::lockfile::package::PackageColumns as _; use crate::lockfile::{Lockfile, reachable}; use crate::package_manager::Options::LogLevel; use crate::package_manager::UpdateTargetWorkspace; +use crate::package_manager::workspace_manifests::ScratchManifests; use crate::package_manager::workspace_selection::{self, RootSelection}; use crate::package_manager_real::command_line_arguments::UpdateGroups; use crate::package_manager_real::install_with_manager::loaded_lockfile_name; @@ -303,18 +304,15 @@ fn exit_on_lockfile_load_failure(manager: &mut PackageManager, subject: &[u8]) - if !manager.options.do_.load_lockfile() { missing(silent, subject); } - let load_result = manager.load_lockfile_from_cwd::(); + let load_result = manager.load_lockfile_from_cwd_if_any::(); let lockfile_name = loaded_lockfile_name(&load_result); - match load_result { - crate::lockfile::LoadResult::Ok(_) => lockfile_name, - crate::lockfile::LoadResult::NotFound => missing(silent, subject), - crate::lockfile::LoadResult::Err(cause) => { - if !silent { - cause.report(manager.log_mut()); - } - Global::exit(1); - } + if matches!(load_result, crate::lockfile::LoadResult::NotFound) { + let Some(empty) = ScratchManifests::empty_lockfile(manager) else { + missing(silent, subject); + }; + *manager.lockfile = empty; } + lockfile_name } /// Turns patterns and group selectors into the concrete names the named path expects. diff --git a/src/runtime/cli/audit_command.rs b/src/runtime/cli/audit_command.rs index 0e21504bc1ee..8034981ce5bb 100644 --- a/src/runtime/cli/audit_command.rs +++ b/src/runtime/cli/audit_command.rs @@ -22,7 +22,6 @@ use bun_url::URL; use crate::cli::Command; use crate::cli::install_command::InstallCommand; -use crate::cli::package_manager_command::PackageManagerCommand; struct VulnerabilityInfo { severity: Box<[u8]>, @@ -156,15 +155,7 @@ impl AuditCommand { Output::flush(); } - { - let log_level = manager.options.log_level; - let load_lockfile = manager.load_lockfile_from_cwd::(); - PackageManagerCommand::handle_load_lockfile_errors_for( - &load_lockfile, - log_level, - "audit", - ); - } + manager.load_lockfile_from_cwd_or_exit::("nothing to audit"); if fix { return Self::audit_fix( @@ -267,7 +258,7 @@ impl AuditCommand { // Audit the saved lockfile, which is what the next `bun audit` reads. The copy in memory // can contain a package that was not saved. - pm.load_lockfile_from_cwd_or_exit("nothing to audit"); + pm.load_lockfile_from_cwd_or_exit::("nothing to audit"); let reaudit = audit_for_fix(pm, audit_level, ignore_list, false)?; if json_output { plan.unaudited = reaudit.unaudited; @@ -495,6 +486,13 @@ fn collect_packages_for_audit( } } + // There is no package to audit, so no registry is asked. + if by_name.is_empty() { + return Ok(CollectPackagesResult { + requests: Vec::new(), + }); + } + let requests = groups .into_iter() .enumerate() @@ -616,7 +614,8 @@ fn is_empty_bulk_body(body: &[u8]) -> bool { fn merge_bulk_bodies(bodies: &[Box<[u8]>]) -> Box<[u8]> { let mut non_empty = bodies.iter().filter(|body| !is_empty_bulk_body(body)); let Some(first) = non_empty.next() else { - return bodies.first().cloned().unwrap_or_default(); + // Without a body, no request was sent, because there was no package to audit. + return (bodies.first().cloned()).unwrap_or_else(|| Box::from(&b"{}"[..])); }; let Some(second) = non_empty.next() else { return first.clone(); diff --git a/src/runtime/cli/outdated_command.rs b/src/runtime/cli/outdated_command.rs index d4fb09393a24..f05f0cbf9e64 100644 --- a/src/runtime/cli/outdated_command.rs +++ b/src/runtime/cli/outdated_command.rs @@ -118,7 +118,7 @@ impl OutdatedCommand { } }; - manager.load_lockfile_from_cwd_or_exit("nothing outdated"); + manager.load_lockfile_from_cwd_or_exit::("nothing outdated"); Self::outdated_dispatch(&original_cwd, manager) } diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 348752e8670d..58a80c9f4171 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -130,36 +130,19 @@ fn cwd_or_exit(buf: &mut bun_paths::PathBuffer) -> &[u8] { pub(crate) struct PackageManagerCommand; impl PackageManagerCommand { - pub(crate) fn handle_load_lockfile_errors_for( + /// Exits if the lockfile exists and didn't load. The caller handles a missing lockfile. + pub(crate) fn exit_on_lockfile_load_error( load_lockfile: &LoadResult<'_>, + log: &bun_ast::Log, log_level: LogLevel, - nothing_to: &str, ) { - let not_silent = log_level != LogLevel::Silent; - - match load_lockfile { - LoadResult::NotFound => { - if not_silent { - if nothing_to.is_empty() { - Output::err_generic("missing lockfile", ()); - } else { - Output::err_generic("missing lockfile, nothing to {s}", (nothing_to,)); - } - bun_core::note!("run 'bun install' first"); - } - Global::exit(1); - } - LoadResult::Err(err) => { - if not_silent && !migration::reported_unsupported_lockfile_version(err) { - Output::err_generic( - "failed to {s} lockfile: {s}", - (err.step.verb(), err.value.name()), - ); - } - Global::exit(1); - } - LoadResult::Ok(_) => {} + let LoadResult::Err(err) = load_lockfile else { + return; + }; + if log_level != LogLevel::Silent { + err.report(log); } + Global::exit(1); } #[cold] @@ -174,7 +157,7 @@ impl PackageManagerCommand { let mut lockfile = Box::::default(); let load_lockfile = lockfile.load_from_bytes(None, bytes, log); - Self::handle_load_lockfile_errors_for(&load_lockfile, LogLevel::Default, "hash"); + Self::exit_on_lockfile_load_error(&load_lockfile, log, LogLevel::Default); Output::flush(); Output::disable_buffering(); @@ -372,9 +355,7 @@ impl PackageManagerCommand { Output::flush(); return Ok(()); } else if matches!(subcommand, b"hash" | b"hash-print" | b"hash-string") { - let log_level = pm.options.log_level; - let load_lockfile = pm.load_lockfile_from_cwd::(); - Self::handle_load_lockfile_errors_for(&load_lockfile, log_level, "hash"); + pm.load_lockfile_from_cwd_or_exit::("nothing to hash"); let print_string = strings::eql_comptime(subcommand, b"hash-string"); if !strings::eql_comptime(subcommand, b"hash-print") { @@ -393,9 +374,7 @@ impl PackageManagerCommand { let action: &[u8] = positionals.get(1).copied().unwrap_or(b""); if matches!(action, b"pack" | b"unpack") { if strings::eql_comptime(action, b"pack") { - let log_level = pm.options.log_level; - let load_lockfile = pm.load_lockfile_from_cwd::(); - Self::handle_load_lockfile_errors_for(&load_lockfile, log_level, "pack"); + pm.load_lockfile_from_cwd_or_exit::("nothing to pack"); } bun_install::cache_pack::exec(pm, &cwd); } @@ -608,7 +587,11 @@ impl PackageManagerCommand { // As in `npm ls`, a package name is searched for among all installed packages, and // `--depth` takes precedence over `--all`. let all = pm.options.depth.is_none() && (!names.is_empty() || cli.all); - let load_lockfile = pm.load_lockfile_from_cwd::(); + let load_lockfile = if global { + pm.load_lockfile_from_cwd_if_any::() + } else { + pm.load_lockfile_from_cwd_or_exit::("nothing to list") + }; // Before the first `bun add -g`, and after the last package was removed. if global && matches!(load_lockfile, LoadResult::NotFound) { Output::println(format_args!( @@ -620,7 +603,6 @@ impl PackageManagerCommand { )); Global::exit(0); } - Self::handle_load_lockfile_errors_for(&load_lockfile, log_level, "list"); let trusted_only = cli.trusted_only; if trusted_only { read_trusted_dependencies(pm); @@ -798,7 +780,7 @@ impl PackageManagerCommand { bun_core::pretty_errorln!("error: could not find any other lockfile"); Global::exit(1); } - Self::handle_load_lockfile_errors_for(&load_lockfile, log_level, ""); + Self::exit_on_lockfile_load_error(&load_lockfile, pm.log_mut(), log_level); if pm.options.dry_run { if log_level != LogLevel::Silent { bun_core::prettyln!( diff --git a/src/runtime/cli/pm_diff_command.rs b/src/runtime/cli/pm_diff_command.rs index 087d77f5a1cc..a8c8daa7cb1c 100644 --- a/src/runtime/cli/pm_diff_command.rs +++ b/src/runtime/cli/pm_diff_command.rs @@ -416,9 +416,8 @@ fn installed_version(pm: &mut PackageManager, name: &[u8]) -> Option<(Vec, V LoadResult::NotFound => false, // A lockfile that exists but will not load is reported as that, not as "package not installed". broken @ LoadResult::Err(_) => { - let _ = log.print(std::ptr::from_mut(Output::error_writer())); - crate::cli::package_manager_command::PackageManagerCommand::handle_load_lockfile_errors_for( - &broken, log_level, "", + crate::cli::package_manager_command::PackageManagerCommand::exit_on_lockfile_load_error( + &broken, &log, log_level, ); false } diff --git a/src/runtime/cli/pm_licenses_command.rs b/src/runtime/cli/pm_licenses_command.rs index e6f846209d47..c0726dc98cf8 100644 --- a/src/runtime/cli/pm_licenses_command.rs +++ b/src/runtime/cli/pm_licenses_command.rs @@ -15,8 +15,6 @@ use bun_parsers::json as JSON; use bun_paths::{AutoAbsPath, resolve_path}; use bun_sys::{self, Dir, Fd, File}; -use crate::cli::package_manager_command::PackageManagerCommand; - const UNKNOWN_LICENSE: &[u8] = b"Unknown"; const MAX_SCAN_DEPTH: usize = 64; @@ -95,8 +93,7 @@ impl PmLicensesCommand { } let configured_linker = pm.options.node_linker; - let load = pm.load_lockfile_from_cwd::(); - PackageManagerCommand::handle_load_lockfile_errors_for(&load, log_level, "list"); + let load = pm.load_lockfile_from_cwd_or_exit::("nothing to list"); let isolated = load.node_linker(configured_linker) == NodeLinker::Isolated; let json_output = pm.options.json_output; diff --git a/src/runtime/cli/pm_trusted_command.rs b/src/runtime/cli/pm_trusted_command.rs index 1e78f334c590..3f5d40cb505c 100644 --- a/src/runtime/cli/pm_trusted_command.rs +++ b/src/runtime/cli/pm_trusted_command.rs @@ -23,7 +23,6 @@ use bun_install_types::NodeLinker::NodeLinker; use bun_paths::AutoAbsPath; use crate::cli::Command; -use crate::package_manager_command::PackageManagerCommand; type TrustedDependenciesSet = ArrayHashMap, ArrayIdentityContext>; @@ -279,10 +278,8 @@ impl UntrustedCommand { // `load_lockfile` mutably borrows `pm.lockfile`, so `pm` is reached through `pm_raw` while it lives. let pm_raw: *mut PackageManager = pm; - let log_level = pm.options.log_level; let configured_linker = pm.options.node_linker; - let load_lockfile = pm.load_lockfile_from_cwd::(); - PackageManagerCommand::handle_load_lockfile_errors_for(&load_lockfile, log_level, "list"); + let load_lockfile = pm.load_lockfile_from_cwd_or_exit::("nothing to list"); let linker = load_lockfile.node_linker(configured_linker); // SAFETY: `update_lockfile_if_needed` never dereferences the lockfile through `load_lockfile`. unsafe { update_lockfile_if_needed(&mut *pm_raw, &load_lockfile)? }; @@ -431,8 +428,7 @@ impl TrustCommand { let dry_run = pm.options.dry_run; let configured_linker = pm.options.node_linker; let max_concurrent = pm.options.max_concurrent_lifecycle_scripts; - let load_lockfile = pm.load_lockfile_from_cwd::(); - PackageManagerCommand::handle_load_lockfile_errors_for(&load_lockfile, log_level, "trust"); + let load_lockfile = pm.load_lockfile_from_cwd_or_exit::("nothing to trust"); let linker = load_lockfile.node_linker(configured_linker); // SAFETY: `update_lockfile_if_needed` never dereferences the lockfile through `load_lockfile`. unsafe { update_lockfile_if_needed(&mut *pm_raw, &load_lockfile)? }; diff --git a/src/runtime/cli/scan_command.rs b/src/runtime/cli/scan_command.rs index 93300b7eb296..86fd0c2af33c 100644 --- a/src/runtime/cli/scan_command.rs +++ b/src/runtime/cli/scan_command.rs @@ -1,8 +1,7 @@ use crate::Command; -use crate::cli::package_manager_command::PackageManagerCommand; use bun_core::{Global, Output}; +use bun_install::PackageManager; use bun_install::package_manager::security_scanner; -use bun_install::{Lockfile, PackageManager}; pub(crate) struct ScanCommand; @@ -31,26 +30,7 @@ impl ScanCommand { ); Output::flush(); - // Reshaped for borrowck — `manager.lockfile.load_from_cwd(&mut self, - // Some(manager), log)` would alias `&mut *manager.lockfile` with `&mut *manager`. - // Project disjoint raw pointers from the singleton first; `load_from_cwd` only - // reads `manager.options`/migration helpers and never re-borrows `manager.lockfile`. - { - let log_level = manager.options.log_level; - let pm_ptr: *mut PackageManager = manager; - // SAFETY: `manager.log` is set non-null by `PackageManager::init`. - let log: &mut bun_ast::Log = unsafe { &mut *(*pm_ptr).log }; - // SAFETY: `lockfile` is the owned `Box` field on the singleton; - // no other live `&mut Lockfile` exists at this point. - let lockfile: &mut Lockfile = unsafe { &mut *(*pm_ptr).lockfile }; - let load_result = lockfile.load_from_cwd::( - // SAFETY: see comment above — `load_from_cwd` accesses `manager` - // fields disjoint from `lockfile`. - Some(unsafe { &mut *pm_ptr }), - log, - ); - PackageManagerCommand::handle_load_lockfile_errors_for(&load_result, log_level, "scan"); - } + manager.load_lockfile_from_cwd_or_exit::("nothing to scan"); let security_scan_results = match security_scanner::perform_security_scan_for_all(manager, &mut *ctx, original_cwd) diff --git a/src/runtime/cli/update_interactive_command.rs b/src/runtime/cli/update_interactive_command.rs index 6c40dc57d30d..05403b9f1079 100644 --- a/src/runtime/cli/update_interactive_command.rs +++ b/src/runtime/cli/update_interactive_command.rs @@ -440,7 +440,7 @@ impl UpdateInteractiveCommand { groups: UpdateGroups, ) -> crate::Result<()> { let not_silent = manager.options.log_level != LogLevel::Silent; - manager.load_lockfile_from_cwd_or_exit("nothing to update"); + manager.load_lockfile_from_cwd_or_exit::("nothing to update"); let workspace_pkg_ids: Vec = if !manager.options.filter_patterns.is_empty() || manager.options.do_.recursive() { diff --git a/src/runtime/cli/why_command.rs b/src/runtime/cli/why_command.rs index d3d060e9d0e6..1124aa3bb23d 100644 --- a/src/runtime/cli/why_command.rs +++ b/src/runtime/cli/why_command.rs @@ -17,7 +17,6 @@ use bun_install_types::NodeLinker::NodeLinker; use bun_semver as semver; use crate::command; -use crate::package_manager_command::PackageManagerCommand; use bun_collections::index_sort; pub(crate) struct WhyCommand; @@ -270,14 +269,13 @@ Explain why a package is installed } else { pm.options.depth.unwrap_or(100) }; - let log_level = pm.options.log_level; let configured_linker = pm.options.node_linker; // Load into `pm.lockfile` itself, because a migration fetches the manifests of the packages // that it finds there. - let load_lockfile = pm.load_lockfile_from_cwd::(); - PackageManagerCommand::handle_load_lockfile_errors_for(&load_lockfile, log_level, ""); - let linker = load_lockfile.node_linker(configured_linker); + let linker = pm + .load_lockfile_from_cwd_or_exit::("") + .node_linker(configured_linker); let lockfile: &Lockfile = &pm.lockfile; let string_bytes = lockfile.buffers.string_bytes.as_slice(); diff --git a/src/runtime/lib.rs b/src/runtime/lib.rs index 09ab23147148..3cee7dc7b60f 100644 --- a/src/runtime/lib.rs +++ b/src/runtime/lib.rs @@ -84,8 +84,7 @@ pub(crate) mod valkey_jsc; // so `*_command.rs` and `test/parallel/*.rs` files resolve their // `use crate::…` lines without per-file edits. pub(crate) use cli::{ - Cli, Command, build_command, command, filter_arg, package_manager_command, run_command, - shell_completions, test_command, + Cli, Command, build_command, command, filter_arg, run_command, shell_completions, test_command, }; pub(crate) mod webview; diff --git a/test/cli/install/bun-audit.test.ts b/test/cli/install/bun-audit.test.ts index 571b0b7ec162..b9fa7dae6435 100644 --- a/test/cli/install/bun-audit.test.ts +++ b/test/cli/install/bun-audit.test.ts @@ -400,6 +400,21 @@ describe("`bun audit`", () => { }, }); + // `bun install` saves no lockfile for a project without dependencies, so this is that project as it is on disk. + doAuditTest("should exit 0 when there are no dependencies in package.json and no lockfile", { + exitCode: 0, + files: { + "package.json": JSON.stringify({ + name: "empty-package", + version: "1.0.0", + }), + }, + fn: async ({ stdout, stderr }) => { + expect(await stderr).toBe(""); + expect(normalizeBunSnapshot(await stdout)).toBe(AUDIT_HEADER + noVulnerabilities(0)); + }, + }); + doAuditTest("should exit 0 when there are no vulnerabilities", { exitCode: 0, files: fixture("safe-is-number@7"), @@ -598,6 +613,124 @@ describe("`bun audit`", () => { expect(exitCode).toBe(0); }); + test.concurrent("the default registry is still asked when every package comes from a scoped registry", async () => { + const bulkBodies: Record[] = []; + await using registry = startRegistry({}, { bulkBodies }); + await using scoped = startRegistry({}); + using dir = tempDir("bun-test-audit-scoped-registry-only", scopedRegistryProject(scoped)); + + await using proc = spawn({ + cmd: [bunExe(), "audit"], + stdout: "pipe", + stderr: "pipe", + cwd: String(dir), + env: { ...bunEnv, NPM_CONFIG_REGISTRY: registryHref(registry) }, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(normalizeBunSnapshot(stdout)).toBe(AUDIT_HEADER + noVulnerabilities(2)); + expect(exitCode).toBe(0); + expect(bulkBodies).toEqual([{}]); + }); + + // With no package to audit there is nothing to send, so the result does not depend on a registry. + const rootOnlyLock = { lockfileVersion: 1, workspaces: { "": { name: "test" } }, packages: {} }; + describe.each<[string, DirectoryTree, string[], string]>([ + ["no dependencies and no lockfile", { "package.json": JSON.stringify({ name: "test", version: "1.0.0" }) }, [], ""], + [ + "a bun.lock with only the root", + { "package.json": JSON.stringify({ name: "test", version: "1.0.0" }), "bun.lock": JSON.stringify(rootOnlyLock) }, + [], + "", + ], + [ + "a package-lock.json with only the root", + { + "package.json": JSON.stringify({ name: "test", version: "1.0.0" }), + "package-lock.json": JSON.stringify({ + name: "test", + version: "1.0.0", + lockfileVersion: 3, + requires: true, + packages: { "": { name: "test", version: "1.0.0" } }, + }), + }, + [], + "migrated lockfile from package-lock.json", + ], + [ + "only a workspace", + { + "package.json": JSON.stringify({ name: "test", version: "1.0.0", workspaces: ["member"] }), + "member/package.json": JSON.stringify({ name: "member", version: "1.0.0" }), + "bun.lock": JSON.stringify({ + ...rootOnlyLock, + workspaces: { ...rootOnlyLock.workspaces, member: { name: "member", version: "1.0.0" } }, + packages: { member: ["member@workspace:member"] }, + }), + }, + [], + "", + ], + [ + "only a file: dependency", + { + "package.json": JSON.stringify({ name: "test", version: "1.0.0", dependencies: { folder: "file:folder" } }), + "folder/package.json": JSON.stringify({ name: "folder", version: "1.0.0" }), + "bun.lock": JSON.stringify({ + ...rootOnlyLock, + workspaces: { "": { name: "test", dependencies: { folder: "file:folder" } } }, + packages: { folder: ["folder@file:folder", {}] }, + }), + }, + [], + "", + ], + [ + "only a devDependency, with --prod", + { + "package.json": JSON.stringify({ name: "test", version: "1.0.0", devDependencies: { ms: "0.7.0" } }), + "bun.lock": JSON.stringify({ + ...rootOnlyLock, + workspaces: { "": { name: "test", devDependencies: { ms: "0.7.0" } } }, + packages: { ms: ["ms@0.7.0", "", {}, fakeIntegrity] }, + }), + }, + ["--prod"], + "", + ], + ])("nothing to audit: %s", (_, files, flags, note) => { + test.concurrent("sends no request", async () => { + const bulkHits = { count: 0 }; + await using registry = startRegistry({}, { bulkHits }); + using dir = tempDir("bun-test-audit-nothing", files); + await writeBunfig(String(dir), registry); + + const report = await audit(String(dir), ...flags); + expect(normalizeBunSnapshot(report.stderr)).toBe(note); + expect(normalizeBunSnapshot(report.stdout)).toBe(AUDIT_HEADER + noVulnerabilities(0)); + expect(report.exitCode).toBe(0); + + const json = await audit(String(dir), "--json", ...flags); + expect({ ...json, stderr: normalizeBunSnapshot(json.stderr) }).toEqual({ + stdout: "{}\n", + stderr: note, + exitCode: 0, + }); + expect(bulkHits.count).toBe(0); + }); + + test.concurrent("needs no registry", async () => { + using dir = tempDir("bun-test-audit-nothing-offline", files); + await writeBunfig(String(dir), await deadRegistryHref()); + + const report = await audit(String(dir), ...flags); + expect(normalizeBunSnapshot(report.stderr)).toBe(note); + expect(normalizeBunSnapshot(report.stdout)).toBe(AUDIT_HEADER + noVulnerabilities(0)); + expect(report.exitCode).toBe(0); + }); + }); + doAuditTest("workspaces print the path to the vulnerable package and include workspace:pkg in the name", { exitCode: 1, files: { @@ -3322,7 +3455,7 @@ describe("`bun audit fix`", () => { test.concurrent("refuses to run without a lockfile", async () => { await using server = startRegistry({}); using dir = tempDir("audit-fix-", { - "package.json": JSON.stringify({ name: "foo" }), + "package.json": JSON.stringify({ name: "foo", dependencies: { "a-dep": "1.0.2" } }), "bunfig.toml": Bun.TOML.stringify({ install: { registry: server.url.href } }), }); diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index a8019214684f..ff2330f6fdd0 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -10321,13 +10321,35 @@ describe.concurrent("pm trust", async () => { env, }); + // `bun install` saves no lockfile for this project, so a missing lockfile means that nothing is installed. let err = await stderr.text(); - expect(err).toContain("error: missing lockfile, nothing to trust\nnote: run 'bun install' first\n"); + expect(normalizeBunSnapshot(err)).toBe( + "bun pm trust ()\n" + + "error: 0 scripts ran. This means all dependencies are already trusted or none have scripts.", + ); let out = await stdout.text(); - expect(out).toBeEmpty(); + expect(out).toBe("\n"); expect(await exited).toBe(1); }); + test("no lockfile", async () => { + const { packageDir, packageJson, env } = await setupTest(); + await writeFile(packageJson, JSON.stringify({ name: "foo", dependencies: { "uses-what-bin": "1.0.0" } })); + + await using proc = spawn({ + cmd: [bunExe(), "pm", "trust", "--all"], + cwd: packageDir, + stdout: "pipe", + stderr: "pipe", + env, + }); + + const [err, out, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect(err).toContain("error: missing lockfile, nothing to trust\nnote: run 'bun install' first\n"); + expect(out).toBeEmpty(); + expect(exitCode).toBe(1); + }); + test("some dependencies, non with scripts", async () => { const { packageDir, packageJson, env } = await setupTest(); await writeFile( @@ -12435,7 +12457,10 @@ describe.concurrent("outdated", () => { } test("errors without a lockfile", async () => { const { packageDir, packageJson, env } = await setupTest(); - await write(packageJson, JSON.stringify({ name: "no-lockfile", version: "1.0.0" })); + await write( + packageJson, + JSON.stringify({ name: "no-lockfile", version: "1.0.0", dependencies: { "no-deps": "1.0.0" } }), + ); await using proc = spawn({ cmd: [bunExe(), "outdated"], @@ -12449,6 +12474,26 @@ describe.concurrent("outdated", () => { expect(err).toContain("error: missing lockfile, nothing outdated"); expect(exitCode).toBe(1); }); + // `bun install` saves no lockfile for a project without dependencies. + test("nothing is outdated in a project without dependencies, which has no lockfile", async () => { + const { packageDir, packageJson, env } = await setupTest(); + await write(packageJson, JSON.stringify({ name: "no-dependencies", version: "1.0.0" })); + + await using proc = spawn({ + cmd: [bunExe(), "outdated"], + cwd: packageDir, + stdout: "pipe", + stderr: "pipe", + env, + }); + + const [err, out, exitCode] = await Promise.all([proc.stderr.text(), proc.stdout.text(), proc.exited]); + expect({ err, out: normalizeBunSnapshot(out), exitCode }).toEqual({ + err: "", + out: "bun outdated ()", + exitCode: 0, + }); + }); test("in workspace", async () => { const { packageDir, packageJson, env } = await setupTest(); await Promise.all([ diff --git a/test/cli/install/bun-lock.test.ts b/test/cli/install/bun-lock.test.ts index 2e25eabe41c1..8d87ccf44677 100644 --- a/test/cli/install/bun-lock.test.ts +++ b/test/cli/install/bun-lock.test.ts @@ -1879,10 +1879,389 @@ describe.concurrent("a bun.lock that does not parse", () => { it("bun pm ls", async () => { const { out, err, exitCode } = await run("unparsable-lockfile-pm-ls", "pm", "ls"); - expect(err).toMatchInlineSnapshot(`"error: failed to parse lockfile: ParserError"`); + expect(err).toMatchInlineSnapshot(` + "1 | this is not a lockfile + ^ + error: Unexpected this + at bun.lock:1:1 + error: failed to parse lockfile: ParserError" + `); expect(out).toMatchInlineSnapshot(`""`); expect(exitCode).toBe(1); }); + + // Some of these commands print their name first, on stdout or on stderr. The error is the same for all of them. + it.each([ + "audit", + "why no-deps", + "pm hash", + "pm licenses", + "pm untrusted", + "pm trust --all", + "pm cache pack cache.pack", + "prune", + "dedupe", + ])("bun %s", async command => { + const { err, exitCode } = await run("unparsable-lockfile-" + command.replaceAll(" ", "-"), ...command.split(" ")); + expect(err).toEndWith( + "1 | this is not a lockfile\n ^\nerror: Unexpected this\n at bun.lock:1:1\nerror: failed to parse lockfile: ParserError", + ); + expect(exitCode).toBe(1); + }); +}); + +// `bun install` saves no bun.lock for a project that has nothing to lock. The commands that read the lockfile treat +// that project as one with nothing installed, which is what a bun.lock with `"packages": {}` says. +describe.concurrent("no bun.lock in a project without dependencies", () => { + async function bun(cwd: string, ...args: string[]) { + await using proc = spawn({ + cmd: [bunExe(), ...args], + cwd, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { out: normalizeBunSnapshot(out, cwd), err: normalizeBunSnapshot(err, cwd), exitCode }; + } + + const packageJson = JSON.stringify({ name: "no-dependencies", version: "1.0.0" }); + const banner = (command: string) => `bun ${command} ()`; + const ok = (out: string, err = "") => ({ out, err, exitCode: 0 }); + const failed = (err: string, out = "") => ({ out, err, exitCode: 1 }); + const missingLockfile = (nothing: string) => `error: missing lockfile, ${nothing}\nnote: run 'bun install' first`; + const noVulnerabilities = "\n\nNo vulnerabilities found (checked 0 packages)"; + const noDuplicates = + "\n🎉 No duplicates — checked 1 package in bun.lock, every one already resolves to a single version"; + const zeroHash = "0000000000000000-0000000000000000-0000000000000000-0000000000000000"; + const installedNothing = "No packages! Deleted empty lockfile"; + + // This is why the advice to run `bun install` first can't be followed here. + it("bun install saves no bun.lock and leaves an empty node_modules", async () => { + using dir = tempDir("no-dependencies-install", { "package.json": packageJson }); + expect(await bun(String(dir), "install")).toEqual(ok(`${banner("install")}\n done`, installedNothing)); + expect(await readdirSorted(String(dir))).toEqual(["node_modules", "package.json"]); + expect(await readdirSorted(join(String(dir), "node_modules"))).toEqual([]); + }); + + // Each row is a command, its output, and its output after `bun install` if the empty node_modules changes it. + type Output = Awaited>; + const nodeModulesNotFound = failed("error: node_modules not found, nothing to list\nnote: run 'bun install' first"); + const commands: [string, Output, Output?][] = [ + ["audit", ok(banner("audit") + noVulnerabilities)], + ["audit --json", ok("{}")], + ["audit fix", ok(banner("audit fix") + noVulnerabilities)], + ["outdated", ok(banner("outdated"))], + ["pm ls", ok("")], + ["pm ls --all", ok("")], + ["list", ok("")], + ["pm hash", ok(zeroHash)], + ["pm hash-string", ok("")], + ["pm hash-print", ok(zeroHash)], + [ + "pm untrusted", + ok( + "Found 0 untrusted dependencies with scripts.\n\n" + + 'This means all packages with scripts are in "trustedDependencies" or none of your dependencies have scripts.\n\n' + + "For more information, visit https://bun.com/docs/install/lifecycle#trusteddependencies", + banner("pm untrusted"), + ), + ], + [ + "prune", + ok(`${banner("prune")}\n\nDone! No node_modules folder (nothing to prune)`), + ok(`${banner("prune")}\n\nDone! Checked 0 installed packages across 1 folder (nothing to prune)`), + ], + ["dedupe", ok(banner("dedupe") + noDuplicates)], + ["dedupe --check", ok(banner("dedupe") + noDuplicates)], + ["update --interactive", ok(`${banner("update --interactive")}\n\nChecked 0 dependencies, nothing to update`)], + ["update --dev", ok(`${banner("update")}\n\nChecked 0 dependencies, none selected by --dev (no changes)`)], + [ + "update --filter=no-dependencies --dev", + ok(`${banner("update")}\n\nChecked 0 dependencies, none selected by --dev (no changes)`), + ], + // Without node_modules this advice can be followed, because `bun install` creates the folder. + [ + "pm licenses", + nodeModulesNotFound, + ok(`${banner("pm licenses")}\n\nNo packages to list (checked 0 packages in bun.lock)`), + ], + ["pm licenses --json", nodeModulesNotFound, ok("{}")], + // These commands fail because nothing is installed, and they say so. + [ + "pm trust --all", + failed( + `${banner("pm trust")}\nerror: 0 scripts ran. This means all dependencies are already trusted or none have scripts.`, + ), + ], + ["why no-deps", failed("error: No packages matching 'no-deps' found in lockfile")], + ["pm why no-deps", failed("error: No packages matching 'no-deps' found in lockfile")], + ["update no-*", failed('error: no packages in bun.lock match "no-*"', banner("update"))], + ["patch --commit no-deps", failed("error: package no-deps not found", banner("patch"))], + ]; + + describe.each([ + ["before", {}], + ["after", { node_modules: {} }], + ])("%s bun install", (when, installed) => { + it.each(commands)("bun %s", async (command, output, outputAfterInstall = output) => { + using dir = tempDir("no-dependencies", { "package.json": packageJson, ...installed }); + expect(await bun(String(dir), ...command.split(" "))).toEqual(when === "after" ? outputAfterInstall : output); + expect(existsSync(join(String(dir), "bun.lock"))).toBeFalse(); + }); + }); + + // `bun update --recursive` installs after it has read the lockfile, as `bun update` does, and so it runs scripts. + it.each(["update --recursive", "update --filter=*", "update"])("bun %s runs the install", async command => { + using dir = tempDir("no-dependencies-update", { + "package.json": JSON.stringify({ + name: "no-dependencies", + scripts: { postinstall: `${bunExe()} -e "require('fs').writeFileSync('postinstall.txt', '')"` }, + }), + }); + const { err, exitCode } = await bun(String(dir), ...command.split(" ")); + expect(err).toStartWith(installedNothing); + expect(await readdirSorted(String(dir))).toEqual(["node_modules", "package.json", "postinstall.txt"]); + expect(exitCode).toBe(0); + }); + + it("bun update --recursive --dry-run installs nothing and runs no script", async () => { + using dir = tempDir("no-dependencies-update", { + "package.json": JSON.stringify({ + name: "no-dependencies", + scripts: { postinstall: `${bunExe()} -e "require('fs').writeFileSync('postinstall.txt', '')"` }, + }), + }); + const { exitCode } = await bun(String(dir), "update", "--recursive", "--dry-run"); + expect(await readdirSorted(String(dir))).toEqual(["package.json"]); + expect(exitCode).toBe(0); + }); + + // The usual way into this state is to remove the last dependency. + it.each(["hoisted", "isolated"] as const)("after bun remove of the last dependency (%s linker)", async linker => { + const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker } }); + await write(packageJson, JSON.stringify({ name: "root", dependencies: { "no-deps": "1.0.0" } })); + await runBunInstall(env, packageDir); + + const removed = await bun(packageDir, "remove", "no-deps"); + expect(removed.err).toContain("package.json has no dependencies! Deleted empty lockfile"); + expect(existsSync(join(packageDir, "bun.lock"))).toBeFalse(); + + expect(await bun(packageDir, "outdated")).toEqual(ok(banner("outdated"))); + // The isolated linker leaves the package in its store, and `bun prune` is the command that removes it. + const pruned = await bun(packageDir, "prune"); + expect(pruned).toEqual( + ok( + `${banner("prune")}\n\n` + + (linker === "isolated" + ? "- no-deps@1.0.0\n1 package removed (checked 1 installed package)" + : "Done! Checked 0 installed packages across 1 folder (nothing to prune)"), + ), + ); + expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual(linker === "isolated" ? [".bun"] : []); + }); + + // A missing bun.lock is still an error if there is something to lock. + it.each([ + ["audit", missingLockfile("nothing to audit")], + ["outdated", missingLockfile("nothing outdated")], + ["pm ls", missingLockfile("nothing to list")], + ["pm hash", missingLockfile("nothing to hash")], + ["pm licenses", missingLockfile("nothing to list")], + ["pm untrusted", missingLockfile("nothing to list")], + ["pm trust --all", missingLockfile("nothing to trust")], + ["pm cache pack cache.pack", missingLockfile("nothing to pack")], + ["why no-deps", "error: missing lockfile\nnote: run 'bun install' first"], + ["prune", missingLockfile("nothing to prune")], + ["dedupe", missingLockfile("nothing to dedupe")], + ["update --recursive", missingLockfile("nothing to update")], + ["update --interactive", missingLockfile("nothing to update")], + ["update --dev", "error: no bun.lock to match --dev against\n bun install"], + [ + "patch --commit no-deps", + "error: Cannot find lockfile. Install packages with `bun install` before patching them.", + ], + ])("bun %s needs a bun.lock when package.json has a dependency", async (command, error) => { + using dir = tempDir("never-installed", { + "package.json": JSON.stringify({ name: "never-installed", dependencies: { "no-deps": "1.0.0" } }), + }); + const { err, exitCode } = await bun(String(dir), ...command.split(" ")); + expect(err).toEndWith(error); + expect(exitCode).toBe(1); + expect(await readdirSorted(String(dir))).toEqual(["package.json"]); + }); + + // A command only reports that nothing is installed if the root package.json shows that by itself. In three of + // these rows it refuses where `bun install` saves no lockfile, on purpose: the workspaces may be missing from + // disk, and an empty file or a list may be what is left of a package.json. + const folder = { "folder/package.json": JSON.stringify({ name: "folder", version: "1.0.0" }) }; + const member = { "packages/member/package.json": JSON.stringify({ name: "member", version: "1.0.0" }) }; + const pnpmWorkspace = { "pnpm-workspace.yaml": 'packages:\n - "packages/*"\n' }; + const nothingOutdated = missingLockfile("nothing outdated"); + // This error can be followed: with `{}` in the file, the command reports that nothing is installed. + const emptyFile = + "error: failed to parse '/package.json': file is empty\n" + + "note: restore package.json, or write {} to start without dependencies"; + // Each row is a name, the files, whether `bun install` saves a bun.lock, and the error of a command that needs one. + const manifests: [string, Record, boolean, string?][] = [ + ["an empty object", { "package.json": "{}" }, false], + ["empty dependencies", { "package.json": JSON.stringify({ name: "root", dependencies: {} }) }, false], + ["overrides only", { "package.json": JSON.stringify({ name: "root", overrides: { "no-deps": "1.0.0" } }) }, false], + [ + "trustedDependencies only", + { "package.json": JSON.stringify({ name: "root", trustedDependencies: ["no-deps"] }) }, + false, + ], + ["scripts only", { "package.json": JSON.stringify({ private: true, scripts: { build: "echo built" } }) }, false], + ["the module type only", { "package.json": JSON.stringify({ type: "module" }) }, false], + ["an empty file", { "package.json": "" }, false, emptyFile], + ["a list", { "package.json": "[]" }, false, nothingOutdated], + [ + "a workspaces pattern that matches nothing", + { "package.json": JSON.stringify({ name: "root", workspaces: ["packages/*"] }) }, + false, + nothingOutdated, + ], + [ + "a pnpm-workspace.yaml that names no workspace", + { "package.json": JSON.stringify({ name: "root" }), "pnpm-workspace.yaml": "catalog:\n no-deps: 1.0.0\n" }, + false, + nothingOutdated, + ], + [ + "a workspace", + { "package.json": JSON.stringify({ name: "root", workspaces: ["packages/*"] }), ...member }, + true, + nothingOutdated, + ], + [ + "a workspace named by pnpm-workspace.yaml", + { "package.json": JSON.stringify({ name: "root" }), ...pnpmWorkspace, ...member }, + true, + nothingOutdated, + ], + [ + "an optional peer named only in peerDependenciesMeta", + { "package.json": JSON.stringify({ name: "root", peerDependenciesMeta: { "no-deps": { optional: true } } }) }, + true, + nothingOutdated, + ], + ...["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"].map( + (group): [string, Record, boolean, string] => [ + `a folder in ${group}`, + { "package.json": JSON.stringify({ name: "root", [group]: { folder: "file:folder" } }), ...folder }, + true, + nothingOutdated, + ], + ), + ]; + describe.each(manifests)("package.json with %s", (_, files, savesLockfile, error) => { + it(`bun install saves ${savesLockfile ? "a" : "no"} bun.lock`, async () => { + using dir = tempDir("lock-or-not", files); + await runBunInstall(env, String(dir), { savesLockfile }); + expect(existsSync(join(String(dir), "bun.lock"))).toBe(savesLockfile); + }); + + it(`bun outdated ${error ? "fails" : "finds nothing"} without a bun.lock`, async () => { + using dir = tempDir("lock-or-not", files); + expect(await bun(String(dir), "outdated")).toEqual( + error ? failed(error, banner("outdated")) : ok(banner("outdated")), + ); + // A command that only reads does not write what an install copies from pnpm-workspace.yaml into package.json. + expect(await file(join(String(dir), "package.json")).text()).toBe(files["package.json"]); + }); + }); + + // A folder below a project may belong to it, so a command there does not report that nothing is installed. The + // project above is one with a lockfile, with dependencies or with workspaces. + const rootOnly = JSON.stringify({ name: "root" }); + const project = { + "package.json": JSON.stringify({ name: "root", dependencies: { folder: "file:folder" } }), + "bun.lock": JSON.stringify({ + lockfileVersion: 1, + workspaces: { "": { name: "root", dependencies: { folder: "file:folder" } } }, + packages: { folder: ["folder@file:folder", {}] }, + }), + ...folder, + }; + const projectAbove = (dir: string) => + "error: missing lockfile\n" + + `note: /package.json has no dependencies, and a project is above it in ${normalizeBunSnapshot(dir)}`; + + it.each<[string, Record]>([ + ["a bun.lock", { "package.json": rootOnly, "bun.lock": "{}" }], + ["a package-lock.json", { "package.json": rootOnly, "package-lock.json": "{}" }], + ["dependencies", { "package.json": project["package.json"], ...folder }], + ["workspaces", { "package.json": JSON.stringify({ name: "root", workspaces: ["packages/*"] }) }], + ])("bun audit names the project above a folder if that project has %s", async (_, above) => { + using dir = tempDir("below-a-project", { ...above, "src/package.json": JSON.stringify({ name: "src" }) }); + expect(await bun(join(String(dir), "src"), "audit")).toEqual(failed(projectAbove(String(dir)), banner("audit"))); + }); + + it.each(['{"type":"module"}', '{"private":true,"scripts":{"build":"echo built"}}', "{}"])( + "bun audit names the project above a folder whose package.json is %s", + async packageJson => { + using dir = tempDir("below-a-project", { ...project, "src/package.json": packageJson }); + expect(await bun(join(String(dir), "src"), "audit")).toEqual(failed(projectAbove(String(dir)), banner("audit"))); + }, + ); + + // A package.json above that has nothing to lock and no lockfile next to it is no such project. + it.each(['{"name":"src"}', '{"type":"module"}', "{}"])( + "bun audit finds nothing in a folder below a package.json without dependencies: %s", + async packageJson => { + using dir = tempDir("below-a-folder", { + "package.json": JSON.stringify({ name: "above", dependencies: {} }), + "src/package.json": packageJson, + }); + expect(await bun(join(String(dir), "src"), "audit")).toEqual(ok(banner("audit") + noVulnerabilities)); + }, + ); + + // `bun install` reads the workspaces of a pnpm-workspace.yaml and saves a lockfile, so its advice holds here. + it("bun audit asks for bun install if a pnpm-workspace.yaml is next to the package.json of the folder", async () => { + using dir = tempDir("below-a-project", { + ...project, + "src/package.json": JSON.stringify({ type: "module" }), + "src/pnpm-workspace.yaml": 'packages:\n - "packages/*"\n', + "src/packages/member/package.json": JSON.stringify({ name: "member", version: "1.0.0" }), + }); + expect(await bun(join(String(dir), "src"), "audit")).toEqual( + failed(missingLockfile("nothing to audit"), banner("audit")), + ); + }); + + // In these projects `bun install` fails or installs something, so a clean audit would be wrong. + it.each<[string, Record]>([ + ["a package.json that does not parse", { "package.json": '{ "name": "root", ' }], + [ + "a pnpm-workspace.yaml that does not parse", + { + "package.json": JSON.stringify({ name: "root" }), + "pnpm-workspace.yaml": 'packages:\n - "packages/*"\n bad: [unclosed\n', + "packages/member/package.json": JSON.stringify({ name: "member", dependencies: { "no-deps": "1.0.0" } }), + }, + ], + ["a workspace that is not on disk", { "package.json": JSON.stringify({ name: "root", workspaces: ["member"] }) }], + ["dependencies that are a list", { "package.json": JSON.stringify({ name: "root", dependencies: [] }) }], + [ + "a dependency whose version is a number", + { "package.json": JSON.stringify({ name: "root", dependencies: { "no-deps": 1 } }) }, + ], + [ + "a dependency without a name", + { "package.json": JSON.stringify({ name: "root", dependencies: { "": "1.0.0" } }) }, + ], + [ + "trustedDependencies that are a string", + { "package.json": JSON.stringify({ name: "root", trustedDependencies: "no-deps" }) }, + ], + ["overrides that are a string", { "package.json": JSON.stringify({ name: "root", overrides: "no-deps" }) }], + ])("bun audit does not report %s as clean", async (_, files) => { + using dir = tempDir("not-empty", files); + expect(await bun(String(dir), "audit")).toEqual(failed(missingLockfile("nothing to audit"), banner("audit"))); + }); }); // The same `dependencies` line can be in the lockfile twice. A merge that was resolved by hand can keep it from both diff --git a/test/cli/install/bun-pm-cache-pack.test.ts b/test/cli/install/bun-pm-cache-pack.test.ts index 9e9990f83ed0..638fd8d10d81 100644 --- a/test/cli/install/bun-pm-cache-pack.test.ts +++ b/test/cli/install/bun-pm-cache-pack.test.ts @@ -1142,10 +1142,32 @@ describe("bun pm cache pack", () => { }); test.concurrent("needs a lockfile", async () => { - using box = sandbox(); + using box = sandbox({ project: { "package.json": JSON.stringify({ dependencies: { "no-deps": "1.0.0" } }) } }); const r = await bun(box, ["pm", "cache", "pack", "cache.pack"]); expect(r.err).toContain("error: missing lockfile, nothing to pack"); expect(existsSync(join(box.project, "cache.pack"))).toBeFalse(); expect(r.code).toBe(1); }); + + // `bun install` saves no lockfile for a project without dependencies, so the pack has no package in it. + test.concurrent("packs nothing for a project without dependencies, which has no lockfile", async () => { + using box = sandbox(); + const r = await bun(box, ["pm", "cache", "pack", "cache.pack"]); + expect(r.err).toBe(""); + expect(r.out).toContain("Packed 0 packages (0 files, 0 KB) into cache.pack "); + expect(r.code).toBe(0); + + const unpacked = await bun(box, ["pm", "cache", "unpack", "cache.pack"]); + expect(unpacked.err).toBe(""); + expect(unpacked.code).toBe(0); + expect(entryNames(box.cache)).toEqual([]); + }); + + test.concurrent("needs a lockfile outside a project too", async () => { + using box = sandbox(); + const r = await bun(box, ["pm", "cache", "pack", "cache.pack"], { cwd: box.victim }); + expect(r.err).toContain("error: missing lockfile, nothing to pack\nnote: run 'bun install' first\n"); + expect(readdirSync(box.victim)).toEqual(["keep.txt"]); + expect(r.code).toBe(1); + }); }); diff --git a/test/cli/install/bun-pm-scan.test.ts b/test/cli/install/bun-pm-scan.test.ts index e1a996afb072..f2249dd76bc3 100644 --- a/test/cli/install/bun-pm-scan.test.ts +++ b/test/cli/install/bun-pm-scan.test.ts @@ -76,7 +76,7 @@ describe.concurrent("bun pm scan", () => { test("shows error when lockfile doesn't exist", async () => { await using dir = tempDir("scan-no-lockfile", { - "package.json": JSON.stringify({ name: "test", dependencies: {} }), + "package.json": leftPadPkg, "bunfig.toml": `[install.security]\nscanner = "test-scanner"`, }); @@ -91,6 +91,32 @@ describe.concurrent("bun pm scan", () => { expect(exitCode).toBe(1); }); + // `bun install` saves no lockfile for a project without dependencies, so the scanner gets an empty list. + test("scans nothing when there are no dependencies and no lockfile", async () => { + await using dir = tempDir("scan-no-dependencies", { + "package.json": JSON.stringify({ name: "test", dependencies: {} }), + "bunfig.toml": scannerBunfig, + "scanner.js": ` + module.exports = { + scanner: { + version: "1", + scan: async function(payload) { + console.error("PACKAGES:", JSON.stringify(payload.packages)); + return []; + } + } + }; + `, + }); + + const [stdout, stderr, exitCode] = await runScan(dir); + + expect(stderr).toContain("PACKAGES: []\n"); + expect(stderr).not.toContain("error:"); + expect(stdout).toBe("No advisories found\n"); + expect(exitCode).toBe(0); + }); + test("shows error when package.json doesn't exist", async () => { await using dir = tempDir("scan-no-package-json", {}); diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index 20dd76928c8c..6f689f3e7ee9 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -3135,6 +3135,65 @@ describe.concurrent("without a package.json", () => { }); }); + // Removing the last global package leaves a package.json without dependencies and no lockfile. + test("bun pm hash -g and bun outdated -g find nothing after the last global package was removed", async () => { + using project = fresh("pm-global-removed"); + expect(await project.run("add", "-g", join(project.root, "pkg"))).toMatchObject({ exitCode: 0 }); + expect(await project.run("remove", "-g", "pkg")).toMatchObject({ exitCode: 0 }); + expect(await readdirSorted(project.globalDir)).toEqual(["node_modules", "package.json"]); + // A package.json above the global directory, such as one in the home directory, changes nothing. + await Bun.write(join(project.root, "package.json"), JSON.stringify({ name: "home", dependencies: { pkg: "1" } })); + + expect(await project.run("pm", "hash", "-g")).toEqual({ + stdout: "0000000000000000-0000000000000000-0000000000000000-0000000000000000", + stderr: "", + exitCode: 0, + }); + const outdated = await project.run("outdated", "-g"); + expect({ ...outdated, stdout: normalizeBunSnapshot(outdated.stdout) }).toEqual({ + stdout: "bun outdated ()", + stderr: "", + exitCode: 0, + }); + }); + + // No folder above the global directory is its project, whatever the global package.json has in it. + test("bun pm hash -g does not look for a project above the global directory", async () => { + using project = fresh("pm-global-module-type"); + await Bun.write(join(project.globalDir, "package.json"), JSON.stringify({ type: "module" })); + await Bun.write(join(project.root, "package.json"), JSON.stringify({ name: "home", dependencies: { pkg: "1" } })); + expect(await project.run("pm", "hash", "-g")).toEqual({ + stdout: "0000000000000000-0000000000000000-0000000000000000-0000000000000000", + stderr: "", + exitCode: 0, + }); + }); + + // A global add that fails leaves the same state: a package.json without dependencies and no lockfile. + test("bun pm hash -g finds nothing after a global add that failed", async () => { + using project = fresh("pm-global-failed-add"); + expect(await project.run("add", "-g", join(project.root, "missing"))).toMatchObject({ exitCode: 1 }); + expect(await readdirSorted(project.globalDir)).toEqual(["package.json"]); + expect(await project.run("pm", "hash", "-g")).toEqual({ + stdout: "0000000000000000-0000000000000000-0000000000000000-0000000000000000", + stderr: "", + exitCode: 0, + }); + }); + + test("bun pm ls -g says why a global lockfile does not load", async () => { + using project = fresh("pm-ls-global-broken-lockfile"); + expect(await project.run("add", "-g", join(project.root, "pkg"))).toMatchObject({ exitCode: 0 }); + await Bun.write(join(project.globalDir, "bun.lock"), "this is not a lockfile"); + expect(await project.run("pm", "ls", "-g")).toEqual({ + stdout: "", + stderr: + "1 | this is not a lockfile\n ^\nerror: Unexpected this\n at bun.lock:1:1\n" + + "error: failed to parse lockfile: ParserError\n", + exitCode: 1, + }); + }); + test("bun pm ls -g skips a global package that's missing on disk", async () => { using project = fresh("pm-ls-global-deleted"); expect(await project.run("add", "-g", join(project.root, "pkg"))).toMatchObject({ exitCode: 0 }); diff --git a/test/cli/install/bun-prune.test.ts b/test/cli/install/bun-prune.test.ts index e92cc053d564..451ee2d089c0 100644 --- a/test/cli/install/bun-prune.test.ts +++ b/test/cli/install/bun-prune.test.ts @@ -637,6 +637,83 @@ test.concurrent("refuses to run without a lockfile", async () => { expect(existsSync(junk)).toBeTrue(); }); +// `bun install` saves no lockfile for a project without dependencies. A missing lockfile there means that nothing +// is installed, so everything in node_modules is extraneous. +test.concurrent("a project without dependencies needs no lockfile", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + await write(packageJson, JSON.stringify({ name: "foo" })); + const junk = plant(packageDir, "node_modules/junk"); + + const check = await prune(packageDir, "--check"); + expect(check.stderr).toBe(""); + expect(lines(check.stdout)).toStrictEqual([BANNER, "", "- junk", CAN_BE_REMOVED(1, 1), APPLY_HINT()]); + expect(existsSync(junk)).toBeTrue(); + expect(check.exitCode).toBe(1); + + // A subfolder belongs to the same project. + mkdirSync(join(packageDir, "src")); + const removed = await prune({ dir: packageDir, cwd: join(packageDir, "src") }); + expect(removed.stderr).toBe(""); + expect(lines(removed.stdout)).toStrictEqual([BANNER, "", "- junk", REMOVED(1, 1)]); + expect(removed.exitCode).toBe(0); + expect(existsSync(junk)).toBeFalse(); + expect(existsSync(join(packageDir, "bun.lock"))).toBeFalse(); +}); + +// Nothing is deleted unless the root package.json shows by itself that the project has nothing to install. These +// projects may have workspaces that are missing from disk, a package.json that was truncated, or a project above. +const NOTHING_TO_PRUNE = "error: missing lockfile, nothing to prune\n" + NOTE; +test.concurrent.each<[string, Record, string, string]>([ + [ + "pnpm-workspace.yaml names a workspace", + { + "package.json": JSON.stringify({ name: "foo" }), + "pnpm-workspace.yaml": 'packages:\n - "packages/*"\n', + "packages/member/package.json": JSON.stringify({ name: "member", version: "1.0.0" }), + }, + ".", + NOTHING_TO_PRUNE, + ], + [ + "pnpm-workspace.yaml does not parse", + { "package.json": JSON.stringify({ name: "foo" }), "pnpm-workspace.yaml": "packages:\n bad: [unclosed\n" }, + ".", + NOTHING_TO_PRUNE, + ], + [ + "package.json has a workspaces field", + { "package.json": JSON.stringify({ name: "foo", workspaces: ["packages/*"] }) }, + ".", + NOTHING_TO_PRUNE, + ], + [ + "package.json is empty", + { "package.json": "" }, + ".", + "error: failed to parse '/package.json': file is empty\n" + + "note: restore package.json, or write {} to start without dependencies", + ], + [ + "a project with dependencies is above the folder", + { + "package.json": JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0" } }), + "src/package.json": JSON.stringify({ name: "src" }), + }, + "src", + "error: missing lockfile\nnote: /src/package.json has no dependencies, and a project is above it in ", + ], +])("refuses to run without a lockfile when %s", async (_, files, cwd, error) => { + using dir = tempDir("prune-refuses", files); + const project = join(String(dir), cwd); + const junk = plant(project, "node_modules/junk"); + + const { stdout, stderr, exitCode } = await prune({ dir: project, cwd: project }); + expect(normalizeBunSnapshot(stderr, String(dir))).toBe(error); + expect(out(stdout)).toBe(BANNER); + expect(exitCode).toBe(1); + expect(existsSync(junk)).toBeTrue(); +}); + // pnpm#9796: hoisted keeps the root's workspace links under --production because they are root->workspace prod edges; the isolated per-importer dev link case is below. test.concurrent("workspaces: prunes workspace folders, keeps workspace links, runs from the root", async () => { const { packageDir: dir, packageJson } = await registry.createTestDir(); @@ -4498,7 +4575,7 @@ test.concurrent("a lockfile that fails to parse is an error and nothing is delet await write(join(dir, "bun.lock"), "{ this is not a lockfile"); const { stdout, stderr, exitCode } = await prune(dir); - expect(stderr).toContain("error: failed to load lockfile: "); + expect(stderr).toContain("error: failed to parse lockfile: "); expect(out(stdout)).toMatchInlineSnapshot(`"bun prune ()"`); expect(exitCode).toBe(1); expect(existsSync(junk)).toBeTrue();