fix: recover pnpm symlink loops during workspace install
This commit is contained in:
@@ -218,6 +218,101 @@ describe('workspace package manager helpers', () => {
|
|||||||
expect(execFileSyncMock).not.toHaveBeenCalled();
|
expect(execFileSyncMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('repairs a stale pnpm self-referential node_modules tree before install', () => {
|
||||||
|
const repoDir = path.join(tempRoot, 'stale-pnpm');
|
||||||
|
fs.mkdirSync(path.join(repoDir, 'node_modules', '.pnpm'), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repoDir, 'package.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'stale-pnpm',
|
||||||
|
packageManager: 'pnpm@10.11.0',
|
||||||
|
scripts: { test: 'vitest run' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repoDir, 'pnpm-lock.yaml'),
|
||||||
|
'lockfileVersion: 9.0\n',
|
||||||
|
);
|
||||||
|
const selfLoopPath = path.join(
|
||||||
|
repoDir,
|
||||||
|
'node_modules',
|
||||||
|
'.pnpm',
|
||||||
|
'node_modules',
|
||||||
|
);
|
||||||
|
fs.symlinkSync(selfLoopPath, selfLoopPath);
|
||||||
|
|
||||||
|
execFileSyncMock.mockImplementation((_file, _args, options) => {
|
||||||
|
const cwd = (options as { cwd: string }).cwd;
|
||||||
|
expect(fs.existsSync(selfLoopPath)).toBe(false);
|
||||||
|
fs.mkdirSync(path.join(cwd, 'node_modules', '.bin'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(cwd, 'node_modules', '.bin', 'vitest'), '');
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = ensureWorkspaceDependenciesInstalled(repoDir);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
installed: true,
|
||||||
|
packageManager: 'pnpm',
|
||||||
|
commandText: 'corepack pnpm install --frozen-lockfile',
|
||||||
|
});
|
||||||
|
expect(execFileSyncMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hasInstalledNodeModules(repoDir)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries once after pnpm reports an ELOOP install failure', () => {
|
||||||
|
const repoDir = path.join(tempRoot, 'eloop-retry');
|
||||||
|
fs.mkdirSync(repoDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repoDir, 'package.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'eloop-retry',
|
||||||
|
packageManager: 'pnpm@10.11.0',
|
||||||
|
scripts: { test: 'vitest run' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repoDir, 'pnpm-lock.yaml'),
|
||||||
|
'lockfileVersion: 9.0\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
execFileSyncMock.mockImplementationOnce((_file, _args, options) => {
|
||||||
|
const cwd = (options as { cwd: string }).cwd;
|
||||||
|
fs.mkdirSync(path.join(cwd, 'node_modules', '.pnpm'), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
const selfLoopPath = path.join(
|
||||||
|
cwd,
|
||||||
|
'node_modules',
|
||||||
|
'.pnpm',
|
||||||
|
'node_modules',
|
||||||
|
);
|
||||||
|
fs.symlinkSync(selfLoopPath, selfLoopPath);
|
||||||
|
throw { stderr: 'ELOOP: too many symbolic links encountered' };
|
||||||
|
});
|
||||||
|
execFileSyncMock.mockImplementationOnce((_file, _args, options) => {
|
||||||
|
const cwd = (options as { cwd: string }).cwd;
|
||||||
|
expect(
|
||||||
|
fs.existsSync(path.join(cwd, 'node_modules', '.pnpm', 'node_modules')),
|
||||||
|
).toBe(false);
|
||||||
|
fs.mkdirSync(path.join(cwd, 'node_modules', '.bin'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(cwd, 'node_modules', '.bin', 'vitest'), '');
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = ensureWorkspaceDependenciesInstalled(repoDir);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
installed: true,
|
||||||
|
packageManager: 'pnpm',
|
||||||
|
commandText: 'corepack pnpm install --frozen-lockfile',
|
||||||
|
});
|
||||||
|
expect(execFileSyncMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(hasInstalledNodeModules(repoDir)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('disables corepack project specs only for lockfile-selected pnpm workspaces under a conflicting ancestor', () => {
|
it('disables corepack project specs only for lockfile-selected pnpm workspaces under a conflicting ancestor', () => {
|
||||||
const parentDir = path.join(tempRoot, 'parent');
|
const parentDir = path.join(tempRoot, 'parent');
|
||||||
fs.mkdirSync(parentDir, { recursive: true });
|
fs.mkdirSync(parentDir, { recursive: true });
|
||||||
|
|||||||
@@ -226,11 +226,63 @@ function writeInstallFingerprint(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasRecoverableSymlinkLoopAt(symlinkPath: string): boolean {
|
||||||
|
let stat: fs.Stats;
|
||||||
|
try {
|
||||||
|
stat = fs.lstatSync(symlinkPath);
|
||||||
|
} catch (error) {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code?: unknown }).code === 'ELOOP'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stat.isSymbolicLink()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = fs.readlinkSync(symlinkPath);
|
||||||
|
const resolvedTarget = path.resolve(path.dirname(symlinkPath), target);
|
||||||
|
if (resolvedTarget === symlinkPath) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.realpathSync(symlinkPath);
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code?: unknown }).code === 'ELOOP'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRecoverableNodeModulesLoop(repoDir: string): boolean {
|
||||||
|
return hasRecoverableSymlinkLoopAt(
|
||||||
|
path.join(repoDir, 'node_modules', '.pnpm', 'node_modules'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNodeModulesTree(repoDir: string): void {
|
||||||
|
fs.rmSync(path.join(repoDir, 'node_modules'), {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function hasRunnableNodeModulesTree(repoDir: string): boolean {
|
function hasRunnableNodeModulesTree(repoDir: string): boolean {
|
||||||
const nodeModulesDir = path.join(repoDir, 'node_modules');
|
const nodeModulesDir = path.join(repoDir, 'node_modules');
|
||||||
if (!fs.existsSync(nodeModulesDir)) {
|
if (!fs.existsSync(nodeModulesDir)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (hasRecoverableNodeModulesLoop(repoDir)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return fs
|
return fs
|
||||||
@@ -383,6 +435,42 @@ export function hasInstalledNodeModules(repoDir: string): boolean {
|
|||||||
return readInstallFingerprint(repoDir) === expectedFingerprint;
|
return readInstallFingerprint(repoDir) === expectedFingerprint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInstallErrorDetail(error: unknown): string {
|
||||||
|
const stderr =
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'stderr' in error &&
|
||||||
|
typeof (error as { stderr?: unknown }).stderr === 'string'
|
||||||
|
? (error as { stderr: string }).stderr.trim()
|
||||||
|
: '';
|
||||||
|
const stdout =
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'stdout' in error &&
|
||||||
|
typeof (error as { stdout?: unknown }).stdout === 'string'
|
||||||
|
? (error as { stdout: string }).stdout.trim()
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
stderr || stdout || (error instanceof Error ? error.message : String(error))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecoverableNodeModulesLoopInstallFailure(detail: string): boolean {
|
||||||
|
return /\bELOOP\b|too many symbolic links/i.test(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWorkspaceInstallCommand(
|
||||||
|
repoDir: string,
|
||||||
|
command: WorkspaceCommandSpec,
|
||||||
|
): void {
|
||||||
|
execFileSync(command.file, command.args, {
|
||||||
|
cwd: repoDir,
|
||||||
|
env: buildWorkspaceCommandEnvironment(repoDir, command.packageManager),
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function ensureWorkspaceDependenciesInstalled(
|
export function ensureWorkspaceDependenciesInstalled(
|
||||||
repoDir: string,
|
repoDir: string,
|
||||||
): WorkspaceDependencyInstallResult {
|
): WorkspaceDependencyInstallResult {
|
||||||
@@ -403,35 +491,29 @@ export function ensureWorkspaceDependenciesInstalled(
|
|||||||
return { installed: false, packageManager };
|
return { installed: false, packageManager };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasRecoverableNodeModulesLoop(repoDir)) {
|
||||||
|
removeNodeModulesTree(repoDir);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync(command.file, command.args, {
|
runWorkspaceInstallCommand(repoDir, command);
|
||||||
cwd: repoDir,
|
|
||||||
env: buildWorkspaceCommandEnvironment(repoDir, command.packageManager),
|
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const stderr =
|
const detail = getInstallErrorDetail(error);
|
||||||
typeof error === 'object' &&
|
if (isRecoverableNodeModulesLoopInstallFailure(detail)) {
|
||||||
error !== null &&
|
removeNodeModulesTree(repoDir);
|
||||||
'stderr' in error &&
|
try {
|
||||||
typeof (error as { stderr?: unknown }).stderr === 'string'
|
runWorkspaceInstallCommand(repoDir, command);
|
||||||
? (error as { stderr: string }).stderr.trim()
|
} catch (retryError) {
|
||||||
: '';
|
const retryDetail = getInstallErrorDetail(retryError);
|
||||||
const stdout =
|
throw new Error(
|
||||||
typeof error === 'object' &&
|
`Failed to install workspace dependencies with "${command.commandText}" in ${repoDir} after cleaning node_modules: ${retryDetail}`,
|
||||||
error !== null &&
|
);
|
||||||
'stdout' in error &&
|
}
|
||||||
typeof (error as { stdout?: unknown }).stdout === 'string'
|
} else {
|
||||||
? (error as { stdout: string }).stdout.trim()
|
throw new Error(
|
||||||
: '';
|
`Failed to install workspace dependencies with "${command.commandText}" in ${repoDir}: ${detail}`,
|
||||||
const detail =
|
);
|
||||||
stderr ||
|
}
|
||||||
stdout ||
|
|
||||||
(error instanceof Error ? error.message : String(error));
|
|
||||||
throw new Error(
|
|
||||||
`Failed to install workspace dependencies with "${command.commandText}" in ${repoDir}: ${detail}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fingerprint = computeInstallFingerprint(repoDir);
|
const fingerprint = computeInstallFingerprint(repoDir);
|
||||||
|
|||||||
Reference in New Issue
Block a user