diff --git a/docs/pm/workspaces.mdx b/docs/pm/workspaces.mdx index 7673b6111780..54cf0f49053a 100644 --- a/docs/pm/workspaces.mdx +++ b/docs/pm/workspaces.mdx @@ -96,6 +96,36 @@ Workspaces have a few major benefits. - **Bun can de-duplicate dependencies.** If `a` and `b` share a common dependency, Bun _hoists_ it to the root `node_modules` directory. This saves disk space and minimizes the "dependency hell" of multiple versions of a package installed at once. - **Run scripts in multiple packages.** Use the [`--filter` flag](/pm/filter) to run `package.json` scripts in several packages at once, or `--workspaces` to run scripts across all workspaces. +## Self-contained workspaces + +With the hoisted linker, dependencies shared by several workspaces are hoisted to the root +`node_modules`. Some tools cannot follow that: Electron packagers and serverless bundlers walk, +prune and repackage _one workspace's_ `node_modules` and expect every dependency to be physically +present under it. Mark such a workspace as self-contained, either in its own `package.json` +(the same key Yarn uses; only the `"workspaces"` value is recognized): + +```json title="apps/desktop/package.json" icon="file-json" +{ + "name": "desktop", + "installConfig": { "hoistingLimits": "workspaces" } +} +``` + +or from the root: + +```toml title="bunfig.toml" icon="settings" +[install] +selfContainedWorkspaces = ["apps/desktop"] # workspace paths or names +``` + +For that workspace `bun install` then behaves as a hoisting barrier — nothing it depends on, +directly or transitively (including through other workspaces it depends on), is placed above +`apps/desktop/node_modules`, so that directory is a complete tree — and materializes those +packages as real copies instead of hardlinks / clones from the cache, so tools that rewrite them +cannot affect the cache or other projects. All other workspaces keep hoisting to the root as usual. +This setting has no effect with the isolated linker, where every package already resolves only its +own dependencies. + ## Share versions with Catalogs When many packages need the same dependency versions, define those versions once diff --git a/docs/runtime/bunfig.mdx b/docs/runtime/bunfig.mdx index 5927b01bf6d6..254e1082f0ff 100644 --- a/docs/runtime/bunfig.mdx +++ b/docs/runtime/bunfig.mdx @@ -514,6 +514,15 @@ Valid values are: | `"offline"` | Skip staleness checks and resolve packages from the local cache. Equivalent to `--prefer-offline`. | | `"latest"` | Always check npm for the latest matching versions. Equivalent to `--prefer-latest`. | +### `install.selfContainedWorkspaces` + +Workspaces (by path or name) that must have a complete, physical `node_modules` of their own when using the hoisted linker — see [Self-contained workspaces](/pm/workspaces#self-contained-workspaces). A workspace can also opt in itself with `"installConfig": { "hoistingLimits": "workspaces" }` in its `package.json`. Default `[]`. + +```toml title="bunfig.toml" icon="settings" +[install] +selfContainedWorkspaces = ["apps/desktop"] +``` + ### `install.frozenLockfile` When `true`, `bun install` does not update `bun.lock`. Default `false`. If `package.json` and the existing `bun.lock` disagree, the install errors. diff --git a/src/bunfig/bunfig.rs b/src/bunfig/bunfig.rs index 34044429cc60..4cc84fa20af3 100644 --- a/src/bunfig/bunfig.rs +++ b/src/bunfig/bunfig.rs @@ -1565,6 +1565,32 @@ impl<'a> Parser<'a> { install.hoist = Some(v); } + if let Some(list) = install_obj.get(b"selfContainedWorkspaces") { + match &list.data { + ExprData::EArray(arr) => { + let raw = arr.items.slice(); + let mut out: Vec> = Vec::with_capacity(raw.len()); + for p in raw { + self.expect_string(p)?; + out.push(estring_to_owned( + p.data + .e_string() + .expect("infallible: variant checked") + .get(), + self.bump, + )); + } + install.self_contained_workspaces = Some(out); + } + _ => { + self.add_error( + list.loc, + b"Expected an array of workspace paths or names for selfContainedWorkspaces", + )?; + } + } + } + Ok(()) } diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index a999c8d2a073..61c205442f8c 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -93,6 +93,10 @@ pub struct PackageInstaller<'a> { pub(crate) successfully_installed: Bitset, pub(crate) command_ctx: Command::Context<'a>, pub(crate) current_tree_id: lockfile::tree::Id, + /// Trees that live under a self-contained workspace: packages there are copied + /// (real files) rather than hardlinked/cloned/symlinked from the cache, so tools + /// that walk, prune or rewrite that node_modules cannot reach the shared cache. + pub(crate) copy_trees: Bitset, // fields used for running lifecycle scripts when it's safe // @@ -1869,10 +1873,17 @@ impl<'a> PackageInstaller<'a> { break 'result result; } + let method = if (self.current_tree_id as usize) < self.copy_trees.bit_length() + && self.copy_trees.is_set(self.current_tree_id as usize) + { + package_install::Method::Copyfile + } else { + installer.get_install_method() + }; break 'result installer.install( self.skip_delete, &destination_dir, - installer.get_install_method(), + method, resolution.tag, ); } diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index ed5bf8a3dd28..0e31ead0455a 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -730,6 +730,20 @@ impl PackageManager { unsafe { &*get() } } + /// `get()` for code that may also run before the manager exists (helpers that + /// only consult options when an install is in progress). + #[inline] + pub fn try_get() -> Option<&'static PackageManager> { + let p = get(); + if p.is_null() { + None + } else { + // SAFETY: same as `get()` — non-null means `allocate_package_manager()` + // ran and the singleton lives for the process. + Some(unsafe { &*p }) + } + } + /// Associated-fn spelling that forwards to the free [`init`] so callers /// can write `PackageManager::init(ctx, cli, subcommand)`. #[inline] @@ -1408,6 +1422,7 @@ fn overlay_bunfig_install(install: &mut Api::BunInstall, bunfig: Api::BunInstall public_hoist_pattern, hoist_pattern, hoist, + self_contained_workspaces, } = bunfig; if let Some(registry) = default_registry { @@ -1462,6 +1477,7 @@ fn overlay_bunfig_install(install: &mut Api::BunInstall, bunfig: Api::BunInstall public_hoist_pattern, hoist_pattern, hoist, + self_contained_workspaces, ); } diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 10504ff5a1cd..689d48a27ef4 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -81,6 +81,11 @@ pub struct Options { /// fallback (pnpm's `hoist=false`); takes precedence over `hoist_pattern`. pub(crate) hoist: bool, + /// Workspaces (path or name) whose node_modules must be complete and physical: + /// a hoisting barrier + copy materialization. Also enabled per workspace by + /// `"installConfig": { "hoistingLimits": "workspaces" }` in its package.json. + pub self_contained_workspaces: &'static [&'static [u8]], + // Security scanner module path pub security_scanner: Option<&'static [u8]>, @@ -156,6 +161,7 @@ impl Default for Options { public_hoist_pattern: None, hoist_pattern: None, hoist: true, + self_contained_workspaces: &[], security_scanner: None, minimum_release_age_ms: None, minimum_release_age_excludes: None, @@ -475,6 +481,12 @@ impl Options { self.hoist = hoist; } + if let Some(list) = &config.self_contained_workspaces { + let leaked: Vec<&'static [u8]> = list.iter().map(|e| leak_static(e)).collect(); + self.self_contained_workspaces = + &*bun_core::heap::release(leaked.into_boxed_slice()); + } + if let Some(security_scanner) = config.security_scanner.as_deref() { self.security_scanner = Some(leak_static(security_scanner)); self.do_.set(Do::PREFETCH_RESOLVED_TARBALLS, false); diff --git a/src/install/PackageManager/install_with_manager.rs b/src/install/PackageManager/install_with_manager.rs index 8fb503659cec..d22492bc7a0a 100644 --- a/src/install/PackageManager/install_with_manager.rs +++ b/src/install/PackageManager/install_with_manager.rs @@ -417,6 +417,13 @@ pub fn install_with_manager( lf.workspace_versions.insert(*key, version); } } + // …and which workspaces asked for a self-contained node_modules + { + lf.self_contained_workspaces.clear_retaining_capacity(); + for key in lockfile.self_contained_workspaces.keys() { + lf.self_contained_workspaces.put(*key, ())?; + } + } // Update patched dependencies { diff --git a/src/install/hoisted_install.rs b/src/install/hoisted_install.rs index fb370c033827..f2fe1dd07161 100644 --- a/src/install/hoisted_install.rs +++ b/src/install/hoisted_install.rs @@ -407,6 +407,19 @@ pub(crate) fn install_hoisted_packages( folder_path_buf: bun_paths::PathBuffer::uninit(), current_tree_id: tree::INVALID_ID, pending_lifecycle_scripts: Vec::new(), + copy_trees: { + let self_contained = this.lockfile.self_contained_workspace_ids(); + let mut set = Bitset::init_empty(trees_count)?; + if !self_contained.is_empty() { + for tid in 0..trees_count { + let owner = this.lockfile.owning_workspace_of_tree(tid as tree::Id); + if owner != 0 && self_contained.contains(&owner) { + set.set(tid); + } + } + } + set + }, }; }; diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index 2fa85d1f5d98..4a030ba7fa72 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -180,6 +180,11 @@ pub struct Lockfile { pub(crate) scripts: Scripts, pub(crate) workspace_paths: NameHashMap, pub workspace_versions: VersionHashMap, + /// Workspaces (by name hash) whose package.json declares + /// `installConfig.hoistingLimits = "workspaces"`; unioned with + /// `install.selfContainedWorkspaces` at hoist time. Not serialized: it is + /// re-derived from the manifests on every install. + pub self_contained_workspaces: ArrayHashMap, /// Optional because `trustedDependencies` in package.json might be an /// empty list or it might not exist @@ -684,6 +689,7 @@ impl Lockfile { self.trusted_dependencies = None; self.workspace_paths = NameHashMap::default(); self.workspace_versions = VersionHashMap::default(); + self.self_contained_workspaces = ArrayHashMap::default(); self.overrides = OverrideMap::default(); self.catalogs = CatalogMap::default(); self.patched_dependencies = PatchedDependenciesMap::default(); @@ -814,6 +820,57 @@ impl Lockfile { /// Does this tree id belong to a workspace (including workspace root)? /// TODO(dylan-conway) fix! + /// Workspace packages whose node_modules must be self-contained: listed in + /// `install.selfContainedWorkspaces` (by path or name) or declaring + /// `"installConfig": { "hoistingLimits": "workspaces" }` in their manifest + /// (recorded in `self_contained_workspaces` while the workspaces were parsed). + pub(crate) fn self_contained_workspace_ids(&self) -> Vec { + let configured: &[&[u8]] = crate::PackageManager::try_get() + .map(|pm| pm.options.self_contained_workspaces) + .unwrap_or(&[]); + if configured.is_empty() && self.self_contained_workspaces.count() == 0 { + return Vec::new(); + } + let mut out = Vec::new(); + let buf = self.buffers.string_bytes.as_slice(); + let pkgs = self.packages.slice(); + for (i, res) in pkgs.items_resolution().iter().enumerate() { + if res.tag != crate::resolution::Tag::Workspace { + continue; + } + let path = res.workspace().slice(buf); + let name = pkgs.items_name()[i].slice(buf); + let name_hash = pkgs.items_name_hash()[i]; + if self.self_contained_workspaces.contains(&name_hash) + || configured.iter().any(|c| { + let c = bun_core::strings::without_trailing_slash(c); + c == path || c == name + }) + { + out.push(i as PackageID); + } + } + out + } + + /// The workspace (or root, 0) package that owns tree `id`: walk up until a + /// workspace/root tree is reached and return the package it stands for. + pub(crate) fn owning_workspace_of_tree(&self, mut id: tree::Id) -> PackageID { + while id != 0 && (id as usize) < self.buffers.trees.len() { + let t = &self.buffers.trees[id as usize]; + let dep_id = t.dependency_id; + if (dep_id as usize) < self.buffers.dependencies.len() + && self.buffers.dependencies[dep_id as usize] + .behavior + .is_workspace() + { + return self.buffers.resolutions[dep_id as usize]; + } + id = t.parent; + } + 0 + } + pub(crate) fn is_workspace_tree_id(&self, id: tree::Id) -> bool { id == 0 || self.buffers.dependencies[self.buffers.trees[id as usize].dependency_id as usize] @@ -1090,6 +1147,9 @@ impl Lockfile { new.workspace_versions.re_index()?; new.workspace_paths.re_index()?; } + for key in old.self_contained_workspaces.keys() { + new.self_contained_workspaces.put(*key, ())?; + } } // When you run `"bun add react" @@ -1322,8 +1382,10 @@ impl Lockfile { // `ParentRef::new` captures `SharedReadOnly` provenance from `&*self`, // which is exactly what `Builder` needs (it only ever `Deref`s); the // `Builder` does not outlive this `&mut self` borrow. + let self_contained = self.self_contained_workspace_ids(); let lockfile_ref = bun_ptr::ParentRef::::new(&*self); let mut builder = tree::Builder:: { + self_contained, queue: tree::TreeFiller::init(), resolution_lists: slice.items_resolutions(), resolutions: self.buffers.resolutions.as_mut_slice(), @@ -1986,6 +2048,7 @@ impl Lockfile { trusted_dependencies: None, workspace_paths: NameHashMap::default(), workspace_versions: VersionHashMap::default(), + self_contained_workspaces: ArrayHashMap::default(), overrides: OverrideMap::default(), catalogs: CatalogMap::default(), meta_hash: ZERO_HASH, @@ -2342,6 +2405,7 @@ impl Lockfile { workspace_versions: &mut self.workspace_versions, patched_dependencies: &mut self.patched_dependencies, trusted_dependencies: &mut self.trusted_dependencies, + self_contained_workspaces: &mut self.self_contained_workspaces, }, ) } @@ -2395,6 +2459,8 @@ pub(crate) struct LockfileFields<'a> { pub(crate) workspace_versions: &'a mut VersionHashMap, pub(crate) patched_dependencies: &'a mut PatchedDependenciesMap, pub(crate) trusted_dependencies: &'a mut Option, + pub(crate) self_contained_workspaces: + &'a mut ArrayHashMap, } // ──────────────────────────────────────────────────────────────────────────── diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index 1645be78d998..da4572aef92f 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -2911,6 +2911,11 @@ impl Package { .workspace_versions .put(external_name.hash, version)?; } + if entry.hoisting_limits { + lockfile + .self_contained_workspaces + .put(external_name.hash, ())?; + } } } } else { diff --git a/src/install/lockfile/Package/WorkspaceMap.rs b/src/install/lockfile/Package/WorkspaceMap.rs index 2f2d415ccd4d..2925b0b0e850 100644 --- a/src/install/lockfile/Package/WorkspaceMap.rs +++ b/src/install/lockfile/Package/WorkspaceMap.rs @@ -26,6 +26,9 @@ pub struct Entry { pub(crate) name: Box<[u8]>, pub(crate) version: Option>, pub(crate) name_loc: bun_ast::Loc, + /// `"installConfig": { "hoistingLimits": "workspaces" }` — this workspace's + /// node_modules must be self-contained (see `Lockfile::self_contained_workspaces`). + pub(crate) hoisting_limits: bool, } impl WorkspaceMap { @@ -67,6 +70,7 @@ impl WorkspaceMap { name: value.name, version: value.version, name_loc: value.name_loc, + hoisting_limits: value.hoisting_limits, }; Ok(()) } @@ -155,6 +159,17 @@ fn process_workspace_name( let entry = Entry { name: Box::<[u8]>::from(name), name_loc: name_expr.loc, + hoisting_limits: workspace_json + .root + .get(b"installConfig") + .and_then(|c| c.get(b"hoistingLimits")) + .and_then(|h| { + h.as_string_cloned(&scratch) + .ok() + .flatten() + .map(|s| s == b"workspaces") + }) + .unwrap_or(false), version: 'brk: { if let Some(version_expr) = workspace_json.root.get(b"version") { if let Some(version) = version_expr.as_string_cloned(&scratch)? { @@ -354,6 +369,7 @@ impl WorkspaceMap { name: workspace_entry.name, name_loc: workspace_entry.name_loc, version: workspace_entry.version, + hoisting_limits: workspace_entry.hoisting_limits, }, )?; } @@ -548,6 +564,7 @@ impl WorkspaceMap { name: workspace_entry.name, version: workspace_entry.version, name_loc: workspace_entry.name_loc, + hoisting_limits: workspace_entry.hoisting_limits, }, )?; } diff --git a/src/install/lockfile/Tree.rs b/src/install/lockfile/Tree.rs index 6ed128eaa9d3..c17ca42f3151 100644 --- a/src/install/lockfile/Tree.rs +++ b/src/install/lockfile/Tree.rs @@ -449,6 +449,8 @@ pub struct Builder<'a, const METHOD: BuilderMethod> { pub(crate) workspace_filters: &'a [WorkspaceFilter], pub(crate) install_root_dependencies: bool, pub(crate) packages_to_install: Option<&'a [PackageID]>, + /// Workspace package ids that are hoisting barriers (self-contained node_modules). + pub(crate) self_contained: Vec, } pub struct BuilderEntry { @@ -659,6 +661,18 @@ impl Tree { // reshaped for borrowck. let next_id = (builder.list.len() - 1) as Id; + // A self-contained workspace is a hoisting barrier: nothing below it may be + // placed above its own node_modules. + let hoist_root_id = if dependency_id != ROOT_DEP_ID + && builder.dependencies[dependency_id as usize] + .behavior + .is_workspace() + && builder.self_contained.contains(&parent_pkg_id) + { + next_id + } else { + hoist_root_id + }; // Copy the `ParentRef` out (it's `Copy`) so the resulting `&Lockfile` // is borrowed from a local, not `&builder` — subsequent `&mut builder` diff --git a/src/options_types/schema.rs b/src/options_types/schema.rs index ffde83bb9211..a98224648b2d 100644 --- a/src/options_types/schema.rs +++ b/src/options_types/schema.rs @@ -258,6 +258,10 @@ pub mod api { pub public_hoist_pattern: Option, pub hoist_pattern: Option, pub hoist: Option, + /// `selfContainedWorkspaces = ["apps/desktop"]` (workspace paths or names): with + /// the hoisted linker, nothing these workspaces depend on is hoisted above their + /// own node_modules, and their packages are copied rather than linked. + pub self_contained_workspaces: Option>>, } #[repr(u8)] diff --git a/test/cli/install/bun-workspaces-self-contained.test.ts b/test/cli/install/bun-workspaces-self-contained.test.ts new file mode 100644 index 000000000000..aade28e7bc87 --- /dev/null +++ b/test/cli/install/bun-workspaces-self-contained.test.ts @@ -0,0 +1,130 @@ +import { spawn } from "bun"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test"; +import { existsSync, readlinkSync, statSync } from "fs"; +import { mkdir, writeFile } from "fs/promises"; +import { bunExe, bunEnv as env, isWindows, readdirSorted } from "harness"; +import { join } from "path"; +import { + dummyAfterAll, + dummyAfterEach, + dummyBeforeAll, + dummyBeforeEach, + dummyRegistry, + package_dir, + root_url, + setHandler, +} from "./dummy.registry"; + +// A workspace that is packaged by tools which walk `node_modules` (Electron packagers, +// serverless bundlers) needs a *complete* and *physical* node_modules of its own: +// nothing it (transitively) depends on may be hoisted above it, and the files must be +// real copies rather than links into the cache. + +beforeAll(dummyBeforeAll); +afterAll(dummyAfterAll); +setDefaultTimeout(1000 * 60 * 5); + +beforeEach(async () => { + await dummyBeforeEach({ linker: "hoisted" }); + setHandler( + dummyRegistry([], { + "0.0.2": {}, + "0.0.3": {}, + "0.1.0": { dependencies: { bar: "0.0.2" } }, + latest: "0.0.3", + }), + ); +}); +afterEach(dummyAfterEach); + +async function install(cwd: string, args: string[] = []) { + await using proc = spawn({ cmd: [bunExe(), "install", ...args], cwd, env, stdout: "pipe", stderr: "pipe" }); + const [out, err, code] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { out, err, code }; +} + +async function writeProject(desktopExtra: object, bunfigExtra: object) { + await writeFile( + join(package_dir, "bunfig.toml"), + Bun.TOML.stringify({ + install: { cache: false, registry: root_url + "/", saveTextLockfile: true, linker: "hoisted", ...bunfigExtra }, + }), + ); + await mkdir(join(package_dir, "apps", "desktop"), { recursive: true }); + await mkdir(join(package_dir, "apps", "web"), { recursive: true }); + await mkdir(join(package_dir, "packages", "shared"), { recursive: true }); + await writeFile( + join(package_dir, "package.json"), + JSON.stringify({ + name: "root", + private: true, + workspaces: ["apps/*", "packages/*"], + dependencies: { bar: "0.0.2" }, + }), + ); + await writeFile( + join(package_dir, "apps", "desktop", "package.json"), + JSON.stringify({ + name: "desktop", + version: "1.0.0", + ...desktopExtra, + dependencies: { "@barn/moo": "0.1.0", shared: "workspace:*" }, + }), + ); + await writeFile( + join(package_dir, "apps", "web", "package.json"), + JSON.stringify({ name: "web", version: "1.0.0", dependencies: { bar: "0.0.2", qux: "0.0.2" } }), + ); + await writeFile( + join(package_dir, "packages", "shared", "package.json"), + JSON.stringify({ name: "shared", version: "1.0.0", dependencies: { baz: "0.0.3" } }), + ); +} + +describe.each([ + [ + "installConfig.hoistingLimits in the workspace's package.json", + { installConfig: { hoistingLimits: "workspaces" } }, + {}, + ], + ["install.selfContainedWorkspaces in bunfig.toml", {}, { selfContainedWorkspaces: ["apps/desktop"] }], +] as const)("self-contained workspace via %s", (_label, desktopExtra, bunfigExtra) => { + it("gets a complete, physical node_modules while other workspaces still hoist", async () => { + await writeProject(desktopExtra, bunfigExtra); + const r = await install(package_dir); + expect(r.err).not.toContain("error:"); + expect(r.code).toBe(0); + + const desktopNm = join(package_dir, "apps", "desktop", "node_modules"); + // desktop's direct dep, its transitive dep, the workspace it depends on and *that* + // workspace's dep are all under apps/desktop/node_modules … + expect(await readdirSorted(desktopNm)).toEqual(["@barn", "bar", "baz", "shared"]); + expect(existsSync(join(desktopNm, "@barn", "moo", "package.json"))).toBeTrue(); + expect(readlinkSync(join(desktopNm, "shared"))).toContain("shared"); + // … as real files, not hardlinks into the cache + if (!isWindows) { + expect(statSync(join(desktopNm, "bar", "package.json")).nlink).toBe(1); + } + // even though `bar` also exists at the root for the root package / other workspaces + expect(existsSync(join(package_dir, "node_modules", "bar", "package.json"))).toBeTrue(); + // the other workspace hoists as usual + expect(existsSync(join(package_dir, "node_modules", "qux", "package.json"))).toBeTrue(); + expect(existsSync(join(package_dir, "apps", "web", "node_modules", "qux"))).toBeFalse(); + + // stable across a repeat / frozen install + const again = await install(package_dir, ["--frozen-lockfile"]); + expect(again.err).not.toContain("error:"); + expect(again.code).toBe(0); + expect(await readdirSorted(desktopNm)).toEqual(["@barn", "bar", "baz", "shared"]); + }); +}); + +it("without either setting the workspace is hoisted normally", async () => { + await writeProject({}, {}); + const r = await install(package_dir); + expect(r.err).not.toContain("error:"); + expect(r.code).toBe(0); + expect(existsSync(join(package_dir, "apps", "desktop", "node_modules"))).toBeFalse(); + expect(existsSync(join(package_dir, "node_modules", "@barn", "moo", "package.json"))).toBeTrue(); + expect(existsSync(join(package_dir, "node_modules", "baz", "package.json"))).toBeTrue(); +});