From c1c9700bf20a24190cd0c21eb42e9f73955eede2 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 22 Jun 2026 21:41:33 +0530 Subject: [PATCH] fix(link): scope `npm link --workspace` to the workspace, not the root (#9592) `npm link --workspace=` did not scope the operation to the target workspace. The linked dependency was attached to the root project instead, and with `--save` (or `--save-dev`/`--save-optional`/etc.) the `file:` spec was written to the root `package.json` rather than `/package.json`. `npm install --workspace= --save` behaved correctly, so only `npm link` was affected. The cause is in `Link.linkInstall()` in `lib/commands/link.js`, which built the local Arborist without the `workspaces` option and passed `workspaces` only to `reify()`. Arborist decides which node receives the `add` request from `this.options.workspaces`, which is captured at construction time, not from the reify-time option. `buildIdealTree` merges reify options into a local variable and `#parseSettings` never copies `options.workspaces` into `this.options`, so the reify-time value never reached `#applyUserRequests`. With `this.options.workspaces` left empty, the `add` and the save were applied to the root node. The fix passes `workspaces: this.workspaceNames` to the local Arborist constructor, matching how `lib/commands/install.js` already does it. Physical placement is unchanged: under the default `hoisted` strategy the symlink still hoists to the root `node_modules`, identical to `npm install --workspace`. ## References Fixes #9590 Related #9589 (cherry picked from commit 1a9ce8e3f56ec26dd283b8957119f721d8d3fe5f) --- lib/commands/link.js | 2 ++ test/lib/commands/link.js | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/lib/commands/link.js b/lib/commands/link.js index 160ba2b707efd..e958dd9330589 100644 --- a/lib/commands/link.js +++ b/lib/commands/link.js @@ -122,6 +122,8 @@ class Link extends ArboristWorkspaceCmd { prune: false, path: this.npm.prefix, save, + // Arborist reads this.options.workspaces (set at construction) to decide which node receives the add, so it must be set here, not only at reify time. + workspaces: this.workspaceNames, allowScripts: allowScriptsPolicy, }) await localArb.reify({ diff --git a/test/lib/commands/link.js b/test/lib/commands/link.js index 4aaf24c9d0a28..7fe72865fc52f 100644 --- a/test/lib/commands/link.js +++ b/test/lib/commands/link.js @@ -289,6 +289,44 @@ t.test('link global linked pkg to local workspace using args', async t => { t.matchSnapshot(await printLinks(), 'should create a local symlink to global pkg') }) +t.test('link --workspace --save targets the workspace manifest, not the root', async t => { + const { link, prefix } = await mockLink(t, { + globalPrefixDir: { + node_modules: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + version: '1.0.0', + }), + }, + }, + }, + prefixDir: { + 'package.json': JSON.stringify({ + name: 'my-project', + version: '1.0.0', + workspaces: ['packages/*'], + }), + packages: { + x: { + 'package.json': JSON.stringify({ + name: 'x', + version: '1.0.0', + }), + }, + }, + }, + config: { workspace: 'x', save: true }, + }) + + await link.exec(['a']) + + const root = JSON.parse(fs.readFileSync(join(prefix, 'package.json'), 'utf8')) + const ws = JSON.parse(fs.readFileSync(join(prefix, 'packages', 'x', 'package.json'), 'utf8')) + t.notOk(root.dependencies, 'root manifest should not get the dependency') + t.match(ws.dependencies, { a: /^file:/ }, 'workspace manifest should get the file: dependency') +}) + t.test('link pkg already in global space', async t => { const { npm, link, printLinks, prefix } = await mockLink(t, { globalPrefixDir: {