Skip to content

Commit 7cd45c6

Browse files
manzoorwanijkowlstronaut
authored andcommitted
fix(arborist): handle npm link with install-strategy=linked
(cherry picked from commit 0dc5585)
1 parent 8e8dadb commit 7cd45c6

File tree

2 files changed

+56
-1
lines changed

2 files changed

+56
-1
lines changed

workspaces/arborist/lib/arborist/isolated-reifier.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,11 @@ module.exports = cls => class IsolatedReifier extends cls {
113113
})
114114
// local `file:` deps are in fsChildren but are not workspaces.
115115
// they are already handled as workspace-like proxies above and should not go through the external/store extraction path.
116-
if (!next.isProjectRoot && !next.isWorkspace && !next.inert && !idealTree.fsChildren.has(next) && !idealTree.fsChildren.has(next.target)) {
116+
// Links with file: resolved paths (from `npm link`) should also be treated as local dependencies and symlinked directly instead of being extracted into the store.
117+
const isLocalFileDep = next.isLink && next.resolved?.startsWith('file:')
118+
if (isLocalFileDep && !idealTree.fsChildren.has(next) && !idealTree.fsChildren.has(next.target)) {
119+
this.idealGraph.workspaces.push(await this.#workspaceProxy(next.target))
120+
} else if (!next.isProjectRoot && !next.isWorkspace && !next.inert && !idealTree.fsChildren.has(next) && !idealTree.fsChildren.has(next.target)) {
117121
this.idealGraph.external.push(await this.#externalProxy(next))
118122
}
119123
}

workspaces/arborist/test/isolated-mode.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1789,6 +1789,57 @@ tap.test('file: dependency with linked strategy', async t => {
17891789
t.ok(setupRequire(dir)('project2'), 'project2 can be required from root')
17901790
})
17911791

1792+
tap.test('npm link (external file: dep) with linked strategy', async t => {
1793+
// Regression test: `npm link` creates a file: dependency pointing outside the project root.
1794+
// The linked strategy should symlink it directly instead of trying to extract it into .store/.
1795+
const graph = {
1796+
registry: [
1797+
{ name: 'abbrev', version: '2.0.0' },
1798+
],
1799+
root: {
1800+
name: 'my-app',
1801+
version: '1.0.0',
1802+
dependencies: { abbrev: '2.0.0' },
1803+
},
1804+
}
1805+
1806+
const { dir, registry } = await getRepo(graph)
1807+
1808+
// Create an external package OUTSIDE the project root (simulates npm link target)
1809+
const externalPkgDir = path.join(path.dirname(dir), 'external-pkg')
1810+
fs.mkdirSync(externalPkgDir, { recursive: true })
1811+
fs.writeFileSync(path.join(externalPkgDir, 'package.json'), JSON.stringify({
1812+
name: 'external-pkg',
1813+
version: '1.0.0',
1814+
}))
1815+
fs.writeFileSync(path.join(externalPkgDir, 'index.js'), "module.exports = 'external'")
1816+
1817+
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
1818+
1819+
// First install without the linked package
1820+
const arb1 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1821+
await arb1.reify({ installStrategy: 'linked' })
1822+
1823+
// Now simulate `npm link external-pkg` by adding a file: dep and reifying
1824+
const arb2 = new Arborist({ path: dir, registry, packumentCache: new Map(), cache })
1825+
await arb2.reify({ installStrategy: 'linked', add: [`file:${externalPkgDir}`] })
1826+
1827+
// The external package should be symlinked in node_modules
1828+
const linkPath = path.join(dir, 'node_modules', 'external-pkg')
1829+
const stat = fs.lstatSync(linkPath)
1830+
t.ok(stat.isSymbolicLink(), 'external-pkg is a symlink in node_modules')
1831+
1832+
// The symlink should resolve to the actual external directory
1833+
const realpath = fs.realpathSync(linkPath)
1834+
t.equal(realpath, externalPkgDir, 'symlink points to the correct external directory')
1835+
1836+
// The existing store packages should still be intact
1837+
const storePath = path.join(dir, 'node_modules', '.store')
1838+
const storeEntries = fs.readdirSync(storePath)
1839+
t.ok(storeEntries.some(e => e.startsWith('abbrev@')), 'abbrev is still in the store')
1840+
t.notOk(storeEntries.some(e => e.startsWith('external-pkg@')), 'external-pkg is NOT in the store')
1841+
})
1842+
17921843
tap.test('subsequent linked install is a no-op', async t => {
17931844
const graph = {
17941845
registry: [

0 commit comments

Comments
 (0)