diff --git a/docs/pm/workspaces.mdx b/docs/pm/workspaces.mdx index 7673b6111780..0162abd200cf 100644 --- a/docs/pm/workspaces.mdx +++ b/docs/pm/workspaces.mdx @@ -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. diff --git a/src/install/PackageManager/workspace_manifests.rs b/src/install/PackageManager/workspace_manifests.rs index d6ce8d4309c1..c46e7c5f92f2 100644 --- a/src/install/PackageManager/workspace_manifests.rs +++ b/src/install/PackageManager/workspace_manifests.rs @@ -3,9 +3,11 @@ use core::fmt; 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; @@ -92,6 +94,8 @@ impl ScratchManifests { /// 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]>, } @@ -108,10 +112,53 @@ impl WorkspaceManifests { } 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) + } + + /// The workspace `workspace:` 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::( + 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::(&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] { diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index 4155b6a8919a..2cfc68ce0b9f 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -2131,7 +2131,8 @@ pub(crate) fn pack( // 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(); @@ -3197,8 +3198,8 @@ fn add_archive_entry( 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:`: that catalog's entry for the dependency. Catalog { catalog_name: &'a [u8] }, } @@ -3210,7 +3211,7 @@ impl<'a> Substitution<'a> { 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:")?; @@ -3218,10 +3219,6 @@ impl<'a> Substitution<'a> { 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. @@ -3257,15 +3254,52 @@ fn needs_workspace_manifests(package_json: Expr) -> bool { .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, String> { + let Some(workspace_name) = manifests.workspace_name_at_path(package_dir, spec) else { + // `workspace:@` installs workspace `` 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), + )); + }; + 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, AllocError> { let bump = pack_bump(); @@ -3303,7 +3337,6 @@ fn edit_root_package_json( // `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( @@ -3317,6 +3350,17 @@ fn edit_root_package_json( }; 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), diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index 9ea9d1f830c6..d457fd823e3d 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -912,6 +912,198 @@ describe("workspaces", () => { expect(JSON.parse(tarball.entries[0].contents).dependencies).toEqual({ "pkg1": "1.1.1" }); }); } + + // pkgs/ui depends on its sibling workspaces by directory (`workspace:../core`) + async function createDirectoryWorkspace(uiDependencies: Record>) { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ name: "root", workspaces: ["pkgs/*", "pkgs/@scoped/*"] }), + ), + write(join(packageDir, "pkgs", "core", "package.json"), JSON.stringify({ name: "@acme/core", version: "1.2.3" })), + write(join(packageDir, "pkgs", "plain", "package.json"), JSON.stringify({ name: "plain", version: "0.5.0" })), + write( + join(packageDir, "pkgs", "@scoped", "thing", "package.json"), + JSON.stringify({ name: "thing", version: "0.0.7" }), + ), + write( + join(packageDir, "pkgs", "ui", "package.json"), + JSON.stringify({ name: "@acme/ui", version: "2.0.0", ...uiDependencies }), + ), + ]); + } + + test("replaces workspace: directories with the version of the workspace in that directory", async () => { + await createDirectoryWorkspace({ + dependencies: { + "core": "workspace:../core", + // declared under the workspace's own name, so no alias is needed + "@acme/core": "workspace:../core", + "core-dot": "workspace:./../core", + "core-slash": "workspace:../core/", + // the `@` in the directory does not make this `workspace:@` + "scoped-dir": "workspace:../@scoped/thing", + "self": "workspace:.", + }, + devDependencies: { + "plain": "workspace:../plain", + }, + peerDependencies: { + "plain-peer": "workspace:../plain", + }, + optionalDependencies: { + "plain-optional": "workspace:../plain", + }, + }); + // every one of these is a spec `bun install` links + await runBunInstall(bunEnv, packageDir); + + await pack(join(packageDir, "pkgs", "ui"), bunEnv); + + const tarball = readTarball(join(packageDir, "pkgs", "ui", "acme-ui-2.0.0.tgz")); + expect(JSON.parse(tarball.entries[0].contents)).toEqual({ + name: "@acme/ui", + version: "2.0.0", + dependencies: { + "core": "npm:@acme/core@1.2.3", + "@acme/core": "1.2.3", + "core-dot": "npm:@acme/core@1.2.3", + "core-slash": "npm:@acme/core@1.2.3", + "scoped-dir": "npm:thing@0.0.7", + "self": "npm:@acme/ui@2.0.0", + }, + devDependencies: { + "plain": "0.5.0", + }, + peerDependencies: { + "plain-peer": "npm:plain@0.5.0", + }, + optionalDependencies: { + "plain-optional": "npm:plain@0.5.0", + }, + }); + }); + + test("replaces workspace: directories in the workspace root with the versions in the workspaces' package.json files", async () => { + await createDirectoryWorkspace({}); + await write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-workspace-root-directories", + version: "3.0.0", + workspaces: ["pkgs/*", "pkgs/@scoped/*"], + dependencies: { + "core": "workspace:pkgs/core", + "plain": "workspace:./pkgs/plain", + }, + }), + ); + await runBunInstall(bunEnv, packageDir); + // bumped after the install, so bun.lock still says 0.5.0 + await write(join(packageDir, "pkgs", "plain", "package.json"), JSON.stringify({ name: "plain", version: "0.6.0" })); + + await pack(packageDir, bunEnv); + + const tarball = readTarball(join(packageDir, "pack-workspace-root-directories-3.0.0.tgz")); + expect(tarball.entries[0]).toMatchObject({ pathname: "package/package.json" }); + expect(JSON.parse(tarball.entries[0].contents).dependencies).toEqual({ + "core": "npm:@acme/core@1.2.3", + "plain": "0.6.0", + }); + }); + + test("replaces a workspace: directory given as a bare directory name", async () => { + // `bun install` joins whatever follows `workspace:` onto the declaring package.json's directory, + // so from the root the directory name alone is enough. Only a dependency that is itself a + // workspace reads it as a version range instead. + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-workspace-bare-directory", + version: "3.0.0", + workspaces: ["core", "plain"], + dependencies: { "bare": "workspace:core", "@acme/core": "workspace:core", "plain": "workspace:0.x" }, + }), + ), + write(join(packageDir, "core", "package.json"), JSON.stringify({ name: "@acme/core", version: "1.2.3" })), + write(join(packageDir, "plain", "package.json"), JSON.stringify({ name: "plain", version: "0.5.0" })), + ]); + await runBunInstall(bunEnv, packageDir); + + await pack(packageDir, bunEnv); + + const tarball = readTarball(join(packageDir, "pack-workspace-bare-directory-3.0.0.tgz")); + expect(tarball.entries[0]).toMatchObject({ pathname: "package/package.json" }); + expect(JSON.parse(tarball.entries[0].contents).dependencies).toEqual({ + "bare": "npm:@acme/core@1.2.3", + "@acme/core": "1.2.3", + "plain": "0.x", + }); + }); + + test("replaces workspace: directories without a lockfile", async () => { + await createDirectoryWorkspace({ dependencies: { "core": "workspace:../core", "plain": "workspace:../plain" } }); + + await pack(join(packageDir, "pkgs", "ui"), bunEnv); + + const tarball = readTarball(join(packageDir, "pkgs", "ui", "acme-ui-2.0.0.tgz")); + expect(JSON.parse(tarball.entries[0].contents).dependencies).toEqual({ + "core": "npm:@acme/core@1.2.3", + "plain": "0.5.0", + }); + }); + + test("copies workspace: aliases as written", async () => { + // `workspace:@` installs workspace under the dependency's name; it is + // neither a directory nor a range of the dependency, and must not be rejected as one. + await createDirectoryWorkspace({ + dependencies: { "core-alias": "workspace:@acme/core@^1.0.0", "plain-alias": "workspace:plain@*" }, + }); + + await pack(join(packageDir, "pkgs", "ui"), bunEnv); + + const tarball = readTarball(join(packageDir, "pkgs", "ui", "acme-ui-2.0.0.tgz")); + expect(JSON.parse(tarball.entries[0].contents).dependencies).toEqual({ + "core-alias": "@acme/core@^1.0.0", + "plain-alias": "plain@*", + }); + }); + + const notAWorkspaceSpecs = [ + // exists on disk, but the root's `workspaces` does not list it (written below) + { group: "devDependencies", name: "unlisted", spec: "workspace:../../unlisted" }, + { group: "dependencies", name: "missing", spec: "workspace:../missing" }, + // a range only means something for a dependency that is itself a workspace + { group: "peerDependencies", name: "not-a-workspace", spec: "workspace:1.2.3" }, + // and `@` only when is a workspace + { group: "optionalDependencies", name: "bogus-alias", spec: "workspace:nope@*" }, + ]; + + for (const { group, name, spec } of notAWorkspaceSpecs) { + test(`fails when a workspace: spec is neither a workspace's directory nor a workspace's range: ${spec}`, async () => { + await createDirectoryWorkspace({ dependencies: { "plain": "workspace:../plain" }, [group]: { [name]: spec } }); + await write(join(packageDir, "unlisted", "package.json"), JSON.stringify({ name: "unlisted", version: "1.0.0" })); + + const { err } = await packExpectError(join(packageDir, "pkgs", "ui"), bunEnv); + expect(err).toContain(`error: Failed to resolve workspace version for "${name}" in \`${group}\` (`); + expect(err).toContain( + `package.json" has no workspace named "${name}" and no workspace in the directory "${spec.slice("workspace:".length)}").`, + ); + expect(await exists(join(packageDir, "pkgs", "ui", "acme-ui-2.0.0.tgz"))).toBeFalse(); + }); + } + + test("fails when the workspace in a workspace: directory has no version", async () => { + await createDirectoryWorkspace({ dependencies: { "no-version": "workspace:../unversioned" } }); + await write(join(packageDir, "pkgs", "unversioned", "package.json"), JSON.stringify({ name: "unversioned" })); + + const { err } = await packExpectError(join(packageDir, "pkgs", "ui"), bunEnv); + expect(err).toContain( + 'error: Failed to resolve workspace version for "no-version" in `dependencies` (the package.json of workspace "unversioned" in the directory "../unversioned" has no version).', + ); + expect(await exists(join(packageDir, "pkgs", "ui", "acme-ui-2.0.0.tgz"))).toBeFalse(); + }); }); test("lifecycle scripts execution order", async () => { diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index 1df0c7863000..4ffd4f085079 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -849,6 +849,59 @@ test("publishes a workspace package next to a bun.lock that does not parse", asy }); }); +test("a published package can depend on another workspace by its directory", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + const bunfig = await registry.authBunfig("workspacepath"); + const corePkgJson = { name: "publish-pkg-path-core", version: "1.2.3" }; + await Promise.all([ + rm(join(registry.packagesPath, "publish-pkg-path-core"), { recursive: true, force: true }), + rm(join(registry.packagesPath, "publish-pkg-path-ui"), { recursive: true, force: true }), + write(join(packageDir, "bunfig.toml"), bunfig), + write(packageJson, JSON.stringify({ name: "root", workspaces: ["packages/*"] })), + write(join(packageDir, "packages", "core", "package.json"), JSON.stringify(corePkgJson)), + write( + join(packageDir, "packages", "ui", "package.json"), + JSON.stringify({ + name: "publish-pkg-path-ui", + version: "2.0.0", + dependencies: { + "core-alias": "workspace:../core", + "publish-pkg-path-core": "workspace:../core", + }, + }), + ), + ]); + + for (const pkg of ["core", "ui"]) { + const { out, err, exitCode } = await publish(env, join(packageDir, "packages", pkg)); + expect(err).not.toContain("error:"); + expect(out).toContain(`+ publish-pkg-path-${pkg}@`); + expect(exitCode).toBe(0); + } + + // consume the published package from the registry + await Promise.all([ + rm(join(packageDir, "packages"), { recursive: true, force: true }), + write(packageJson, JSON.stringify({ name: "root", dependencies: { "publish-pkg-path-ui": "2.0.0" } })), + ]); + await runBunInstall(env, packageDir); + + expect(await file(join(packageDir, "node_modules", "publish-pkg-path-ui", "package.json")).json()).toEqual({ + name: "publish-pkg-path-ui", + version: "2.0.0", + dependencies: { + "core-alias": "npm:publish-pkg-path-core@1.2.3", + "publish-pkg-path-core": "1.2.3", + }, + }); + expect( + await Promise.all([ + file(join(packageDir, "node_modules", "core-alias", "package.json")).json(), + file(join(packageDir, "node_modules", "publish-pkg-path-core", "package.json")).json(), + ]), + ).toEqual([corePkgJson, corePkgJson]); +}); + describe("--dry-run", async () => { test("does not publish", async () => { const { packageDir, packageJson } = await registry.createTestDir();