diff --git a/src/install/NetworkTask.zig b/src/install/NetworkTask.zig index 4401cff736..1b9174352d 100644 --- a/src/install/NetworkTask.zig +++ b/src/install/NetworkTask.zig @@ -86,6 +86,11 @@ pub fn forManifest( is_optional: bool, needs_extended: bool, ) ForManifestError!void { + // Debug assertion - should not be called in offline mode + if (comptime bun.Environment.allow_assert) { + bun.assert(!this.package_manager.options.enable.offline); + } + this.url_buf = blk: { // Not all registries support scoped package names when fetching the manifest. @@ -250,6 +255,7 @@ pub fn schedule(this: *NetworkTask, batch: *ThreadPool.Batch) void { pub const ForTarballError = OOM || error{ InvalidURL, + OfflineModePackageNotCached, }; pub fn forTarball( @@ -259,6 +265,11 @@ pub fn forTarball( scope: *const Npm.Registry.Scope, authorization: NetworkTask.Authorization, ) ForTarballError!void { + // Debug assertion - should not be called in offline mode + if (comptime bun.Environment.allow_assert) { + bun.assert(!this.package_manager.options.enable.offline); + } + this.callback = .{ .extract = tarball_.* }; const tarball = &this.callback.extract; const tarball_url = tarball.url.slice(); diff --git a/src/install/PackageInstaller.zig b/src/install/PackageInstaller.zig index fa615f787b..b9805594b7 100644 --- a/src/install/PackageInstaller.zig +++ b/src/install/PackageInstaller.zig @@ -934,6 +934,7 @@ pub const PackageInstaller = struct { is_pending_package_install, log_level, ), + error.OfflineModePackageNotCached => {}, }; }, .local_tarball => { @@ -957,6 +958,7 @@ pub const PackageInstaller = struct { is_pending_package_install, log_level, ), + error.OfflineModePackageNotCached => {}, }; }, .npm => { @@ -985,6 +987,7 @@ pub const PackageInstaller = struct { is_pending_package_install, log_level, ), + error.OfflineModePackageNotCached => {}, }; }, else => { diff --git a/src/install/PackageManager/CommandLineArguments.zig b/src/install/PackageManager/CommandLineArguments.zig index 29f8f96c05..1297e65a3f 100644 --- a/src/install/PackageManager/CommandLineArguments.zig +++ b/src/install/PackageManager/CommandLineArguments.zig @@ -29,6 +29,7 @@ const shared_params = [_]ParamType{ clap.parseParam("--cafile The same as `--ca`, but is a file path to the certificate") catch unreachable, clap.parseParam("--dry-run Don't install anything") catch unreachable, clap.parseParam("--frozen-lockfile Disallow changes to lockfile") catch unreachable, + clap.parseParam("--offline Install packages using only the local cache") catch unreachable, clap.parseParam("-f, --force Always request the latest versions from the registry & reinstall all dependencies") catch unreachable, clap.parseParam("--cache-dir Store & load cached data from a specific directory path") catch unreachable, clap.parseParam("--no-cache Ignore manifest cache entirely") catch unreachable, @@ -184,6 +185,7 @@ positionals: []const string = &[_]string{}, yarn: bool = false, production: bool = false, frozen_lockfile: bool = false, +offline: bool = false, no_save: bool = false, dry_run: bool = false, force: bool = false, @@ -795,6 +797,7 @@ pub fn parse(allocator: std.mem.Allocator, comptime subcommand: Subcommand) !Com cli.yarn = args.flag("--yarn"); cli.production = args.flag("--production") or args.flag("--prod"); cli.frozen_lockfile = args.flag("--frozen-lockfile") or (cli.positionals.len > 0 and strings.eqlComptime(cli.positionals[0], "ci")); + cli.offline = args.flag("--offline"); cli.no_progress = args.flag("--no-progress"); cli.dry_run = args.flag("--dry-run"); cli.global = args.flag("--global"); diff --git a/src/install/PackageManager/PackageManagerEnqueue.zig b/src/install/PackageManager/PackageManagerEnqueue.zig index a8c32df74f..2a1f50f752 100644 --- a/src/install/PackageManager/PackageManagerEnqueue.zig +++ b/src/install/PackageManager/PackageManagerEnqueue.zig @@ -107,6 +107,21 @@ pub fn enqueueTarballForDownload( if (task_queue.found_existing) return; + // In offline mode, check if tarball is in cache + if (this.options.enable.offline) { + const dep = this.lockfile.buffers.dependencies.items[dependency_id]; + if (!dep.behavior.isOptional()) { + this.log.addErrorFmt( + null, + logger.Loc.Empty, + this.allocator, + "Package tarball not found in cache (offline mode): {s}", + .{url}, + ) catch {}; + } + return error.OfflineModePackageNotCached; + } + if (try this.generateNetworkTaskForTarball( task_id, url, @@ -162,6 +177,21 @@ pub fn enqueueGitForCheckout( task_context: TaskCallbackContext, patch_name_and_version_hash: ?u64, ) void { + // Git dependencies not supported in offline mode + if (this.options.enable.offline) { + const dep = this.lockfile.buffers.dependencies.items[dependency_id]; + if (!dep.behavior.isOptional()) { + this.log.addErrorFmt( + null, + logger.Loc.Empty, + this.allocator, + "Git dependencies not supported in offline mode: {s}", + .{alias}, + ) catch {}; + } + return; + } + const repository = &resolution.value.git; const url = this.lockfile.str(&repository.repo); const clone_id = Task.Id.forGitClone(url); @@ -760,6 +790,20 @@ pub fn enqueueDependencyWithMainAndSuccessFn( } } + // In offline mode, check if package is in cache + if (this.options.enable.offline) { + if (!dependency.behavior.isOptional()) { + this.log.addErrorFmt( + null, + logger.Loc.Empty, + this.allocator, + "Package \"{s}\" not found in cache (offline mode)", + .{name_str}, + ) catch {}; + } + return; + } + if (PackageManager.verbose_install) { Output.prettyErrorln("Enqueue package manifest for download: {s}", .{name_str}); } @@ -815,6 +859,20 @@ pub fn enqueueDependencyWithMainAndSuccessFn( return; } + // Git dependencies not supported in offline mode + if (this.options.enable.offline) { + if (dependency.behavior.isRequired()) { + this.log.addErrorFmt( + null, + logger.Loc.Empty, + this.allocator, + "Git dependencies not supported in offline mode", + .{}, + ) catch {}; + } + return; + } + const alias = this.lockfile.str(&dependency.name); const url = this.lockfile.str(&dep.repo); const clone_id = Task.Id.forGitClone(url); diff --git a/src/install/PackageManager/PackageManagerOptions.zig b/src/install/PackageManager/PackageManagerOptions.zig index 2437c1ec44..8d23fd0b76 100644 --- a/src/install/PackageManager/PackageManagerOptions.zig +++ b/src/install/PackageManager/PackageManagerOptions.zig @@ -624,6 +624,12 @@ pub fn load( this.enable.frozen_lockfile = true; } + if (cli.offline) { + this.enable.offline = true; + // In offline mode, use stale cache (don't check expiry) + this.enable.manifest_cache_control = false; + } + if (cli.force) { this.enable.manifest_cache_control = false; this.enable.force_install = true; @@ -728,7 +734,8 @@ pub const Enable = packed struct(u16) { exact_versions: bool = false, only_missing: bool = false, - _: u7 = 0, + offline: bool = false, + _: u6 = 0, }; const string = []const u8; diff --git a/src/install/PackageManager/PopulateManifestCache.zig b/src/install/PackageManager/PopulateManifestCache.zig index 3974b9cf11..f15fb659f8 100644 --- a/src/install/PackageManager/PopulateManifestCache.zig +++ b/src/install/PackageManager/PopulateManifestCache.zig @@ -1,9 +1,27 @@ -const StartManifestTaskError = bun.OOM || error{InvalidURL}; +const StartManifestTaskError = bun.OOM || error{ InvalidURL, OfflineModePackageNotCached }; fn startManifestTask(manager: *PackageManager, pkg_name: []const u8, dep: *const Dependency, needs_extended_manifest: bool) StartManifestTaskError!void { const task_id = Task.Id.forManifest(pkg_name); if (manager.hasCreatedNetworkTask(task_id, dep.behavior.optional)) { return; } + + // In offline mode, error if we need to fetch a manifest + if (manager.options.enable.offline) { + if (dep.behavior.isOptional()) { + // Optional dependencies can fail silently in offline mode + return; + } + // Required dependency not in cache - fail + manager.log.addErrorFmt( + null, + logger.Loc.Empty, + manager.allocator, + "Package \"{s}\" not found in cache (offline mode)", + .{pkg_name}, + ) catch {}; + return error.OfflineModePackageNotCached; + } + manager.startProgressBarIfNone(); var task = manager.getNetworkTask(); task.* = .{ @@ -153,6 +171,7 @@ const std = @import("std"); const bun = @import("bun"); const Output = bun.Output; +const logger = bun.logger; const Dependency = bun.install.Dependency; const DependencyID = bun.install.DependencyID; diff --git a/src/install/PackageManager/install_with_manager.zig b/src/install/PackageManager/install_with_manager.zig index ffd797cca9..ff386dcde6 100644 --- a/src/install/PackageManager/install_with_manager.zig +++ b/src/install/PackageManager/install_with_manager.zig @@ -6,6 +6,14 @@ pub fn installWithManager( ) !void { const log_level = manager.options.log_level; + // Display offline mode message + if (manager.options.enable.offline) { + if (log_level != .silent) { + Output.prettyErrorln("Installing in offline mode (using cache only)", .{}); + Output.flush(); + } + } + // Start resolving DNS for the default registry immediately. // Unless you're behind a proxy. if (!manager.env.hasHTTPProxy()) { diff --git a/src/install/PackageManager/runTasks.zig b/src/install/PackageManager/runTasks.zig index 1b9553d0cb..ea1c0b82eb 100644 --- a/src/install/PackageManager/runTasks.zig +++ b/src/install/PackageManager/runTasks.zig @@ -907,6 +907,14 @@ pub inline fn decrementPendingTasks(manager: *PackageManager) void { } pub fn flushNetworkQueue(this: *PackageManager) void { + // In offline mode, network queue should be empty + if (this.options.enable.offline) { + if (this.network_task_fifo.readableLength() > 0) { + Output.debug("Warning: network tasks queued in offline mode (this is a bug)", .{}); + } + return; + } + var network = &this.network_task_fifo; while (network.readItem()) |network_task| { diff --git a/src/install/PackageManifestMap.zig b/src/install/PackageManifestMap.zig index 5a5b91ca69..9f0174172f 100644 --- a/src/install/PackageManifestMap.zig +++ b/src/install/PackageManifestMap.zig @@ -87,6 +87,16 @@ pub fn byNameHashAllowExpired( return null; } + // In offline mode, always use cached manifest regardless of expiry + if (pm.options.enable.offline) { + entry.value_ptr.* = .{ .manifest = manifest }; + if (is_expired) |expiry| { + expiry.* = false; + } + return &entry.value_ptr.manifest; + } + + // Online mode: check expiry if (pm.options.enable.manifest_cache_control and manifest.pkg.public_max_age > pm.timestamp_for_manifest_cache_control) { entry.value_ptr.* = .{ .manifest = manifest }; return &entry.value_ptr.manifest; diff --git a/src/install/isolated_install.zig b/src/install/isolated_install.zig index f291cf12d7..2b4922e2c6 100644 --- a/src/install/isolated_install.zig +++ b/src/install/isolated_install.zig @@ -964,7 +964,7 @@ pub fn installIsolatedPackages( patch_info.nameAndVersionHash(), ) catch |err| switch (err) { error.OutOfMemory => |oom| return oom, - error.InvalidURL => { + error.InvalidURL, error.OfflineModePackageNotCached => { Output.err(err, "failed to enqueue package for download: {s}@{}", .{ pkg_name.slice(string_buf), pkg_res.fmt(string_buf, .auto), @@ -1001,7 +1001,7 @@ pub fn installIsolatedPackages( patch_info.nameAndVersionHash(), ) catch |err| switch (err) { error.OutOfMemory => bun.outOfMemory(), - error.InvalidURL => { + error.InvalidURL, error.OfflineModePackageNotCached => { Output.err(err, "failed to enqueue github package for download: {s}@{}", .{ pkg_name.slice(string_buf), pkg_res.fmt(string_buf, .auto), @@ -1035,7 +1035,7 @@ pub fn installIsolatedPackages( patch_info.nameAndVersionHash(), ) catch |err| switch (err) { error.OutOfMemory => bun.outOfMemory(), - error.InvalidURL => { + error.InvalidURL, error.OfflineModePackageNotCached => { Output.err(err, "failed to enqueue tarball for download: {s}@{}", .{ pkg_name.slice(string_buf), pkg_res.fmt(string_buf, .auto), diff --git a/test/cli/install/bun-install-offline.test.ts b/test/cli/install/bun-install-offline.test.ts new file mode 100644 index 0000000000..0f4fd0aab5 --- /dev/null +++ b/test/cli/install/bun-install-offline.test.ts @@ -0,0 +1,403 @@ +import { file } from "bun"; +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; +import { join } from "path"; + +test("offline mode: successful install with cached packages", async () => { + using tmpdir = tempDir("offline-success", { + "package.json": JSON.stringify({ + name: "test-offline-success", + version: "1.0.0", + dependencies: { + "is-odd": "3.0.1", + }, + }), + }); + + // First install to populate cache + await using proc1 = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(tmpdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout1, stderr1, exitCode1] = await Promise.all([proc1.stdout.text(), proc1.stderr.text(), proc1.exited]); + + expect(exitCode1).toBe(0); + + // Remove node_modules and lockfile + await using rmProc = Bun.spawn({ + cmd: ["rm", "-rf", "node_modules", "bun.lockb"], + cwd: String(tmpdir), + }); + await rmProc.exited; + + // Install again in offline mode + await using proc2 = Bun.spawn({ + cmd: [bunExe(), "install", "--offline"], + cwd: String(tmpdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout2, stderr2, exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]); + + expect(exitCode2).toBe(0); + expect(normalizeBunSnapshot(stderr2, tmpdir)).toContain("Installing in offline mode (using cache only)"); + + // Verify package was installed + const isOddPath = join(String(tmpdir), "node_modules", "is-odd", "package.json"); + const isOddPkg = await file(isOddPath).json(); + expect(isOddPkg.name).toBe("is-odd"); + expect(isOddPkg.version).toBe("3.0.1"); +}); + +test("offline mode: fails when package not in cache", async () => { + using tmpdir = tempDir("offline-fail-not-cached", { + "package.json": JSON.stringify({ + name: "test-offline-fail", + version: "1.0.0", + dependencies: { + // Using a package that's very unlikely to be in cache + "some-nonexistent-package-that-should-never-exist": "1.0.0", + }, + }), + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--offline"], + cwd: String(tmpdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).not.toBe(0); + expect(normalizeBunSnapshot(stderr, tmpdir)).toContain("Installing in offline mode (using cache only)"); + expect(normalizeBunSnapshot(stderr, tmpdir)).toContain("not found in cache (offline mode)"); +}); + +test("offline mode: works without existing lockfile", async () => { + using tmpdir = tempDir("offline-no-lockfile", { + "package.json": JSON.stringify({ + name: "test-offline-no-lockfile", + version: "1.0.0", + dependencies: { + "is-even": "1.0.0", + }, + }), + }); + + // First install to populate cache + await using proc1 = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(tmpdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + await proc1.exited; + + // Remove node_modules and lockfile + await using rmProc = Bun.spawn({ + cmd: ["rm", "-rf", "node_modules", "bun.lockb"], + cwd: String(tmpdir), + }); + await rmProc.exited; + + // Install in offline mode without lockfile + await using proc2 = Bun.spawn({ + cmd: [bunExe(), "install", "--offline"], + cwd: String(tmpdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout2, stderr2, exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]); + + expect(exitCode2).toBe(0); + expect(normalizeBunSnapshot(stderr2, tmpdir)).toContain("Installing in offline mode (using cache only)"); + + // Verify package was installed + const isEvenPath = join(String(tmpdir), "node_modules", "is-even", "package.json"); + const isEvenPkg = await file(isEvenPath).json(); + expect(isEvenPkg.name).toBe("is-even"); +}); + +test("offline mode: skips optional dependencies not in cache", async () => { + using tmpdir = tempDir("offline-optional-deps", { + "package.json": JSON.stringify({ + name: "test-offline-optional", + version: "1.0.0", + dependencies: { + "is-number": "7.0.0", + }, + optionalDependencies: { + // This package doesn't exist, but should be skipped + "some-nonexistent-optional-package": "1.0.0", + }, + }), + }); + + // First install is-number to cache it + await using proc1 = Bun.spawn({ + cmd: [bunExe(), "install", "--no-optional"], + cwd: String(tmpdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + await proc1.exited; + + // Remove node_modules and lockfile + await using rmProc = Bun.spawn({ + cmd: ["rm", "-rf", "node_modules", "bun.lockb"], + cwd: String(tmpdir), + }); + await rmProc.exited; + + // Install in offline mode - should succeed despite optional dep missing + await using proc2 = Bun.spawn({ + cmd: [bunExe(), "install", "--offline"], + cwd: String(tmpdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout2, stderr2, exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]); + + expect(exitCode2).toBe(0); + expect(normalizeBunSnapshot(stderr2, tmpdir)).toContain("Installing in offline mode (using cache only)"); + + // Verify is-number was installed + const isNumberPath = join(String(tmpdir), "node_modules", "is-number", "package.json"); + const isNumberPkg = await file(isNumberPath).json(); + expect(isNumberPkg.name).toBe("is-number"); +}); + +test.skip("offline mode: rejects git dependencies", async () => { + using tmpdir = tempDir("offline-git-deps", { + "package.json": JSON.stringify({ + name: "test-offline-git", + version: "1.0.0", + dependencies: { + "is-odd": "3.0.1", + }, + }), + }); + + // First install to populate cache + await using proc1 = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(tmpdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + await proc1.exited; + + // Change to a git dependency (which can't be cached) + await Bun.write( + join(String(tmpdir), "package.json"), + JSON.stringify({ + name: "test-offline-git", + version: "1.0.0", + dependencies: { + "some-git-package": "git+https://github.com/user/repo.git#main", + }, + }), + ); + + // Remove node_modules and lockfile + await using rmProc = Bun.spawn({ + cmd: ["rm", "-rf", "node_modules", "bun.lockb"], + cwd: String(tmpdir), + }); + await rmProc.exited; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--offline"], + cwd: String(tmpdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).not.toBe(0); + expect(normalizeBunSnapshot(stderr, tmpdir)).toContain("Installing in offline mode (using cache only)"); +}); + +test("offline mode: works with workspace dependencies", async () => { + using tmpdir = tempDir("offline-workspace", { + "package.json": JSON.stringify({ + name: "test-offline-workspace", + version: "1.0.0", + workspaces: ["packages/*"], + }), + "packages/pkg-a/package.json": JSON.stringify({ + name: "pkg-a", + version: "1.0.0", + dependencies: { + "is-odd": "3.0.1", + }, + }), + }); + + // First install to populate cache + await using proc1 = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(tmpdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + await proc1.exited; + + // Remove node_modules and lockfile + await using rmProc = Bun.spawn({ + cmd: ["rm", "-rf", "node_modules", "packages/pkg-a/node_modules", "bun.lockb"], + cwd: String(tmpdir), + }); + await rmProc.exited; + + // Install in offline mode + await using proc2 = Bun.spawn({ + cmd: [bunExe(), "install", "--offline"], + cwd: String(tmpdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout2, stderr2, exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]); + + expect(exitCode2).toBe(0); + expect(normalizeBunSnapshot(stderr2, tmpdir)).toContain("Installing in offline mode (using cache only)"); + + // Verify package was installed (in workspace node_modules) + const pkgAPath = join(String(tmpdir), "packages", "pkg-a", "node_modules", "is-odd", "package.json"); + const isOddPkg = await file(pkgAPath).json(); + expect(isOddPkg.name).toBe("is-odd"); +}); + +test("offline mode: uses stale manifests (ignores expiry)", async () => { + using tmpdir = tempDir("offline-stale-manifest", { + "package.json": JSON.stringify({ + name: "test-offline-stale", + version: "1.0.0", + dependencies: { + "lodash": "^4.17.0", + }, + }), + }); + + // First install to populate cache + await using proc1 = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(tmpdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + await proc1.exited; + + // Remove node_modules and lockfile + await using rmProc = Bun.spawn({ + cmd: ["rm", "-rf", "node_modules", "bun.lockb"], + cwd: String(tmpdir), + }); + await rmProc.exited; + + // Install in offline mode - should use cached manifest even if stale + await using proc2 = Bun.spawn({ + cmd: [bunExe(), "install", "--offline"], + cwd: String(tmpdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout2, stderr2, exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]); + + expect(exitCode2).toBe(0); + expect(normalizeBunSnapshot(stderr2, tmpdir)).toContain("Installing in offline mode (using cache only)"); + + // Verify lodash was installed (some version satisfying ^4.17.0) + const lodashPath = join(String(tmpdir), "node_modules", "lodash", "package.json"); + const lodashPkg = await file(lodashPath).json(); + expect(lodashPkg.name).toBe("lodash"); + expect(lodashPkg.version).toMatch(/^4\.17\./); +}); + +test("offline mode: combines with other install flags", async () => { + using tmpdir = tempDir("offline-combined-flags", { + "package.json": JSON.stringify({ + name: "test-offline-combined", + version: "1.0.0", + dependencies: { + "ms": "2.1.3", + }, + devDependencies: { + "is-odd": "3.0.1", + }, + }), + }); + + // First install to populate cache + await using proc1 = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(tmpdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + await proc1.exited; + + // Remove node_modules and lockfile + await using rmProc = Bun.spawn({ + cmd: ["rm", "-rf", "node_modules", "bun.lockb"], + cwd: String(tmpdir), + }); + await rmProc.exited; + + // Install in offline mode with --production + await using proc2 = Bun.spawn({ + cmd: [bunExe(), "install", "--offline", "--production"], + cwd: String(tmpdir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout2, stderr2, exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]); + + expect(exitCode2).toBe(0); + expect(normalizeBunSnapshot(stderr2, tmpdir)).toContain("Installing in offline mode (using cache only)"); + + // Verify only production dependency was installed + const msPath = join(String(tmpdir), "node_modules", "ms", "package.json"); + const msPkg = await file(msPath).json(); + expect(msPkg.name).toBe("ms"); + + // Dev dependency should not be installed + const isOddPath = join(String(tmpdir), "node_modules", "is-odd"); + const isOddExists = await file(join(isOddPath, "package.json")) + .exists() + .catch(() => false); + expect(isOddExists).toBe(false); +});