fix(scripts): remove dangling pnpm links in clean

pnpm install keeps hoisted links to deleted workspace packages, and the Desktop development build fails reading their package.json.

Fixes #6823
This commit is contained in:
Turtle committed 2026-10-21 14:22:12 +08:00
1 parent 051ac9154b
commit 81ee28eebb
2 files changed
+53 -3

No files matched your search

+22
View File
@@ -82,6 +82,28 @@ describe('RepositoryCleaner', () => {
expect(existsSync(join(root, 'native/system/tsconfig.tsbuildinfo'))).toBe(false)
})
it('removes pnpm links to deleted packages and keeps live links', async () => {
const root = fixture()
const type = process.platform === 'win32' ? 'junction' : 'dir'
addProject(root, 'packages/core/live')
write(join(root, 'packages/core/live/package.json'), '{}')
const hoisted = join(root, 'node_modules/.pnpm/node_modules')
mkdirSync(join(hoisted, '@deepseek-ai'), { recursive: true })
symlinkSync(join(root, 'packages/core/live'), join(hoisted, '@deepseek-ai/dsh-live'), type)
symlinkSync(join(root, 'packages/hooks/gone'), join(hoisted, '@deepseek-ai/dsh-gone'), type)
symlinkSync(join(root, 'packages/hooks/plain'), join(hoisted, 'plain'), type)
symlinkSync(join(root, 'packages/hooks/top'), join(root, 'node_modules/top'), type)
const removed = await new RepositoryCleaner(root).clean()
expect(removed).toEqual([
'node_modules/.pnpm/node_modules/@deepseek-ai/dsh-gone',
'node_modules/.pnpm/node_modules/plain',
'node_modules/top',
])
expect(existsSync(join(hoisted, '@deepseek-ai/dsh-live/package.json'))).toBe(true)
})
it('refuses project outputs reached through a symlink outside the repository', async () => {
const root = fixture()
const externalProject = fixture()
+31 -3
View File
@@ -1,4 +1,4 @@
import { lstat, readdir, realpath, rm } from 'node:fs/promises'
import { lstat, readdir, realpath, rm, stat } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
@@ -10,9 +10,9 @@ function isMissing(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
}
async function exists(path: string): Promise<boolean> {
async function exists(path: string, probe: typeof lstat = lstat): Promise<boolean> {
try {
await lstat(path)
await probe(path)
return true
} catch (error) {
if (isMissing(error)) return false
@@ -30,6 +30,27 @@ async function childDirectories(path: string): Promise<string[]> {
}
}
/** Lists symbolic links whose targets are missing in a node_modules directory and its `@scope` directories. */
async function danglingLinks(modulesDirectory: string): Promise<string[]> {
const links: string[] = []
const scan = async (directory: string, scopes: boolean): Promise<void> => {
let entries
try {
entries = await readdir(directory, { withFileTypes: true })
} catch (error) {
if (isMissing(error)) return
throw error
}
for (const entry of entries) {
const path = join(directory, entry.name)
if (scopes && entry.isDirectory() && entry.name.startsWith('@')) await scan(path, false)
else if (entry.isSymbolicLink() && !await exists(path, stat)) links.push(path)
}
}
await scan(modulesDirectory, true)
return links
}
function repositoryPath(root: string, path: string): string {
return relative(root, path).split(sep).join('/')
}
@@ -88,6 +109,13 @@ export class RepositoryCleaner {
await this.addIfPresent(targets, outputDirectory, canonicalRoot)
}
// pnpm install keeps links whose targets are gone, such as hoisted links to deleted workspace packages.
for (const modulesDirectory of ['node_modules', 'node_modules/.pnpm/node_modules']) {
for (const link of await danglingLinks(join(this.root, modulesDirectory))) {
await this.addIfPresent(targets, link, canonicalRoot)
}
}
for (const groupDirectory of await childDirectories(join(this.root, 'packages'))) {
for (const packageDirectory of await childDirectories(groupDirectory)) {
// A package.json marks a live package; its output was discovered from the