Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli/bunx): support --no-install flag #16315

Merged
merged 3 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/bun.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1613,6 +1613,7 @@ pub const renamer = @import("./renamer.zig");
// TODO: Rename to SourceMap as this is a struct.
pub const sourcemap = @import("./sourcemap/sourcemap.zig");

/// Attempt to coerce some value into a byte slice.
pub fn asByteSlice(buffer: anytype) []const u8 {
return switch (@TypeOf(buffer)) {
[]const u8, []u8, [:0]const u8, [:0]u8 => buffer.ptr[0..buffer.len],
Expand Down
48 changes: 41 additions & 7 deletions src/cli/bunx_command.zig
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
const std = @import("std");
const bun = @import("root").bun;
const string = bun.string;
const Allocator = std.mem.Allocator;
const Output = bun.Output;
const Global = bun.Global;
const Environment = bun.Environment;
Expand All @@ -8,11 +10,11 @@ const MutableString = bun.MutableString;
const stringZ = bun.stringZ;
const default_allocator = bun.default_allocator;
const C = bun.C;
const std = @import("std");
const cli = @import("../cli.zig");

const Command = cli.Command;
const Run = @import("./run_command.zig").RunCommand;
const Allocator = std.mem.Allocator;
const UpdateRequest = bun.PackageManager.UpdateRequest;

const debug = Output.scoped(.bunx, false);

Expand All @@ -30,6 +32,9 @@ pub const BunxCommand = struct {
// purposes.
verbose_install: bool = false,
silent_install: bool = false,
/// Skip installing the package, only running the target command if its
/// already downloaded. If its not, `bunx` exits with an error.
no_install: bool = false,
allocator: Allocator,

/// Create a new `Options` instance by parsing CLI arguments. `ctx` may be mutated.
Expand Down Expand Up @@ -65,6 +70,8 @@ pub const BunxCommand = struct {
opts.silent_install = true;
} else if (strings.eqlComptime(positional, "--bun") or strings.eqlComptime(positional, "-b")) {
ctx.debug.run_in_bun = true;
} else if (strings.eqlComptime(positional, "--no-install")) {
opts.no_install = true;
}
} else {
if (!found_subcommand_name) {
Expand Down Expand Up @@ -298,9 +305,9 @@ pub const BunxCommand = struct {
var opts = try Options.parse(ctx, argv);
defer opts.deinit();

var requests_buf = bun.PackageManager.UpdateRequest.Array.initCapacity(ctx.allocator, 64) catch bun.outOfMemory();
var requests_buf = UpdateRequest.Array.initCapacity(ctx.allocator, 64) catch bun.outOfMemory();
defer requests_buf.deinit(ctx.allocator);
const update_requests = bun.PackageManager.UpdateRequest.parse(
const update_requests = UpdateRequest.parse(
ctx.allocator,
null,
ctx.log,
Expand Down Expand Up @@ -335,6 +342,7 @@ pub const BunxCommand = struct {

// fast path: they're actually using this interchangeably with `bun run`
// so we use Bun.which to check
// SAFETY: initialized by Run.configureEnvForRun
var this_transpiler: bun.Transpiler = undefined;
var ORIGINAL_PATH: string = "";

Expand Down Expand Up @@ -384,7 +392,7 @@ pub const BunxCommand = struct {
else => ":",
};

const has_banned_char = bun.strings.indexAnyComptime(update_request.name, banned_path_chars) != null or bun.strings.indexAnyComptime(display_version, banned_path_chars) != null;
const has_banned_char = strings.indexAnyComptime(update_request.name, banned_path_chars) != null or strings.indexAnyComptime(display_version, banned_path_chars) != null;

break :brk try if (has_banned_char)
// This branch gets hit usually when a URL is requested as the package
Expand Down Expand Up @@ -503,8 +511,10 @@ pub const BunxCommand = struct {
const passthrough = opts.passthrough_list.items;

var do_cache_bust = update_request.version.tag == .dist_tag;
const look_for_existing_bin = update_request.version.literal.isEmpty() or update_request.version.tag != .dist_tag;

if (update_request.version.literal.isEmpty() or update_request.version.tag != .dist_tag) try_run_existing: {
debug("try run existing? {}", .{look_for_existing_bin});
if (look_for_existing_bin) try_run_existing: {
var destination_: ?[:0]const u8 = null;

// Only use the system-installed version if there is no version specified
Expand Down Expand Up @@ -563,11 +573,17 @@ pub const BunxCommand = struct {
};

if (is_stale) {
debug("found stale binary: {s}", .{out});
do_cache_bust = true;
break :try_run_existing;
if (opts.no_install) {
Output.warn("Using a stale installation of <b>{s}<r> because --no-install was passed. Run `bunx` without --no-install to use a fresh binary.", .{update_request.name});
} else {
break :try_run_existing;
}
}
}

debug("running existing binary: {s}", .{destination});
try Run.runBinary(
ctx,
try this_transpiler.fs.dirname_store.append(@TypeOf(out), out),
Expand Down Expand Up @@ -626,6 +642,24 @@ pub const BunxCommand = struct {
}
}
}
// If we've reached this point, it means we couldn't find an existing binary to run.
// Next step is to install, then run it.

// NOTE: npx prints errors like this:
//
// npm error npx canceled due to missing packages and no YES option: ["[email protected]"]
// npm error A complete log of this run can be found in: [folder]/debug.log
//
// Which is not very helpful.

if (opts.no_install) {
Output.errGeneric(
"Could not find an existing '{s}' binary to run. Stopping because --no-install was passed.",
.{initial_bin_name},
);
Global.exit(1);
}

const bunx_install_dir = try std.fs.cwd().makeOpenPath(bunx_cache_dir, .{});

create_package_json: {
Expand Down
80 changes: 77 additions & 3 deletions test/cli/install/bunx.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { spawn } from "bun";
import { beforeAll, beforeEach, expect, it, setDefaultTimeout } from "bun:test";
import { describe, beforeAll, beforeEach, expect, it, setDefaultTimeout } from "bun:test";
import { rm, writeFile } from "fs/promises";
import { bunEnv, bunExe, isWindows, tmpdirSync, readdirSorted } from "harness";
import { readdirSync } from "node:fs";
Expand Down Expand Up @@ -83,7 +83,7 @@ it("should choose the tagged versions instead of the PATH versions when a tag is
cwd: x_dir,
stdout: "pipe",
stdin: "ignore",
stderr: "inherit",
stderr: "ignore",
env: {
...env,
// BUN_DEBUG_QUIET_LOGS: undefined,
Expand Down Expand Up @@ -280,7 +280,7 @@ it("should work for github repository with committish", async () => {

// cached
const cached = spawn({
cmd: [bunExe(), "x", "github:piuccio/cowsay#HEAD", "hello bun!"],
cmd: [bunExe(), "x", "--no-install", "github:piuccio/cowsay#HEAD", "hello bun!"],
cwd: x_dir,
stdout: "pipe",
stdin: "inherit",
Expand Down Expand Up @@ -396,3 +396,77 @@ it('should set "npm_config_user_agent" to bun', async () => {
expect(out.trim()).toContain(`bun/${Bun.version}`);
expect(exited).toBe(0);
});

/**
* IMPORTANT
* Please only use packages with small unpacked sizes for tests. It helps keep them fast.
*/
describe("bunx --no-install", () => {
const run = (...args: string[]): Promise<[stderr: string, stdout: string, exitCode: number]> => {
const subprocess = spawn({
cmd: [bunExe(), "x", ...args],
cwd: x_dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});

return Promise.all([
new Response(subprocess.stderr).text(),
new Response(subprocess.stdout).text(),
subprocess.exited,
] as const);
};

it("if the package is not installed, it should fail and print an error message", async () => {
const [err, out, exited] = await run("--no-install", "http-server", "--version");

expect(err.trim()).toContain(
"Could not find an existing 'http-server' binary to run.",
);
expect(out).toHaveLength(0);
expect(exited).toBe(1);
});

/*
yes, multiple package tests are neccessary.
1. there's specialized logic for `bunx tsc` and `bunx typescript`
2. http-server checks for non-alphanumeric edge cases. Plus it's small
3. eslint is alphanumeric and extremely common
*/
it.each(["typescript", "http-server", "eslint"])("`bunx --no-install %s` should find cached packages", async pkg => {
// not cached
{
const [err, out, code] = await run(pkg, "--version");
expect(err).not.toContain("error:");
expect(out).not.toBeEmpty();
expect(code).toBe(0);
}

// cached
{
const [err, out, code] = await run("--no-install", pkg, "--version");
expect(err).not.toContain("error:");
expect(out).not.toBeEmpty();
expect(code).toBe(0);
}
});

it("when an exact version match is found, should find cached packages", async () => {
// not cached
{
const [err, out, code] = await run("[email protected]", "--version");
expect(err).not.toContain("error:");
expect(out).not.toBeEmpty();
expect(code).toBe(0);
}

// cached
{
const [err, out, code] = await run("--no-install", "[email protected]", "--version");
expect(err).not.toContain("error:");
expect(out).not.toBeEmpty();
expect(code).toBe(0);
}
});
});
Loading