Compare commits

...

3 Commits

Author SHA1 Message Date
Claude Bot
6954e63107 Fix security vulnerabilities and robustness issues
Security fixes:
- Replace vulnerable shell script generation with secure symlinks/copy
- Use proper BUN_INSTALL fallback to ~/.bun (like other Bun commands)
- Fix import patterns to use bun.* instead of direct imports

Robustness improvements:
- Implement tar.gz extraction using system tar command
- Fix error handling and validation
- Update tests to reflect new fallback behavior

All tests pass and functionality works correctly.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 18:25:51 +00:00
Claude Bot
a0ed374077 Clean up NodeVersionCommand implementation
Removed unnecessary abstractions and unused code:
- Eliminated NodeVersion struct in favor of simple helper functions
- Removed unused imports (string, stringZ, logger, JSON, Headers, which)
- Fixed string types to use []const u8 instead of string alias
- Simplified function signatures and removed over-engineering

All functionality preserved and tests still pass.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-19 23:51:36 +00:00
Claude Bot
39a4c693f8 Add node version manager for Bun
This implements a `bun node <version>` command that downloads, installs, and manages different Node.js versions:

- Downloads Node.js tarballs from nodejs.org
- Installs to $BUN_INSTALL/node/vX.X.X/
- Creates a node shim in $BUN_INSTALL/bin/node
- Validates version format (X.Y.Z)
- Handles already installed versions
- Includes comprehensive tests

Architecture:
- src/cli/NodeVersionCommand.zig: Main implementation
- Integrates with existing CLI command system
- Uses HTTP client and progress reporting like upgrade command
- Cross-platform support (Windows ZIP, Unix tar.gz)

Current limitation: tar.gz extraction uses placeholder (TODO: libarchive)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-19 15:19:20 +00:00
4 changed files with 532 additions and 1 deletions

View File

@@ -338,6 +338,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/NodeVersionCommand.zig
src/cli/outdated_command.zig
src/cli/pack_command.zig
src/cli/package_manager_command.zig

View File

