diff --git a/src/install/bin.rs b/src/install/bin.rs index d28f5941115b..aa3c910a5b9a 100644 --- a/src/install/bin.rs +++ b/src/install/bin.rs @@ -903,6 +903,11 @@ impl<'a> Linker<'a> { // Skip if the target does not exist. This is important because placing a dangling // shim in path might break a postinstall if !sys::exists(abs_target) { + // A project build script can still create the target. A bunx tree has + // no build step, and a stale entry there hides the missing file from bunx. + if bun_core::env_var::feature_flag::BUN_INTERNAL_BUNX_INSTALL.get() == Some(true) { + Self::unlink_bin_or_shim(abs_dest); + } self.skipped_due_to_missing_bin = true; return; } diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index 42af649bb36a..52c6a0e0c79c 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -1013,6 +1013,9 @@ impl BunxCommand { let passthrough: &[Box<[u8]>] = opts.passthrough_list.as_slice(); let mut do_cache_bust = update_request.version.tag == VersionTag::DistTag; + // Untrusted tree only: `--force` re-links every cached package, which is + // slow on Windows (#41211); `--no-cache` alone keeps #4981 fixed. + let mut force_reinstall = false; let look_for_existing_bin = update_request.version.literal.is_empty() || update_request.version.tag != VersionTag::DistTag; @@ -1075,6 +1078,7 @@ impl BunxCommand { BStr::new(out) ); do_cache_bust = true; + force_reinstall = true; break 'try_run_existing; } let is_stale: bool = 'is_stale: { @@ -1120,7 +1124,10 @@ impl BunxCommand { } #[cfg(not(windows))] { - let stat = match bun_sys::stat(destination) { + // `lstat`: every install re-creates this link, like the + // Windows shim. Its target can be a hardlink of the + // install cache entry, whose mtime no install renews. + let stat = match bun_sys::lstat(destination) { Ok(s) => s, Err(_) => break 'is_stale true, }; @@ -1246,6 +1253,7 @@ impl BunxCommand { BStr::new(out) ); do_cache_bust = true; + force_reinstall = true; break 'try_run_existing; } let stored = fs.dirname_store.append_slice(out)?; @@ -1329,96 +1337,121 @@ impl BunxCommand { install_param.as_slice(), b"--no-summary", ]; - let mut args: BoundedArray<&[u8], 8> = - BoundedArray::from_slice(&install_args).expect("unreachable"); // upper bound is known + env_loader + .map + .put(b"BUN_INTERNAL_BUNX_INSTALL", b"true") + .expect("oom"); - if do_cache_bust { - // disable the manifest cache when a tag is specified - // so that @latest is fetched from the registry - args.append(b"--no-cache").expect("unreachable"); // upper bound is known + // `envp` owns a copy. The tool run below must not inherit the marker: + // an install it spawns in the user's project would read it too. + let envp = env_loader.map.create_null_delimited_env_map()?; + env_loader.map.remove(b"BUN_INTERNAL_BUNX_INSTALL"); - // forcefully re-install packages in this mode too - args.append(b"--force").expect("unreachable"); // upper bound is known - } + // Two passes at most: an install without `--force` keeps a package whose + // version matches even when files are missing, so a second pass forces it. + loop { + let mut args: BoundedArray<&[u8], 8> = + BoundedArray::from_slice(&install_args).expect("unreachable"); // upper bound is known - if opts.verbose_install { - args.append(b"--verbose").expect("unreachable"); // upper bound is known - } + if do_cache_bust { + // disable the manifest cache when a tag is specified + // so that @latest is fetched from the registry + args.append(b"--no-cache").expect("unreachable"); // upper bound is known + } - if opts.silent_install { - args.append(b"--silent").expect("unreachable"); // upper bound is known - } + if force_reinstall { + args.append(b"--force").expect("unreachable"); // upper bound is known + } - let argv_to_use = args.slice(); + if opts.verbose_install { + args.append(b"--verbose").expect("unreachable"); // upper bound is known + } - bun_output::scoped_log!( - bunx, - "installing package: {}", - bun_core::fmt::fmt_slice(argv_to_use, " "), - ); - env_loader - .map - .put(b"BUN_INTERNAL_BUNX_INSTALL", b"true") - .expect("oom"); + if opts.silent_install { + args.append(b"--silent").expect("unreachable"); // upper bound is known + } - let envp = env_loader.map.create_null_delimited_env_map()?; + let argv_to_use = args.slice(); - let spawn_result = match proc_sync::spawn(&proc_sync::Options { - argv: argv_to_use.iter().map(|s| Box::<[u8]>::from(*s)).collect(), + bun_output::scoped_log!( + bunx, + "installing package: {}", + bun_core::fmt::fmt_slice(argv_to_use, " "), + ); - envp: Some(envp.as_ptr().cast::<*const ::core::ffi::c_char>()), + let spawn_result = match proc_sync::spawn(&proc_sync::Options { + argv: argv_to_use.iter().map(|s| Box::<[u8]>::from(*s)).collect(), - cwd: Box::<[u8]>::from(bunx_cache_dir), - stderr: proc_sync::SyncStdio::Inherit, - stdout: proc_sync::SyncStdio::Inherit, - stdin: proc_sync::SyncStdio::Inherit, + envp: Some(envp.as_ptr().cast::<*const ::core::ffi::c_char>()), - #[cfg(windows)] - windows: proc_sync::WindowsOptions { - loop_: bun_jsc::EventLoopHandle::init_mini( - bun_event_loop::MiniEventLoop::init_global( - // `this_transpiler.env` is the process-lifetime loader - // singleton populated during transpiler init. - // - // Aliasing: do NOT call `this_transpiler.env_mut()` here — - // `env_loader` (line 594) is still live and is used again below at the - // post-install `Run::run_binary` calls. A second `env_mut()` would - // `unsafe { &mut *self.env }` from the raw field, popping `env_loader`'s - // Unique tag under Stacked Borrows (UB on later use). Instead reborrow - // *through* `env_loader` so the new `&mut` is a child of its tag; the - // child is consumed by `init_global` (converted to `NonNull`) before - // `env_loader` is touched again. - // SAFETY: `env_loader` is a valid `&'static mut Loader`; this is a - // stacked reborrow, not a sibling alias. - Some(unsafe { &mut *(env_loader as *mut _) }), - None, + cwd: Box::<[u8]>::from(bunx_cache_dir), + stderr: proc_sync::SyncStdio::Inherit, + stdout: proc_sync::SyncStdio::Inherit, + stdin: proc_sync::SyncStdio::Inherit, + + #[cfg(windows)] + windows: proc_sync::WindowsOptions { + loop_: bun_jsc::EventLoopHandle::init_mini( + bun_event_loop::MiniEventLoop::init_global( + // `this_transpiler.env` is the process-lifetime loader + // singleton populated during transpiler init. + // + // Aliasing: do NOT call `this_transpiler.env_mut()` here — + // `env_loader` (line 594) is still live and is used again below at the + // post-install `Run::run_binary` calls. A second `env_mut()` would + // `unsafe { &mut *self.env }` from the raw field, popping `env_loader`'s + // Unique tag under Stacked Borrows (UB on later use). Instead reborrow + // *through* `env_loader` so the new `&mut` is a child of its tag; the + // child is consumed by `init_global` (converted to `NonNull`) before + // `env_loader` is touched again. + // SAFETY: `env_loader` is a valid `&'static mut Loader`; this is a + // stacked reborrow, not a sibling alias. + Some(unsafe { &mut *(env_loader as *mut _) }), + None, + ), ), - ), + ..Default::default() + }, ..Default::default() - }, - ..Default::default() - }) { - Err(err) => { - bun_core::pretty_errorln!( - "error: bunx failed to install {} due to error {}", - BStr::new(&install_param), - err.name(), - ); - Global::exit(1); - } - Ok(maybe) => match maybe { - bun_sys::Result::Err(_err) => { + }) { + Err(err) => { + bun_core::pretty_errorln!( + "error: bunx failed to install {} due to error {}", + BStr::new(&install_param), + err.name(), + ); Global::exit(1); } - bun_sys::Result::Ok(result) => result, - }, - }; + Ok(maybe) => match maybe { + bun_sys::Result::Err(_err) => { + Global::exit(1); + } + bun_sys::Result::Ok(result) => result, + }, + }; + + match &spawn_result.status { + SpawnStatus::Exited(exited) => { + // Any non-zero byte (incl. RT signals >31) is a valid signal. + // `signal_code()` would drop RT signals, so check the raw byte directly. + if exited.signal != 0 { + if bun_core::env_var::feature_flag::BUN_INTERNAL_SUPPRESS_CRASH_IN_BUN_RUN + .get() + .unwrap_or(false) + { + bun_crash_handler::suppress_reporting(); + } - match &spawn_result.status { - SpawnStatus::Exited(exited) => { - // Any non-zero byte (incl. RT signals >31) is a valid signal. - // `signal_code()` would drop RT signals, so check the raw byte directly. - if exited.signal != 0 { + Global::raise_ignoring_panic_handler_raw(core::ffi::c_int::from( + exited.signal, + )); + } + + if exited.code != 0 { + Global::exit(exited.code as u32); + } + } + SpawnStatus::Signaled(sig) => { if bun_core::env_var::feature_flag::BUN_INTERNAL_SUPPRESS_CRASH_IN_BUN_RUN .get() .unwrap_or(false) @@ -1426,154 +1459,154 @@ impl BunxCommand { bun_crash_handler::suppress_reporting(); } - Global::raise_ignoring_panic_handler_raw(core::ffi::c_int::from(exited.signal)); + // RT signals (>31) are valid payloads; forward the + // raw byte instead of lossy `signal_code()` so this arm always + // diverges with the *actual* signal. + Global::raise_ignoring_panic_handler_raw(core::ffi::c_int::from(*sig)); } - - if exited.code != 0 { - Global::exit(exited.code as u32); - } - } - SpawnStatus::Signaled(sig) => { - if bun_core::env_var::feature_flag::BUN_INTERNAL_SUPPRESS_CRASH_IN_BUN_RUN - .get() - .unwrap_or(false) - { - bun_crash_handler::suppress_reporting(); + SpawnStatus::Err(err) => { + bun_core::pretty_errorln!( + "error: bunx failed to install {} due to error:\n{}", + BStr::new(&install_param), + err, + ); + Global::exit(1); } - - // RT signals (>31) are valid payloads; forward the - // raw byte instead of lossy `signal_code()` so this arm always - // diverges with the *actual* signal. - Global::raise_ignoring_panic_handler_raw(core::ffi::c_int::from(*sig)); - } - SpawnStatus::Err(err) => { - bun_core::pretty_errorln!( - "error: bunx failed to install {} due to error:\n{}", - BStr::new(&install_param), - err, - ); - Global::exit(1); + _ => {} } - _ => {} - } - - absolute_in_cache_dir = { - let mut cursor: &mut [u8] = &mut absolute_in_cache_dir_buf[..]; - write!( - cursor, - "{cache}{sep}node_modules{sep}.bin{sep}{bin}{exe}", - cache = BStr::new(bunx_cache_dir), - sep = bun_paths::SEP as char, - bin = BStr::new(initial_bin_name), - exe = EXE_SUFFIX, - ) - .expect("unreachable"); - let written = buf_total - cursor.len(); - // SAFETY: `written` bytes initialized above - unsafe { core::slice::from_raw_parts(absolute_in_cache_dir_buf.as_ptr(), written) } - }; - // Similar to "npx": - // - // 1. Try the bin in the global cache - // Do not try $PATH because we already checked it above if we should - if let Some(destination) = bun_which::which( - &mut path_buf, - bunx_cache_dir, - if !ignore_cwd.is_empty() { - b"".as_slice() - } else { - top_level_dir - }, - absolute_in_cache_dir, - ) { - let out: &[u8] = destination.as_bytes(); - // The install we just ran should have created this symlink as the - // current user, but the cache lives in a world-writable temp dir; an - // attacker can race the install and plant a uid-mismatched entry. - // Bail out to the generic error rather than execute it. - if Self::is_trusted_cached_binary(destination, uid) { - let stored = fs.dirname_store.append_slice(out)?; - Run::run_binary( - ctx, - stored, - destination, - top_level_dir, - env_loader, - passthrough, - None, - )?; - // run_binary is noreturn - } else { - bun_output::scoped_log!( - bunx, - "refusing untrusted cached binary: {}", - BStr::new(out) - ); - } - } + absolute_in_cache_dir = { + let mut cursor: &mut [u8] = &mut absolute_in_cache_dir_buf[..]; + write!( + cursor, + "{cache}{sep}node_modules{sep}.bin{sep}{bin}{exe}", + cache = BStr::new(bunx_cache_dir), + sep = bun_paths::SEP as char, + bin = BStr::new(initial_bin_name), + exe = EXE_SUFFIX, + ) + .expect("unreachable"); + let written = buf_total - cursor.len(); + // SAFETY: `written` bytes initialized above + unsafe { core::slice::from_raw_parts(absolute_in_cache_dir_buf.as_ptr(), written) } + }; - // 2. The "bin" is possibly not the same as the package name, so we load the package.json to figure out what "bin" to use - // BUT: Skip this if --package was used, as the user explicitly specified the binary name - if opts.binary_name.is_none() { - if let Ok(package_name_for_bin) = Self::get_bin_name_from_temp_directory( - this_transpiler, + // Similar to "npx": + // + // 1. Try the bin in the global cache + // Do not try $PATH because we already checked it above if we should + if let Some(destination) = bun_which::which( + &mut path_buf, bunx_cache_dir, - result_package_name, - false, + if !ignore_cwd.is_empty() { + b"".as_slice() + } else { + top_level_dir + }, + absolute_in_cache_dir, ) { - if !strings::eql_long(&package_name_for_bin, initial_bin_name, true) { - absolute_in_cache_dir = { - let mut cursor: &mut [u8] = &mut absolute_in_cache_dir_buf[..]; - write!( - cursor, - "{}/node_modules/.bin/{}{}", - BStr::new(bunx_cache_dir), - BStr::new(&package_name_for_bin), - EXE_SUFFIX, - ) - .expect("unreachable"); - let written = buf_total - cursor.len(); - // SAFETY: `written` bytes initialized above - unsafe { - core::slice::from_raw_parts(absolute_in_cache_dir_buf.as_ptr(), written) - } - }; + let out: &[u8] = destination.as_bytes(); + // The install we just ran should have created this symlink as the + // current user, but the cache lives in a world-writable temp dir; an + // attacker can race the install and plant a uid-mismatched entry. + // Bail out to the generic error rather than execute it. + if Self::is_trusted_cached_binary(destination, uid) { + let stored = fs.dirname_store.append_slice(out)?; + Run::run_binary( + ctx, + stored, + destination, + top_level_dir, + env_loader, + passthrough, + None, + )?; + // run_binary is noreturn + } else { + bun_output::scoped_log!( + bunx, + "refusing untrusted cached binary: {}", + BStr::new(out) + ); + } + } - if let Some(destination) = bun_which::which( - &mut path_buf, - bunx_cache_dir, - if !ignore_cwd.is_empty() { - b"".as_slice() - } else { - top_level_dir - }, - absolute_in_cache_dir, - ) { - let out: &[u8] = destination.as_bytes(); - // Same TOCTOU hardening as the post-install probe above. - if Self::is_trusted_cached_binary(destination, uid) { - let stored = fs.dirname_store.append_slice(out)?; - Run::run_binary( - ctx, - stored, - destination, - top_level_dir, - env_loader, - passthrough, - None, - )?; - // run_binary is noreturn - } else { - bun_output::scoped_log!( - bunx, - "refusing untrusted cached binary: {}", - BStr::new(out) - ); + // 2. The "bin" is possibly not the same as the package name, so we load the package.json to figure out what "bin" to use + // BUT: Skip this if --package was used, as the user explicitly specified the binary name + if opts.binary_name.is_none() { + match Self::get_bin_name_from_temp_directory( + this_transpiler, + bunx_cache_dir, + result_package_name, + false, + ) { + // The package declares no bin. A forced install cannot add one. + Err(crate::Error::NoBinFound) => break, + Err(_) => {} + Ok(package_name_for_bin) => { + if !strings::eql_long(&package_name_for_bin, initial_bin_name, true) { + absolute_in_cache_dir = { + let mut cursor: &mut [u8] = &mut absolute_in_cache_dir_buf[..]; + write!( + cursor, + "{}/node_modules/.bin/{}{}", + BStr::new(bunx_cache_dir), + BStr::new(&package_name_for_bin), + EXE_SUFFIX, + ) + .expect("unreachable"); + let written = buf_total - cursor.len(); + // SAFETY: `written` bytes initialized above + unsafe { + core::slice::from_raw_parts( + absolute_in_cache_dir_buf.as_ptr(), + written, + ) + } + }; + + if let Some(destination) = bun_which::which( + &mut path_buf, + bunx_cache_dir, + if !ignore_cwd.is_empty() { + b"".as_slice() + } else { + top_level_dir + }, + absolute_in_cache_dir, + ) { + let out: &[u8] = destination.as_bytes(); + // Same TOCTOU hardening as the post-install probe above. + if Self::is_trusted_cached_binary(destination, uid) { + let stored = fs.dirname_store.append_slice(out)?; + Run::run_binary( + ctx, + stored, + destination, + top_level_dir, + env_loader, + passthrough, + None, + )?; + // run_binary is noreturn + } else { + bun_output::scoped_log!( + bunx, + "refusing untrusted cached binary: {}", + BStr::new(out) + ); + } + } } } } } + + if force_reinstall { + break; + } + bun_output::scoped_log!(bunx, "no bin after install, installing again with --force"); + force_reinstall = true; } if let (Some(_), Some(binary_name)) = (opts.specified_package, opts.binary_name) { diff --git a/test/cli/install/bunx.test.ts b/test/cli/install/bunx.test.ts index ec0a9fb5a508..41c355cc7bf9 100644 --- a/test/cli/install/bunx.test.ts +++ b/test/cli/install/bunx.test.ts @@ -1,8 +1,17 @@ import { spawn } from "bun"; import { afterAll, beforeAll, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test"; import { mkdir, rm, writeFile } from "fs/promises"; -import { bunEnv, bunExe, isWindows, readdirSorted, tmpdirSync } from "harness"; -import { chmodSync, copyFileSync, readdirSync, symlinkSync } from "node:fs"; +import { bunEnv, bunExe, isWindows, readdirSorted, tempDir, tmpdirSync } from "harness"; +import { + chmodSync, + copyFileSync, + existsSync, + lutimesSync, + readdirSync, + rmSync, + symlinkSync, + utimesSync, +} from "node:fs"; import { tmpdir } from "os"; import { delimiter, join, resolve } from "path"; import { dummyAfterAll, dummyBeforeAll, dummyBeforeEach, dummyRegistry, getPort, setHandler } from "./dummy.registry"; @@ -840,6 +849,233 @@ console.log("EXECUTED: multi-tool-alt (alternate binary)"); }); }); +// The `bun add` that bunx spawns into `/bunx--@` passes +// `--force` only for an untrusted tree or when the first install left no bin. +describe("bunx cache", () => { + const cli = (label: string) => + `#!/usr/bin/env node\nconsole.log(${JSON.stringify(label)} + " with " + require("dep"));\n`; + + type Versions = Record; files: Record }>; + const registryPackages: Record = { + "tool": { + "1.0.0": { + manifest: { bin: { tool: "cli.js" }, dependencies: { dep: "1.0.0" } }, + files: { "cli.js": cli("tool 1.0.0") }, + }, + "1.1.0": { + manifest: { bin: { tool: "cli.js" }, dependencies: { dep: "1.0.0" } }, + files: { "cli.js": cli("tool 1.1.0") }, + }, + }, + "no-bin-file": { + "1.0.0": { manifest: { bin: { "no-bin-file": "cli.js" } }, files: { "index.js": "" } }, + }, + "no-bin": { + "1.0.0": { manifest: {}, files: { "index.js": "" } }, + }, + "env-probe": { + "1.0.0": { + manifest: { bin: { "env-probe": "cli.js" } }, + files: { "cli.js": `#!/usr/bin/env node\nconsole.log(String(process.env.BUN_INTERNAL_BUNX_INSTALL));\n` }, + }, + }, + "dep": { + "1.0.0": { manifest: { main: "index.js" }, files: { "index.js": `module.exports = "dep 1.0.0";\n` } }, + }, + }; + + // `/-.tgz`, each packed from `/-/package/`. + let staging: ReturnType; + let tgzDir: string; + + beforeAll(async () => { + const staged: Record = {}; + for (const [name, versions] of Object.entries(registryPackages)) { + for (const [version, { manifest, files }] of Object.entries(versions)) { + staged[`${name}-${version}/package/package.json`] = JSON.stringify({ name, version, ...manifest }); + for (const [path, content] of Object.entries(files)) staged[`${name}-${version}/package/${path}`] = content; + } + } + staging = tempDir("bunx-registry", staged); + tgzDir = String(staging); + for (const [name, versions] of Object.entries(registryPackages)) { + for (const version of Object.keys(versions)) { + await Bun.$`tar -czf ${join(tgzDir, `${name}-${version}.tgz`)} package` + .cwd(join(tgzDir, `${name}-${version}`)) + .quiet(); + } + } + }); + + afterAll(() => staging[Symbol.dispose]()); + + // Each test gets a registry, a temp directory and an install cache of its + // own, so the tests can run at the same time. + function fixture() { + const { x_dir, env } = setup(); + const requests: string[] = []; + const latest: Record = { tool: "1.0.0" }; + const server = Bun.serve({ + port: 0, + fetch(req) { + const path = decodeURIComponent(new URL(req.url).pathname).slice(1); + requests.push(path); + if (path.endsWith(".tgz")) return new Response(Bun.file(join(tgzDir, path))); + const versions = registryPackages[path]; + if (!versions) return new Response("not found", { status: 404 }); + return Response.json({ + name: path, + "dist-tags": { latest: latest[path] ?? Object.keys(versions).at(-1) }, + versions: Object.fromEntries( + Object.entries(versions).map(([version, { manifest }]) => [ + version, + { name: path, version, ...manifest, dist: { tarball: `${server.url}${path}-${version}.tgz` } }, + ]), + ), + }); + }, + }); + + return { + latest, + // Runs `bunx`. `requests` holds what this run asked the registry for. + async run(...args: string[]) { + const first = requests.length; + await using proc = spawn({ + cmd: [bunExe(), "x", ...args], + cwd: x_dir, + env: { ...env, npm_config_registry: server.url.href }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode, requests: requests.slice(first) }; + }, + // The directory bunx keeps `spec` in. Found by its name, so the test + // does not depend on how each OS derives the uid. + tree(spec: string) { + const match = readdirSync(env.TMPDIR).filter(d => d.startsWith("bunx-") && d.endsWith(`-${spec}`)); + expect(match).toHaveLength(1); + return join(env.TMPDIR, match[0]); + }, + [Symbol.dispose]: () => void server.stop(true), + }; + } + + // An install with `--force` removes each package directory and links it + // again, so a file the test adds to a package tells the two apart. + async function plantMarkers(tree: string, ...packages: string[]) { + const markers = packages.map(pkg => join(tree, "node_modules", pkg, "MARKER")); + for (const marker of markers) await writeFile(marker, ""); + return () => markers.map(marker => existsSync(marker)); + } + + it.concurrent("a warm dist-tag run asks the registry and links nothing again", async () => { + using bunx = fixture(); + expect(await bunx.run("tool@latest")).toMatchObject({ stdout: "tool 1.0.0 with dep 1.0.0\n", exitCode: 0 }); + + const markers = await plantMarkers(bunx.tree("tool@latest"), "tool", "dep"); + + expect(await bunx.run("tool@latest")).toMatchObject({ + stdout: "tool 1.0.0 with dep 1.0.0\n", + exitCode: 0, + requests: ["tool"], + }); + expect(markers()).toEqual([true, true]); + }); + + it.concurrent("a dist-tag run follows the tag when it moves", async () => { + using bunx = fixture(); + expect(await bunx.run("tool@latest")).toMatchObject({ stdout: "tool 1.0.0 with dep 1.0.0\n", exitCode: 0 }); + + bunx.latest.tool = "1.1.0"; + expect(await bunx.run("tool@latest")).toMatchObject({ stdout: "tool 1.1.0 with dep 1.0.0\n", exitCode: 0 }); + + bunx.latest.tool = "1.0.0"; + expect(await bunx.run("tool@latest")).toMatchObject({ stdout: "tool 1.0.0 with dep 1.0.0\n", exitCode: 0 }); + }); + + it.concurrent("a tree older than 24 hours asks the registry once and links nothing again", async () => { + using bunx = fixture(); + expect(await bunx.run("tool")).toMatchObject({ stdout: "tool 1.0.0 with dep 1.0.0\n", exitCode: 0 }); + expect(await bunx.run("tool")).toMatchObject({ stdout: "tool 1.0.0 with dep 1.0.0\n", requests: [] }); + + // The age of a tree is the age of its `.bin` entry: a link on POSIX, a + // shim on Windows. `lutimes` sets the time of the link, not of its target. + const tree = bunx.tree("tool@latest"); + const old = new Date(Date.now() - 25 * 60 * 60 * 1000); + for (const entry of readdirSync(join(tree, "node_modules", ".bin"))) { + (isWindows ? utimesSync : lutimesSync)(join(tree, "node_modules", ".bin", entry), old, old); + } + const markers = await plantMarkers(tree, "tool", "dep"); + + expect(await bunx.run("tool")).toMatchObject({ + stdout: "tool 1.0.0 with dep 1.0.0\n", + exitCode: 0, + requests: ["tool"], + }); + expect(markers()).toEqual([true, true]); + + // That install made the `.bin` entry again, so the tree is new again. + expect(await bunx.run("tool")).toMatchObject({ stdout: "tool 1.0.0 with dep 1.0.0\n", requests: [] }); + }); + + it.concurrent("a tree an earlier install left without its bin file is linked again", async () => { + using bunx = fixture(); + expect(await bunx.run("tool@latest")).toMatchObject({ stdout: "tool 1.0.0 with dep 1.0.0\n", exitCode: 0 }); + + // The package keeps its package.json, so an install without `--force` + // skips it and removes the `.bin` entry that points at the missing file. + // A second install with `--force` puts the file back. + const tree = bunx.tree("tool@latest"); + rmSync(join(tree, "node_modules", "tool", "cli.js")); + const markers = await plantMarkers(tree, "tool", "dep"); + + expect(await bunx.run("tool@latest")).toMatchObject({ + stdout: "tool 1.0.0 with dep 1.0.0\n", + exitCode: 0, + requests: ["tool", "tool"], + }); + expect(markers()).toEqual([false, false]); + }); + + it.concurrent("a package that does not ship the file its bin names fails after one forced install", async () => { + using bunx = fixture(); + const first = await bunx.run("no-bin-file@latest"); + expect(first.stderr).toContain("error: could not determine executable to run for package no-bin-file"); + expect(first.exitCode).toBe(1); + + const markers = await plantMarkers(bunx.tree("no-bin-file@latest"), "no-bin-file"); + + const second = await bunx.run("no-bin-file@latest"); + expect(second.stderr).toContain("error: could not determine executable to run for package no-bin-file"); + expect(second).toMatchObject({ exitCode: 1, requests: ["no-bin-file", "no-bin-file"] }); + expect(markers()).toEqual([false]); + }); + + // The install bunx spawns carries the marker. The tool, and any install the + // tool spawns in the user's project, must not. + it.concurrent("the tool does not inherit the bunx install marker", async () => { + using bunx = fixture(); + expect(await bunx.run("env-probe@latest")).toMatchObject({ stdout: "undefined\n", exitCode: 0 }); + }); + + it.concurrent("a package that declares no bin fails after one install", async () => { + using bunx = fixture(); + const first = await bunx.run("no-bin@latest"); + expect(first.stderr).toContain("error: could not determine executable to run for package no-bin"); + expect(first.exitCode).toBe(1); + + const markers = await plantMarkers(bunx.tree("no-bin@latest"), "no-bin"); + + const second = await bunx.run("no-bin@latest"); + expect(second.stderr).toContain("error: could not determine executable to run for package no-bin"); + expect(second).toMatchObject({ exitCode: 1, requests: ["no-bin"] }); + expect(markers()).toEqual([true]); + }); +}); + // Regression: `bunx @scope/name` guesses the bin name as `name` (the // unscoped portion), then searched the full system $PATH with it. When // `name` happened to match an unrelated system binary — e.g.