diff --git a/src/cli/bunx_command.zig b/src/cli/bunx_command.zig index a89bdf5197..e7cca3368a 100644 --- a/src/cli/bunx_command.zig +++ b/src/cli/bunx_command.zig @@ -267,6 +267,12 @@ pub const BunxCommand = struct { force_using_bun, ); + const ignore_cwd = this_bundler.env.map.get("BUN_WHICH_IGNORE_CWD") orelse ""; + + if (ignore_cwd.len > 0) { + _ = this_bundler.env.map.map.swapRemove("BUN_WHICH_IGNORE_CWD"); + } + var PATH = this_bundler.env.map.get("PATH").?; const display_version = if (update_request.version.literal.isEmpty()) "latest" @@ -288,7 +294,32 @@ pub const BunxCommand = struct { ); }; - const PATH_FOR_BIN_DIRS = PATH; + const PATH_FOR_BIN_DIRS = brk: { + if (ignore_cwd.len == 0) break :brk PATH; + + // Remove the cwd passed through BUN_WHICH_IGNORE_CWD from path. This prevents temp node-gyp script from finding and running itself + var new_path = try std.ArrayList(u8).initCapacity(ctx.allocator, PATH.len); + var path_iter = std.mem.tokenizeScalar(u8, PATH, std.fs.path.delimiter); + if (path_iter.next()) |segment| { + if (!strings.eqlLong(strings.withoutTrailingSlash(segment), strings.withoutTrailingSlash(ignore_cwd), true)) { + try new_path.appendSlice(segment); + } + } + while (path_iter.next()) |segment| { + if (!strings.eqlLong(strings.withoutTrailingSlash(segment), strings.withoutTrailingSlash(ignore_cwd), true)) { + try new_path.append(std.fs.path.delimiter); + try new_path.appendSlice(segment); + } + } + + break :brk new_path.items; + }; + + defer { + if (ignore_cwd.len > 0) { + ctx.allocator.free(PATH_FOR_BIN_DIRS); + } + } if (PATH.len > 0) { PATH = try std.fmt.allocPrint( ctx.allocator, @@ -318,7 +349,7 @@ pub const BunxCommand = struct { destination_ = bun.which( &path_buf, PATH_FOR_BIN_DIRS, - this_bundler.fs.top_level_dir, + if (ignore_cwd.len > 0) "" else this_bundler.fs.top_level_dir, initial_bin_name, ); } @@ -329,7 +360,7 @@ pub const BunxCommand = struct { if (destination_ orelse bun.which( &path_buf, bunx_cache_dir, - this_bundler.fs.top_level_dir, + if (ignore_cwd.len > 0) "" else this_bundler.fs.top_level_dir, absolute_in_cache_dir, )) |destination| { const out = bun.asByteSlice(destination); @@ -355,7 +386,7 @@ pub const BunxCommand = struct { destination_ = bun.which( &path_buf, PATH_FOR_BIN_DIRS, - this_bundler.fs.top_level_dir, + if (ignore_cwd.len > 0) "" else this_bundler.fs.top_level_dir, package_name_for_bin, ); } @@ -363,7 +394,7 @@ pub const BunxCommand = struct { if (destination_ orelse bun.which( &path_buf, bunx_cache_dir, - this_bundler.fs.top_level_dir, + if (ignore_cwd.len > 0) "" else this_bundler.fs.top_level_dir, absolute_in_cache_dir, )) |destination| { const out = bun.asByteSlice(destination); @@ -450,7 +481,7 @@ pub const BunxCommand = struct { if (bun.which( &path_buf, bunx_cache_dir, - this_bundler.fs.top_level_dir, + if (ignore_cwd.len > 0) "" else this_bundler.fs.top_level_dir, absolute_in_cache_dir, )) |destination| { const out = bun.asByteSlice(destination); @@ -473,7 +504,7 @@ pub const BunxCommand = struct { if (bun.which( &path_buf, bunx_cache_dir, - this_bundler.fs.top_level_dir, + if (ignore_cwd.len > 0) "" else this_bundler.fs.top_level_dir, absolute_in_cache_dir, )) |destination| { const out = bun.asByteSlice(destination); diff --git a/src/cli/run_command.zig b/src/cli/run_command.zig index 1b0c03d692..85983f1168 100644 --- a/src/cli/run_command.zig +++ b/src/cli/run_command.zig @@ -459,20 +459,20 @@ pub const RunCommand = struct { this_bundler.configureLinker(); } - const bun_node_dir = switch (@import("builtin").target.os.tag) { + pub const bun_node_dir = switch (@import("builtin").target.os.tag) { // TODO: .windows => "TMPDIR", .macos => "/private/tmp", else => "/tmp", } ++ if (!Environment.isDebug) - "/bun-node-" ++ Environment.git_sha_short + "/bun-node" ++ if (Environment.git_sha_short.len > 0) "-" ++ Environment.git_sha_short else "" else - "/bun-debug-node"; + "/bun-debug-node" ++ (if (Environment.git_sha_short.len > 0) "-" ++ Environment.git_sha_short else ""); var self_exe_bin_path_buf: [bun.MAX_PATH_BYTES + 1]u8 = undefined; - fn createFakeTemporaryNodeExecutable(PATH: *std.ArrayList(u8), optional_bun_path: *string) !void { + pub fn createFakeTemporaryNodeExecutable(PATH: *std.ArrayList(u8), optional_bun_path: *string) !void { // If we are already running as "node", the path should exist if (CLI.pretend_to_be_node) return; @@ -514,7 +514,7 @@ pub const RunCommand = struct { break; } - if (PATH.items.len > 0) { + if (PATH.items.len > 0 and PATH.items[PATH.items.len - 1] != std.fs.path.delimiter) { try PATH.append(std.fs.path.delimiter); } diff --git a/src/env_loader.zig b/src/env_loader.zig index bc2bedbe92..7f74f8999d 100644 --- a/src/env_loader.zig +++ b/src/env_loader.zig @@ -168,6 +168,49 @@ pub const Loader = struct { } return http_proxy; } + + var did_load_ccache_path: bool = false; + + pub fn loadCCachePath(this: *Loader, fs: *Fs.FileSystem) void { + if (did_load_ccache_path) { + return; + } + did_load_ccache_path = true; + loadCCachePathImpl(this, fs) catch {}; + } + + fn loadCCachePathImpl(this: *Loader, fs: *Fs.FileSystem) !void { + + // if they have ccache installed, put it in env variable `CMAKE_CXX_COMPILER_LAUNCHER` so + // cmake can use it to hopefully speed things up + var buf: [bun.MAX_PATH_BYTES]u8 = undefined; + const ccache_path = bun.which( + &buf, + this.map.get("PATH") orelse return, + fs.top_level_dir, + "ccache", + ) orelse ""; + + if (ccache_path.len > 0) { + var cxx_gop = try this.map.getOrPutWithoutValue("CMAKE_CXX_COMPILER_LAUNCHER"); + if (!cxx_gop.found_existing) { + cxx_gop.key_ptr.* = try this.allocator.dupe(u8, cxx_gop.key_ptr.*); + cxx_gop.value_ptr.* = .{ + .value = try this.allocator.dupe(u8, ccache_path), + .conditional = false, + }; + } + var c_gop = try this.map.getOrPutWithoutValue("CMAKE_C_COMPILER_LAUNCHER"); + if (!c_gop.found_existing) { + c_gop.key_ptr.* = try this.allocator.dupe(u8, c_gop.key_ptr.*); + c_gop.value_ptr.* = .{ + .value = try this.allocator.dupe(u8, ccache_path), + .conditional = false, + }; + } + } + } + var node_path_to_use_set_once: []const u8 = ""; pub fn loadNodeJSConfig(this: *Loader, fs: *Fs.FileSystem, override_node: []const u8) !bool { var buf: Fs.PathBuffer = undefined; @@ -179,9 +222,9 @@ pub const Loader = struct { } else { var node = this.getNodePath(fs, &buf) orelse return false; node_path_to_use = try fs.dirname_store.append([]const u8, bun.asByteSlice(node)); - node_path_to_use_set_once = node_path_to_use; } } + node_path_to_use_set_once = node_path_to_use; try this.map.put("NODE", node_path_to_use); try this.map.put("npm_node_execpath", node_path_to_use); return true; diff --git a/src/install/install.zig b/src/install/install.zig index 701926f111..84052e4168 100644 --- a/src/install/install.zig +++ b/src/install/install.zig @@ -1844,6 +1844,7 @@ const Waiter = struct { pub const PackageManager = struct { cache_directory_: ?std.fs.IterableDir = null, temp_dir_: ?std.fs.IterableDir = null, + temp_dir_name: string = "", root_dir: *Fs.FileSystem.DirEntry, allocator: std.mem.Allocator, log: *logger.Log, @@ -1896,10 +1897,9 @@ pub const PackageManager = struct { root_lifecycle_scripts: ?Package.Scripts.List = null, - env_configure: ?struct { - root_dir_info: *DirInfo, - bundler: bundler.Bundler, - } = null, + node_gyp_tempdir_name: string = "", + + env_configure: ?ScriptRunEnvironment = null, lockfile: *Lockfile = undefined, @@ -1928,6 +1928,11 @@ pub const PackageManager = struct { const NetworkTaskQueue = std.HashMapUnmanaged(u64, void, IdentityContext(u64), 80); pub var verbose_install = false; + pub const ScriptRunEnvironment = struct { + root_dir_info: *DirInfo, + bundler: bundler.Bundler, + }; + const PackageDedupeList = std.HashMapUnmanaged( u32, void, @@ -1949,68 +1954,57 @@ pub const PackageManager = struct { return false; } - pub fn configureEnvForScripts(this: *PackageManager, ctx: Command.Context, log_level: Options.LogLevel) !struct { *DirInfo, bundler.Bundler } { - if (this.env_configure) |env_configure| { - return .{ env_configure.root_dir_info, env_configure.bundler }; + pub fn configureEnvForScripts(this: *PackageManager, ctx: Command.Context, log_level: Options.LogLevel) !*bundler.Bundler { + if (this.env_configure) |*env_configure| { + return &env_configure.bundler; } // We need to figure out the PATH and other environment variables // to do that, we re-use the code from bun run // this is expensive, it traverses the entire directory tree going up to the root // so we really only want to do it when strictly necessary - var this_bundler: bundler.Bundler = undefined; - var ORIGINAL_PATH: string = ""; + this.env_configure = .{ + .root_dir_info = undefined, + .bundler = undefined, + }; + var this_bundler: *bundler.Bundler = &this.env_configure.?.bundler; const root_dir_info = try RunCommand.configureEnvForRun( ctx, - &this_bundler, + this_bundler, this.env, log_level != .silent, ); var init_cwd_gop = try this.env.map.getOrPutWithoutValue("INIT_CWD"); if (!init_cwd_gop.found_existing) { + init_cwd_gop.key_ptr.* = try ctx.allocator.dupe(u8, init_cwd_gop.key_ptr.*); init_cwd_gop.value_ptr.* = .{ .value = try ctx.allocator.dupe(u8, FileSystem.instance.top_level_dir), .conditional = false, }; } - { - // if they have ccache installed, put it in env variable `CMAKE_CXX_COMPILER_LAUNCHER` so - // cmake can use it to hopefully speed things up - var buf: [bun.MAX_PATH_BYTES]u8 = undefined; - const ccache_path = bun.which( - &buf, - ORIGINAL_PATH, - FileSystem.instance.top_level_dir, - "ccache", - ) orelse ""; + this.env.loadCCachePath(this_bundler.fs); - if (ccache_path.len > 0) { - var cxx_gop = try this.env.map.getOrPutWithoutValue("CMAKE_CXX_COMPILER_LAUNCHER"); - if (!cxx_gop.found_existing) { - cxx_gop.value_ptr.* = .{ - .value = try this.env.allocator.dupe(u8, "ccache"), - .conditional = false, - }; - } - var c_gop = try this.env.map.getOrPutWithoutValue("CMAKE_C_COMPILER_LAUNCHER"); - if (!c_gop.found_existing) { - c_gop.value_ptr.* = .{ - .value = try this.env.allocator.dupe(u8, "ccache"), - .conditional = false, - }; - } + { + var node_path: [bun.MAX_PATH_BYTES]u8 = undefined; + if (this.env.getNodePath(this_bundler.fs, &node_path)) |node_pathZ| { + _ = try this.env.loadNodeJSConfig(this_bundler.fs, bun.default_allocator.dupe(u8, node_pathZ) catch bun.outOfMemory()); + } else brk: { + var current_path = this.env.get("PATH") orelse ""; + var PATH = try std.ArrayList(u8).initCapacity(bun.default_allocator, current_path.len); + try PATH.appendSlice(current_path); + var bun_path: string = ""; + RunCommand.createFakeTemporaryNodeExecutable(&PATH, &bun_path) catch break :brk; + try this.env.map.put("PATH", PATH.items); + _ = try this.env.loadNodeJSConfig(this_bundler.fs, bun.default_allocator.dupe(u8, RunCommand.bun_node_dir) catch bun.outOfMemory()); } } - this.env_configure = .{ - .root_dir_info = root_dir_info, - .bundler = this_bundler, - }; + this.env_configure.?.root_dir_info = root_dir_info; - return .{ this.env_configure.?.root_dir_info, this.env_configure.?.bundler }; + return this_bundler; } pub fn httpProxy(this: *PackageManager, url: URL) ?URL { @@ -2379,9 +2373,10 @@ pub const PackageManager = struct { var cache_directory = this.getCacheDirectory(); // The chosen tempdir must be on the same filesystem as the cache directory // This makes renameat() work - const default_tempdir = Fs.FileSystem.RealFS.getDefaultTempDir(); + this.temp_dir_name = Fs.FileSystem.RealFS.getDefaultTempDir(); + var tried_dot_tmp = false; - var tempdir: std.fs.IterableDir = std.fs.cwd().makeOpenPathIterable(default_tempdir, .{}) catch brk: { + var tempdir: std.fs.IterableDir = std.fs.cwd().makeOpenPathIterable(this.temp_dir_name, .{}) catch brk: { tried_dot_tmp = true; break :brk cache_directory.dir.makeOpenPathIterable(".tmp", .{}) catch |err| { Output.prettyErrorln("error: bun is unable to access tempdir: {s}", .{@errorName(err)}); @@ -2441,6 +2436,69 @@ pub const PackageManager = struct { return tempdir; } + pub fn ensureTempNodeGypScript(this: *PackageManager) !void { + if (comptime Environment.isWindows) { + @panic("TODO: command prompt version of temp node-gyp script"); + } + + if (this.node_gyp_tempdir_name.len > 0) return; + + const tempdir = this.getTemporaryDirectory(); + var path_buf: [bun.MAX_PATH_BYTES]u8 = undefined; + const node_gyp_tempdir_name = bun.span(try Fs.FileSystem.instance.tmpname("node-gyp", &path_buf, 12345)); + + // used later for adding to path for scripts + this.node_gyp_tempdir_name = try this.allocator.dupe(u8, node_gyp_tempdir_name); + + var node_gyp_tempdir = tempdir.dir.makeOpenPath(this.node_gyp_tempdir_name, .{}) catch |err| { + if (err == error.EEXIST) { + // it should not exist + Output.prettyErrorln("error: node-gyp tempdir already exists", .{}); + Global.crash(); + } + Output.prettyErrorln("error: {s} creating node-gyp tempdir", .{@errorName(err)}); + Global.crash(); + }; + defer node_gyp_tempdir.close(); + + var node_gyp_file = node_gyp_tempdir.createFile("node-gyp", .{ .mode = 0o777 }) catch |err| { + Output.prettyErrorln("error: {s} creating node-gyp tempdir", .{@errorName(err)}); + Global.crash(); + }; + defer node_gyp_file.close(); + + var bytes: string = "#!/usr/bin/env sh\nbun x node-gyp \"$@\""; + var index: usize = 0; + while (index < bytes.len) { + switch (bun.sys.write(bun.toFD(node_gyp_file.handle), bytes[index..])) { + .result => |written| { + index += written; + }, + .err => |err| { + Output.prettyErrorln("error: {s} writing to node-gyp file", .{@tagName(err.getErrno())}); + Global.crash(); + }, + } + } + + // Add our node-gyp tempdir to the path + var existing_path = this.env.get("PATH") orelse ""; + var PATH = try std.ArrayList(u8).initCapacity(bun.default_allocator, existing_path.len + 1 + this.node_gyp_tempdir_name.len); + try PATH.appendSlice(existing_path); + if (existing_path.len > 0 and existing_path[existing_path.len - 1] != std.fs.path.delimiter) + try PATH.append(std.fs.path.delimiter); + try PATH.appendSlice(this.temp_dir_name); + try PATH.append(std.fs.path.sep); + try PATH.appendSlice(this.node_gyp_tempdir_name); + try this.env.map.put("PATH", PATH.items); + + const path_to_ignore = try std.fmt.bufPrint(&path_buf, "{s}" ++ &[_]u8{std.fs.path.sep} ++ "{s}", .{ + strings.withoutTrailingSlash(this.temp_dir_name), + this.node_gyp_tempdir_name, + }); + try this.env.map.put("BUN_WHICH_IGNORE_CWD", try this.allocator.dupe(u8, path_to_ignore)); + } + pub var instance: PackageManager = undefined; pub fn getNetworkTask(this: *PackageManager) *NetworkTask { @@ -9299,11 +9357,57 @@ pub const PackageManager = struct { list: Lockfile.Package.Scripts.List, comptime log_level: PackageManager.Options.LogLevel, ) !void { - const root_dir_info, const this_bundler = try this.configureEnvForScripts(ctx, log_level); - var original_path: string = undefined; - try RunCommand.configurePathForRun(ctx, root_dir_info, &this_bundler, &original_path, list.first().cwd, false); + var uses_node_gyp = false; + var any_scripts = false; + for (list.items) |_item| { + if (_item) |item| { + any_scripts = true; + // to be safe, add the temporary script for any usage + // of the string `node-gyp`. + if (strings.containsComptime(item.script, "node-gyp")) { + uses_node_gyp = true; + break; + } + } + } + if (!any_scripts) { + return; + } + + if (uses_node_gyp) { + try this.ensureTempNodeGypScript(); + } + + const cwd = list.first().cwd; + const this_bundler = try this.configureEnvForScripts(ctx, log_level); + var original_path = this_bundler.env.map.get("PATH") orelse ""; + + var PATH = try std.ArrayList(u8).initCapacity(bun.default_allocator, original_path.len + 1 + "node_modules/.bin".len + cwd.len + 1); + var current_dir: ?*DirInfo = this_bundler.resolver.readDirInfo(cwd) catch null; + std.debug.assert(current_dir != null); + while (current_dir) |dir| { + if (PATH.items.len > 0 and PATH.items[PATH.items.len - 1] != std.fs.path.delimiter) { + try PATH.append(std.fs.path.delimiter); + } + try PATH.appendSlice(strings.withoutTrailingSlash(dir.abs_path)); + try PATH.append(std.fs.path.sep); + try PATH.appendSlice(this.options.bin_path); + current_dir = dir.getParent(); + } + + if (original_path.len > 0) { + if (PATH.items.len > 0 and PATH.items[PATH.items.len - 1] != std.fs.path.delimiter) { + try PATH.append(std.fs.path.delimiter); + } + + try PATH.appendSlice(original_path); + } + + this_bundler.env.map.put("PATH", PATH.items) catch unreachable; + const envp = try this_bundler.env.map.createNullDelimitedEnvMap(this.allocator); try this_bundler.env.map.put("PATH", original_path); + PATH.deinit(); try LifecycleScriptSubprocess.spawnPackageScripts(this, list, envp); } diff --git a/test/cli/install/registry/bun-install-registry.test.ts b/test/cli/install/registry/bun-install-registry.test.ts index ba8b962f75..8e1765c9c3 100644 --- a/test/cli/install/registry/bun-install-registry.test.ts +++ b/test/cli/install/registry/bun-install-registry.test.ts @@ -2527,6 +2527,36 @@ for (const forceWaiterThread of [false, true]) { ]); expect(await exited).toBe(0); }); + test("node-gyp should always be available for lifecycle scripts", async () => { + await writeFile( + join(packageDir, "package.json"), + JSON.stringify({ + name: "foo", + version: "1.0.0", + scripts: { + install: "node-gyp --version", + }, + }), + ); + + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stdout: null, + stdin: "pipe", + stderr: "pipe", + env, + }); + + const err = await new Response(stderr).text(); + expect(err).not.toContain("Saved lockfile"); + expect(err).not.toContain("not found"); + expect(err).not.toContain("error:"); + const out = await new Response(stdout).text(); + + // if node-gyp isn't available, it would return a non-zero exit code + expect(await exited).toBe(0); + }); }); }