@@ -120,6 +120,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 NodeVersionCommand = @import("./cli/NodeVersionCommand.zig").NodeVersionCommand;
const PackageManager = Install.PackageManager;
const PmViewCommand = @import("./cli/pm_view_command.zig");
@@ -621,6 +622,7 @@ pub const Command = struct {
RootCommandMatcher.case("prune") => .ReservedCommand,
RootCommandMatcher.case("list") => .ReservedCommand,
RootCommandMatcher.case("why") => .WhyCommand,
RootCommandMatcher.case("node") => .NodeVersionCommand,
RootCommandMatcher.case("-e") => .AutoCommand,
@@ -646,6 +648,7 @@ pub const Command = struct {
"x",
"repl",
"info",
"node",
};
const reject_list = default_completions_list ++ [_]string{
@@ -930,6 +933,11 @@ pub const Command = struct {
Output.flush();
try HelpCommand.exec(allocator);
},
.NodeVersionCommand => {
const ctx = try Command.init(allocator, log, .NodeVersionCommand);
try NodeVersionCommand.exec(ctx);
return;
},
.ExecCommand => {
const ctx = try Command.init(allocator, log, .ExecCommand);
@@ -972,6 +980,7 @@ pub const Command = struct {
PublishCommand,
AuditCommand,
WhyCommand,
NodeVersionCommand,
/// Used by crash reports.
///
@@ -1009,6 +1018,7 @@ pub const Command = struct {
.PublishCommand => 'k',
.AuditCommand => 'A',
.WhyCommand => 'W',
.NodeVersionCommand => 'N',
};
}
@@ -1317,7 +1327,7 @@ pub const Command = struct {
Output.flush();
},
else => {
HelpCommand.printWithReason(.explicit);
HelpCommand.printWithReason(.explicit, false);
},
}
}

View File

@@ -0,0 +1,374 @@
const bun = @import("bun");
const Output = bun.Output;
const Global = bun.Global;
const Environment = bun.Environment;
const MutableString = bun.MutableString;
const std = @import("std");
const Progress = bun.Progress;
const Command = @import("../cli.zig").Command;
const fs = bun.fs;
const URL = bun.URL;
const HTTP = bun.http;
const DotEnv = bun.DotEnv;
const platform_label = switch (Environment.os) {
.mac => "darwin",
.linux => "linux",
.windows => "win",
else => @compileError("Unsupported OS for Node.js installation"),
};
const arch_label = if (Environment.isAarch64) "arm64" else "x64";
fn getDownloadURL(version: []const u8, allocator: std.mem.Allocator) ![]const u8 {
const extension = if (Environment.isWindows) "zip" else "tar.gz";
return try std.fmt.allocPrint(
allocator,
"https://nodejs.org/dist/v{s}/node-v{s}-{s}-{s}.{s}",
.{ version, version, platform_label, arch_label, extension }
);
}
fn getBunInstallDir(allocator: std.mem.Allocator) ![]const u8 {
if (bun.getenvZ("BUN_INSTALL")) |install_dir| {
return try allocator.dupe(u8, install_dir);
}
// Fall back to ~/.bun like other Bun commands
const home_dir = bun.getenvZ("HOME") orelse bun.getenvZ("USERPROFILE") orelse {
Output.prettyErrorln("<r><red>error<r><d>:<r> Could not determine home directory. Please set BUN_INSTALL", .{});
Global.exit(1);
};
return try std.fs.path.join(allocator, &.{ home_dir, ".bun" });
}
pub const NodeVersionCommand = struct {
pub fn exec(ctx: Command.Context) !void {
@branchHint(.cold);
const args = bun.argv;
if (args.len < 3) {
Output.prettyErrorln("<r><red>error<r><d>:<r> Please specify a Node.js version to install", .{});
Output.prettyErrorln("Usage: bun node \\<version\\>", .{});
Output.prettyErrorln("Example: bun node 20.11.0", .{});
Global.exit(1);
}
const version_arg = args[2];
// Validate version format (basic check)
if (!isValidVersion(version_arg)) {
Output.prettyErrorln("<r><red>error<r><d>:<r> Invalid Node.js version format: {s}", .{version_arg});
Output.prettyErrorln("Expected format: X.Y.Z (e.g., 20.11.0)", .{});
Global.exit(1);
}
try installNodeVersion(ctx, version_arg);
}
fn isValidVersion(version: []const u8) bool {
// Basic validation: check if it matches X.Y.Z pattern
var dot_count: u32 = 0;
var has_digits = false;
for (version) |char| {
if (char == '.') {
dot_count += 1;
if (dot_count > 2) return false;
} else if (char >= '0' and char <= '9') {
has_digits = true;
} else {
return false;
}
}
return dot_count == 2 and has_digits;
}
fn installNodeVersion(ctx: Command.Context, version: []const u8) !void {
Output.prettyErrorln("<r><b>Installing Node.js v{s}<r>", .{version});
// Get BUN_INSTALL directory with fallback to ~/.bun
const bun_install_dir = try getBunInstallDir(ctx.allocator);
defer ctx.allocator.free(bun_install_dir);
// Create node installation directory
const node_install_path = try std.fmt.allocPrint(
ctx.allocator,
"{s}/node/v{s}",
.{ bun_install_dir, version }
);
defer ctx.allocator.free(node_install_path);
// Check if version is already installed
if (std.fs.openDirAbsolute(node_install_path, .{})) |_| {
Output.prettyErrorln("<r><b>Node.js v{s} is already installed<r>", .{version});
try updateNodeShim(ctx, version, bun_install_dir);
return;
} else |_| {
// Directory doesn't exist, proceed with installation
}
// Download and install
try downloadAndInstallNode(ctx, version, bun_install_dir);
try updateNodeShim(ctx, version, bun_install_dir);
Output.prettyErrorln("<r><b><green>Successfully installed Node.js v{s}<r>", .{version});
}
fn downloadAndInstallNode(ctx: Command.Context, version: []const u8, bun_install_dir: []const u8) !void {
var env_loader: DotEnv.Loader = brk: {
const map = try ctx.allocator.create(DotEnv.Map);
map.* = DotEnv.Map.init(ctx.allocator);
break :brk DotEnv.Loader.init(map, ctx.allocator);
};
env_loader.loadProcess();
const download_url = try getDownloadURL(version, ctx.allocator);
defer ctx.allocator.free(download_url);
const url = URL.parse(download_url);
const http_proxy: ?URL = env_loader.getHttpProxyFor(url);
Output.prettyErrorln("<r>Downloading from {s}<r>", .{download_url});
var refresher = Progress{};
var progress = refresher.start("Downloading Node.js", 0);
refresher.refresh();
var async_http = try ctx.allocator.create(HTTP.AsyncHTTP);
var download_buffer = try ctx.allocator.create(MutableString);
download_buffer.* = try MutableString.init(ctx.allocator, 1024 * 1024); // 1MB initial
async_http.* = HTTP.AsyncHTTP.initSync(
ctx.allocator,
.GET,
url,
.{},
"",
download_buffer,
"",
http_proxy,
null,
HTTP.FetchRedirect.follow,
);
async_http.client.progress_node = progress;
async_http.client.flags.reject_unauthorized = env_loader.getTLSRejectUnauthorized();
const response = try async_http.sendSync();
switch (response.status_code) {
404 => {
progress.end();
refresher.refresh();
Output.prettyErrorln("<r><red>error<r><d>:<r> Node.js version {s} not found", .{version});
Global.exit(1);
},
200 => {},
else => {
progress.end();
refresher.refresh();
Output.prettyErrorln("<r><red>error<r><d>:<r> Failed to download Node.js (HTTP {d})", .{response.status_code});
Global.exit(1);
},
}
const bytes = download_buffer.slice();
progress.end();
refresher.refresh();
if (bytes.len == 0) {
Output.prettyErrorln("<r><red>error<r><d>:<r> Downloaded empty file", .{});
Global.exit(1);
}
Output.prettyErrorln("<r>Downloaded {d} bytes<r>", .{bytes.len});
// Extract the archive
try extractNodeArchive(ctx, bytes, version, bun_install_dir);
}
fn extractNodeArchive(ctx: Command.Context, archive_data: []const u8, version: []const u8, bun_install_dir: []const u8) !void {
Output.prettyErrorln("<r>Extracting Node.js v{s}<r>", .{version});
// Create installation directory
const node_install_path = try std.fmt.allocPrint(
ctx.allocator,
"{s}/node/v{s}",
.{ bun_install_dir, version }
);
defer ctx.allocator.free(node_install_path);
std.fs.makeDirAbsolute(node_install_path) catch |err| switch (err) {
error.PathAlreadyExists => {},
else => return err,
};
if (Environment.isWindows) {
// For Windows, we expect a ZIP file
try extractZipArchive(ctx, archive_data, node_install_path);
} else {
// For Unix systems, we expect a tar.gz file
try extractTarGzArchive(ctx, archive_data, node_install_path);
}
}
fn extractZipArchive(ctx: Command.Context, archive_data: []const u8, extract_path: []const u8) !void {
// For now, implement a basic ZIP extraction using system tools
// This is similar to how the upgrade command handles ZIP files on Windows
var filesystem = try fs.FileSystem.init(null);
var temp_dir = filesystem.tmpdir() catch |err| {
Output.errGeneric("Failed to open temporary directory: {s}", .{@errorName(err)});
Global.exit(1);
};
const temp_file = try temp_dir.createFile("node.zip", .{});
defer temp_file.close();
defer temp_dir.deleteFile("node.zip") catch {};
try temp_file.writeAll(archive_data);
// Use PowerShell to extract the ZIP
const extract_script = try std.fmt.allocPrint(
ctx.allocator,
"$global:ProgressPreference='SilentlyContinue';Expand-Archive -Path \"node.zip\" \"{s}\" -Force",
.{bun.fmt.escapePowershell(extract_path)},
);
defer ctx.allocator.free(extract_script);
var buf: bun.PathBuffer = undefined;
const powershell_path = bun.which(&buf, bun.getenvZ("PATH") orelse "", "", "powershell") orelse {
Output.prettyErrorln("<r><red>error<r><d>:<r> PowerShell not found", .{});
Global.exit(1);
};
var extract_argv = [_][]const u8{
powershell_path,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
extract_script,
};
const temp_path = try bun.FD.fromStdDir(temp_dir).getFdPath(&buf);
_ = (bun.spawnSync(&.{
.argv = &extract_argv,
.envp = null,
.cwd = temp_path,
.stderr = .inherit,
.stdout = .inherit,
.stdin = .inherit,
.windows = if (Environment.isWindows) .{
.loop = bun.JSC.EventLoopHandle.init(bun.JSC.MiniEventLoop.initGlobal(null)),
},
}) catch |err| {
Output.prettyErrorln("<r><red>error<r><d>:<r> Failed to extract ZIP: {s}", .{@errorName(err)});
Global.exit(1);
}).unwrap() catch |err| {
Output.prettyErrorln("<r><red>error<r><d>:<r> Failed to extract ZIP: {s}", .{@errorName(err)});
Global.exit(1);
};
}
fn extractTarGzArchive(ctx: Command.Context, archive_data: []const u8, extract_path: []const u8) !void {
// Write archive to temporary file and use system tar for extraction
var filesystem = try fs.FileSystem.init(null);
var temp_dir = filesystem.tmpdir() catch |err| {
Output.errGeneric("Failed to open temporary directory: {s}", .{@errorName(err)});
Global.exit(1);
};
const temp_file = try temp_dir.createFile("node.tar.gz", .{});
defer temp_file.close();
defer temp_dir.deleteFile("node.tar.gz") catch {};
try temp_file.writeAll(archive_data);
// Use system tar to extract
var buf: bun.PathBuffer = undefined;
const temp_path = try bun.FD.fromStdDir(temp_dir).getFdPath(&buf);
const tar_argv = [_][]const u8{
"tar",
"-xzf",
"node.tar.gz",
"--strip-components=1",
"-C",
extract_path,
};
_ = (bun.spawnSync(&.{
.argv = &tar_argv,
.envp = null,
.cwd = temp_path,
.stderr = .inherit,
.stdout = .inherit,
.stdin = .inherit,
}) catch |err| {
Output.prettyErrorln("<r><red>error<r><d>:<r> Failed to extract tar.gz: {s}", .{@errorName(err)});
Global.exit(1);
}).unwrap() catch |err| {
Output.prettyErrorln("<r><red>error<r><d>:<r> tar command failed: {s}", .{@errorName(err)});
Global.exit(1);
};
_ = ctx; // Silence unused parameter warning
}
fn updateNodeShim(ctx: Command.Context, version: []const u8, bun_install_dir: []const u8) !void {
// Create or update the node shim executable
const bin_dir = try std.fmt.allocPrint(
ctx.allocator,
"{s}/bin",
.{bun_install_dir}
);
defer ctx.allocator.free(bin_dir);
std.fs.makeDirAbsolute(bin_dir) catch |err| switch (err) {
error.PathAlreadyExists => {},
else => return err,
};
const node_shim_path = try std.fmt.allocPrint(
ctx.allocator,
"{s}/node{s}",
.{ bin_dir, if (Environment.isWindows) ".exe" else "" }
);
defer ctx.allocator.free(node_shim_path);
const actual_node_path = try std.fmt.allocPrint(
ctx.allocator,
"{s}/node/v{s}/bin/node{s}",
.{ bun_install_dir, version, if (Environment.isWindows) ".exe" else "" }
);
defer ctx.allocator.free(actual_node_path);
// Remove existing shim if it exists
std.fs.deleteFileAbsolute(node_shim_path) catch |err| switch (err) {
error.FileNotFound => {},
else => return err,
};
if (Environment.isWindows) {
// On Windows, copy the executable directly since symlinks require admin privileges
std.fs.copyFileAbsolute(actual_node_path, node_shim_path, .{}) catch |err| {
Output.prettyErrorln("<r><red>error<r><d>:<r> Failed to copy node binary: {s}", .{@errorName(err)});
Global.exit(1);
};
} else {
// On Unix systems, use symlinks (secure, no shell injection possible)
std.fs.symLinkAbsolute(actual_node_path, node_shim_path, .{}) catch |err| {
Output.prettyErrorln("<r><red>error<r><d>:<r> Failed to create node symlink: {s}", .{@errorName(err)});
Global.exit(1);
};
}
Output.prettyErrorln("<r>Node.js shim updated: {s}<r>", .{node_shim_path});
}
};

View File

@@ -0,0 +1,146 @@
import { spawnSync } from "bun";
import { describe, expect, test, afterAll } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
import fs from "node:fs";
import path from "node:path";
describe("bun node", () => {
const originalBunInstall = process.env.BUN_INSTALL;
let testBunInstall: string;
test("setup test directory", () => {
testBunInstall = tempDirWithFiles("node-version-test", {});
process.env.BUN_INSTALL = testBunInstall;
});
afterAll(() => {
if (originalBunInstall) {
process.env.BUN_INSTALL = originalBunInstall;
} else {
delete process.env.BUN_INSTALL;
}
});
test("shows error when no version specified", () => {
const { stderr, exitCode } = spawnSync({
cmd: [bunExe(), "node"],
env: { ...bunEnv, BUN_INSTALL: testBunInstall },
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(1);
expect(stderr.toString()).toContain("Please specify a Node.js version to install");
});
test("shows error for invalid version format", () => {
const { stderr, exitCode } = spawnSync({
cmd: [bunExe(), "node", "invalid-version"],
env: { ...bunEnv, BUN_INSTALL: testBunInstall },
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(1);
expect(stderr.toString()).toContain("Invalid Node.js version format");
});
test("accepts valid version format", () => {
const { stderr, exitCode } = spawnSync({
cmd: [bunExe(), "node", "20.11.0"],
env: { ...bunEnv, BUN_INSTALL: testBunInstall },
stdout: "pipe",
stderr: "pipe",
timeout: 30000, // 30 seconds timeout for download
});
// The command should start the installation process
// We expect it to either succeed or fail due to network/download issues
// but not due to argument validation
const stderrOutput = stderr.toString();
expect(stderrOutput).not.toContain("Invalid Node.js version format");
expect(stderrOutput).not.toContain("Please specify a Node.js version");
// Should show installation message
expect(stderrOutput).toContain("Installing Node.js v20.11.0");
});
test("falls back to ~/.bun when BUN_INSTALL not set", () => {
const env = { ...bunEnv };
delete env.BUN_INSTALL; // Ensure BUN_INSTALL is not set
const { stderr, exitCode } = spawnSync({
cmd: [bunExe(), "node", "20.11.0"],
env,
stdout: "pipe",
stderr: "pipe",
timeout: 30000, // 30 seconds timeout for download
});
// Should start installation process using ~/.bun fallback
const stderrOutput = stderr.toString();
expect(stderrOutput).toContain("Installing Node.js v20.11.0");
expect(stderrOutput).toContain("Downloading from https://nodejs.org/dist/v20.11.0/node-v20.11.0-linux-arm64.tar.gz");
});
describe("version validation", () => {
const validVersions = ["20.11.0", "18.19.1", "16.20.2", "14.21.3"];
const invalidVersions = ["20", "20.11", "v20.11.0", "20.11.0-beta", "latest", ""];
test.each(validVersions)("accepts valid version: %s", (version) => {
const { stderr, exitCode } = spawnSync({
cmd: [bunExe(), "node", version],
env: { ...bunEnv, BUN_INSTALL: testBunInstall },
stdout: "pipe",
stderr: "pipe",
timeout: 5000, // Short timeout since we just want to test validation
});
const stderrOutput = stderr.toString();
expect(stderrOutput).not.toContain("Invalid Node.js version format");
expect(stderrOutput).toContain(`Installing Node.js v${version}`);
});
test.each(invalidVersions)("rejects invalid version: %s", (version) => {
const { stderr, exitCode } = spawnSync({
cmd: [bunExe(), "node", version],
env: { ...bunEnv, BUN_INSTALL: testBunInstall },
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(1);
expect(stderr.toString()).toContain("Invalid Node.js version format");
});
});
test("creates proper directory structure", () => {
// Mock a successful installation by creating the expected directories
const nodeDir = path.join(testBunInstall, "node", "v20.11.0");
const binDir = path.join(testBunInstall, "bin");
fs.mkdirSync(nodeDir, { recursive: true });
fs.mkdirSync(binDir, { recursive: true });
// Create a fake node binary to simulate installation
const nodeBinaryPath = path.join(nodeDir, "bin", "node");
fs.mkdirSync(path.dirname(nodeBinaryPath), { recursive: true });
fs.writeFileSync(nodeBinaryPath, "#!/bin/sh\necho 'fake node'");
fs.chmodSync(nodeBinaryPath, 0o755);
// Now test that running the command on an already installed version works
const { stderr, exitCode } = spawnSync({
cmd: [bunExe(), "node", "20.11.0"],
env: { ...bunEnv, BUN_INSTALL: testBunInstall },
stdout: "pipe",
stderr: "pipe",
});
const stderrOutput = stderr.toString();
expect(stderrOutput).toContain("Node.js v20.11.0 is already installed");
// Check that the shim was created
const shimPath = path.join(binDir, "node");
expect(fs.existsSync(shimPath)).toBe(true);
});
});