install: support path-form link: dependencies
Problem
"pkg": "link:./lib/pkg"in package.json failsbun installwitherror: Package "pkg" is not linked(the yarn/pnpm spelling for "symlink this directory"; #4719, #5045).- Cause: every consumer of a
Symlinkresolution treats the value as a name registered bybun linkand joins it onto the global link dir (PackageManagerEnqueue.rsTag::Symlinkarm,PackageInstaller.rs,PackageManagerDirectories.rs,isolated_install/Installer.rs::append_store_path). link:../xis the one shape that got further on main:normalize_package_json_pathresolves a.-prefixed value against the project root regardless of the global dir, so it resolves, is written to bun.lock aslink:../x, and then fails at link time (FileNotFound: failed linking dependency). See lockfile note below.- The pnpm-lock.yaml migration (
pnpm.rs) already had a notion of path-formlink:(it stored pnpm's value verbatim) that disagreed with the rest of install on both what counts as a path and what the stored value is relative to.
Fix
dependency::is_link_path: alink:value is a path unless it is an npm package name. This is the rulepnpm.rsalready used, andpnpm.rsnow calls the shared helper, so the two writers of bun.lock agree (link:lib/foois a path on both;link:fooandlink:@s/foostay global).- Stored form:
Package::parse_dependencyre-bases a path-form value from the declaring package.json onto the project root (as it already does forfile:), andlink_path_for_lockfilemakes it/-separated and./-prefixed, so what is written always reads back as a path.pnpm.rsproduces the same form from pnpm's importer-relative value. - Resolution: a path-form value resolves via a new
Relative(Symlink)arm infolder_resolver.rs(project-relative,Features::LINKas before); name-form is unchanged. An overlong target is reported asENAMETOOLONG, as the other path-buffer overflows in the crate are. - Install: the three installer sites symlink a path-form value from the project root instead of the global dir; the isolated linker only pre-opens the global dir if a name-form resolution exists.
- Containment, same rule as
file:: a target inside the project is always allowed, including one declared by a transitivefile:package. One that leaves the project (..or absolute) is allowed only when the root or a workspace declared the dependency, or a rootoverrides/resolutionsentry names it (Lockfile::link_target_allowed_for_*). Checked at resolve time and again in both installers, because an install driven by an existing lockfile never resolves (hoisted: per package inPackageInstaller; isolated: in the pass over resolutions that runs before any task is scheduled, so no worker can create the link first). Refusal is its own error (UnsafeLinkTarget, "refusing to link ...") rather than a missing package.json. bun add --filter(add_remove_with_filter.rs::local_relative_path) re-spells a local path relative to each target; it now decides "is thislink:value a path" withis_link_pathtoo, sobun add link:vendor/foo --filter apiwriteslink:../../vendor/foointo the workspace rather than a value the workspace would read aspackages/api/vendor/foo. Name-form values are still passed through untouched.Version::eqlforSymlinkcomparesliterallikeFolderdoes; bun.lock round-tripsliteral, so comparing the re-based value made every second install see a diff.- Missing target reports
Could not find package.json at "<path>"instead of thebun linkhint.
Why this is right: link: with a path has meant "symlink this directory, do not install its dependencies" in yarn and pnpm for years, and file: in bun already defines where a relative path is anchored and who may point outside the project; this makes link: follow both.
Lockfile note: bun.lock entries of the form x@link:../y or x@link:/abs that main already writes (and then fails to install) start installing once this lands, subject to the containment rule above. Name-form entries are unaffected. Migrated pnpm lockfiles that previously carried importer-relative targets are now written root-relative; ones migrated by older versions were never installable.
Verified:
test/cli/install/bun-link.test.ts:./, barelib/x(asserts the./stored form),../, absolute, scoped, workspace-member-relative, missing target; each under both linkers with a--frozen-lockfilere-install. Containment block: transitive escape refused at resolve and from an existing lockfile (asserting nothing by that name exists anywhere under node_modules, isolated store entries included; the released binary creates one at.bun/<pkg>/node_modules/<name>), transitive in-project target installed,overrides/resolutionsexemption honoured; both linkers.test/cli/install/bun-add-filter.test.ts:link:./vendor/fooandlink:vendor/fooare written to the target aslink:../../vendor/foo, recorded asfoo@link:./vendor/foo, install, and survive--frozen-lockfile; a missing target is refused naming the per-target spelling and writes nothing. This replaces the test that asserted the old "is not linked" failure for alink:path.test/cli/install/migration/pnpm-lock-migration.test.ts:file:specifier from a non-root importer migrates to a root-relative target and installs.- Rebased onto main after #38333 (which added
bun add --filter, scoped overrides andpnpm-lock-v9.test.ts); that file, the other pnpm migration suites, thefile:containment tests inbun-install.test.ts,bun-add-catalog.test.tslocal-path tests, and the name-formlink:tests inbun-prune/bun-pm-licensespass locally. Inbun-link.test.ts, the pre-existingshould link dependency without crashingfails locally under a debug build because the install failure it expects dumps a symbolized trace to stdout (cfg(bun_debug)inPackageInstaller.rs, not touched here); it is green in CI. cargo check/clippyon linux andcargo checkforx86_64-pc-windows-msvc.
Background
bun link/ name form:bun linkin a package dir symlinks it into~/.bun/install/global/node_modules/<name>;"dep": "link:<name>"elsewhere then symlinks from there. That is the only form bun supported; this PR adds the path form beside it.Symlinkresolution: the lockfile entry for alink:dependency. Its one string field was always a name; it is now either a name or a root-relative/absolute path, andis_link_pathtells them apart.Features::LINK: the package.json parse mode for link targets; it reads the manifest but does not enqueue the target's dependencies (unchanged).bin_target_escapes_package_dir: the existingfile:check for "does this relative path have a..that climbs above its base, or is it absolute"; reused here with the project root as the base.- Resolve vs install:
bun installwith no usable lockfile runs resolution (PackageManagerEnqueue.rs); with one, it goes straight to the installers. Any policy about a resolution therefore has to be enforced in both places, which is how thefile:checks are laid out. bun add --filter: adds a positional to the selected workspaces. A local path on the command line is relative to the cwd, so it is re-spelled relative to each workspace's package.json before being written;local_relative_pathdecides which positionals get that treatment.
no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-add-filter.test.ts test/cli/install/bun-link.test.ts