From 9d9e63901e325b864b2ca30de1b2d56d9cb73ff9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:56:01 +0000 Subject: [PATCH 1/3] install: keep the separator of a drive root `C:\` is the root of drive C. Without the separator it is `C:`, the current directory of the drive. `C:` is not an absolute path. `PackageManager::init` removed the separator from a cwd of `C:\`. It then used `C:` as the project directory. `read_directory("C:")` opens the name `C:` below the cwd handle and returns ENOENT. The same happened for a cwd below the root when the package.json is in the root. - `init` keeps the root separator in the cwd and in each parent it walks to. It joins `package.json` onto the directory, so a root gets no second separator. - The package.json path helpers of add, update, audit fix and the hoisted installer pass `top_level_dir` to the join as it is. The join asserts an absolute cwd in debug builds. - `bun pm pack`, `bun publish` and `bun link` keep the root separator in the package directory. They could not open `C:`, ran scripts in `C:\C:`, and could not create the junction. Fixes #29273 --- src/install/PackageInstaller.rs | 3 +- src/install/PackageManager.rs | 65 +++++++-------- src/install/PackageManager/add_catalog.rs | 2 +- .../PackageManager/add_remove_with_filter.rs | 4 +- .../PackageManager/package_json_write_back.rs | 4 +- src/install/audit_fix/package_json_edits.rs | 2 +- src/runtime/cli/link_command.rs | 4 +- src/runtime/cli/pack_command.rs | 2 +- src/runtime/cli/publish_command.rs | 10 ++- test/cli/install/bun-install.test.ts | 83 +++++++++++++++++++ test/cli/install/bun-link.test.ts | 35 ++++++++ test/cli/install/bun-pack.test.ts | 26 +++++- test/cli/install/bun-publish.test.ts | 26 ++++++ test/harness.ts | 16 ++++ 14 files changed, 232 insertions(+), 50 deletions(-) diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index 0756e601a21c..c455d72772fd 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -358,8 +358,7 @@ fn abs_node_modules_path( &mut rel_buf, &mut depth_buf, ); - let top = strings::without_trailing_slash(FileSystem::instance().top_level_dir()); - let mut abs = AbsPath::from(top).unwrap_or_oom(); + let mut abs = AbsPath::init_top_level_dir(); abs.append(rel.as_bytes()).unwrap_or_oom(); abs } diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index d1075ccba9a3..c28a77c70011 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -23,6 +23,7 @@ use bun_event_loop::{self, AnyEventLoop, EventLoopHandle}; use bun_http as http; use bun_ini as ini; use bun_paths::resolve_path::{self, PosixToWinNormalizer, platform}; +use bun_paths::string_paths::without_trailing_slash_windows_path; use bun_paths::{DELIMITER, PathBuffer, SEP, SEP_STR}; use bun_semver as Semver; use bun_sys::{self, Fd}; @@ -1472,6 +1473,15 @@ fn overlay_bunfig_install(install: &mut Api::BunInstall, bunfig: Api::BunInstall ); } +/// Appends `package.json` and a NUL to the directory in `path`. A filesystem root (`/`, `C:\`) +/// already ends in a separator, so it gets no second one. +fn push_package_json(path: &mut Vec) { + if !path.last().is_some_and(|&c| bun_paths::is_sep_native(c)) { + path.push(SEP); + } + path.extend_from_slice(b"package.json\0"); +} + /// Returns `&'static mut PackageManager` — the process-singleton (held in /// `holder::RAW_PTR`) is leaked for the process lifetime and `init()` is called /// exactly once on the single CLI dispatch thread. Every @@ -1506,7 +1516,8 @@ pub fn init( // and seeds `top_level_dir` from `getcwd`. bun_resolver::fs::FileSystem::init(None)?; let fs = FileSystem::instance(); - let top_level_dir_no_trailing_slash = strings::without_trailing_slash(fs.top_level_dir()); + // `C:\` keeps its separator: `C:` is the current directory of drive C, not its root. + let top_level_dir_no_trailing_slash = without_trailing_slash_windows_path(fs.top_level_dir()); // SAFETY: CWD_BUF is a process-global path buffer only touched on the main thread. // repr(transparent) makes the `*mut PathBuffer → *mut u8` cast sound. unsafe { @@ -1533,19 +1544,12 @@ pub fn init( let _ = cwd_ptr; } - // Per-cfg const literal, no runtime alloc. - #[cfg(windows)] - const SEP_PACKAGE_JSON: &[u8] = b"\\package.json"; - #[cfg(not(windows))] - const SEP_PACKAGE_JSON: &[u8] = b"/package.json"; - let mut original_package_json_path_buf: Vec = - Vec::with_capacity(top_level_dir_no_trailing_slash.len() + SEP_PACKAGE_JSON.len() + 1); + Vec::with_capacity(top_level_dir_no_trailing_slash.len() + "/package.json".len() + 1); original_package_json_path_buf.extend_from_slice(top_level_dir_no_trailing_slash); - original_package_json_path_buf.extend_from_slice(SEP_PACKAGE_JSON); - original_package_json_path_buf.push(0); + push_package_json(&mut original_package_json_path_buf); - let path_len = top_level_dir_no_trailing_slash.len() + SEP_PACKAGE_JSON.len(); + let path_len = original_package_json_path_buf.len() - 1; // SAFETY: NUL written at `path_len` above. Not `from_buf`: this borrow is // intentionally detached — `original_package_json_path_buf` is mutated and // re-sliced below (the directory-walk rewrites the tail in place), and @@ -1554,7 +1558,7 @@ pub fn init( let mut original_package_json_path = unsafe { ZStr::from_raw(original_package_json_path_buf.as_ptr(), path_len) }; let original_cwd = - strings::without_suffix_comptime(original_package_json_path.as_bytes(), SEP_PACKAGE_JSON); + &original_package_json_path.as_bytes()[..top_level_dir_no_trailing_slash.len()]; let original_cwd_clone = Box::<[u8]>::from(original_cwd); let mut workspace_names = Package::WorkspaceMap::WorkspaceMap::init(); @@ -1584,14 +1588,10 @@ pub fn init( loop { let mut package_json_path_buf = bun_paths::path_buffer_pool::get(); - package_json_path_buf[..this_cwd.len()].copy_from_slice(this_cwd); - package_json_path_buf[this_cwd.len()..this_cwd.len() + b"/package.json".len()] - .copy_from_slice(b"/package.json"); - package_json_path_buf[this_cwd.len() + b"/package.json".len()] = 0; - // SAFETY: NUL written above - let package_json_path = ZStr::from_buf( - &package_json_path_buf[..], - this_cwd.len() + b"/package.json".len(), + let package_json_path = resolve_path::join_abs_string_buf_z::( + this_cwd, + &mut package_json_path_buf[..], + &[b"package.json"], ); match bun_sys::File::openat( @@ -1607,7 +1607,7 @@ pub fn init( Ok(f) => break 'child f, Err(e) if e.get_errno() == bun_sys::E::ENOENT => { if let Some(parent) = bun_core::dirname(this_cwd) { - this_cwd = strings::without_trailing_slash(parent); + this_cwd = without_trailing_slash_windows_path(parent); continue; } else { break; @@ -1666,11 +1666,9 @@ pub fn init( true, )); original_package_json_path_buf.truncate(this_cwd.len()); - original_package_json_path_buf.push(SEP); - original_package_json_path_buf.extend_from_slice(b"package.json"); - original_package_json_path_buf.push(0); + push_package_json(&mut original_package_json_path_buf); - let new_path_len = this_cwd.len() + "/package.json".len(); + let new_path_len = original_package_json_path_buf.len() - 1; // SAFETY: NUL written above original_package_json_path = ZStr::from_buf(&original_package_json_path_buf[..], new_path_len); @@ -1680,20 +1678,17 @@ pub fn init( if subcommand.should_chdir_to_root() { if !created_package_json && !no_project { while let Some(parent) = bun_core::dirname(this_cwd) { - let parent_without_trailing_slash = strings::without_trailing_slash(parent); let mut parent_path_buf = bun_paths::path_buffer_pool::get(); - parent_path_buf[..parent_without_trailing_slash.len()] - .copy_from_slice(parent_without_trailing_slash); - parent_path_buf[parent_without_trailing_slash.len() - ..parent_without_trailing_slash.len() + b"/package.json".len()] - .copy_from_slice(b"/package.json"); - parent_path_buf[parent_without_trailing_slash.len() + b"/package.json".len()] = - 0; + let parent_package_json_path = + resolve_path::join_abs_string_buf_z::( + parent, + &mut parent_path_buf[..], + &[b"package.json"], + ); let json_file = match bun_sys::File::openat( bun_sys::Fd::cwd(), - &parent_path_buf - [..parent_without_trailing_slash.len() + b"/package.json".len()], + parent_package_json_path.as_bytes(), bun_sys::O::RDWR | bun_sys::O::CLOEXEC, 0, ) { diff --git a/src/install/PackageManager/add_catalog.rs b/src/install/PackageManager/add_catalog.rs index 0dede5332a5c..1efc977195e3 100644 --- a/src/install/PackageManager/add_catalog.rs +++ b/src/install/PackageManager/add_catalog.rs @@ -341,7 +341,7 @@ fn collect_root_entries(root: &Expr, group: &[u8], out: &mut Vec) { } fn member_targets(ws: &WorkspaceMembers) -> Vec { - let top_level = strings::without_trailing_slash(FileSystem::instance().top_level_dir()); + let top_level = FileSystem::instance().top_level_dir(); let mut buf = path_buffer_pool::get(); ws.members .keys() diff --git a/src/install/PackageManager/add_remove_with_filter.rs b/src/install/PackageManager/add_remove_with_filter.rs index 1ae652f46c3e..5bfcd9dd9484 100644 --- a/src/install/PackageManager/add_remove_with_filter.rs +++ b/src/install/PackageManager/add_remove_with_filter.rs @@ -37,7 +37,7 @@ pub(crate) struct WorkspaceTarget { } pub(crate) fn root_package_json_path() -> Box<[u8]> { - let top_level = strings::without_trailing_slash(FileSystem::instance().top_level_dir()); + let top_level = FileSystem::instance().top_level_dir(); let mut buf = path_buffer_pool::get(); let path: Box<[u8]> = join_abs_string_buf::(top_level, &mut buf.0, &[b"package.json"]).into(); @@ -152,7 +152,7 @@ pub(crate) fn select_targets( manager: &mut PackageManager, original_cwd: &[u8], ) -> Result, Error> { - let top_level = strings::without_trailing_slash(FileSystem::instance().top_level_dir()); + let top_level = FileSystem::instance().top_level_dir(); let WorkspaceMembers { root_path, root_name, diff --git a/src/install/PackageManager/package_json_write_back.rs b/src/install/PackageManager/package_json_write_back.rs index becf4d246f84..5f887daaeb91 100644 --- a/src/install/PackageManager/package_json_write_back.rs +++ b/src/install/PackageManager/package_json_write_back.rs @@ -1,6 +1,6 @@ use bun_collections::DynamicBitSet; use bun_collections::bit_set::Range as BitRange; -use bun_core::{Global, strings}; +use bun_core::Global; use bun_paths::path_buffer_pool; use bun_paths::resolve_path::{join_abs_string_buf, platform}; use bun_sys::{Fd, File}; @@ -114,7 +114,7 @@ fn edit_update_targets( edited: &mut Vec, exact: bool, ) -> crate::Result<()> { - let top_level = strings::without_trailing_slash(FileSystem::instance().top_level_dir()); + let top_level = FileSystem::instance().top_level_dir(); let mut selected: Vec = Vec::new(); { let lockfile: &Lockfile = &manager.lockfile; diff --git a/src/install/audit_fix/package_json_edits.rs b/src/install/audit_fix/package_json_edits.rs index 294acbfd0d29..652add86825e 100644 --- a/src/install/audit_fix/package_json_edits.rs +++ b/src/install/audit_fix/package_json_edits.rs @@ -115,7 +115,7 @@ fn target_for(manager: &PackageManager, owner: PackageID) -> Option { let buf = lockfile.buffers.string_bytes.as_slice(); - let top_level = strings::without_trailing_slash(FileSystem::instance().top_level_dir()); + let top_level = FileSystem::instance().top_level_dir(); let mut path_buf = path_buffer_pool::get(); Some(WorkspaceTarget { name: Box::from(lockfile.packages.items_name()[owner as usize].slice(buf)), diff --git a/src/runtime/cli/link_command.rs b/src/runtime/cli/link_command.rs index 37e454a1ce58..9fbb3a83a40d 100644 --- a/src/runtime/cli/link_command.rs +++ b/src/runtime/cli/link_command.rs @@ -171,7 +171,9 @@ fn link(ctx: command::Context) -> crate::Result<()> { use bun_core::ZStr; use bun_paths::{platform, resolve_path}; // create the junction - let top_level = FileSystem::instance().top_level_dir_without_trailing_slash(); + let top_level = bun_paths::string_paths::without_trailing_slash_windows_path( + FileSystem::instance().top_level_dir(), + ); let mut link_path_buf = bun_paths::path_buffer_pool::get(); link_path_buf.0[..top_level.len()].copy_from_slice(top_level); link_path_buf.0[top_level.len()] = 0; diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index c523a546df1d..c9157d7be8b0 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -2133,7 +2133,7 @@ pub(crate) fn pack( Global::crash(); } - let abs_workspace_path: &[u8] = strings::without_trailing_slash( + let abs_workspace_path: &[u8] = path::string_paths::without_trailing_slash_windows_path( strings::without_suffix_comptime(abs_package_json_path.as_bytes(), b"package.json"), ); // SAFETY: `configure_env_for_run` fully initialized `this_transpiler`. diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index 888310142752..5e8702865282 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -672,10 +672,12 @@ impl PublishCommand { .contains(install::PackageManagerDoStub::RUN_SCRIPTS) { let abs_workspace_path: Box<[u8]> = - strings::without_trailing_slash(strings::without_suffix_comptime( - PackageManager::get().original_package_json_path.as_bytes(), - b"package.json", - )) + path::string_paths::without_trailing_slash_windows_path( + strings::without_suffix_comptime( + PackageManager::get().original_package_json_path.as_bytes(), + b"package.json", + ), + ) .into(); let script_env = context .script_env diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 7ac1c0c03b0e..6fe80b89c369 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -11,6 +11,7 @@ import { normalizeBunSnapshot, readdirSorted, runBunInstall, + substDrive, tempDir, textLockfile, toBeValidBin, @@ -7420,6 +7421,88 @@ describe.concurrent("bun-install", () => { }); }); + // https://github.com/oven-sh/bun/issues/29273 + // + // `C:` is the current directory of drive C, it is not the root `C:\`. `bun install` removed the + // separator from a cwd of `C:\`, and the project directory it then opened did not exist. + describe.skipIf(!isWindows)("project in the root of a drive", () => { + async function run(cwd: string, ...args: string[]) { + await using proc = spawn({ + cmd: [bunExe(), ...args], + cwd, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + const bar = { name: "bar", version: "0.0.2" }; + + it.each(["hoisted", "isolated"] as const)("bun install with the %s linker", async linker => { + await withContext({ linker }, async ctx => { + setContextHandler(ctx, dummyRegistryForContext(ctx, [])); + await writeFile( + join(ctx.package_dir, "package.json"), + JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { bar: "^0" } }), + ); + using drive = substDrive(ctx.package_dir); + + const { stdout, stderr, exitCode } = await run(drive.root, "install"); + expect(stderr).toContain("Saved lockfile"); + expect(stdout).toContain("+ bar@0.0.2"); + expect(exitCode).toBe(0); + expect(await file(join(drive.root, "node_modules", "bar", "package.json")).json()).toEqual(bar); + await access(join(drive.root, "bun.lockb")); + }); + }); + + it("bun install from a directory below a package.json in the root", async () => { + await withContext(defaultOpts, async ctx => { + setContextHandler(ctx, dummyRegistryForContext(ctx, [])); + await writeFile( + join(ctx.package_dir, "package.json"), + JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { bar: "^0" } }), + ); + await mkdir(join(ctx.package_dir, "moo", "src"), { recursive: true }); + using drive = substDrive(ctx.package_dir); + + const { stdout, stderr, exitCode } = await run(join(drive.root, "moo", "src"), "install"); + expect(stderr).toContain("Saved lockfile"); + expect(stdout).toContain("+ bar@0.0.2"); + expect(exitCode).toBe(0); + expect(await file(join(drive.root, "node_modules", "bar", "package.json")).json()).toEqual(bar); + expect(await readdirSorted(join(drive.root, "moo"))).toEqual(["src"]); + }); + }); + + it("bun add and bun remove", async () => { + await withContext(defaultOpts, async ctx => { + setContextHandler(ctx, dummyRegistryForContext(ctx, [])); + await writeFile(join(ctx.package_dir, "package.json"), JSON.stringify({ name: "foo", version: "0.0.1" })); + using drive = substDrive(ctx.package_dir); + + const added = await run(drive.root, "add", "bar"); + expect(added.stderr).toContain("Saved lockfile"); + expect(added.stdout).toContain("installed bar@0.0.2"); + expect(added.exitCode).toBe(0); + expect(await file(join(drive.root, "package.json")).json()).toEqual({ + name: "foo", + version: "0.0.1", + dependencies: { bar: "^0.0.2" }, + }); + expect(await file(join(drive.root, "node_modules", "bar", "package.json")).json()).toEqual(bar); + + const removed = await run(drive.root, "remove", "bar"); + expect(removed.stderr).toContain("package.json has no dependencies! Deleted empty lockfile"); + expect(removed.exitCode).toBe(0); + expect(await file(join(drive.root, "package.json")).json()).toEqual({ name: "foo", version: "0.0.1" }); + }); + }); + }); + // https://github.com/oven-sh/bun/issues/19088 // // Workspace package.jsons are parsed without the root's duplicate check, so a name listed in diff --git a/test/cli/install/bun-link.test.ts b/test/cli/install/bun-link.test.ts index 8a937dad63fd..ccb27eb08a22 100644 --- a/test/cli/install/bun-link.test.ts +++ b/test/cli/install/bun-link.test.ts @@ -7,6 +7,7 @@ import { isWindows, readdirSorted, runBunInstall, + substDrive, tmpdirSync, toBeValidBin, toHaveBins, @@ -471,3 +472,37 @@ it("should link dependency without crashing", async () => { // This should fail with a non-zero exit code. expect(await exited4).toBe(1); }); + +// https://github.com/oven-sh/bun/issues/29273 +// `C:` is the current directory of drive C, it is not the root `C:\`. +it.skipIf(!isWindows)("should link a package in the root of a drive", async () => { + const link_name = basename(link_dir).slice("bun-link.".length) + "-drive-root"; + await writeFile(join(link_dir, "package.json"), JSON.stringify({ name: link_name, version: "0.0.1" })); + await writeFile(join(package_dir, "package.json"), JSON.stringify({ name: "bar", version: "0.0.2" })); + using drive = substDrive(link_dir); + + async function run(cwd: string, ...args: string[]) { + await using proc = spawn({ cmd: [bunExe(), ...args], cwd, stdout: "pipe", stdin: "ignore", stderr: "pipe", env }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + const registered = await run(drive.root, "link"); + expect(registered.stderr).toBe(""); + expect(registered.stdout).toContain(`Success! Registered "${link_name}"`); + expect(registered.exitCode).toBe(0); + + const linked = await run(package_dir, "link", link_name); + expect(linked.stderr).toBe(""); + expect(linked.stdout).toContain(`installed ${link_name}@link:${link_name}`); + expect(linked.exitCode).toBe(0); + expect(await file(join(package_dir, "node_modules", link_name, "package.json")).json()).toEqual({ + name: link_name, + version: "0.0.1", + }); + + const unlinked = await run(drive.root, "unlink"); + expect(unlinked.stderr).toBe(""); + expect(unlinked.stdout).toContain(`success: unlinked package "${link_name}"`); + expect(unlinked.exitCode).toBe(0); +}); diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index 29c0114f4b7a..213bf91d0265 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -3,7 +3,7 @@ import { readTarball } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; import { randomBytes } from "crypto"; import { readdir, rm } from "fs/promises"; -import { bunEnv, bunExe, isLinux, isWindows, normalizeBunSnapshot, runBunInstall, tempDir } from "harness"; +import { bunEnv, bunExe, isLinux, isWindows, normalizeBunSnapshot, runBunInstall, substDrive, tempDir } from "harness"; import { join } from "path"; // Runs `bun pm pack` for the package in `dir`, from `cwd`. @@ -136,6 +136,30 @@ test.concurrent("in subdirectory", async () => { expect(readTarball(join(dir, "pack-from-subdir-7.7.7.tgz"))).toEqual(firstTarball); }); +// https://github.com/oven-sh/bun/issues/29273 +// `C:` is the current directory of drive C, it is not the root `C:\`. +test.concurrent.skipIf(!isWindows)("in the root of a drive", async () => { + using dir = tempDir("pack-drive-root", { + "package.json": JSON.stringify({ + name: "pack-drive-root", + version: "1.2.3", + scripts: { prepack: "touch prepack.txt" }, + }), + "index.js": indexJs, + }); + using drive = substDrive(dir); + + // The script runs in the root, and the root is the directory that is packed. + const { err, exitCode } = await runPack(drive.root); + expect(err).toBe("$ touch prepack.txt"); + expect(exitCode).toBe(0); + expect(tarballEntries(join(drive.root, "pack-drive-root-1.2.3.tgz"))).toEqual([ + "package/package.json", + "package/index.js", + "package/prepack.txt", + ]); +}); + describe.concurrent("package.json names and versions", () => { test("rejects name and version containing parent directory components", async () => { using dir = tempDir("pack-traversal", { diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index c701b322806c..6b97a863a4e6 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -10,6 +10,7 @@ import { isWindows, pack, runBunInstall, + substDrive, tempDir, tmpdirSync, } from "harness"; @@ -1243,6 +1244,31 @@ it("$npm_lifecycle_event is accurate during publish", async () => { expect(exitCode).toBe(0); }); +// https://github.com/oven-sh/bun/issues/29273 +// `C:` is the current directory of drive C, it is not the root `C:\`. +it.skipIf(!isWindows)("publishes a package in the root of a drive", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + await write( + packageJson, + JSON.stringify({ + name: "publish-pkg-drive-root", + version: "1.0.0", + scripts: { prepack: "touch prepack.txt", publish: "touch publish.txt" }, + }), + ); + await write(join(packageDir, "bunfig.toml"), await registry.authBunfig("driveroot")); + await rm(join(registry.packagesPath, "publish-pkg-drive-root"), { recursive: true, force: true }); + using drive = substDrive(packageDir); + + const { out, err, exitCode } = await publish(env, drive.root); + expect(err).toBe("$ touch prepack.txt\n$ touch publish.txt\n"); + expect(out).toContain(" + publish-pkg-drive-root@1.0.0"); + expect(exitCode).toBe(0); + // Each script ran in the root. + expect(existsSync(join(packageDir, "prepack.txt"))).toBe(true); + expect(existsSync(join(packageDir, "publish.txt"))).toBe(true); +}); + describe("readme", () => { // Regression for https://github.com/oven-sh/bun/issues/30255 — `bun publish` // packed the README into the tarball but never populated the version-level diff --git a/test/harness.ts b/test/harness.ts index 085766dc3c00..186394384a40 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -509,6 +509,22 @@ export function tempDir( return new DisposableString(base) as string & DisposableString & AsyncDisposable; } +/** + * Windows only. Makes `dir` the root of a new drive with `subst`, and removes the drive on dispose. + * A test cannot write to the root of a real drive. + */ +export function substDrive(dir: string): Disposable & { root: string } { + for (const letter of "ZYXWVUTSRQPONMLKJIHGFED") { + if (Bun.spawnSync({ cmd: ["subst", `${letter}:`, dir], env: bunEnv }).exitCode === 0) { + return { + root: `${letter}:\\`, + [Symbol.dispose]: () => void Bun.spawnSync({ cmd: ["subst", `${letter}:`, "/d"], env: bunEnv }), + }; + } + } + throw new Error("no free drive letter for subst"); +} + export function tempDirWithFilesAnon(filesOrAbsolutePathToCopyFolderFrom: DirectoryTree | string): string { const base = tmpdirSync(); makeTreeSync(base, filesOrAbsolutePathToCopyFolderFrom); From 246d037fdd2a049fbfc66816f0fa48dce9b7dd6b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:18:44 +0000 Subject: [PATCH 2/3] install: spell the root package.json path one way, keep `C:\` for scripts - `bun add`, `remove`, `update` and `link ` edit the package.json cache entry keyed by `original_package_json_path`. They then install from the entry keyed by a path that updatePackageJSONAndInstall.rs builds. With the project in `/` the first was `/package.json` and the second `//package.json`, so the install did not see the edit. Both paths now come from `push_package_json`. The runtime manager uses it too. - `INIT_CWD` for lifecycle scripts keeps the separator of a drive root. - `bun run