Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/pm/cli/audit.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/pm/cli/dedupe.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/pm/cli/pm.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/pm/cli/prune.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
11 changes: 11 additions & 0 deletions docs/pm/lockfile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ print = "yarn"

</CodeGroup>

#### 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`.
Expand Down
125 changes: 111 additions & 14 deletions src/install/PackageManager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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<const ATTEMPT_OTHER: bool>(
pub(crate) fn load_lockfile_from_cwd<const ATTEMPT_OTHER: bool>(
&mut self,
) -> lockfile::LoadResult<'_> {
self.clear_cached_items_depending_on_lockfile_buffer();
Expand All @@ -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<const ATTEMPT_OTHER: bool>(
&mut self,
) -> Option<LoadedParts> {
// Returning the `Ok` itself would keep `self` borrowed in the other arms of the callers.
match self.load_lockfile_from_cwd::<ATTEMPT_OTHER>() {
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::<true>() {
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<const ATTEMPT_OTHER: bool>(
&mut self,
nothing: &str,
) -> lockfile::LoadResult<'_> {
let Some(parts) = self.load_lockfile_or_exit_if_broken::<ATTEMPT_OTHER>() 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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/// 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<const ATTEMPT_OTHER: bool>(
&mut self,
) -> lockfile::LoadResult<'_> {
match self.load_lockfile_or_exit_if_broken::<ATTEMPT_OTHER>() {
Some(parts) => self.loaded_lockfile(parts),
None => lockfile::LoadResult::NotFound,
}
Global::exit(1);
}

pub(crate) fn crash(&mut self) -> ! {
Expand Down Expand Up @@ -2003,6 +2060,8 @@ pub fn user_cache_directory(env: &dot_env::Loader, cwd: &[u8]) -> Option<Box<[u8
struct PackageJsonWalk<'a> {
cwd: &'a [u8],
next_dir: Option<&'a [u8]>,
/// Passes over a package.json in a shared directory without the warning.
quiet: bool,
/// `<dir>/package.json` for the directory that `next` returned last. It's a `Vec` because the
/// cwd alone can fill a path buffer.
path: Vec<u8>,
Expand All @@ -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()),
}
}
Expand All @@ -2036,10 +2096,12 @@ impl<'a> PackageJsonWalk<'a> {
&& dir.len() != self.cwd.len()
&& is_shared_directory(dir)
{
bun_core::warn!(
"ignoring package.json in <b>{}<r>: directory is writable by other users",
bstr::BStr::new(dir),
);
if !self.quiet {
bun_core::warn!(
"ignoring package.json in <b>{}<r>: directory is writable by other users",
bstr::BStr::new(dir),
);
}
continue;
}
return Some((dir, opened));
Expand Down Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions src/install/PackageManager/patchPackage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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 `<cyan>bun install<r>` before patching them.",
(),
);
Global::crash();
let Some(empty) = ScratchManifests::empty_lockfile(manager) else {
Output::err_generic(
"Cannot find lockfile. Install packages with `<cyan>bun install<r>` before patching them.",
(),
);
Global::crash();
};
*lockfile = empty;
}
lockfile::LoadResult::Err(cause) => {
if log_level != LogLevel::Silent {
Expand Down
2 changes: 1 addition & 1 deletion src/install/PackageManager/updatePackageJSONAndInstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<true>("nothing to update");
let filter_patterns = manager.options.filter_patterns;
let workspace_selection::LockfileSelection {
ids: selected,
Expand Down
53 changes: 53 additions & 0 deletions src/install/PackageManager/workspace_manifests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Lockfile> {
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!(
"<b>{}<r> has no dependencies, and a project is above it in <b>{}<r>",
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<()> {
Expand Down
14 changes: 9 additions & 5 deletions src/install/dedupe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading