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
13 changes: 7 additions & 6 deletions src/install/PackageManager/PackageManagerEnqueue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2290,12 +2290,13 @@ fn get_or_put_resolved_package(
let buf = this.lockfile.buffers.string_bytes.as_slice();
let npm_group = &version.npm().version;
if this.options.link_workspace_packages
&& ((workspace_version.is_some()
&& npm_group.satisfies(*workspace_version.unwrap(), buf, buf))
// https://github.com/oven-sh/bun/pull/10899#issuecomment-2099609419
// if the workspace doesn't have a version, it can still be used if
// dependency version is wildcard
|| (workspace_path.is_some() && npm_group.is_star()))
&& dependency::npm_range_accepts_workspace_member(
npm_group,
workspace_version.copied(),
workspace_path.is_some(),
buf,
buf,
)
{
let Some(root_package) = this.lockfile.root_package() else {
break 'resolve_from_workspace;
Expand Down
17 changes: 17 additions & 0 deletions src/install/dependency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,23 @@ pub(crate) fn is_safe_install_folder_name(name: &[u8]) -> bool {
true
}

/// Whether an npm range accepts a same-name workspace member. Parse
/// (`Package::parse_dependency`) and resolution
/// (`get_or_put_resolved_package`) share this rule; if they disagree,
/// link-vs-registry depends on which package declares the dependency.
/// A wildcard accepts any present member, even versionless (#10899) or
/// with a prerelease version the range does not otherwise satisfy.
Comment thread
robobun marked this conversation as resolved.
pub(crate) fn npm_range_accepts_workspace_member(
range: &Semver::query::Group,
workspace_version: Option<Semver::Version>,
has_workspace_path: bool,
range_buf: &[u8],
version_buf: &[u8],
) -> bool {
workspace_version.is_some_and(|v| range.satisfies(v, range_buf, version_buf))
|| (has_workspace_path && range.is_star())
}

/// assumes version is valid
pub fn without_build_tag(version: &[u8]) -> &[u8] {
if let Some(plus) = strings::index_of_char(version, b'+') {
Expand Down
14 changes: 8 additions & 6 deletions src/install/lockfile/Package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1751,12 +1751,14 @@ impl Package<u64> {
}
dependency::version::Tag::Npm => {
if let Some(workspace_version) = workspace_version {
let satisfies =
dependency_version
.npm()
.version
.satisfies(workspace_version, buf, buf);
if pm.options.link_workspace_packages && satisfies {
let accepts = dependency::npm_range_accepts_workspace_member(
&dependency_version.npm().version,
Some(workspace_version),
true,
buf,
buf,
);
if pm.options.link_workspace_packages && accepts {
// `String::sliced` takes `&'a self`; bind the unwrapped
// value so the borrow outlives the parse call.
let wp = workspace_path.unwrap();
Expand Down
192 changes: 192 additions & 0 deletions test/cli/install/bun-workspaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,198 @@ test("dependency on workspace without version in package.json", async () => {
}
});

// A `*` range accepts a workspace member whose version is a prerelease even
// though `*` does not satisfy prereleases under npm semver rules, and the
// outcome must not depend on whether the root or another member declares the
// dependency (`no-deps` also exists in the registry with 2.0.0 as latest).
test.concurrent("root wildcard dependency on a prerelease workspace member links the workspace", async () => {
using ctx = await setupTest();
const { packageDir, packageJson, env } = ctx;
await Promise.all([
write(
packageJson,
JSON.stringify({
name: "foo",
workspaces: ["packages/*"],
dependencies: {
"no-deps": "*",
},
}),
),
write(
join(packageDir, "packages", "no-deps", "package.json"),
JSON.stringify({ name: "no-deps", version: "3.0.0-beta.1" }),
),
]);

{
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
stdout: "ignore",
stderr: "pipe",
env,
});
const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(err).toContain("Saved lockfile");
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);
}

const lock = await file(join(packageDir, "bun.lock")).text();
expect(lock).toContain("no-deps@workspace:packages/no-deps");
expect(lock).not.toContain("no-deps@2.0.0");
expect((await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).version).toBe("3.0.0-beta.1");

// repeat install converges: no re-save, identical lockfile
{
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
stdout: "ignore",
stderr: "pipe",
env,
});
const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(err).not.toContain("Saved lockfile");
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);
}
expect(await file(join(packageDir, "bun.lock")).text()).toBe(lock);

{
await using proc = spawn({
cmd: [bunExe(), "install", "--frozen-lockfile"],
cwd: packageDir,
stdout: "ignore",
stderr: "pipe",
env,
});
const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);
}
});

test.concurrent("member wildcard dependency on a prerelease workspace member stays linked and stable", async () => {
using ctx = await setupTest();
const { packageDir, packageJson, env } = ctx;
await Promise.all([
write(
packageJson,
JSON.stringify({
name: "foo",
workspaces: ["packages/*"],
}),
),
write(
join(packageDir, "packages", "no-deps", "package.json"),
JSON.stringify({ name: "no-deps", version: "3.0.0-beta.1" }),
),
write(
join(packageDir, "packages", "pkg1", "package.json"),
JSON.stringify({
name: "pkg1",
version: "1.0.0",
dependencies: {
"no-deps": "*",
},
}),
),
]);

{
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
stdout: "ignore",
stderr: "pipe",
env,
});
const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(err).toContain("Saved lockfile");
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);
}

const lock = await file(join(packageDir, "bun.lock")).text();
expect(lock).toContain("no-deps@workspace:packages/no-deps");
expect(lock).not.toContain("no-deps@2.0.0");
const hoisted = join(packageDir, "node_modules", "no-deps", "package.json");
const nested = join(packageDir, "packages", "pkg1", "node_modules", "no-deps", "package.json");
const installedPkgJson = (await exists(nested)) ? nested : hoisted;
expect((await file(installedPkgJson).json()).version).toBe("3.0.0-beta.1");

// repeat install converges: no re-save, identical lockfile
{
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
stdout: "ignore",
stderr: "pipe",
env,
});
const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(err).not.toContain("Saved lockfile");
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);
}
expect(await file(join(packageDir, "bun.lock")).text()).toBe(lock);

{
await using proc = spawn({
cmd: [bunExe(), "install", "--frozen-lockfile"],
cwd: packageDir,
stdout: "ignore",
stderr: "pipe",
env,
});
const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);
}
});

// Boundary guard: only a wildcard gets the prerelease exception. A range the
// member's prerelease version does not satisfy keeps installing from the
// registry. Passes on an unmodified build by design.
test.concurrent("non-wildcard range on a prerelease workspace member still installs from the registry", async () => {
using ctx = await setupTest();
const { packageDir, packageJson, env } = ctx;
await Promise.all([
write(
packageJson,
JSON.stringify({
name: "foo",
workspaces: ["packages/*"],
dependencies: {
"no-deps": "^1.0.0",
},
}),
),
write(
join(packageDir, "packages", "no-deps", "package.json"),
JSON.stringify({ name: "no-deps", version: "3.0.0-beta.1" }),
),
]);

await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
stdout: "ignore",
stderr: "pipe",
env,
});
const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);

const lock = await file(join(packageDir, "bun.lock")).text();
expect(lock).toContain("no-deps@1.1.0");
expect(lock).not.toContain("no-deps@workspace:packages/no-deps");
expect((await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).version).toBe("1.1.0");
});

test.concurrent("allowing negative workspace patterns", async () => {
using ctx = await setupTest();
const { packageDir, env } = ctx;
Expand Down