diff --git a/.vscode/settings.json b/.vscode/settings.json index 41736b2877..717cb88fd5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -168,5 +168,5 @@ "WebKit/WebInspectorUI": true, }, "git.detectSubmodules": false, - // "bun.test.customScript": "./build/debug/bun-debug test" + "bun.test.customScript": "./build/debug/bun-debug test" } diff --git a/cmake/sources/ZigSources.txt b/cmake/sources/ZigSources.txt index a4c663983d..d15eaa2a6d 100644 --- a/cmake/sources/ZigSources.txt +++ b/cmake/sources/ZigSources.txt @@ -350,6 +350,7 @@ src/cli/install_command.zig src/cli/install_completions_command.zig src/cli/link_command.zig src/cli/list-of-yarn-commands.zig +src/cli/node_command.zig src/cli/outdated_command.zig src/cli/pack_command.zig src/cli/package_manager_command.zig diff --git a/src/cli.zig b/src/cli.zig index 81d7793e2e..86650847b1 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -91,6 +91,7 @@ pub const PackCommand = @import("./cli/pack_command.zig").PackCommand; pub const AuditCommand = @import("./cli/audit_command.zig").AuditCommand; pub const InitCommand = @import("./cli/init_command.zig").InitCommand; pub const WhyCommand = @import("./cli/why_command.zig").WhyCommand; +pub const NodeCommand = @import("./cli/node_command.zig").NodeCommand; pub const Arguments = @import("./cli/Arguments.zig"); @@ -612,6 +613,8 @@ pub const Command = struct { "x", "repl", "info", + "why", + "node", }; const reject_list = default_completions_list ++ [_]string{ @@ -803,6 +806,13 @@ pub const Command = struct { try TestCommand.exec(ctx); return; }, + .NodeCommand => { + if (comptime bun.fast_debug_build_mode and bun.fast_debug_build_cmd != .NodeCommand) unreachable; + const ctx = try Command.init(allocator, log, .NodeCommand); + + try NodeCommand.exec(ctx); + return; + }, .GetCompletionsCommand => { if (comptime bun.fast_debug_build_mode and bun.fast_debug_build_cmd != .GetCompletionsCommand) unreachable; try @"bun getcompletes"(allocator, log); @@ -920,6 +930,7 @@ pub const Command = struct { InstallCommand, InstallCompletionsCommand, LinkCommand, + NodeCommand, PackageManagerCommand, RemoveCommand, RunCommand, @@ -957,6 +968,7 @@ pub const Command = struct { .InstallCommand => 'i', .InstallCompletionsCommand => 'C', .LinkCommand => 'l', + .NodeCommand => 'N', .PackageManagerCommand => 'P', .RemoveCommand => 'R', .RunCommand => 'r', @@ -1340,6 +1352,7 @@ pub const Command = struct { .AutoCommand = true, .RunCommand = true, .RunAsNodeCommand = true, + .NodeCommand = true, .OutdatedCommand = true, .UpdateInteractiveCommand = true, .PublishCommand = true, @@ -1380,6 +1393,7 @@ pub const Command = struct { .RemoveCommand = false, .UnlinkCommand = false, .UpdateCommand = false, + .NodeCommand = false, }); }; diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig index c7d41bfe0f..e26e6345e6 100644 --- a/src/cli/Arguments.zig +++ b/src/cli/Arguments.zig @@ -330,6 +330,7 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C .stop_after_positional_at = switch (cmd) { .RunCommand => 2, .AutoCommand, .RunAsNodeCommand => 1, + .NodeCommand => 0, else => 0, }, }) catch |err| { diff --git a/src/cli/node_command.zig b/src/cli/node_command.zig new file mode 100644 index 0000000000..b8aec4685a --- /dev/null +++ b/src/cli/node_command.zig @@ -0,0 +1,805 @@ +const bun = @import("bun"); +const Command = @import("../cli.zig").Command; +const std = @import("std"); +const Output = bun.Output; +const Global = bun.Global; +const strings = bun.strings; +const Env = bun.Environment; +const Fs = bun.fs; +const which = @import("../which.zig").which; +const Options = @import("../install/PackageManager/PackageManagerOptions.zig"); +const Bin = @import("../install/bin.zig"); +const HTTP = bun.http; +const AsyncHTTP = HTTP.AsyncHTTP; +const URL = bun.URL; +const MutableString = bun.MutableString; +const Archiver = bun.libarchive.Archiver; +const Zlib = @import("../zlib.zig"); + +pub const NodeCommand = struct { + pub fn exec(ctx: Command.Context) !void { + const relevant_args = bun.argv[2..]; + + if (relevant_args.len == 0) { + printHelp(); + return; + } + + const first_arg = relevant_args[0]; + + if (strings.eqlComptime(first_arg, "bun")) { + try handleNodeAlias(ctx, "bun"); + return; + } + + if (looksLikeVersion(first_arg)) { + if (relevant_args.len == 1) { + try installNodeVersion(ctx, first_arg, true); + } else { + try runWithNodeVersion(ctx, first_arg, relevant_args[1..]); + } + } else { + try runWithDefaultNode(ctx, relevant_args); + } + } + + fn printHelp() void { + Output.prettyln("bun node v" ++ Global.package_json_version_with_sha ++ "", .{}); + + Output.prettyln( + \\Install & manage Node.js versions or configure node to use Bun instead. + \\ + \\Examples: + \\ $ bun node latest Install latest Node.js and set as default + \\ $ bun node lts Install latest LTS Node.js and set as default + \\ $ bun node 24 Install latest Node.js v24.x and set as default + \\ $ bun node foo.js Run foo.js with default Node.js + \\ $ bun node 24.0.0 foo.js Run foo.js with Node.js v24.0.0 + \\ $ bun node bun Make 'node' command run Bun instead + \\ + \\Note: Latest version information is cached for 24 hours. + \\ + , .{}); + } + + fn looksLikeVersion(arg: []const u8) bool { + if (arg.len == 0) return false; + + if (strings.eqlComptime(arg, "latest") or + strings.eqlComptime(arg, "lts") or + strings.eqlComptime(arg, "current")) + { + return true; + } + + if (arg[0] != 'v' and !std.ascii.isDigit(arg[0])) return false; + + const start: usize = if (arg[0] == 'v') 1 else 0; + for (arg[start..]) |c| { + if (!std.ascii.isDigit(c) and c != '.' and c != '-') return false; + } + + return true; + } + + fn getNodeCacheDir(allocator: std.mem.Allocator) ![]const u8 { + var global_dir = try Options.openGlobalDir(""); + defer global_dir.close(); + var path_buf: bun.PathBuffer = undefined; + const path = try bun.getFdPath(bun.FD.fromStdDir(global_dir), &path_buf); + const result = try std.fs.path.join(allocator, &.{ path, "node" }); + return result; + } + + fn getNodeBinDir(allocator: std.mem.Allocator) ![]const u8 { + if (bun.getenvZ("BUN_INSTALL_BIN")) |bin_dir| { + return try allocator.dupe(u8, bin_dir); + } + + const install_dir = bun.getenvZ("BUN_INSTALL") orelse brk: { + const home = bun.getenvZ("HOME") orelse bun.getenvZ("USERPROFILE") orelse { + Output.prettyErrorln("error: unable to find home directory", .{}); + Global.crash(); + }; + break :brk try std.fs.path.join(allocator, &.{ home, ".bun" }); + }; + + if (bun.getenvZ("BUN_INSTALL") != null) { + return try std.fs.path.join(allocator, &.{ install_dir, "bin" }); + } else { + defer allocator.free(install_dir); + return try std.fs.path.join(allocator, &.{ install_dir, "bin" }); + } + } + + fn normalizeVersion(version: []const u8) []const u8 { + if (version.len > 0 and version[0] == 'v') { + return version[1..]; + } + return version; + } + + fn resolveVersion(allocator: std.mem.Allocator, version_spec: []const u8) ![]const u8 { + const normalized = normalizeVersion(version_spec); + + if (strings.eqlComptime(normalized, "latest")) { + return try fetchNodeVersion(allocator, .latest, null); + } else if (strings.eqlComptime(normalized, "lts")) { + return try fetchNodeVersion(allocator, .lts, null); + } else if (strings.eqlComptime(normalized, "current")) { + return try fetchNodeVersion(allocator, .latest, null); + } + + if (strings.indexOf(normalized, ".")) |_| { + return try allocator.dupe(u8, normalized); + } + + return try fetchNodeVersion(allocator, .major, normalized); + } + + fn getCachedVersionInfo(allocator: std.mem.Allocator) !?[]const u8 { + const cache_dir = try getNodeCacheDir(allocator); + defer allocator.free(cache_dir); + + const cache_file = try std.fs.path.join(allocator, &.{ cache_dir, ".version-cache" }); + defer allocator.free(cache_file); + + var file = std.fs.openFileAbsolute(cache_file, .{}) catch return null; + defer file.close(); + + const stat = try file.stat(); + const now = std.time.timestamp(); + const age = now - @divFloor(stat.mtime, std.time.ns_per_s); + + if (age > 24 * 60 * 60) { + return null; + } + + const content = try file.readToEndAlloc(allocator, 1024 * 1024); + return content; + } + + fn saveCachedVersionInfo(allocator: std.mem.Allocator, data: []const u8) !void { + const cache_dir = try getNodeCacheDir(allocator); + defer allocator.free(cache_dir); + + std.fs.makeDirAbsolute(cache_dir) catch |err| { + if (err != error.PathAlreadyExists) return err; + }; + + const cache_file = try std.fs.path.join(allocator, &.{ cache_dir, ".version-cache" }); + defer allocator.free(cache_file); + + var file = try std.fs.createFileAbsolute(cache_file, .{}); + defer file.close(); + + try file.writeAll(data); + } + + fn fetchNodeVersion(allocator: std.mem.Allocator, filter: VersionFilter, major_version: ?[]const u8) ![]const u8 { + if (try getCachedVersionInfo(allocator)) |cached| { + defer allocator.free(cached); + return try parseVersionFromCache(allocator, cached, filter, major_version); + } + + const version_data = try fetchNodeVersionsFromAPI(allocator); + defer allocator.free(version_data); + + saveCachedVersionInfo(allocator, version_data) catch {}; + + return try parseVersionFromCache(allocator, version_data, filter, major_version); + } + + fn fetchNodeVersionsFromAPI(allocator: std.mem.Allocator) ![]const u8 { + const url = URL.parse("https://nodejs.org/dist/index.json"); + var response_buffer = try MutableString.init(allocator, 0); + defer response_buffer.deinit(); + + var req = AsyncHTTP.initSync(allocator, .GET, url, .{}, "", &response_buffer, "", null, null, .follow); + + const response = req.sendSync() catch |err| { + Output.prettyErrorln("error: Failed to fetch Node.js versions from API: {}", .{err}); + Global.exit(1); + }; + + if (response.status_code != 200) { + Output.prettyErrorln("error: Failed to fetch Node.js versions: HTTP {d}", .{response.status_code}); + Global.exit(1); + } + + return try allocator.dupe(u8, response_buffer.list.items); + } + + const VersionFilter = enum { + latest, + lts, + major, + }; + + fn parseVersionFromCache(allocator: std.mem.Allocator, json_data: []const u8, filter: VersionFilter, major_version: ?[]const u8) ![]const u8 { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_data, .{}); + defer parsed.deinit(); + + const array = switch (parsed.value) { + .array => |arr| arr, + else => { + Output.prettyErrorln("error: Invalid Node.js version data format", .{}); + Global.exit(1); + }, + }; + + for (array.items) |item| { + const obj = switch (item) { + .object => |o| o, + else => continue, + }; + + const version_value = obj.get("version") orelse continue; + const version_str = switch (version_value) { + .string => |s| s, + else => continue, + }; + + var version = version_str; + if (version.len > 0 and version[0] == 'v') { + version = version[1..]; + } + + switch (filter) { + .latest => { + return try allocator.dupe(u8, version); + }, + .lts => { + if (obj.get("lts")) |lts_val| { + switch (lts_val) { + .string => return try allocator.dupe(u8, version), + else => {}, + } + } + }, + .major => { + if (strings.indexOf(version, ".")) |dot_idx| { + const major = version[0..dot_idx]; + if (strings.eql(major, major_version.?)) { + return try allocator.dupe(u8, version); + } + } + }, + } + } + + switch (filter) { + .latest => { + Output.prettyErrorln("error: Could not determine latest Node.js version", .{}); + Global.exit(1); + }, + .lts => { + Output.prettyErrorln("error: Could not find LTS version in Node.js version data", .{}); + Global.exit(1); + }, + .major => { + Output.prettyErrorln("error: Node.js version {s} not found", .{major_version.?}); + Global.exit(1); + }, + } + } + + const node_platform = blk: { + if (Env.isMac) { + if (Env.isAarch64) { + break :blk "darwin-arm64"; + } else { + break :blk "darwin-x64"; + } + } else if (Env.isLinux) { + if (Env.isAarch64) { + break :blk "linux-arm64"; + } else { + break :blk "linux-x64"; + } + } else if (Env.isWindows) { + if (Env.isAarch64) { + break :blk "win-arm64"; + } else { + break :blk "win-x64"; + } + } + break :blk "unknown"; + }; + + const node_binary_name = if (Env.isWindows) "node.exe" else "node"; + const node_archive_ext = if (Env.isWindows) "zip" else "tar.gz"; + const node_archive_url_fmt = "https://nodejs.org/dist/v{s}/node-v{s}-" ++ node_platform ++ "." ++ node_archive_ext; + + fn downloadNode(allocator: std.mem.Allocator, version: []const u8, dest_dir: []const u8) !void { + const url_str = try std.fmt.allocPrint(allocator, node_archive_url_fmt, .{ version, version }); + defer allocator.free(url_str); + + Output.prettyln("Downloading Node.js v{s}", .{version}); + Output.flush(); + + try downloadNodeInternal(allocator, url_str, dest_dir, version, false); + } + + fn downloadNodeSilent(allocator: std.mem.Allocator, version: []const u8, dest_dir: []const u8) !void { + const url_str = try std.fmt.allocPrint(allocator, node_archive_url_fmt, .{ version, version }); + defer allocator.free(url_str); + + if (Output.enable_ansi_colors_stderr) { + Output.prettyError("Downloading Node.js v{s}", .{version}); + Output.flush(); + } + + try downloadNodeInternal(allocator, url_str, dest_dir, version, true); + + if (Output.enable_ansi_colors_stderr) { + Output.prettyError("\r\x1b[K", .{}); + Output.flush(); + } + } + + fn downloadNodeInternal(allocator: std.mem.Allocator, url_str: []const u8, dest_dir: []const u8, version: []const u8, is_quiet: bool) !void { + const parent_dir = std.fs.path.dirname(dest_dir); + if (parent_dir) |parent| { + std.fs.cwd().makePath(parent) catch {}; + } + std.fs.makeDirAbsolute(dest_dir) catch |err| { + if (err != error.PathAlreadyExists) return err; + }; + + const temp_archive = try std.fmt.allocPrint(allocator, "{s}.download." ++ node_archive_ext, .{dest_dir}); + defer allocator.free(temp_archive); + defer std.fs.deleteFileAbsolute(temp_archive) catch {}; + + const url = URL.parse(url_str); + var response_buffer = try MutableString.init(allocator, 0); + defer response_buffer.deinit(); + + var req = AsyncHTTP.initSync(allocator, .GET, url, .{}, "", &response_buffer, "", null, null, .follow); + + const response = req.sendSync() catch |err| { + if (is_quiet) Output.prettyErrorln("", .{}); + Output.prettyErrorln("error: Failed to download Node.js v{s}: {}", .{ version, err }); + Global.exit(1); + }; + + if (response.status_code != 200) { + if (is_quiet) Output.prettyErrorln("", .{}); + Output.prettyErrorln("error: Failed to download Node.js v{s}: HTTP {d}", .{ version, response.status_code }); + Global.exit(1); + } + + var file = try std.fs.createFileAbsolute(temp_archive, .{}); + defer file.close(); + + try file.writeAll(response_buffer.list.items); + + try extractNodeArchive(allocator, temp_archive, dest_dir, version); + } + + fn extractNodeArchive(allocator: std.mem.Allocator, archive_path: []const u8, dest_dir: []const u8, version: []const u8) !void { + // Read the archive file into memory + const archive_data = std.fs.cwd().readFileAlloc(allocator, archive_path, std.math.maxInt(usize)) catch |err| { + Output.prettyErrorln("error: Failed to read archive {s}: {}", .{ archive_path, err }); + Global.exit(1); + }; + defer allocator.free(archive_data); + + // Open the destination directory + var dest_dir_handle = std.fs.openDirAbsolute(dest_dir, .{}) catch |err| { + Output.prettyErrorln("error: Failed to open destination directory {s}: {}", .{ dest_dir, err }); + Global.exit(1); + }; + defer dest_dir_handle.close(); + + // Use Bun's built-in libarchive-based extraction + _ = Archiver.extractToDir( + archive_data, + dest_dir_handle, + null, + void, + {}, + .{ + .depth_to_skip = 0, + .close_handles = false, + }, + ) catch |err| { + Output.prettyErrorln("error: Failed to extract archive {s}: {}", .{ archive_path, err }); + Global.exit(1); + }; + + const extracted_dir = try std.fmt.allocPrint(allocator, "{s}/node-v{s}-" ++ node_platform, .{ dest_dir, version }); + defer allocator.free(extracted_dir); + + const src_binary = try std.fs.path.join(allocator, &.{ extracted_dir, "bin", node_binary_name }); + defer allocator.free(src_binary); + + const dest_binary = try std.fs.path.join(allocator, &.{ dest_dir, node_binary_name }); + defer allocator.free(dest_binary); + + try std.fs.copyFileAbsolute(src_binary, dest_binary, .{}); + + if (Env.isPosix) { + var dest_file = try std.fs.openFileAbsolute(dest_binary, .{ .mode = .read_write }); + defer dest_file.close(); + try dest_file.chmod(0o755); + } + + std.fs.deleteTreeAbsolute(extracted_dir) catch {}; + } + + fn installNodeVersion(ctx: Command.Context, version_spec: []const u8, set_as_default: bool) !void { + const allocator = ctx.allocator; + const version = try resolveVersion(allocator, version_spec); + defer allocator.free(version); + + const cache_dir = try getNodeCacheDir(allocator); + defer allocator.free(cache_dir); + + const version_dir = try std.fmt.allocPrint(allocator, "{s}/node-{s}", .{ cache_dir, version }); + defer allocator.free(version_dir); + + const version_binary = try std.fmt.allocPrint(allocator, "{s}/" ++ node_binary_name, .{version_dir}); + defer allocator.free(version_binary); + + if (std.fs.accessAbsolute(version_binary, .{})) |_| { + if (set_as_default) { + Output.prettyln(" Node.js v{s} is already installed", .{version}); + } + } else |_| { + if (set_as_default) { + try downloadNode(allocator, version, version_dir); + Output.prettyln(" Successfully installed Node.js v{s}", .{version}); + } else { + try downloadNodeSilent(allocator, version, version_dir); + } + } + + if (set_as_default) { + try updateGlobalNodeSymlink(ctx, version); + Output.prettyln(" Set Node.js v{s} as default", .{version}); + + try checkPathPriority(allocator); + } + } + + fn updateGlobalNodeSymlink(ctx: Command.Context, version: []const u8) !void { + const allocator = ctx.allocator; + + const cache_dir = try getNodeCacheDir(allocator); + defer allocator.free(cache_dir); + + const bin_dir = try getNodeBinDir(allocator); + defer allocator.free(bin_dir); + + std.fs.makeDirAbsolute(bin_dir) catch |err| { + if (err != error.PathAlreadyExists) return err; + }; + + const version_binary = try std.fmt.allocPrint(allocator, "{s}/node-{s}/" ++ node_binary_name, .{ cache_dir, version }); + defer allocator.free(version_binary); + + std.fs.accessAbsolute(version_binary, .{}) catch { + const version_dir = try std.fmt.allocPrint(allocator, "{s}/node-{s}", .{ cache_dir, version }); + defer allocator.free(version_dir); + + Output.prettyErrorln("warn: Node.js v{s} binary not found, downloading...", .{version}); + try downloadNode(allocator, version, version_dir); + }; + + const global_binary = try std.fs.path.join(allocator, &.{ bin_dir, node_binary_name }); + defer allocator.free(global_binary); + + std.fs.deleteFileAbsolute(global_binary) catch {}; + + if (Env.isWindows) { + std.fs.createFileAbsolute(global_binary, .{}) catch {}; + std.fs.deleteFileAbsolute(global_binary) catch {}; + + std.fs.Dir.hardLink( + std.fs.cwd(), + version_binary, + std.fs.cwd(), + global_binary, + ) catch |err| { + if (err == error.NotSameFileSystem) { + try std.fs.copyFileAbsolute(version_binary, global_binary, .{}); + } else { + return err; + } + }; + } else { + std.posix.link(version_binary, global_binary) catch |err| { + if (err == error.CrossDevice) { + try std.fs.copyFileAbsolute(version_binary, global_binary, .{}); + } else { + return err; + } + }; + } + } + + fn handleNodeAlias(ctx: Command.Context, target: []const u8) !void { + const allocator = ctx.allocator; + + if (!strings.eqlComptime(target, "bun")) { + Output.prettyErrorln("error: only 'bun' is supported as alias target", .{}); + Global.exit(1); + } + + const bin_dir = try getNodeBinDir(allocator); + defer allocator.free(bin_dir); + + std.fs.makeDirAbsolute(bin_dir) catch |err| { + if (err != error.PathAlreadyExists) return err; + }; + + const global_binary = try std.fs.path.join(allocator, &.{ bin_dir, node_binary_name }); + defer allocator.free(global_binary); + + const bun_exe = bun.selfExePath() catch { + Output.prettyErrorln("error: failed to determine bun executable path", .{}); + Global.crash(); + }; + + std.fs.deleteFileAbsolute(global_binary) catch {}; + + if (Env.isWindows) { + std.fs.Dir.hardLink( + std.fs.cwd(), + bun_exe, + std.fs.cwd(), + global_binary, + ) catch |err| { + if (err == error.NotSameFileSystem) { + try std.fs.copyFileAbsolute(bun_exe, global_binary, .{}); + } else { + return err; + } + }; + } else { + std.posix.link(bun_exe, global_binary) catch |err| { + if (err == error.CrossDevice) { + try std.fs.copyFileAbsolute(bun_exe, global_binary, .{}); + } else { + return err; + } + }; + } + + Output.prettyln(" Successfully aliased 'node' to Bun", .{}); + Output.prettyln("The 'node' command will now run Bun", .{}); + + try checkPathPriority(allocator); + } + + fn runWithNodeVersion(ctx: Command.Context, version_spec: []const u8, args: []const []const u8) !void { + const allocator = ctx.allocator; + const version = try resolveVersion(allocator, version_spec); + defer allocator.free(version); + + const cache_dir = try getNodeCacheDir(allocator); + defer allocator.free(cache_dir); + + const version_binary = try std.fmt.allocPrint(allocator, "{s}/node-{s}/" ++ node_binary_name, .{ cache_dir, version }); + defer allocator.free(version_binary); + + if (std.fs.accessAbsolute(version_binary, .{})) |_| { + try runNode(allocator, version_binary, args); + } else |_| { + try installNodeVersion(ctx, version_spec, false); + try runNode(allocator, version_binary, args); + } + } + + fn runWithDefaultNode(ctx: Command.Context, args: []const []const u8) !void { + const allocator = ctx.allocator; + + const bin_dir = try getNodeBinDir(allocator); + defer allocator.free(bin_dir); + + const node_symlink = try std.fs.path.join(allocator, &.{ bin_dir, node_binary_name }); + defer allocator.free(node_symlink); + + if (std.fs.accessAbsolute(node_symlink, .{})) |_| { + try runNode(allocator, node_symlink, args); + return; + } else |_| {} + + var path_buf2: bun.PathBuffer = undefined; + const path_env2 = bun.getenvZ("PATH") orelse ""; + var cwd_buf: bun.PathBuffer = undefined; + const cwd_tmp = bun.getcwd(&cwd_buf) catch ""; + + if (which(&path_buf2, path_env2, cwd_tmp, "node")) |node_path| { + try runNode(allocator, node_path, args); + } else { + Output.prettyErrorln("error: Node.js not found", .{}); + Output.prettyln("Run 'bun node lts' to install Node.js", .{}); + Global.exit(1); + } + } + + fn runNode(allocator: std.mem.Allocator, node_path: []const u8, args: []const []const u8) !void { + var argv = try allocator.alloc([]const u8, args.len + 1); + defer allocator.free(argv); + + argv[0] = node_path; + for (args, 1..) |arg, i| { + argv[i] = arg; + } + + const bin_dir = try getNodeBinDir(allocator); + defer allocator.free(bin_dir); + + const current_path = bun.getenvZ("PATH") orelse ""; + const new_path = try std.fmt.allocPrint(allocator, "{s}{c}{s}", .{ bin_dir, if (Env.isWindows) ';' else ':', current_path }); + defer allocator.free(new_path); + + var env_map = try std.process.getEnvMap(allocator); + defer env_map.deinit(); + try env_map.put("PATH", new_path); + + const envp_count = env_map.count(); + const envp_buf = try allocator.allocSentinel(?[*:0]const u8, envp_count, null); + { + var it = env_map.iterator(); + var i: usize = 0; + while (it.next()) |pair| : (i += 1) { + const env_buf = try allocator.allocSentinel(u8, pair.key_ptr.len + pair.value_ptr.len + 1, 0); + bun.copy(u8, env_buf, pair.key_ptr.*); + env_buf[pair.key_ptr.len] = '='; + bun.copy(u8, env_buf[pair.key_ptr.len + 1 ..], pair.value_ptr.*); + envp_buf[i] = env_buf.ptr; + } + } + + const node_pathZ = try allocator.dupeZ(u8, node_path); + defer allocator.free(node_pathZ); + + const spawn_result = bun.spawnSync(&.{ + .argv = argv, + .argv0 = node_pathZ, + .envp = envp_buf, + .cwd = try bun.getcwdAlloc(allocator), + .stderr = .inherit, + .stdout = .inherit, + .stdin = .inherit, + .use_execve_on_macos = true, + .windows = if (Env.isWindows) .{ + .loop = bun.JSC.EventLoopHandle.init(bun.JSC.MiniEventLoop.initGlobal(null)), + .hide_window = false, + } else {}, + }) catch |err| { + Output.prettyErrorln("error: Failed to run Node.js: {}", .{err}); + Global.exit(1); + }; + + switch (spawn_result) { + .result => |result| { + switch (result.status) { + .exited => |exit| { + Global.exit(exit.code); + }, + .signaled => |signal| { + Output.prettyErrorln("error: Node.js terminated by signal: {}", .{signal}); + Global.exit(1); + }, + .err => |err| { + Output.prettyErrorln("error: Failed to run Node.js: {}", .{err}); + Global.exit(1); + }, + .running => { + Global.exit(1); + }, + } + }, + .err => |err| { + Output.prettyErrorln("error: Failed to spawn Node.js: {}", .{err.toSystemError()}); + Global.exit(1); + }, + } + } + + fn checkPathPriority(allocator: std.mem.Allocator) !void { + const bin_dir = try getNodeBinDir(allocator); + defer allocator.free(bin_dir); + + var path_buf: bun.PathBuffer = undefined; + const path_env = bun.getenvZ("PATH") orelse ""; + var cwd_buf: bun.PathBuffer = undefined; + const cwd = bun.getcwd(&cwd_buf) catch ""; + + if (which(&path_buf, path_env, cwd, "node")) |node_path| { + const node_dir = std.fs.path.dirname(node_path) orelse ""; + + if (!strings.eql(node_dir, bin_dir)) { + const inner_path_env = bun.getenvZ("PATH") orelse ""; + var path_entries = strings.split(inner_path_env, if (Env.isWindows) ";" else ":"); + + var found_bun_dir = false; + var found_other_dir = false; + + while (path_entries.next()) |entry| { + if (strings.eql(entry, bin_dir)) { + found_bun_dir = true; + if (found_other_dir) { + if (Env.isWindows) { + const msg = + \\ + \\⚠️ Warning: The 'node' command may not use the Bun-managed version + \\ Found 'node' at: {s} + \\ Bun's bin directory ({s}) appears after another 'node' in PATH + \\ + \\ To fix this, add Bun's bin directory earlier in your PATH: + \\ 1. Open System Properties → Advanced → Environment Variables + \\ 2. Edit the "Path" variable in System or User variables + \\ 3. Move "{s}" before other directories containing 'node' + \\ 4. Click OK and restart your terminal + \\ + \\ Or run this in PowerShell as Administrator: + \\ [Environment]::SetEnvironmentVariable("Path", "{s};" + $env:Path, [System.EnvironmentVariableTarget]::User) + \\ + ; + Output.prettyln(msg, .{ node_path, bin_dir, bin_dir, bin_dir }); + } else { + const msg = + \\ + \\⚠️ Warning: The 'node' command may not use the Bun-managed version + \\ Found 'node' at: {s} + \\ Bun's bin directory ({s}) appears after another 'node' in PATH + \\ + \\ To fix this, add the following to the end of your shell configuration: + \\ export PATH="{s}:$PATH" + \\ + ; + Output.prettyln(msg, .{ node_path, bin_dir, bin_dir }); + } + } + break; + } else if (entry.len > 0) { + const test_node = try std.fs.path.join(allocator, &.{ entry, "node" }); + defer allocator.free(test_node); + if (std.fs.accessAbsolute(test_node, .{})) |_| { + found_other_dir = true; + } else |_| {} + } + } + + if (!found_bun_dir) { + if (Env.isWindows) { + const msg = + \\ + \\ ⚠️ Warning: Bun's bin directory is not in PATH + \\ + \\ The 'node' command will not be available globally + \\ + \\ To fix this: + \\ 1. Open System Properties → Advanced → Environment Variables + \\ 2. Edit the "Path" variable in System or User variables + \\ 3. Add "{s}" to the list (and ensure it's before other directories containing 'node') + \\ 4. Click OK and restart your terminal + \\ + \\ Or run this in PowerShell as Administrator: + \\ [Environment]::SetEnvironmentVariable("Path", $env:Path + ";{s}", [System.EnvironmentVariableTarget]::User) + \\ + ; + Output.prettyln(msg, .{ bin_dir, bin_dir }); + } else { + const msg = + \\ + \\ ⚠️ Warning: Bun's bin directory is not in PATH + \\ + \\ The 'node' command will not be available globally + \\ + \\ To fix this, add the following to the end of your shell configuration: + \\ export PATH="{s}:$PATH" + \\ + ; + Output.prettyln(msg, .{bin_dir}); + } + } + } + } + } +}; diff --git a/test/cli/node-command.test.ts b/test/cli/node-command.test.ts new file mode 100644 index 0000000000..5f9f5926cf --- /dev/null +++ b/test/cli/node-command.test.ts @@ -0,0 +1,343 @@ +import { test, expect, describe, beforeAll, setDefaultTimeout } from "bun:test"; +import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { join } from "path"; +import { existsSync, mkdirSync, writeFileSync, chmodSync, rmSync } from "fs"; + +// if BUN_DEBUG_QUIET_LOGS is set, we'll wait longer for tests to complete +if (process.env.BUN_DEBUG_QUIET_LOGS) { + setDefaultTimeout(100000); +} + +describe("bun node", () => { + let sharedDir: string; + let sharedEnv: any; + let sharedBinDir: string; + + beforeAll(() => { + const testDir = tempDirWithFiles("node-test-shared", {}); + const bunInstallDir = join(testDir, ".bun"); + const binDir = join(bunInstallDir, "bin"); + mkdirSync(binDir, { recursive: true }); + + sharedDir = testDir; + sharedBinDir = binDir; + sharedEnv = { + ...bunEnv, + BUN_INSTALL: bunInstallDir, + BUN_INSTALL_BIN: binDir, + PATH: `${binDir}:${process.env.PATH || ""}`, + HOME: testDir, + }; + }); + + test("shows help when no arguments", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "node"], + env: bunEnv, + stdout: "pipe", + }); + + const output = await new Response(proc.stdout).text(); + expect(await proc.exited).toBe(0); + expect(output).toInclude("Examples:"); + expect(output).toInclude("$ bun node lts"); + expect(output).toInclude("$ bun node bun"); + }); + + describe("version management", () => { + test("installs and runs Node.js", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "node", "24"], + env: sharedEnv, + cwd: sharedDir, + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = await new Response(proc.stdout).text(); + expect(await proc.exited).toBe(0); + expect(stdout).toInclude("Successfully installed Node.js v24"); + + writeFileSync(join(sharedDir, "test.js"), "console.log(process.version);"); + + await using runProc = Bun.spawn({ + cmd: [bunExe(), "node", "test.js"], + env: sharedEnv, + cwd: sharedDir, + stdout: "pipe", + }); + + const runOutput = await new Response(runProc.stdout).text(); + expect(await runProc.exited).toBe(0); + expect(runOutput).toMatch(/v24\.\d+\.\d+/); + }); + + test("handles already installed version", async () => { + await using _ = Bun.spawn({ + cmd: [bunExe(), "node", "24"], + env: sharedEnv, + cwd: sharedDir, + stdout: "pipe", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "node", "24"], + env: sharedEnv, + cwd: sharedDir, + stdout: "pipe", + }); + + const output = await new Response(proc.stdout).text(); + expect(await proc.exited).toBe(0); + expect(output).toMatch(/Set Node\.js v\d+\.\d+\.\d+ as default/); + }); + }); + + describe("script execution", () => { + test("runs scripts with arguments", async () => { + writeFileSync(join(sharedDir, "args.js"), `console.log('Args:', process.argv.slice(2).join(' '));`); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "node", "args.js", "arg1", "arg2", "--flag"], + env: sharedEnv, + cwd: sharedDir, + stdout: "pipe", + }); + + const stdout = await new Response(proc.stdout).text(); + expect(await proc.exited).toBe(0); + expect(stdout).toInclude("Args: arg1 arg2 --flag"); + }); + + test("runs with specific version", async () => { + writeFileSync(join(sharedDir, "version.js"), "console.log(process.version);"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "node", "24", "version.js"], + env: sharedEnv, + cwd: sharedDir, + stdout: "pipe", + }); + + const stdout = await new Response(proc.stdout).text(); + expect(stdout).toMatch(/v24\.\d+\.\d+/); + }); + + test("handles node flags", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "node", "--print", "'hello'"], + env: sharedEnv, + cwd: sharedDir, + stdout: "pipe", + }); + + const stdout = await new Response(proc.stdout).text(); + expect(await proc.exited).toBe(0); + expect(stdout.trim()).toBe("hello"); + }); + }); + + describe("bun alias", () => { + test("creates node -> bun alias", async () => { + const testEnv = (() => { + const testDir = tempDirWithFiles("node-alias", {}); + const bunInstallDir = join(testDir, ".bun"); + const binDir = join(bunInstallDir, "bin"); + mkdirSync(binDir, { recursive: true }); + + return { + dir: testDir, + env: { + ...bunEnv, + BUN_INSTALL: bunInstallDir, + BUN_INSTALL_BIN: binDir, + PATH: `${binDir}:${process.env.PATH || ""}`, + HOME: testDir, + }, + binDir, + }; + })(); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "node", "bun"], + env: testEnv.env, + stdout: "pipe", + }); + + const stdout = await new Response(proc.stdout).text(); + expect(await proc.exited).toBe(0); + expect(stdout).toInclude("Successfully aliased 'node' to Bun"); + + const nodePath = join(testEnv.binDir, process.platform === "win32" ? "node.exe" : "node"); + expect(existsSync(nodePath)).toBe(true); + + await using runProc = Bun.spawn({ + cmd: ["node", "--print", "process.versions.bun"], + env: testEnv.env, + cwd: testEnv.dir, + stdout: "pipe", + }); + const runOutput = await new Response(runProc.stdout).text(); + expect(runOutput.trim()).toBe(Bun.version); + }); + }); + + describe("PATH warnings", () => { + test("warns when bun bin dir comes after another node", async () => { + const testEnv = (() => { + const testDir = tempDirWithFiles("node-path-warn", {}); + const bunInstallDir = join(testDir, ".bun"); + const binDir = join(bunInstallDir, "bin"); + const otherDir = join(testDir, "other-bin"); + + mkdirSync(binDir, { recursive: true }); + mkdirSync(otherDir); + + if (process.platform !== "win32") { + writeFileSync(join(otherDir, "node"), "#!/bin/bash\necho 'other node'"); + chmodSync(join(otherDir, "node"), 0o755); + } else { + writeFileSync(join(otherDir, "node.cmd"), "@echo off\necho 'other node'"); + } + + return { + dir: testDir, + env: { + ...bunEnv, + BUN_INSTALL: bunInstallDir, + BUN_INSTALL_BIN: binDir, + PATH: `${otherDir}:${binDir}:${process.env.PATH || ""}`, + HOME: testDir, + }, + }; + })(); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "node", "24"], + env: testEnv.env, + cwd: testEnv.dir, + stdout: "pipe", + }); + await proc.exited; + + const output = await new Response(proc.stdout).text(); + if (process.platform !== "win32") { + expect(output).toInclude("Warning:"); + expect(output).toInclude("appears after another 'node' in PATH"); + } + }); + }); + + describe("error handling", () => { + test("handles invalid version", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "node", "99"], + env: sharedEnv, + cwd: sharedDir, + stdout: "pipe", + stderr: "pipe", + }); + + const stderr = await new Response(proc.stderr).text(); + expect(await proc.exited).toBe(1); + expect(stderr).toInclude("error"); + }); + + test("handles missing script file", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "node", "nonexistent.js"], + env: sharedEnv, + cwd: sharedDir, + stdout: "pipe", + stderr: "pipe", + }); + + const stderr = await new Response(proc.stderr).text(); + expect(await proc.exited).not.toBe(0); + expect(stderr).toInclude("Cannot find module"); + }); + }); + + describe("environment variables", () => { + test("respects BUN_INSTALL_BIN", async () => { + const customBin = join(sharedDir, "custom-bin"); + mkdirSync(customBin); + + const env = { + ...sharedEnv, + BUN_INSTALL_BIN: customBin, + PATH: `${customBin}:${process.env.PATH || ""}`, + }; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "node", "bun"], + env, + cwd: sharedDir, + stdout: "pipe", + }); + + expect(await proc.exited).toBe(0); + + const binaryName = process.platform === "win32" ? "node.exe" : "node"; + const nodePath = join(customBin, binaryName); + expect(existsSync(nodePath)).toBe(true); + }); + + test("prepends bin dir to PATH for child process", async () => { + writeFileSync( + join(sharedDir, "check-path.js"), + ` + const path = process.env.PATH || ''; + const binDir = '${sharedBinDir}'; + console.log(path.startsWith(binDir) ? 'PATH correct' : 'PATH wrong'); + `, + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "node", "check-path.js"], + env: sharedEnv, + cwd: sharedDir, + stdout: "pipe", + }); + + const stdout = await new Response(proc.stdout).text(); + expect(stdout).toInclude("PATH correct"); + }); + }); + + test("downloads silently when running scripts", async () => { + const testEnv = (() => { + const testDir = tempDirWithFiles("node-silent-dl", {}); + const bunInstallDir = join(testDir, ".bun"); + const binDir = join(bunInstallDir, "bin"); + mkdirSync(binDir, { recursive: true }); + + return { + dir: testDir, + env: { + ...bunEnv, + BUN_INSTALL: bunInstallDir, + BUN_INSTALL_BIN: binDir, + PATH: `${binDir}:${process.env.PATH || ""}`, + HOME: testDir, + }, + }; + })(); + + writeFileSync(join(testEnv.dir, "output.js"), "console.log('OUTPUT');"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "node", "21", "output.js"], + env: testEnv.env, + cwd: testEnv.dir, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + + expect(stdout.trim()).toBe("OUTPUT"); + expect(stderr).not.toInclude("Downloading"); + expect(stderr).not.toInclude("Successfully"); + }); +});