Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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/workspaces.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ Each workspace has its own `package.json`. To reference another package in the m
}
```

A workspace needs a `"name"`. Bun skips a matched `package.json` that has no `"name"`, such as a test fixture containing only `{ "type": "module" }`. Bun does not link the skipped directory into `node_modules` and does not install its dependencies. If the skipped `package.json` declares dependencies, `bun install` prints a warning that names the directory.

`bun install` installs dependencies for all workspaces in the monorepo, de-duplicating packages if possible. To install dependencies for specific workspaces only, use the `--filter` flag.

```bash
Expand Down
3 changes: 0 additions & 3 deletions src/install/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,6 @@ pub enum Error {
CorruptLockfile,
#[error("Lockfile is missing resolution data")]
LockfileIsMissingResolutionData,
#[error("MissingPackageName")]
MissingPackageName,
#[error("GlobError")]
GlobError,
#[error("Invalid")]
Expand Down Expand Up @@ -343,7 +341,6 @@ impl Error {
}
Self::CorruptLockfile => "CorruptLockfile",
Self::LockfileIsMissingResolutionData => "Lockfile is missing resolution data",
Self::MissingPackageName => "MissingPackageName",
Self::GlobError => "GlobError",
Self::Invalid => "Invalid",
Self::LockfileValidationFailedListIsImpossiblyLong => {
Expand Down
2 changes: 2 additions & 0 deletions src/install/lockfile/Package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2465,6 +2465,8 @@ impl Package<u64> {
}
}

workspace_names.warn_skipped(log);

if FEATURES.trusted_dependencies {
if let Some(q) = json.as_property(b"trustedDependencies") {
let count = match &q.expr.data {
Expand Down
152 changes: 97 additions & 55 deletions src/install/lockfile/Package/WorkspaceMap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use bun_paths as path;
use bun_paths::resolve_path;
use bun_paths::{MAX_PATH_BYTES, PathBuffer, SEP_STR};

use super::DependencyGroup;
use crate::lockfile_real::{Lockfile, StringBuilder, pruned_workspaces};
use crate::package_manager::workspace_package_json_cache::{
GetJSONOptions, WorkspacePackageJSONCache,
Expand All @@ -17,6 +18,9 @@ bun_output::declare_scope!(Lockfile, hidden);

pub(crate) struct WorkspaceMap {
map: Map,
/// Nameless members whose dependencies are therefore not installed. Only the
/// root package.json parse reports them; the other scans of one command stay quiet.
Comment thread
robobun marked this conversation as resolved.
Outdated
skipped_with_dependencies: Vec<Box<[u8]>>,
}

type Map = StringArrayHashMap<Entry>;
Expand All @@ -28,10 +32,52 @@ pub struct Entry {
pub(crate) name_loc: bun_ast::Loc,
}

enum Scanned {
Workspace(Entry),
/// No usable `"name"`, so nothing to link or resolve it by; skipped, as pnpm and npm do.
Nameless {
declares_dependencies: bool,
},
}

impl WorkspaceMap {
pub(crate) fn init() -> WorkspaceMap {
WorkspaceMap {
map: Map::default(),
skipped_with_dependencies: Vec::new(),
}
}

fn workspace_entry(&mut self, scanned: Scanned, relative_dir: &[u8]) -> Option<Entry> {
match scanned {
Scanned::Workspace(entry) => Some(entry),
Scanned::Nameless {
declares_dependencies,
} => {
// Overlapping patterns match the same directory more than once.
if declares_dependencies
&& !self
.skipped_with_dependencies
.iter()
.any(|dir| **dir == *relative_dir)
{
self.skipped_with_dependencies.push(relative_dir.into());
}
None
}
}
}
Comment thread
claude[bot] marked this conversation as resolved.

pub(crate) fn warn_skipped(&self, log: &mut bun_ast::Log) {
for dir in &self.skipped_with_dependencies {
log.add_warning_fmt(
None,
bun_ast::Loc::EMPTY,
format_args!(
"Skipping workspace \"{}\": its package.json has no \"name\", so its dependencies will not be installed",
BStr::new(dir)
),
);
}
}

Expand Down Expand Up @@ -127,7 +173,7 @@ fn process_workspace_name(
json_cache: &mut WorkspacePackageJSONCache,
abs_package_json_path: &[u8],
log: &mut bun_ast::Log,
) -> crate::Result<Entry> {
) -> crate::Result<Scanned> {
let workspace_json = json_cache
.get_with_path(
log,
Expand All @@ -144,17 +190,32 @@ fn process_workspace_name(
// results are immediately boxed so the bump can drop at scope exit.
let scratch = Arena::new();

let name_expr = workspace_json
.root
.get(b"name")
.ok_or(crate::Error::MissingPackageName)?;
let name = name_expr
.as_string_cloned(&scratch)?
.ok_or(crate::Error::MissingPackageName)?;
let name = match workspace_json.root.get(b"name") {
Some(name_expr) => name_expr
.as_string_cloned(&scratch)?
.filter(|name| !name.is_empty())
.map(|name| (name, name_expr.loc)),
None => None,
};
let Some((name, name_loc)) = name else {
bun_output::scoped_log!(
Lockfile,
"processWorkspaceName({}) has no name, skipping",
BStr::new(abs_package_json_path)
);
return Ok(Scanned::Nameless {
declares_dependencies: DependencyGroup::FOUR.iter().any(|group| {
workspace_json
.root
.get(group.prop)
.is_some_and(|deps| deps.property_count() > 0)
}),
});
};

let entry = Entry {
name: Box::<[u8]>::from(name),
name_loc: name_expr.loc,
name_loc,
version: 'brk: {
if let Some(version_expr) = workspace_json.root.get(b"version") {
if let Some(version) = version_expr.as_string_cloned(&scratch)? {
Expand All @@ -171,7 +232,7 @@ fn process_workspace_name(
BStr::new(&entry.name)
);

Ok(entry)
Ok(Scanned::Workspace(entry))
}

fn workspace_dir_of(abs_package_json_path: &[u8]) -> &[u8] {
Expand Down Expand Up @@ -265,12 +326,12 @@ impl WorkspaceMap {
}

process_workspace_name(json_cache, abs_package_json_path, log)
.map(|entry| (abs_package_json_path, entry))
.map(|scanned| (abs_package_json_path, scanned))
}
None => Err(crate::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG)),
};

let (abs_package_json_path, workspace_entry) = match processed {
let (abs_package_json_path, scanned) = match processed {
Ok(processed) => processed,
Err(err) => {
if err == crate::Error::Sys(bun_errno::SystemErrno::ENOENT) {
Expand Down Expand Up @@ -302,15 +363,6 @@ impl WorkspaceMap {
arr.item_loc(source, i),
format_args!("Workspace not found \"{}\"", BStr::new(input_path)),
);
} else if err == crate::Error::MissingPackageName {
let _ = log.add_error_fmt(
Some(source),
loc,
format_args!(
"Missing \"name\" from package.json in {}",
BStr::new(input_path)
),
);
} else {
let mut cwd_buf = vec![0u8; MAX_PATH_BYTES];
let cwd_len = bun_sys::getcwd(&mut cwd_buf).expect("unreachable");
Expand All @@ -329,16 +381,17 @@ impl WorkspaceMap {
}
};

if workspace_entry.name.len() == 0 {
continue;
}

let rel_input_path = relative_workspace_path(
&mut rel_path_buf.0,
root_dir,
workspace_dir_of(abs_package_json_path),
);

let Some(workspace_entry) = workspace_names.workspace_entry(scanned, rel_input_path)
else {
continue;
};

if let Some(builder) = string_builder.as_deref_mut() {
builder.count(&workspace_entry.name);
builder.count(rel_input_path);
Expand Down Expand Up @@ -484,55 +537,44 @@ impl WorkspaceMap {
) {
Some(abs_package_json_path) => {
process_workspace_name(json_cache, abs_package_json_path, log)
.map(|entry| (abs_package_json_path, entry))
.map(|scanned| (abs_package_json_path, scanned))
}
None => Err(crate::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG)),
};

let (abs_package_json_path, workspace_entry) = match processed {
let (abs_package_json_path, scanned) = match processed {
Ok(processed) => processed,
Err(err) => {
let entry_base: &[u8] = path::basename(matched_path);
if err == crate::Error::Sys(bun_errno::SystemErrno::ENOENT) {
continue;
} else if err == crate::Error::MissingPackageName {
let _ = log.add_error_fmt(
Some(source),
bun_ast::Loc::EMPTY,
format_args!(
"Missing \"name\" from package.json in {}{}{}",
BStr::new(entry_dir),
SEP_STR,
BStr::new(entry_base),
),
);
} else {
let _ = log.add_error_fmt(
Some(source),
bun_ast::Loc::EMPTY,
format_args!(
"{} reading package.json for workspace package \"{}\" from \"{}\"",
err.name(),
BStr::new(entry_dir),
BStr::new(entry_base),
),
);
}

let entry_base: &[u8] = path::basename(matched_path);
let _ = log.add_error_fmt(
Some(source),
bun_ast::Loc::EMPTY,
format_args!(
"{} reading package.json for workspace package \"{}\" from \"{}\"",
err.name(),
BStr::new(entry_dir),
BStr::new(entry_base),
),
);
continue;
}
};

if workspace_entry.name.len() == 0 {
continue;
}

let workspace_path: &[u8] = relative_workspace_path(
&mut rel_path_buf.0,
root_dir,
workspace_dir_of(abs_package_json_path),
);

let Some(workspace_entry) =
workspace_names.workspace_entry(scanned, workspace_path)
else {
continue;
};

if let Some(builder) = string_builder.as_deref_mut() {
builder.count(&workspace_entry.name);
builder.count(workspace_path);
Expand Down
5 changes: 0 additions & 5 deletions src/install/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,6 @@ pub fn detect_and_load_other_lockfile<'a>(
"Relative link dependencies aren't supported yet. Please follow along at <magenta>https://github.com/oven-sh/bun/issues/23026<r>",
);
}
MigratePnpmLockfileError::WorkspaceNameMissing => {
bun_core::warn!(
"pnpm-lock.yaml migration failed due to missing workspace name.",
);
}
MigratePnpmLockfileError::YamlParseError => {
bun_core::warn!("Failed to parse pnpm-lock.yaml.");
}
Expand Down
10 changes: 5 additions & 5 deletions src/install/pnpm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,8 +364,6 @@ pub enum MigratePnpmLockfileError {
NonExistentWorkspaceDependency,
#[error("RelativeLinkDependency")]
RelativeLinkDependency,
#[error("WorkspaceNameMissing")]
WorkspaceNameMissing,
#[error("DependencyLoop")]
DependencyLoop,
#[error("PnpmLockfileNotObject")]
Expand Down Expand Up @@ -770,9 +768,11 @@ pub(crate) fn migrate_pnpm_lockfile<'a>(

let workspace_root = &importer_pkg_json.root;

let Some((name, _)) = get_string(workspace_root, b"name") else {
// we require workspace names.
return Err(MigratePnpmLockfileError::WorkspaceNameMissing);
// Nameless importers are skipped like `WorkspaceMap::process_workspace_name` skips them.
let Some((name, _)) =
get_string(workspace_root, b"name").filter(|(name, _)| !name.is_empty())
else {
continue;
};

let name_hash = semver::string::Builder::string_hash(name);
Expand Down
Loading
Loading