diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index d4c6e5770873..a55c98efdc42 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -2263,8 +2263,11 @@ impl Package { } if bin.expr.is_string() { if let Some(str_) = bin.expr.as_utf8(&bump) { - string_builder.count(str_); - break 'bin; + // The build pass reads `directories.bin` when `bin` is empty. + if !str_.is_empty() { + string_builder.count(str_); + break 'bin; + } } } } diff --git a/src/install/npm.rs b/src/install/npm.rs index 0addc627fc1f..ef369c52c5f1 100644 --- a/src/install/npm.rs +++ b/src/install/npm.rs @@ -2182,8 +2182,12 @@ impl PackageManifest { } } JSON::E::JsonValue::String(str_) => { - string_builder.count(str_.slice()); - break 'bin; + // The build pass reads `directories.bin` when `bin` is empty. + let str_ = str_.slice(); + if !str_.is_empty() { + string_builder.count(str_); + break 'bin; + } } _ => {} } diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index 08bdf5c9bd11..7db3d4db5ef0 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -3620,6 +3620,102 @@ describe("binaries", () => { expect(err).not.toContain("error:"); expect(await exited).toBe(0); }); + + // An empty `bin` names no file, so the bins come from `directories.bin`. Each path below is + // longer than the room that a parsed package.json or manifest has for a string that its + // counting pass missed. The `./` segments drop out of the resolved path, so each one still + // names the folder in its last segment. + describe("`directories.bin` next to an empty `bin`", () => { + const longDirOf = (folder: string) => Buffer.alloc(512, "./").toString() + folder; + + /** Runs `bun install` in `packageDir`, expects it to succeed, and returns `bun.lock`. */ + async function install(expectedOut: string) { + await using proc = spawn({ + cmd: [bunExe(), "install", "--save-text-lockfile"], + cwd: packageDir, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out, err, exitCode, signalCode: proc.signalCode }).toEqual({ + out: expect.stringContaining(expectedOut), + err: expect.not.stringContaining("error:"), + exitCode: 0, + signalCode: null, + }); + const lockfile = file(join(packageDir, "bun.lock")); + return (await lockfile.exists()) ? ((Bun.JSONC.parse(await lockfile.text()) as any).packages ?? {}) : undefined; + } + + test("in the root package.json", async () => { + await write(packageJson, JSON.stringify({ name: "foo", bin: "", directories: { bin: longDirOf("bins") } })); + + expect(await install("done")).toBeUndefined(); + }); + + test("in a folder dependency", async () => { + const binDir = longDirOf("executables"); + await Promise.all([ + write(packageJson, JSON.stringify({ name: "foo", dependencies: { "dir-bin": "./dir-bin" } })), + write( + join(packageDir, "dir-bin", "package.json"), + JSON.stringify({ name: "dir-bin", version: "1.1.1", bin: "", directories: { bin: binDir } }), + ), + write( + join(packageDir, "dir-bin", "executables", "dir-bin-1.js"), + `#!/usr/bin/env node\nconsole.log("dir-bin-1")`, + ), + ]); + + expect(await install("1 package installed")).toEqual({ + "dir-bin": ["dir-bin@file:dir-bin", { binDir }], + }); + expect(join(packageDir, "node_modules", ".bin", "dir-bin-1.js")).toBeValidBin( + join("..", "dir-bin", "executables", "dir-bin-1.js"), + ); + }); + + test("in a registry manifest", async () => { + const name = "dep-with-directory-bins"; + const binDir = longDirOf("bins"); + await using server = Bun.serve({ + port: 0, + fetch(request) { + const { origin, pathname } = new URL(request.url); + if (pathname.endsWith(".tgz")) { + return new Response(file(join(import.meta.dir, "registry", "packages", name, `${name}-1.0.0.tgz`))); + } + return Response.json({ + name, + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name, + version: "1.0.0", + bin: "", + directories: { bin: binDir }, + dist: { tarball: `${origin}/${name}-1.0.0.tgz` }, + }, + }, + }); + }, + }); + await Promise.all([ + write(packageJson, JSON.stringify({ name: "foo", dependencies: { [name]: "1.0.0" } })), + write( + join(packageDir, "bunfig.toml"), + Bun.TOML.stringify({ install: { cache: false, registry: server.url.href, linker: "hoisted" } }), + ), + ]); + + expect(await install("1 package installed")).toEqual({ + [name]: [`${name}@1.0.0`, `${server.url.origin}/${name}-1.0.0.tgz`, { binDir }, ""], + }); + await runBin("directory-bin-1", "directory-bin-1\n", false); + await runBin("directory-bin-2", "directory-bin-2\n", false); + }); + }); }); test("--config cli flag works", async () => {