Compare commits

..

3 Commits

Author SHA1 Message Date
Claude
c0ff8577bd fix: read stdout/stderr before checking exit code in second test
Match the pattern used in the first test's bun install setup block
for better error diagnostics on failure.

https://claude.ai/code/session_01Smc4WMsFod6jYXKRgQa9wH
2026-02-21 04:59:43 +00:00
autofix-ci[bot]
261636934b [autofix.ci] apply automated fixes 2026-02-20 05:05:44 +00:00
Claude Bot
9d08d2dec2 fix(install): resolve catalog: dependencies during bun link
`bun link <package>` was the only subcommand that skipped workspace root
discovery, causing `catalog:` dependency versions to fail resolution with
"failed to resolve". Now all subcommands discover the workspace root, and
`bun link` (no args) uses the original cwd for symlink creation so it
still registers the correct member package.

Closes #20015

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 05:03:51 +00:00
5 changed files with 151 additions and 86 deletions

View File

@@ -90,16 +90,18 @@ fn link(ctx: Command.Context) !void {
}
}
// Use original_cwd (the directory of the package being linked) rather than
// top_level_dir (which may be the workspace root) so that the symlink points
// to the correct package directory.
if (comptime Environment.isWindows) {
// create the junction
const top_level = Fs.FileSystem.instance.topLevelDirWithoutTrailingSlash();
var link_path_buf: bun.PathBuffer = undefined;
@memcpy(
link_path_buf[0..top_level.len],
top_level,
link_path_buf[0..original_cwd.len],
original_cwd,
);
link_path_buf[top_level.len] = 0;
const link_path = link_path_buf[0..top_level.len :0];
link_path_buf[original_cwd.len] = 0;
const link_path = link_path_buf[0..original_cwd.len :0];
const global_path = manager.globalLinkDirPath();
const dest_path = Path.joinAbsStringZ(global_path, &.{name}, .windows);
switch (bun.sys.sys_uv.symlinkUV(
@@ -115,7 +117,7 @@ fn link(ctx: Command.Context) !void {
}
} else {
// create the symlink
node_modules.symLink(Fs.FileSystem.instance.topLevelDirWithoutTrailingSlash(), name, .{ .is_directory = true }) catch |err| {
node_modules.symLink(original_cwd, name, .{ .is_directory = true }) catch |err| {
if (manager.options.log_level != .silent)
Output.prettyErrorln("<r><red>error:<r> failed to create symlink to node_modules in global dir due to error {s}", .{@errorName(err)});
Global.crash();
@@ -202,9 +204,6 @@ const strings = bun.strings;
const Command = bun.cli.Command;
const File = bun.sys.File;
const Fs = bun.fs;
const FileSystem = Fs.FileSystem;
const Bin = bun.install.Bin;
const Features = bun.install.Features;

View File

@@ -195,10 +195,8 @@ pub const Subcommand = enum {
// TODO: make all subcommands find root and chdir
pub fn shouldChdirToRoot(this: Subcommand) bool {
return switch (this) {
.link => false,
else => true,
};
_ = this;
return true;
}
};

View File

@@ -117,20 +117,19 @@ class JSCallback {
class CString extends String {
constructor(ptr, byteOffset?, byteLength?) {
const str = ptr
? typeof byteLength === "number" && Number.isSafeInteger(byteLength)
? BunCString(ptr, byteOffset || 0, byteLength)
: BunCString(ptr, byteOffset || 0)
: "";
super(str);
super(
ptr
? typeof byteLength === "number" && Number.isSafeInteger(byteLength)
? BunCString(ptr, byteOffset || 0, byteLength)
: BunCString(ptr, byteOffset || 0)
: "",
);
this.ptr = typeof ptr === "number" ? ptr : 0;
this.byteOffset = typeof byteOffset === "number" ? byteOffset : 0;
if (typeof byteLength === "number") {
if (typeof byteOffset !== "undefined") {
this.byteOffset = byteOffset;
}
if (typeof byteLength !== "undefined") {
this.byteLength = byteLength;
} else if (this.ptr) {
this.byteLength = Buffer.byteLength(str, "utf8");
} else {
this.byteLength = 0;
}
}

View File

@@ -0,0 +1,130 @@
import { spawn } from "bun";
import { expect, test } from "bun:test";
import { bunEnv, bunExe, stderrForInstall, tempDir } from "harness";
import { join } from "path";
// https://github.com/oven-sh/bun/issues/20015
// `bun link <package>` from a workspace member with `catalog:` dependencies
// should resolve catalogs from the workspace root, not fail with "failed to resolve".
test("bun link resolves catalog: dependencies in workspace member", async () => {
using dir = tempDir("issue-20015", {
"package.json": JSON.stringify({
name: "my-workspace-root",
workspaces: {
packages: ["packages/*"],
catalog: {
"is-number": "7.0.0",
},
},
}),
"packages/api/package.json": JSON.stringify({
name: "api",
version: "1.0.0",
devDependencies: {
"is-number": "catalog:",
},
}),
"packages/lib/package.json": JSON.stringify({
name: "lib",
version: "1.0.0",
}),
});
// First, run `bun install` from root to set up the workspace
{
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const stderr = stderrForInstall(await proc.stderr.text());
const stdout = await proc.stdout.text();
expect(stderr).not.toContain("error");
expect(await proc.exited).toBe(0);
}
// Register the "lib" package globally with `bun link` (no args)
{
await using proc = spawn({
cmd: [bunExe(), "link"],
cwd: join(String(dir), "packages", "lib"),
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const stdout = await proc.stdout.text();
const stderr = stderrForInstall(await proc.stderr.text());
expect(stdout).toContain(`Success! Registered "lib"`);
expect(await proc.exited).toBe(0);
}
// Link the "lib" package into the "api" workspace member
// This previously failed with: error: is-number@catalog: failed to resolve
{
await using proc = spawn({
cmd: [bunExe(), "link", "lib"],
cwd: join(String(dir), "packages", "api"),
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const stdout = await proc.stdout.text();
const stderr = await proc.stderr.text();
expect(stderr).not.toContain("failed to resolve");
expect(stderr).not.toContain("error:");
expect(stdout).toContain("installed lib");
expect(await proc.exited).toBe(0);
}
});
// Verify that `bun link` (no args) from a workspace member still registers
// the member package (not the workspace root).
test("bun link (no args) from workspace member registers member, not root", async () => {
using dir = tempDir("issue-20015-noargs", {
"package.json": JSON.stringify({
name: "my-root",
workspaces: ["packages/*"],
}),
"packages/member/package.json": JSON.stringify({
name: "member-pkg",
version: "1.0.0",
}),
});
// Run install first
{
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const stderr = stderrForInstall(await proc.stderr.text());
const stdout = await proc.stdout.text();
expect(stderr).not.toContain("error");
expect(await proc.exited).toBe(0);
}
// Run `bun link` from the workspace member directory
{
await using proc = spawn({
cmd: [bunExe(), "link"],
cwd: join(String(dir), "packages", "member"),
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const stdout = await proc.stdout.text();
// Should register "member-pkg", not "my-root"
expect(stdout).toContain(`Success! Registered "member-pkg"`);
expect(stdout).not.toContain("my-root");
expect(await proc.exited).toBe(0);
}
});

View File

@@ -1,61 +0,0 @@
import { CString, ptr } from "bun:ffi";
import { expect, test } from "bun:test";
test("CString byteLength and byteOffset are defined when constructed with only a pointer", () => {
const buffer = Buffer.from("Hello world!\0");
const bufferPtr = ptr(buffer);
const cString = new CString(bufferPtr);
expect(cString.byteOffset).toBe(0);
expect(cString.byteLength).toBe(12);
expect(cString.toString()).toBe("Hello world!");
});
test("CString byteOffset defaults to 0 when only ptr and byteLength are provided", () => {
const buffer = Buffer.from("Hello world!\0");
const bufferPtr = ptr(buffer);
const cString = new CString(bufferPtr, 0, 12);
expect(cString.byteOffset).toBe(0);
expect(cString.byteLength).toBe(12);
expect(cString.toString()).toBe("Hello world!");
});
test("CString with byteOffset", () => {
const buffer = Buffer.from("Hello world!\0");
const bufferPtr = ptr(buffer);
const cString = new CString(bufferPtr, 6);
expect(cString.byteOffset).toBe(6);
expect(cString.byteLength).toBe(6);
expect(cString.toString()).toBe("world!");
});
test("CString with byteOffset and byteLength", () => {
const buffer = Buffer.from("Hello world!\0");
const bufferPtr = ptr(buffer);
const cString = new CString(bufferPtr, 6, 5);
expect(cString.byteOffset).toBe(6);
expect(cString.byteLength).toBe(5);
expect(cString.toString()).toBe("world");
});
test("CString with null pointer has byteLength 0 and byteOffset 0", () => {
const cString = new CString(0);
expect(cString.byteOffset).toBe(0);
expect(cString.byteLength).toBe(0);
expect(cString.toString()).toBe("");
});
test("CString byteLength is correct for multi-byte UTF-8 strings", () => {
// "café" in UTF-8 is 5 bytes (c=1, a=1, f=1, é=2)
const buffer = Buffer.from("café\0");
const bufferPtr = ptr(buffer);
const cString = new CString(bufferPtr);
expect(cString.byteOffset).toBe(0);
expect(cString.byteLength).toBe(5);
expect(cString.toString()).toBe("café");
});