Compare commits

...

8 Commits

Author SHA1 Message Date
RiskyMH
09c1177f9e Update node_command.zig 2025-07-25 04:51:14 +10:00
RiskyMH
8820043ce6 Update node_command.zig 2025-07-25 04:46:52 +10:00
RiskyMH
ee0af66e4a i dont like windows anymore 2025-07-25 04:46:30 +10:00
RiskyMH
3829b62c8e Update node_command.zig 2025-07-25 04:37:41 +10:00
RiskyMH
943794d8ba da windows? 2025-07-25 04:37:30 +10:00
RiskyMH
f2e85408ab windows hopefully fix 2025-07-25 04:28:16 +10:00
autofix-ci[bot]
0bff5aa266 [autofix.ci] apply automated fixes 2025-07-24 17:35:36 +00:00
RiskyMH
ab6a51ccd0 implement bun node cmd for managing node version 2025-07-25 03:30:51 +10:00
6 changed files with 1351 additions and 1 deletions

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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");
@@ -566,6 +567,8 @@ pub const Command = struct {
RootCommandMatcher.case("run") => .RunCommand,
RootCommandMatcher.case("help") => .HelpCommand,
RootCommandMatcher.case("node") => .NodeCommand,
RootCommandMatcher.case("exec") => .ExecCommand,
RootCommandMatcher.case("outdated") => .OutdatedCommand,
@@ -612,6 +615,8 @@ pub const Command = struct {
"x",
"repl",
"info",
"why",
"node",
};
const reject_list = default_completions_list ++ [_]string{
@@ -803,6 +808,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 +932,7 @@ pub const Command = struct {
InstallCommand,
InstallCompletionsCommand,
LinkCommand,
NodeCommand,
PackageManagerCommand,
RemoveCommand,
RunCommand,
@@ -957,6 +970,7 @@ pub const Command = struct {
.InstallCommand => 'i',
.InstallCompletionsCommand => 'C',
.LinkCommand => 'l',
.NodeCommand => 'N',
.PackageManagerCommand => 'P',
.RemoveCommand => 'R',
.RunCommand => 'r',
@@ -985,6 +999,7 @@ pub const Command = struct {
.BuildCommand => Arguments.build_params,
.TestCommand => Arguments.test_params,
.BunxCommand => Arguments.run_params,
.NodeCommand => Arguments.auto_only_params,
else => Arguments.base_params_ ++ Arguments.runtime_params_ ++ Arguments.transpiler_params_,
};
}
@@ -1340,6 +1355,7 @@ pub const Command = struct {
.AutoCommand = true,
.RunCommand = true,
.RunAsNodeCommand = true,
.NodeCommand = true,
.OutdatedCommand = true,
.UpdateInteractiveCommand = true,
.PublishCommand = true,
@@ -1380,6 +1396,7 @@ pub const Command = struct {
.RemoveCommand = false,
.UnlinkCommand = false,
.UpdateCommand = false,
.NodeCommand = false,
});
};

View File

@@ -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| {

988
src/cli/node_command.zig Normal file
View File

@@ -0,0 +1,988 @@
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("<r><b>bun node<r> <d>v" ++ Global.package_json_version_with_sha ++ "<r>", .{});
Output.prettyln(
\\Install & manage Node.js versions or configure node to use Bun instead.
\\
\\<b>Examples:<r>
\\ <d>$<r> <b><green>bun node<r> <cyan>latest<r> <d>Install latest Node.js and set as default<r>
\\ <d>$<r> <b><green>bun node<r> <cyan>lts<r> <d>Install latest LTS Node.js and set as default<r>
\\ <d>$<r> <b><green>bun node<r> <blue>24<r> <d>Install latest Node.js v24.x and set as default<r>
\\ <d>$<r> <b><green>bun node<r> <blue>foo.js<r> <d>Run foo.js with default Node.js<r>
\\ <d>$<r> <b><green>bun node<r> <blue>24.0.0 foo.js<r> <d>Run foo.js with Node.js v24.0.0<r>
\\ <d>$<r> <b><green>bun node<r> <cyan>bun<r> <d>Make 'node' command run Bun instead<r>
\\
\\<d><b>Note:<r><d> Latest version information is cached for 24 hours.<r>
\\
, .{});
}
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("<r><red>error:<r> 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);
const cache_dirZ = try allocator.dupeZ(u8, cache_dir);
defer allocator.free(cache_dirZ);
switch (bun.sys.mkdir(cache_dirZ, 0o755)) {
.result => {},
.err => |err| {
if (err.errno != @intFromEnum(bun.sys.E.EXIST)) {
return err.toZigErr();
}
},
}
const cache_file = try std.fs.path.join(allocator, &.{ cache_dir, ".version-cache" });
defer allocator.free(cache_file);
const cache_fileZ = try allocator.dupeZ(u8, cache_file);
defer allocator.free(cache_fileZ);
const fd = switch (bun.sys.open(cache_fileZ, bun.O.WRONLY | bun.O.CREAT | bun.O.TRUNC, 0o644)) {
.result => |fd| fd,
.err => |err| return err.toZigErr(),
};
defer fd.close();
var file = bun.sys.File{ .handle = fd };
switch (file.writeAll(data)) {
.result => {},
.err => |err| return err.toZigErr(),
}
}
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("<r><red>error:<r> Failed to fetch Node.js versions from API: {}", .{err});
Global.exit(1);
};
if (response.status_code != 200) {
Output.prettyErrorln("<r><red>error:<r> 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("<r><red>error:<r> 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("<r><red>error:<r> Could not determine latest Node.js version", .{});
Global.exit(1);
},
.lts => {
Output.prettyErrorln("<r><red>error:<r> Could not find LTS version in Node.js version data", .{});
Global.exit(1);
},
.major => {
Output.prettyErrorln("<r><red>error:<r> 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("<r><green>Downloading<r> 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("<r><green>Downloading<r> 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 {};
}
const dest_dirZ = try allocator.dupeZ(u8, dest_dir);
defer allocator.free(dest_dirZ);
switch (bun.sys.mkdir(dest_dirZ, 0o755)) {
.result => {},
.err => |err| {
if (err.errno != @intFromEnum(bun.sys.E.EXIST)) {
if (is_quiet) Output.prettyErrorln("", .{});
Output.prettyErrorln("<r><red>error:<r> Failed to create directory {s}: {}", .{ dest_dir, err });
Global.exit(1);
}
},
}
const temp_archive = try std.fmt.allocPrint(allocator, "{s}.download." ++ node_archive_ext, .{dest_dir});
defer allocator.free(temp_archive);
defer {
if (allocator.dupeZ(u8, temp_archive)) |temp_archiveZ| {
defer allocator.free(temp_archiveZ);
_ = bun.sys.unlink(temp_archiveZ);
} else |_| {}
}
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("<r><red>error:<r> Failed to download Node.js v{s}: {}", .{ version, err });
Global.exit(1);
};
if (response.status_code != 200) {
if (is_quiet) Output.prettyErrorln("", .{});
Output.prettyErrorln("<r><red>error:<r> Failed to download Node.js v{s}: HTTP {d}", .{ version, response.status_code });
Global.exit(1);
}
const temp_archiveZ = try allocator.dupeZ(u8, temp_archive);
defer allocator.free(temp_archiveZ);
const fd = switch (bun.sys.open(temp_archiveZ, bun.O.WRONLY | bun.O.CREAT | bun.O.TRUNC, 0o644)) {
.result => |fd| fd,
.err => |err| {
if (is_quiet) Output.prettyErrorln("", .{});
Output.prettyErrorln("<r><red>error:<r> Failed to create file {s}: {}", .{ temp_archive, err });
Global.exit(1);
},
};
defer fd.close();
var file = bun.sys.File{ .handle = fd };
switch (file.writeAll(response_buffer.list.items)) {
.result => {},
.err => |err| {
if (is_quiet) Output.prettyErrorln("", .{});
Output.prettyErrorln("<r><red>error:<r> Failed to write archive {s}: {}", .{ temp_archive, err });
Global.exit(1);
},
}
try extractNodeArchive(allocator, temp_archive, dest_dir, version, is_quiet);
}
fn extractNodeArchive(allocator: std.mem.Allocator, archive_path: []const u8, dest_dir: []const u8, version: []const u8, is_quiet: bool) !void {
_ = version;
const archive_data = std.fs.cwd().readFileAlloc(allocator, archive_path, std.math.maxInt(usize)) catch |err| {
if (is_quiet) Output.prettyErrorln("", .{});
Output.prettyErrorln("<r><red>error:<r> Failed to read archive {s}: {}", .{ archive_path, err });
Global.exit(1);
};
defer allocator.free(archive_data);
var dest_dir_handle = std.fs.openDirAbsolute(dest_dir, .{}) catch |err| {
if (is_quiet) Output.prettyErrorln("", .{});
Output.prettyErrorln("<r><red>error:<r> Failed to open destination directory {s}: {}", .{ dest_dir, err });
Global.exit(1);
};
defer dest_dir_handle.close();
_ = Archiver.extractToDir(
archive_data,
dest_dir_handle,
null,
void,
{},
.{
.depth_to_skip = 1,
.close_handles = true,
},
) catch |err| {
if (is_quiet) Output.prettyErrorln("", .{});
Output.prettyErrorln("<r><red>error:<r> Failed to extract archive {s}: {}", .{ archive_path, err });
Global.exit(1);
};
const src_binary = if (Env.isWindows)
try std.fs.path.join(allocator, &.{ dest_dir, node_binary_name })
else
try std.fs.path.join(allocator, &.{ dest_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);
if (!Env.isWindows or !strings.eql(src_binary, dest_binary)) {
if (Env.isWindows) {
var src_buf: bun.OSPathBuffer = undefined;
var dest_buf: bun.OSPathBuffer = undefined;
const src_path = bun.strings.toWPathNormalized(&src_buf, src_binary);
const dest_path = bun.strings.toWPathNormalized(&dest_buf, dest_binary);
bun.copyFile(src_path, dest_path).unwrap() catch |err| {
if (is_quiet) Output.prettyErrorln("", .{});
Output.prettyErrorln("<r><red>error:<r> Failed to copy binary: {}", .{err});
Global.exit(1);
};
} else {
const src_fd = switch (bun.sys.open(try allocator.dupeZ(u8, src_binary), bun.O.RDONLY, 0)) {
.result => |fd| fd,
.err => |err| {
if (is_quiet) Output.prettyErrorln("", .{});
Output.prettyErrorln("<r><red>error:<r> Failed to open source binary: {}", .{err});
Global.exit(1);
},
};
defer src_fd.close();
const dest_fd = switch (bun.sys.open(try allocator.dupeZ(u8, dest_binary), bun.O.WRONLY | bun.O.CREAT | bun.O.TRUNC, 0o755)) {
.result => |fd| fd,
.err => |err| {
if (is_quiet) Output.prettyErrorln("", .{});
Output.prettyErrorln("<r><red>error:<r> Failed to create dest binary: {}", .{err});
Global.exit(1);
},
};
defer dest_fd.close();
bun.copyFile(src_fd, dest_fd).unwrap() catch |err| {
if (is_quiet) Output.prettyErrorln("", .{});
Output.prettyErrorln("<r><red>error:<r> Failed to copy binary: {}", .{err});
Global.exit(1);
};
}
}
if (Env.isPosix) {
const dest_binaryZ = try allocator.dupeZ(u8, dest_binary);
defer allocator.free(dest_binaryZ);
switch (bun.sys.chmod(dest_binaryZ, 0o755)) {
.result => {},
.err => |err| {
if (is_quiet) Output.prettyErrorln("", .{});
Output.prettyErrorln("<r><red>error:<r> Failed to chmod {s}: {}", .{ dest_binary, err });
Global.exit(1);
},
}
}
}
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.allocPrintZ(allocator, "{s}/" ++ node_binary_name, .{version_dir});
defer allocator.free(version_binary);
const access_result = if (Env.isWindows) blk: {
var buf: bun.OSPathBuffer = undefined;
const path = bun.strings.toWPathNormalized(&buf, version_binary);
break :blk bun.sys.access(path, 0);
} else bun.sys.access(version_binary, 0);
if (access_result == .result) {
if (set_as_default) {
Output.prettyln("<r><green>✓<r> Node.js v{s} is already installed", .{version});
}
} else {
if (set_as_default) {
try downloadNode(allocator, version, version_dir);
Output.prettyln("<r><green>✓<r> Successfully installed Node.js v{s}", .{version});
} else {
try downloadNodeSilent(allocator, version, version_dir);
}
}
if (set_as_default) {
try updateGlobalNodeSymlink(ctx, version);
Output.prettyln("<r><green>✓<r> 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);
const bin_dirZ = try allocator.dupeZ(u8, bin_dir);
defer allocator.free(bin_dirZ);
switch (bun.sys.mkdir(bin_dirZ, 0o755)) {
.result => {},
.err => |err| {
if (err.errno != @intFromEnum(bun.sys.E.EXIST)) {
return err.toZigErr();
}
},
}
const version_binary = try std.fmt.allocPrintZ(allocator, "{s}/node-{s}/" ++ node_binary_name, .{ cache_dir, version });
defer allocator.free(version_binary);
const access_result2 = if (Env.isWindows) blk: {
var buf: bun.OSPathBuffer = undefined;
const path = bun.strings.toWPathNormalized(&buf, version_binary);
break :blk bun.sys.access(path, 0);
} else bun.sys.access(version_binary, 0);
if (access_result2 != .result) {
const version_dir = try std.fmt.allocPrint(allocator, "{s}/node-{s}", .{ cache_dir, version });
defer allocator.free(version_dir);
Output.prettyErrorln("<r><yellow>warn:<r> Node.js v{s} binary not found, downloading...", .{version});
try downloadNode(allocator, version, version_dir);
}
const global_binary = try std.fs.path.joinZ(allocator, &.{ bin_dir, node_binary_name });
defer allocator.free(global_binary);
_ = bun.sys.unlink(global_binary);
switch (bun.sys.link(u8, version_binary, global_binary)) {
.result => {},
.err => |err| switch (err.getErrno()) {
.XDEV => {
if (Env.isWindows) {
var src_buf: bun.OSPathBuffer = undefined;
var dest_buf: bun.OSPathBuffer = undefined;
const src_path = bun.strings.toWPathNormalized(&src_buf, version_binary);
const dest_path = bun.strings.toWPathNormalized(&dest_buf, global_binary);
bun.copyFile(src_path, dest_path).unwrap() catch |copy_err| {
Output.prettyErrorln("<r><red>error:<r> Failed to copy Node binary: {}", .{copy_err});
Global.exit(1);
};
} else {
const src_fd = switch (bun.sys.open(version_binary, bun.O.RDONLY, 0)) {
.result => |fd| fd,
.err => |open_err| {
Output.prettyErrorln("<r><red>error:<r> Failed to open source binary: {}", .{open_err});
Global.exit(1);
},
};
defer src_fd.close();
const dest_fd = switch (bun.sys.open(global_binary, bun.O.WRONLY | bun.O.CREAT | bun.O.TRUNC, 0o755)) {
.result => |fd| fd,
.err => |open_err| {
Output.prettyErrorln("<r><red>error:<r> Failed to create dest binary: {}", .{open_err});
Global.exit(1);
},
};
defer dest_fd.close();
bun.copyFile(src_fd, dest_fd).unwrap() catch |copy_err| {
Output.prettyErrorln("<r><red>error:<r> Failed to copy Node binary: {}", .{copy_err});
Global.exit(1);
};
}
},
else => return err.toZigErr(),
},
}
}
fn handleNodeAlias(ctx: Command.Context, target: []const u8) !void {
const allocator = ctx.allocator;
if (!strings.eqlComptime(target, "bun")) {
Output.prettyErrorln("<r><red>error:<r> only 'bun' is supported as alias target", .{});
Global.exit(1);
}
const bin_dir = try getNodeBinDir(allocator);
defer allocator.free(bin_dir);
const bin_dirZ2 = try allocator.dupeZ(u8, bin_dir);
defer allocator.free(bin_dirZ2);
switch (bun.sys.mkdir(bin_dirZ2, 0o755)) {
.result => {},
.err => |err| {
if (err.errno != @intFromEnum(bun.sys.E.EXIST)) {
return err.toZigErr();
}
},
}
const global_binary = try std.fs.path.joinZ(allocator, &.{ bin_dir, node_binary_name });
defer allocator.free(global_binary);
const bun_exe = bun.selfExePath() catch {
Output.prettyErrorln("<r><red>error:<r> failed to determine bun executable path", .{});
Global.crash();
};
_ = bun.sys.unlink(global_binary);
switch (bun.sys.link(u8, bun_exe, global_binary)) {
.result => {},
.err => |err| switch (err.getErrno()) {
.XDEV => {
if (Env.isWindows) {
var src_buf: bun.OSPathBuffer = undefined;
var dest_buf: bun.OSPathBuffer = undefined;
const src_path = bun.strings.toWPathNormalized(&src_buf, bun_exe);
const dest_path = bun.strings.toWPathNormalized(&dest_buf, global_binary);
bun.copyFile(src_path, dest_path).unwrap() catch |copy_err| {
Output.prettyErrorln("<r><red>error:<r> Failed to copy Bun binary: {}", .{copy_err});
Global.exit(1);
};
} else {
const src_fd = switch (bun.sys.open(try allocator.dupeZ(u8, bun_exe), bun.O.RDONLY, 0)) {
.result => |fd| fd,
.err => |open_err| {
Output.prettyErrorln("<r><red>error:<r> Failed to open Bun binary: {}", .{open_err});
Global.exit(1);
},
};
defer src_fd.close();
const dest_fd = switch (bun.sys.open(global_binary, bun.O.WRONLY | bun.O.CREAT | bun.O.TRUNC, 0o755)) {
.result => |fd| fd,
.err => |open_err| {
Output.prettyErrorln("<r><red>error:<r> Failed to create dest binary: {}", .{open_err});
Global.exit(1);
},
};
defer dest_fd.close();
bun.copyFile(src_fd, dest_fd).unwrap() catch |copy_err| {
Output.prettyErrorln("<r><red>error:<r> Failed to copy Bun binary: {}", .{copy_err});
Global.exit(1);
};
}
},
else => return err.toZigErr(),
},
}
Output.prettyln("<r><green>✓<r> Successfully aliased 'node' to Bun", .{});
Output.prettyln("<r><d>The 'node' command will now run Bun<r>", .{});
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);
const version_binaryZ3 = try allocator.dupeZ(u8, version_binary);
defer allocator.free(version_binaryZ3);
const access_result3 = if (Env.isWindows) blk: {
var buf: bun.OSPathBuffer = undefined;
const path = bun.strings.toWPathNormalized(&buf, version_binary);
break :blk bun.sys.access(path, 0);
} else bun.sys.access(version_binaryZ3, 0);
if (access_result3 == .result) {
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.joinZ(allocator, &.{ bin_dir, node_binary_name });
defer allocator.free(node_symlink);
const access_result4 = if (Env.isWindows) blk: {
var buf: bun.OSPathBuffer = undefined;
const path = bun.strings.toWPathNormalized(&buf, node_symlink);
break :blk bun.sys.access(path, 0);
} else bun.sys.access(node_symlink, 0);
if (access_result4 == .result) {
try runNode(allocator, node_symlink, args);
return;
}
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("<r><red>error:<r> 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("<r><red>error:<r> 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("<r><red>error:<r> Node.js terminated by signal: {}", .{signal});
Global.exit(1);
},
.err => |err| {
Output.prettyErrorln("<r><red>error:<r> Failed to run Node.js: {}", .{err});
Global.exit(1);
},
.running => {
Global.exit(1);
},
}
},
.err => |err| {
Output.prettyErrorln("<r><red>error:<r> 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 =
\\
\\<r><yellow>⚠️ Warning:<r> 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:
\\ <cyan>[Environment]::SetEnvironmentVariable("Path", "{s};" + $env:Path, [System.EnvironmentVariableTarget]::User)<r>
\\
;
Output.prettyln(msg, .{ node_path, bin_dir, bin_dir, bin_dir });
} else {
const msg =
\\
\\<r><yellow>⚠️ Warning:<r> 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:
\\ <cyan>export PATH="{s}:$PATH"<r>
\\
;
Output.prettyln(msg, .{ node_path, bin_dir, bin_dir });
}
}
break;
} else if (entry.len > 0) {
const test_node = try std.fs.path.joinZ(allocator, &.{ entry, "node" });
defer allocator.free(test_node);
const access_result5 = if (Env.isWindows) blk: {
var buf: bun.OSPathBuffer = undefined;
const path = bun.strings.toWPathNormalized(&buf, test_node);
break :blk bun.sys.access(path, 0);
} else bun.sys.access(test_node, 0);
if (access_result5 == .result) {
found_other_dir = true;
}
}
}
if (!found_bun_dir) {
if (Env.isWindows) {
const msg =
\\
\\ <r><yellow>⚠️ Warning:<r> 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:
\\ <cyan>[Environment]::SetEnvironmentVariable("Path", $env:Path + ";{s}", [System.EnvironmentVariableTarget]::User)<r>
\\
;
Output.prettyln(msg, .{ bin_dir, bin_dir });
} else {
const msg =
\\
\\ <r><yellow>⚠️ Warning:<r> 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:
\\ <cyan>export PATH="{s}:$PATH"<r>
\\
;
Output.prettyln(msg, .{bin_dir});
}
}
}
}
}
};
const Options = @import("../install/PackageManager/PackageManagerOptions.zig");
const std = @import("std");
const Command = @import("../cli.zig").Command;
const which = @import("../which.zig").which;
const bun = @import("bun");
const Env = bun.Environment;
const Global = bun.Global;
const MutableString = bun.MutableString;
const Output = bun.Output;
const URL = bun.URL;
const strings = bun.strings;
const Archiver = bun.libarchive.Archiver;
const HTTP = bun.http;
const AsyncHTTP = HTTP.AsyncHTTP;

View File

@@ -0,0 +1,343 @@
import { beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "fs";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
import { join } from "path";
// 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");
});
});