Repository navigation
Description
Verify latest release
- I verified that the issue exists in the latest pnpm release
pnpm version
12.15.0
Which area(s) of pnpm are affected? (leave empty if unsure)
No response
Link to the code that reproduces this issue or a replay of the bug
No response
Reproduction steps
- Save the script below as
repro.mjs. - Run
node repro.mjs /absolute/path/to/pnpm-12.15.0. Alternatively, if that version is on PATH, runnode repro.mjs pnpm. - Inspect the two JSON result rows: both deploy commands succeed and install the dependency, but neither output contains the generated alias symlink.
The script creates a tiny local dependency, installs it through pnpm, and then creates a working project symlink to that installed dependency. It tests both default and legacy production deploy. Everything is created in a fresh temporary directory with isolated HOME/config/cache/store; all commands use --offline. No registry, downloads, Next.js installation, tar utility, or pre-existing fixtures are needed.
The file: dependency is intentional: it makes the example fully self-contained. The script verifies that the dependency is resolvable in the deployed project and that its actual installed files are inside the output, rather than linked back to the source project. A separate symlink whose target is included in .next serves as a positive control.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
const pnpm = process.argv[2] || 'pnpm';
const command = /\.[cm]js$/.test(pnpm) ? [process.execPath, pnpm] : [pnpm];
const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-deploy-dependency-alias-')));
const project = path.join(base, 'app');
const home = path.join(base, 'home');
fs.mkdirSync(home);
const npmrc = path.join(home, 'empty.npmrc');
fs.writeFileSync(npmrc, '');
const env = Object.fromEntries(Object.entries(process.env).filter(
([key]) => !/^(npm|pnpm)_config_/i.test(key)
));
Object.assign(env, {
HOME: home, USERPROFILE: home, CI: '', NO_COLOR: '1',
XDG_CONFIG_HOME: home, XDG_CACHE_HOME: path.join(home, 'cache'),
XDG_DATA_HOME: path.join(home, 'data'), XDG_STATE_HOME: path.join(home, 'state'),
npm_config_userconfig: npmrc,
});
function write(relative, value) {
const file = path.join(project, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, typeof value === 'string' ? value : JSON.stringify(value, null, 2));
}
function run(args) {
const result = spawnSync(command[0], [...command.slice(1), ...args], {
cwd: project, env, encoding: 'utf8', timeout: 60000,
});
if (result.error) throw result.error;
assert.equal(result.status, 0, result.stdout + result.stderr);
return result;
}
function isSymlink(file) {
try { return fs.lstatSync(file).isSymbolicLink(); } catch (error) {
if (error.code !== 'ENOENT') throw error;
return false;
}
}
write('package.json', {
name: 'deploy-alias-repro', version: '1.0.0', private: true,
files: ['.next'],
dependencies: { 'repro-dependency': 'file:vendor/repro-dependency' },
});
write('pnpm-workspace.yaml', 'packages: [.]\ninjectWorkspacePackages: true\nignoreScripts: true\n');
write('vendor/repro-dependency/package.json', {
name: 'repro-dependency', version: '1.0.0', main: 'index.cjs',
});
write('vendor/repro-dependency/index.cjs', 'module.exports = "dependency works";\n');
const store = '--store-dir=' + path.join(base, 'store');
console.log(`pnpm=${run(['--version']).stdout.trim()} node=${process.version} platform=${process.platform}/${process.arch}`);
console.log(`Temporary project: ${project}`);
run(['install', '--offline', '--ignore-scripts', store]);
// The dependency has genuinely been installed by pnpm before creating the alias.
const aliasPath = '.next/node_modules/repro-dependency-alias';
const linkText = '../../node_modules/repro-dependency';
fs.mkdirSync(path.dirname(path.join(project, aliasPath)), { recursive: true });
fs.symlinkSync(linkText, path.join(project, aliasPath));
const sourceRequire = createRequire(path.join(project, 'package.json'));
assert.equal(sourceRequire('./' + aliasPath), 'dependency works');
assert.ok(fs.existsSync(path.join(project, aliasPath, 'index.cjs')));
write('.next/control.txt', 'control\n');
fs.symlinkSync('control.txt', path.join(project, '.next/control-link'));
for (const mode of ['default', 'legacy']) {
const destination = path.join(base, 'deployed-' + mode);
run(['--filter=.', 'deploy', '--prod', '--offline', '--ignore-scripts',
...(mode === 'legacy' ? ['--legacy'] : []), store, destination]);
const deployedRequire = createRequire(path.join(destination, 'package.json'));
const dependencyWorks = deployedRequire('repro-dependency') === 'dependency works';
assert.ok(dependencyWorks, 'The dependency must be installed in the deploy output');
assert.ok(fs.realpathSync(deployedRequire.resolve('repro-dependency')).startsWith(destination + path.sep),
'The installed dependency files must be inside the output, not linked to the source project');
const control = path.join(destination, '.next/control-link');
assert.ok(isSymlink(control), 'The included-target control symlink must be preserved');
assert.equal(fs.readlinkSync(control), 'control.txt');
assert.equal(fs.readFileSync(control, 'utf8'), 'control\n');
const alias = path.join(destination, aliasPath);
const aliasIsSymlink = isSymlink(alias);
const row = {
mode, deployExitCode: 0, sourceAliasResolves: true,
dependencyInstalledInOutput: dependencyWorks,
intendedAliasTargetExistsInOutput: fs.existsSync(path.resolve(path.dirname(alias), linkText)),
controlSymlinkPreserved: true,
aliasEntryExists: fs.existsSync(alias) || aliasIsSymlink,
aliasIsSymlink,
aliasResolves: fs.existsSync(path.join(alias, 'index.cjs')),
sameReadlink: aliasIsSymlink && fs.readlinkSync(alias) === linkText,
};
assert.ok(row.intendedAliasTargetExistsInOutput);
console.log(JSON.stringify(row));
}Observed result rows on 12.15.0:
{"mode":"default","deployExitCode":0,"sourceAliasResolves":true,"dependencyInstalledInOutput":true,"intendedAliasTargetExistsInOutput":true,"controlSymlinkPreserved":true,"aliasEntryExists":false,"aliasIsSymlink":false,"aliasResolves":false,"sameReadlink":false}
{"mode":"legacy","deployExitCode":0,"sourceAliasResolves":true,"dependencyInstalledInOutput":true,"intendedAliasTargetExistsInOutput":true,"controlSymlinkPreserved":true,"aliasEntryExists":false,"aliasIsSymlink":false,"aliasResolves":false,"sameReadlink":false}The assertions verify that installation and both deploys return exit code 0, the source alias works before deploy, the dependency works from inside each deploy output, the intended target exists there, and the included-target control symlink is preserved. The script prints the problematic alias state rather than asserting that it must be missing, so it can also be run against a fixed version.
Describe the Bug
A generated project symlink can point to an ordinary installed dependency. For example, Next.js can emit aliases under .next/node_modules that point into the project's root node_modules.
In this reproduction, files: [".next"] includes .next/node_modules/repro-dependency-alias, whose original relative target is ../../node_modules/repro-dependency. The link exists and resolves before deploy. Both pnpm deploy --prod and pnpm deploy --legacy --prod install repro-dependency into their output, but silently omit the alias entry. The dependency and the exact intended target path exist in the completed output; only the generated alias is missing. Both commands return exit code 0.
This is also confirmed with full production deploys of real Next.js build output and its original dependency manifest/lockfile: native 12.15.0 preserves 64 of 67 project symlinks, omitting three aliases to dependency directories that are present in the final output. Our pnpm 11.28.7 build with a local preserve-symlinks patch retains all 67. Multiple aliases whose targets are included in the project-copy set are preserved by both versions.
Cause in the 12.15.0 source
The following links are pinned to the released commit 91774e72a04d52ce61db06339696a433d383c764.
- Deploy copies selected project files before installing the deployed dependencies.
copy_projectexplicitly enablespreserve_symlinks. The project packlist excludes root node_modules, so the alias is selected but its dependency target is not part of this copy operation. imported_pathsbuilds a set of the current import's file paths and parent directories.imports_link_targetchecks whether the link target belongs to that set. It does not check whether the target exists in the source or will be installed as a dependency.- For an existing directory target outside that set,
place_symlink_entryreturnsOk(is_dir), which isOk(true), without creating the link. The caller treats true as handled and returns success. This explains the silent omission and successful exit. - Dependency installation subsequently provides the target directory, but does not recreate the generated project alias discarded during copying. The two phases therefore produce an incomplete project payload even though the dependency installation succeeds.
The defect is the requirement that a preserved project's symlink target must be included in the same copy operation. Here the final target is supplied by the following dependency-installation phase. This reproduction uses existing source targets and installed output targets throughout; it does not rely on dangling source links.
Expected Behavior
Both deploy modes should preserve .next/node_modules/repro-dependency-alias as a symlink with the original readlink value ../../node_modules/repro-dependency.
The dependency should still be installed normally. Once deploy completes, both the package's normal dependency name and the generated alias should resolve to the installed package inside the output.
For both result rows, aliasEntryExists, aliasIsSymlink, aliasResolves, and sameReadlink should all be true; the other successful checks should remain true, and deployExitCode should remain 0. The project-copy phase should preserve the selected symlink independently of whether the dependency target is copied by that phase.
Which Node.js version are you using?
v26.12.0
Which operating systems have you used?
- macOS
- Windows
- Linux
If your OS is a Linux based, which one it is? (Include the version if relevant)
No response
Contributing a fix
- I am working on a fix
Activity
zaverden commentedon Oct 23, 2026
For pnpm@10 and pnpm@11 we made a patch. Our patch makes
deploypreserve selected symlinks as-is, keeping their original link targets even when those targets are outside the copied file set, or even when symlink is dangling at the moment of deploy. By the idea if users includes broken link into output, they'll provide a file for it later.fix(deploy): preserve links to deployed dependencies (pnpm#17296)
Update pnpm to v12.16.1 (#2164)