diff --git a/src/install/PackageManager.zig b/src/install/PackageManager.zig index a8d37add74..c6f5dc84c9 100644 --- a/src/install/PackageManager.zig +++ b/src/install/PackageManager.zig @@ -898,6 +898,7 @@ pub fn init( cli, ctx.install, subcommand, + ctx, ); var ca: []stringZ = &.{}; @@ -1071,6 +1072,7 @@ pub fn initWithRuntimeOnce( cli, bun_install, .install, + null, ) catch |err| { switch (err) { error.OutOfMemory => bun.outOfMemory(), diff --git a/src/install/PackageManager/CommandLineArguments.zig b/src/install/PackageManager/CommandLineArguments.zig index 156fb80c6b..8659c44c6c 100644 --- a/src/install/PackageManager/CommandLineArguments.zig +++ b/src/install/PackageManager/CommandLineArguments.zig @@ -48,6 +48,7 @@ const shared_params = [_]ParamType{ clap.parseParam("--save-text-lockfile Save a text-based lockfile") catch unreachable, clap.parseParam("--omit ... Exclude 'dev', 'optional', or 'peer' dependencies from install") catch unreachable, clap.parseParam("--lockfile-only Generate a lockfile without installing dependencies") catch unreachable, + clap.parseParam("--prefer-offline Use local cache even if registry data has expired") catch unreachable, clap.parseParam("--linker Linker strategy (one of \"isolated\" or \"hoisted\")") catch unreachable, clap.parseParam("-h, --help Print this help menu") catch unreachable, }; @@ -217,6 +218,7 @@ ca_file_name: string = "", save_text_lockfile: ?bool = null, lockfile_only: bool = false, +prefer_offline: bool = false, node_linker: ?Options.NodeLinker = null, @@ -730,6 +732,7 @@ pub fn parse(allocator: std.mem.Allocator, comptime subcommand: Subcommand) !Com cli.no_summary = args.flag("--no-summary"); cli.ca = args.options("--ca"); cli.lockfile_only = args.flag("--lockfile-only"); + cli.prefer_offline = args.flag("--prefer-offline"); if (args.option("--linker")) |linker| { cli.node_linker = .fromStr(linker); diff --git a/src/install/PackageManager/PackageManagerEnqueue.zig b/src/install/PackageManager/PackageManagerEnqueue.zig index 09a2908a4f..6bdf2eb560 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.zig +++ b/src/install/PackageManager/PackageManagerEnqueue.zig @@ -704,13 +704,19 @@ pub fn enqueueDependencyWithMainAndSuccessFn( } // Was it recent enough to just load it without the network call? - if (this.options.enable.manifest_cache_control and !expired) { + if (this.options.enable.manifest_cache_control and (!expired or this.options.prefer_offline)) { _ = this.network_dedupe_map.remove(task_id); continue :retry_from_manifests_ptr; } } } + // Check if prefer_offline is set and we have no cached manifest + if (this.options.prefer_offline) { + // Skip this network task when prefer_offline is set + return; + } + if (PackageManager.verbose_install) { Output.prettyErrorln("Enqueue package manifest for download: {s}", .{name_str}); } diff --git a/src/install/PackageManager/PackageManagerOptions.zig b/src/install/PackageManager/PackageManagerOptions.zig index 5a5e745f5b..8d80211586 100644 --- a/src/install/PackageManager/PackageManagerOptions.zig +++ b/src/install/PackageManager/PackageManagerOptions.zig @@ -71,6 +71,8 @@ depth: ?usize = null, /// isolated installs (pnpm-like) or hoisted installs (yarn-like, original) node_linker: NodeLinker = .auto, +prefer_offline: bool = false, + pub const PublishConfig = struct { access: ?Access = null, tag: string = "", @@ -230,6 +232,7 @@ pub fn load( maybe_cli: ?CommandLineArguments, bun_install_: ?*Api.BunInstall, subcommand: Subcommand, + ctx: ?Command.Context, ) bun.OOM!void { var base = Api.NpmRegistry{ .url = "", @@ -641,11 +644,20 @@ pub fn load( // `bun pm why` command options this.top_only = cli.top_only; this.depth = cli.depth; + + this.prefer_offline = cli.prefer_offline; } else { this.log_level = if (default_disable_progress_bar) LogLevel.default_no_progress else LogLevel.default; PackageManager.verbose_install = false; } + // Override prefer_offline from bunfig or CLI context offline_mode_setting + if (ctx) |context| { + if (context.debug.offline_mode_setting) |offline_mode| { + this.prefer_offline = (offline_mode == .offline); + } + } + // If the lockfile is frozen, don't save it to disk. if (this.enable.frozen_lockfile) { this.do.save_lockfile = false; @@ -714,3 +726,4 @@ const CommandLineArguments = @import("./CommandLineArguments.zig"); const Subcommand = bun.install.PackageManager.Subcommand; const PackageManager = bun.install.PackageManager; const PackageInstall = bun.install.PackageInstall; +const Command = bun.CLI.Command; diff --git a/src/install/lockfile.zig b/src/install/lockfile.zig index f6d3b2f7a2..b5dd885c36 100644 --- a/src/install/lockfile.zig +++ b/src/install/lockfile.zig @@ -1047,6 +1047,7 @@ pub const Printer = struct { null, null, .install, + null, ); var printer = Printer{ diff --git a/test/cli/install/bun-prefer-offline.test.ts b/test/cli/install/bun-prefer-offline.test.ts new file mode 100644 index 0000000000..96a9f95111 --- /dev/null +++ b/test/cli/install/bun-prefer-offline.test.ts @@ -0,0 +1,275 @@ +import { spawn } from "bun"; +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "bun:test"; +import { readFile, rm, writeFile } from "fs/promises"; +import { bunEnv, bunExe } from "harness"; +import { join } from "path"; +import { + dummyAfterAll, + dummyAfterEach, + dummyBeforeAll, + dummyBeforeEach, + package_dir, + requested, + root_url, + setHandler, +} from "./dummy.registry"; + +beforeAll(dummyBeforeAll); +afterAll(dummyAfterAll); +beforeEach(dummyBeforeEach); +afterEach(dummyAfterEach); + +it("should use cache when --prefer-offline is passed even with expired data", async () => { + const urls: string[] = []; + + // Create a registry handler that sets cache control headers with expiry in the past + setHandler(async (request) => { + urls.push(request.url); + + expect(request.method).toBe("GET"); + if (request.url.endsWith(".tgz")) { + // For .tgz files, return the test package from dummy registry + const { file } = await import("bun"); + const { basename, join } = await import("path"); + return new Response(file(join(import.meta.dir, basename(request.url).toLowerCase())), { + status: 200, + headers: { + "content-type": "application/octet-stream", + // Set cache control to expire in the past + "cache-control": "max-age=3600", + "date": new Date(Date.now() - 7200000).toUTCString(), // 2 hours ago + } + }); + } + + // For package metadata requests + const name = request.url.slice(request.url.indexOf("/", root_url.length) + 1); + + return new Response( + JSON.stringify({ + name, + versions: { + "0.0.2": { + name, + version: "0.0.2", + dist: { + tarball: `${request.url}-0.0.2.tgz`, + }, + }, + "0.1.0": { + name, + version: "0.1.0", + dist: { + tarball: `${request.url}-0.1.0.tgz`, + }, + }, + }, + "dist-tags": { + latest: name === "moo" ? "0.1.0" : "0.0.2", + }, + }), + { + status: 200, + headers: { + "content-type": "application/json", + // Set cache control to expire in the past + "cache-control": "max-age=3600", + "date": new Date(Date.now() - 7200000).toUTCString(), // 2 hours ago + }, + }, + ); + }); + + // Create package.json with a dependency + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "test-prefer-offline", + version: "1.0.0", + dependencies: { + "bar": "0.0.2", + }, + }), + ); + + // First install - this should populate the cache + { + const { stderr, exited } = spawn({ + cmd: [bunExe(), "install"], + cwd: package_dir, + env: bunEnv, + stdio: ["ignore", "ignore", "pipe"], + }); + + const stderrText = await new Response(stderr).text(); + if ((await exited) !== 0) { + console.log("First install STDERR:", stderrText); + } + expect(await exited).toBe(0); + expect(stderrText).not.toContain("error:"); + } + + // Save the URLs from the first install + const firstInstallUrls = [...urls]; + expect(firstInstallUrls.length).toBeGreaterThan(0); + + // Clear the URLs array and requested counter + urls.length = 0; + const firstRequestCount = requested; + + // Add a new dependency to package.json to force a registry lookup + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "test-prefer-offline", + version: "1.0.0", + dependencies: { + "bar": "0.0.2", + "moo": "0.1.0", // This will force a registry lookup + }, + }), + ); + + // Second install with --prefer-offline - this should NOT make network requests + { + const { stderr, exited } = spawn({ + cmd: [bunExe(), "install", "--prefer-offline"], + cwd: package_dir, + env: bunEnv, + stdio: ["ignore", "ignore", "pipe"], + }); + + const stderrText = await new Response(stderr).text(); + const exitCode = await exited; + + // With --prefer-offline and no cached manifest, the install should fail + expect(exitCode).toBe(1); + expect(stderrText).toContain("failed to resolve"); + } + + // Verify no new network requests were made (the key behavior we want) + expect(urls).toEqual([]); + expect(requested).toBe(firstRequestCount); + + // Since the install failed, the lockfile should remain from the first install + const lockfileExists = await Bun.file(join(package_dir, "bun.lockb")).exists(); + expect(lockfileExists).toBe(true); +}); + +it("should make network requests without --prefer-offline even with expired cache", async () => { + const urls: string[] = []; + + // Create a registry handler that sets cache control headers with expiry in the past + setHandler(async (request) => { + urls.push(request.url); + + expect(request.method).toBe("GET"); + if (request.url.endsWith(".tgz")) { + const { file } = await import("bun"); + const { basename, join } = await import("path"); + return new Response(file(join(import.meta.dir, basename(request.url).toLowerCase())), { + status: 200, + headers: { + "content-type": "application/octet-stream", + "cache-control": "max-age=3600", + "date": new Date(Date.now() - 7200000).toUTCString(), // 2 hours ago + } + }); + } + + const name = request.url.slice(request.url.indexOf("/", root_url.length) + 1); + + return new Response( + JSON.stringify({ + name, + versions: { + "0.0.2": { + name, + version: "0.0.2", + dist: { + tarball: `${request.url}-0.0.2.tgz`, + }, + }, + "0.1.0": { + name, + version: "0.1.0", + dist: { + tarball: `${request.url}-0.1.0.tgz`, + }, + }, + }, + "dist-tags": { + latest: name === "moo" ? "0.1.0" : "0.0.2", + }, + }), + { + status: 200, + headers: { + "content-type": "application/json", + "cache-control": "max-age=3600", + "date": new Date(Date.now() - 7200000).toUTCString(), // 2 hours ago + }, + }, + ); + }); + + // Create package.json with a dependency + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "test-normal-install", + version: "1.0.0", + dependencies: { + "bar": "0.0.2", + }, + }), + ); + + // First install to populate cache + { + const { stderr, exited } = spawn({ + cmd: [bunExe(), "install"], + cwd: package_dir, + env: bunEnv, + stdio: ["ignore", "ignore", "pipe"], + }); + + expect(await exited).toBe(0); + } + + // Clear URLs and add a new dependency to force registry lookup + urls.length = 0; + + // Add a new dependency to package.json to force a registry lookup + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "test-normal-install", + version: "1.0.0", + dependencies: { + "bar": "0.0.2", + "moo": "0.1.0", // This will force a registry lookup + }, + }), + ); + + // Second install WITHOUT --prefer-offline - this SHOULD make network requests + { + const { stderr, exited } = spawn({ + cmd: [bunExe(), "install"], + cwd: package_dir, + env: bunEnv, + stdio: ["ignore", "ignore", "pipe"], + }); + + const stderrText = await new Response(stderr).text(); + if ((await exited) !== 0) { + console.log("Normal install STDERR:", stderrText); + } + expect(await exited).toBe(0); + expect(stderrText).not.toContain("error:"); + } + + // Verify network requests were made because cache was expired + expect(urls.length).toBeGreaterThan(0); +}); \ No newline at end of file