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
7 changes: 7 additions & 0 deletions docs/pm/workspaces.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ A specific version takes precedence over the package's `package.json` version:
"workspace:1.0.2" -> "1.0.2" // Even if current version is 1.0.1
```

You can also reference a workspace package by the path of its directory. The path is relative to the `package.json` that declares the dependency. Bun publishes the version of the package in that directory. When the dependency name differs from the package name, Bun publishes an `npm:` alias:

```
"pkg-b": "workspace:../pkg-b" -> "pkg-b": "1.0.1"
"b": "workspace:../pkg-b" -> "b": "npm:pkg-b@1.0.1"
```

Workspaces have a few major benefits.

- **Split code into logical parts.** If one package relies on another, add it as a dependency in `package.json`. If package `b` depends on `a`, `bun install` installs your local `packages/a` directory into `node_modules` instead of downloading it from the npm registry.
Expand Down
49 changes: 48 additions & 1 deletion src/install/PackageManager/workspace_manifests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
use bstr::BStr;
use bun_collections::HashMap;
use bun_core::{Global, Output};
use bun_paths::{platform, resolve_path};
use bun_resolver::fs::FileSystem;

use crate::dependency::{Behavior, Tag as DependencyTag};
use crate::lockfile::{Lockfile, Package};
use crate::lockfile::{DependencySlice, Lockfile, Package};
use crate::{Features, PackageNameHash};

use super::PackageManager;
Expand Down Expand Up @@ -92,6 +94,8 @@
/// and releases bump versions between that install and the publish.
pub struct WorkspaceManifests {
lockfile: Lockfile,
/// Has a `Behavior::WORKSPACE` entry per workspace: its name and root-relative directory.
root_dependencies: DependencySlice,
root_package_json_path: Box<[u8]>,
}

Expand All @@ -108,10 +112,53 @@
}
WorkspaceManifests {
lockfile: scratch.lockfile,
root_dependencies: scratch.root.dependencies,
root_package_json_path: root_package_json_path(),
}
}

/// Whether `name` is one of the workspaces (the root package is not one).
pub fn has_workspace(&self, name: &[u8]) -> bool {
let name_hash: PackageNameHash = bun_semver::string::Builder::string_hash(name);
self.lockfile.workspace_paths.contains(&name_hash)
}

Check warning on line 124 in src/install/PackageManager/workspace_manifests.rs

View check run for this annotation

Claude / Claude Code Review

has_workspace false positive: root workspace: deps self-insert into workspace_paths

`has_workspace` has a false-positive hole: `workspace_paths` is not just the glob-matched workspace names — `Package::parse_dependency`'s `features.is_main` branch (Package.rs:2017) also inserts the *root's own* `workspace:`-tagged dependency keys into it. So when packing the root with `"nope": "workspace:1.2.3"` where `nope` is not a workspace, `has_workspace("nope")` returns true (self-inserted while parsing this very dependency) and pack copies `1.2.3` verbatim instead of failing. Not a regre
Comment on lines +121 to +124

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 has_workspace has a false-positive hole: workspace_paths is not just the glob-matched workspace names — Package::parse_dependency's features.is_main branch (Package.rs:2017) also inserts the root's own workspace:-tagged dependency keys into it. So when packing the root with "nope": "workspace:1.2.3" where nope is not a workspace, has_workspace("nope") returns true (self-inserted while parsing this very dependency) and pack copies 1.2.3 verbatim instead of failing. Not a regression (pre-PR copied verbatim too) and only bites when packing the root itself, but the doc comment is inaccurate and the new error path has a gap; iterating root_dependencies for a Behavior::WORKSPACE entry with the given name (mirroring workspace_name_at_path) would be robust.

Extended reasoning...

What the bug is

has_workspace(name) at workspace_manifests.rs:121-124 is documented as "Whether name is one of the workspaces" and is implemented as self.lockfile.workspace_paths.contains(&name_hash). The assumption is that workspace_paths contains exactly the names matched by the root's workspaces glob. That assumption is wrong: workspace_paths also contains the dependency keys of every workspace:-tagged entry in the root's own dependencies / devDependencies / peerDependencies / optionalDependencies, whether or not those keys name a real workspace.

The code path that triggers it

ScratchManifests::parse_root calls Package::parse_with_json with Features::main() (resolver_hooks.rs:1252-1261: is_main=true, dependencies=true, workspaces=true). parse_with_json_impl (Package.rs:2271-2286) processes the WORKSPACES group first, then DEPENDENCIES etc. When it reaches a root entry like "nope": "workspace:1.2.3":

  1. At Package.rs:1834, workspace_paths.get(&hash("nope")) is None (the workspaces glob just ran and nope is not one of them), so workspace_path is None.
  2. Control falls into the else if features.is_main || features.is_workspace branch (Package.rs:1959).
  3. Line 2017 executes workspace_paths.get_or_put(name_hash), which inserts hash("nope") into the map. workspace_version is None and found_existing is false, so no error is logged; line 2069 sets the entry's value.
  4. fail_on_logged_errors() passes, and WorkspaceManifests is built with the polluted workspace_paths.

Now publish_spec_for_workspace_range_or_directory (pack_command.rs:3268-3280) processes the same "nope": "workspace:1.2.3" while packing the root:

  • workspace_name_at_path(root_dir, "1.2.3") returns None — the nope entry in root_dependencies has the DEPENDENCIES-group behavior, not Behavior::WORKSPACE, so the .is_workspace() filter excludes it, and no real workspace lives at <root>/1.2.3.
  • is_alias is false (no @ after index 0).
  • manifests.has_workspace("nope") returns true (the self-inserted entry), so the function returns Ok("1.2.3") verbatim instead of the intended Err("…has no workspace named \"nope\"…").

Why existing code doesn't prevent it

The installer's own check at Package.rs:1834 runs before the self-insertion at line 2017, so bun install correctly sees nope as not-a-workspace and later fails resolution. But pack's has_workspace runs after the whole root parse, when the self-insertion has already happened — a temporal mismatch. workspace_name_at_path is unaffected because it filters on Behavior::WORKSPACE, which the self-inserted entry lacks; only the workspace_paths.contains check is fooled. The notAWorkspaceSpecs tests don't catch this because they pack pkgs/ui (a member), and parse_root never processes a member's dependencies with is_main=true.

Step-by-step repro

root/package.json:
  { "name": "root", "version": "1.0.0", "workspaces": ["pkgs/*"],
    "dependencies": { "nope": "workspace:1.2.3" } }
pkgs/pkg1/package.json:
  { "name": "pkg1", "version": "1.0.0" }

$ bun pm pack   # from root
  • parse_root: WORKSPACES pass inserts hash("pkg1"); DEPENDENCIES pass hits Package.rs:2017 and inserts hash("nope"). No error logged.
  • edit_root_package_jsonWorkspaceRangeOrDirectory("1.2.3")publish_spec_for_workspace_range_or_directory:
    • workspace_name_at_path(root, "1.2.3")None
    • is_aliasfalse
    • has_workspace("nope")true (false positive)
    • returns Ok(b"1.2.3")
  • Tarball ships "nope": "1.2.3" and pack exits 0. bun install on this same root would have failed, so the PR's stated invariant ("pack fails when bun install would fail on the same spec") is violated for this case.

Impact

Low, hence nit: (a) not a regression — pre-PR WorkspaceRange also copied 1.2.3 verbatim, so the published output is byte-identical to before; (b) only reachable when packing the root package (roots are usually private and rarely packed); (c) the input configuration already fails bun install, so nobody with a green install hits it. The concrete defects are that the new error detection has a hole for exactly the case the notAWorkspaceSpecs tests target (just at the root instead of a member), and the doc comment on has_workspace is inaccurate.

How to fix

Mirror workspace_name_at_path: iterate self.root_dependencies for an entry whose behavior.is_workspace() and whose name matches, instead of consulting workspace_paths:

pub fn has_workspace(&self, name: &[u8]) -> bool {
    let string_buf = self.lockfile.buffers.string_bytes.as_slice();
    self.root_dependencies
        .get(self.lockfile.buffers.dependencies.as_slice())
        .iter()
        .any(|d| d.behavior.is_workspace() && d.name.slice(string_buf) == name)
}

That reads the same Behavior::WORKSPACE entries workspace_name_at_path already trusts, so both lookups agree on what counts as a workspace. Alternatively, workspace_versions (populated only from the glob walk) would work as the membership set.


/// The workspace `workspace:<path>` in `package_dir` links, per `Package::parse_dependency`.
pub fn workspace_name_at_path(&self, package_dir: &[u8], path: &[u8]) -> Option<&[u8]> {
// Joined as a path, `workspace:` alone would name `package_dir` itself.
if path.is_empty() {
return None;
}
let top_level_dir = FileSystem::get().top_level_dir();
let mut directory_buf = bun_paths::path_buffer_pool::get();
let directory = resolve_path::join_abs_string_buf_checked::<platform::Auto>(
top_level_dir,
&mut directory_buf[..],
&[package_dir, path],
)?;
let relative_directory: &[u8] = resolve_path::relative(top_level_dir, directory);
// The workspaces' directories are stored with `/` separators on every platform.
#[cfg(windows)]
let mut posix_buf = bun_paths::path_buffer_pool::get();
#[cfg(windows)]
let relative_directory: &[u8] = {
let len = relative_directory.len();
posix_buf[..len].copy_from_slice(relative_directory);
bun_paths::dangerously_convert_path_to_posix_in_place::<u8>(&mut posix_buf[..len]);
&posix_buf[..len]
};

let string_buf = self.lockfile.buffers.string_bytes.as_slice();
self.root_dependencies
.get(self.lockfile.buffers.dependencies.as_slice())
.iter()
.find(|dependency| {
dependency.behavior.is_workspace()
&& dependency.version.workspace().slice(string_buf) == relative_directory
})
.map(|dependency| dependency.name.slice(string_buf))
}

/// The package.json whose `workspaces` and catalogs these are: the workspace root's when the
/// package being packed is one of its workspaces, otherwise the package's own.
pub fn root_package_json_path(&self) -> &[u8] {
Expand Down
64 changes: 54 additions & 10 deletions src/runtime/cli/pack_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2131,7 +2131,8 @@
// Loading added the other workspaces' package.json files to the cache `json` points into.
json = read_package_json(manager_ptr, abs_package_json_path);
}
let edited_package_json = edit_root_package_json(workspace_manifests.as_ref(), json)?;
let edited_package_json =
edit_root_package_json(workspace_manifests.as_ref(), abs_workspace_path, json)?;

let root_dir: Dir = 'root_dir: {
let mut path_buf = PathBuffer::uninit();
Expand Down Expand Up @@ -3197,8 +3198,8 @@
enum Substitution<'a> {
/// `workspace:^`, `workspace:~`, `workspace:*`: the workspace's current version behind that prefix.
WorkspaceVersion { prefix: &'static str },
/// `workspace:1.2.3`, `workspace:1.x`, ...: the range as written.
WorkspaceRange(&'a [u8]),
/// `workspace:1.x` on a dependency that is a workspace, or a directory, `workspace:../core`.
WorkspaceRangeOrDirectory(&'a [u8]),
/// `catalog:` / `catalog:<name>`: that catalog's entry for the dependency.
Catalog { catalog_name: &'a [u8] },
}
Expand All @@ -3210,18 +3211,14 @@
b"^" => Substitution::WorkspaceVersion { prefix: "^" },
b"~" => Substitution::WorkspaceVersion { prefix: "~" },
b"*" => Substitution::WorkspaceVersion { prefix: "" },
_ => Substitution::WorkspaceRange(range),
_ => Substitution::WorkspaceRangeOrDirectory(range),
});
}
let catalog_name = strings::without_prefix_if_possible_comptime(spec, b"catalog:")?;
Some(Substitution::Catalog {
catalog_name: strings::trim(catalog_name, &strings::WHITESPACE_CHARS),
})
}

fn needs_workspace_manifests(&self) -> bool {
!matches!(self, Substitution::WorkspaceRange(_))
}
}

/// Section order is the order errors get reported in.
Expand Down Expand Up @@ -3257,15 +3254,52 @@
.as_ref()
.and_then(Expr::as_utf8_string_literal)
.and_then(Substitution::for_spec)
.is_some_and(|substitution| substitution.needs_workspace_manifests());
.is_some();
});
needed
}

/// Directory first: `"pkg1": "workspace:../pkg1"` reads both ways; only a directory has a version.
fn publish_spec_for_workspace_range_or_directory(
manifests: &WorkspaceManifests,
package_dir: &[u8],
dependency_name: &[u8],
spec: &[u8],
) -> Result<Vec<u8>, String> {
let Some(workspace_name) = manifests.workspace_name_at_path(package_dir, spec) else {
// `workspace:<name>@<range>` installs workspace `<name>` under `dependency_name`.
let is_alias = strings::last_index_of_char(spec, b'@')
.is_some_and(|at| at > 0 && manifests.has_workspace(&spec[..at]));
if manifests.has_workspace(dependency_name) || is_alias {
return Ok(spec.to_vec());
}
return Err(format!(
"\"{}\" has no workspace named \"{}\" and no workspace in the directory \"{}\"",
bstr::BStr::new(manifests.root_package_json_path()),
bstr::BStr::new(dependency_name),
bstr::BStr::new(spec),
));
};

Check warning on line 3282 in src/runtime/cli/pack_command.rs

View check run for this annotation

Claude / Claude Code Review

Directory-first ordering can publish a different workspace than install linked

The directory-first ordering here diverges from the installer's precedence: `Package::parse_dependency` checks `workspace_paths` for the dependency key first and only falls through to the directory reading when the key is not a workspace name. So for `{ "pkg-a": "workspace:b" }` where both `pkg-a` and `pkg-b` are workspaces (at directories `a` and `b`), install links pkg-a but pack now publishes `npm:pkg-b@…` — a different package. Contrived, and pre-PR was also wrong (published `b` verbatim), s
Comment on lines +3262 to +3282

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The directory-first ordering here diverges from the installer's precedence: Package::parse_dependency checks workspace_paths for the dependency key first and only falls through to the directory reading when the key is not a workspace name. So for { "pkg-a": "workspace:b" } where both pkg-a and pkg-b are workspaces (at directories a and b), install links pkg-a but pack now publishes npm:pkg-b@… — a different package. Contrived, and pre-PR was also wrong (published b verbatim), so not a regression; but consider only preferring the directory reading when !has_workspace(dependency_name) or when both readings resolve to the same workspace.

Extended reasoning...

What the bug is

publish_spec_for_workspace_range_or_directory tries the directory reading first (per the comment: "Directory first: 'pkg1': 'workspace:../pkg1' reads both ways; only a directory has a version"), then falls back to the range reading only when no workspace lives in that directory. The installer does the opposite: Package::parse_dependency (src/install/lockfile/Package.rs:1799-1958) computes name_hash from the dependency key, looks that up in workspace_paths first, and only treats the spec as a directory when the dependency key is not a workspace name. When both readings apply and point at different workspaces, install and pack disagree on which package the dependency is.

Concrete trigger

Root package.json:

{ "workspaces": ["a", "b"], "dependencies": { "pkg-a": "workspace:b" } }

with a/package.json = { "name": "pkg-a", "version": "1.0.0" } and b/package.json = { "name": "pkg-b", "version": "2.0.0" }.

Installer path (Package.rs:1822 → 1834 → 1938-1941): name_hash = hash("pkg-a") is in workspace_paths, so workspace_path = "a" and the spec b is parsed as a range. range.satisfies fails, but SemverVersion::is_tagged_version_only("b") is true (single alphabetic byte, Version.rs:392-407), so dependency_version.value.workspace = "a" — install links pkg-a.

Pack path (this function): workspace_name_at_path(root, "b") finds the workspace at directory b, whose name is pkg-b. Since workspace_name ("pkg-b") != dependency_name ("pkg-a"), it returns npm:pkg-b@2.0.0. The published tarball would install pkg-b under node_modules/pkg-a — the opposite package from what bun install linked locally.

Why the existing code doesn't prevent it

The directory-first ordering was chosen so that "pkg1": "workspace:../pkg1" (which reads both ways and resolves to the same workspace) publishes a version instead of the verbatim path. That's correct when both readings agree, but nothing checks that they agree. When the dependency key names one workspace and the bare spec happens to be another workspace's root-relative directory, the two readings pick different workspaces, and the directory reading silently wins over the reading the installer would use.

Impact

Low. The trigger requires (1) the dependency key to be a workspace's package name, and (2) the spec after workspace: to be a bare alphanumeric string (so is_tagged_version_only accepts it as a range) that also happens to be a different workspace's root-relative directory. Before this PR the same input published "b" verbatim — an uninstallable dist-tag on pkg-a — so this is not a regression from working behavior; it swaps one wrong answer for a differently-wrong one. It does, however, contradict the PR's stated invariant that "pack publishes what install linked."

How to fix

The naive fix — check has_workspace(dependency_name) before workspace_name_at_path — would break this PR's own "@acme/core": "workspace:core" test (both readings resolve to @acme/core, but returning early would publish the range core verbatim). A safer change is to keep the directory lookup first, but when it finds a workspace whose name differs from dependency_name and has_workspace(dependency_name) is also true, defer to the installer's precedence — i.e. treat it as a range on the workspace named by the dependency key (or publish that workspace's version). Equivalently: only let the directory reading override the range reading when both readings resolve to the same workspace or when the range reading doesn't apply.

let Some(version) = manifests.workspace_version(workspace_name) else {
return Err(format!(
"the package.json of workspace \"{}\" in the directory \"{}\" has no version",
bstr::BStr::new(workspace_name),
bstr::BStr::new(spec),
));
};
Ok(if workspace_name == dependency_name {
format!("{version}").into_bytes()
} else {
// What pnpm publishes too: the alias installs the workspace's package under this name.
format!("npm:{}@{version}", bstr::BStr::new(workspace_name)).into_bytes()
})
}

/// Edits `json.root` in place (`bun publish` sends that tree to the registry) and returns it printed.
/// `workspace_manifests` is `Some` whenever `needs_workspace_manifests(json.root)` is.
fn edit_root_package_json(
workspace_manifests: Option<&WorkspaceManifests>,
package_dir: &[u8],
json: &mut WorkspacePackageJSONCache::MapEntry,
) -> Result<Box<[u8]>, AllocError> {
let bump = pack_bump();
Expand Down Expand Up @@ -3303,7 +3337,6 @@

// `E::EString::init` keeps a pointer to the bytes, so they go into the pack arena.
let replacement: &[u8] = match substitution {
Substitution::WorkspaceRange(range) => bump.alloc_slice_copy(range),
Substitution::WorkspaceVersion { prefix } => {
let Some(version) = manifests().workspace_version(dependency_name) else {
fail(
Expand All @@ -3317,6 +3350,17 @@
};
bump.alloc_slice_copy(format!("{prefix}{version}").as_bytes())
}
Substitution::WorkspaceRangeOrDirectory(spec) => {
match publish_spec_for_workspace_range_or_directory(
manifests(),
package_dir,
dependency_name,
spec,
) {
Ok(published) => bump.alloc_slice_copy(&published),
Err(why) => fail("workspace", format_args!("{why}")),
}
}
Substitution::Catalog { catalog_name } => {
match manifests().catalog_version(catalog_name, dependency_name) {
Some(version) => bump.alloc_slice_copy(version),
Expand Down
Loading
Loading