diff --git a/e2e-win/bun.Tests.ps1 b/e2e-win/bun.Tests.ps1 new file mode 100644 index 0000000000..1f3644cb71 --- /dev/null +++ b/e2e-win/bun.Tests.ps1 @@ -0,0 +1,24 @@ + +Describe 'bun' { + It 'installs bun and produces a working bunx alongside bun.exe' { + # The upstream bun-windows-*.zip ships only bun.exe; the bunx + # entry is created post-install (see oven-sh/bun:src/cli/ + # install_completions_command.zig `installBunxSymlinkWindows`). + # Mise mirrors that step so `bunx` works out of the box on Windows. + mise install bun@1.3.13 --force | Out-Null + + $installPath = (mise where bun@1.3.13).Trim() + $binDir = Join-Path $installPath 'bin' + + # bun.exe should always be present. + (Join-Path $binDir 'bun.exe') | Should -Exist + + # A bunx entry — either the hardlinked bunx.exe or the cmd-shim + # fallback — must sit next to bun.exe. + $bunxExe = Join-Path $binDir 'bunx.exe' + $bunxCmd = Join-Path $binDir 'bunx.cmd' + ((Test-Path $bunxExe) -or (Test-Path $bunxCmd)) | Should -BeTrue + + mise x bun@1.3.13 -- bunx --version | Should -BeLike "1.3.13*" + } +} diff --git a/src/plugins/core/bun.rs b/src/plugins/core/bun.rs index e31c4ca055..950ff315c5 100644 --- a/src/plugins/core/bun.rs +++ b/src/plugins/core/bun.rs @@ -81,9 +81,60 @@ impl BunPlugin { file::make_executable(self.bun_bin(tv))?; file::make_symlink(Path::new("./bun"), &tv.install_path().join("bin/bunx"))?; } + #[cfg(windows)] + { + self.install_bunx_windows(tv)?; + } Ok(()) } + /// Create a `bunx` entry next to `bun.exe` on Windows. + /// + /// Upstream `bun-windows-*.zip` ships only `bun.exe`; the `bunx` entry + /// that exists as a symlink in the unix releases is created post-install + /// by the bun PowerShell installer (which invokes `bun completions`, + /// see oven-sh/bun:src/cli/install_completions_command.zig + /// `installBunxSymlinkWindows`). Mirror that step here so users get a + /// working `bunx` after `mise install bun`. + #[cfg(windows)] + fn install_bunx_windows(&self, tv: &ToolVersion) -> Result<()> { + let bin_dir = tv.install_path().join("bin"); + let bun_exe = bin_dir.join("bun.exe"); + let bunx_exe = bin_dir.join("bunx.exe"); + let bunx_cmd = bin_dir.join("bunx.cmd"); + + // Defensive cleanup: `install()` already wipes the entire install + // path before reaching here, but be idempotent for any future caller + // that invokes this directly. `file::remove_all` is a no-op when the + // target is missing and propagates real errors (e.g. permission / + // locked-file failures) so we don't silently leave a stale shim. + file::remove_all(&bunx_exe)?; + file::remove_all(&bunx_cmd)?; + + // Prefer a hardlink (matches upstream): bun inspects argv[0] and + // switches into bunx mode, the same way the unix symlink does. + match std::fs::hard_link(&bun_exe, &bunx_exe) { + Ok(()) => Ok(()), + Err(e) => { + // Hardlinks can fail across volumes or on filesystems that + // disallow them. Fall back to the cmd shim upstream uses. + debug!( + "bun: hardlink {bunx_exe} -> {bun_exe} failed ({e}); writing bunx.cmd shim", + bunx_exe = bunx_exe.display(), + bun_exe = bun_exe.display(), + ); + // Quote the expanded path so spaces in the install dir + // (e.g. `C:\Users\First Last\...`) don't make cmd.exe + // split the command at the first space. Upstream's + // literal omits the quotes — that's a latent bug there + // too, but mise install paths under `%LOCALAPPDATA%` + // commonly contain a user name with spaces, so we cannot + // rely on the upstream form being safe in practice. + file::write(&bunx_cmd, b"@\"%~dp0bun.exe\" x %*\r\n") + } + } + } + fn verify(&self, ctx: &InstallContext, tv: &ToolVersion) -> Result<()> { self.test_bun(ctx, tv) }