/** * Clear a stale `node_modules/` entry inside a DSH profile. * * WHY THIS EXISTS — the ERR_PNPM_EPERM install failure * pnpm installs a package by building a temporary directory NEXT TO the target * (`_tmp__`) and then renaming it onto ``. A rename * cannot replace an existing, non-empty directory, so the move fails with EPERM * on Windows: * * [ERR_PNPM_EPERM] [importPackage …\node_modules\dsh-plugin-git-commit-push] * EPERM: operation not permitted, rename '…_tmp_5280_1' -> '…' * * Measured on this machine (Node 24, NTFS): renaming onto an existing non-empty * directory throws EPERM, and the very same rename succeeds once the destination * is gone. pnpm's own recovery path cannot always help, because the classic * leftover is a `link:` install — a junction/symlink — whose checkout was later * moved or deleted. Removing a dangling link needs an operation pnpm's retry * loop swallows, so it retries the rename until it gives up and reports the * failure, while the package may already be present enough for DSH to load after * a restart. That is exactly the "install failed, but after restarting DSH the * plugin shows as installed" report this module answers. * * WHAT IT DOES * Removes the entry so pnpm can import into a clean path. The removal is safe by * construction: a symbolic link or junction is UNLINKED, never followed — the * checkout it points at keeps every file (verified by the `junction` check in * self-test.mjs). A plain directory is removed recursively, which is the only * way to clear a destination pnpm no longer tracks. * * AND WHY IT CAN ALSO CREATE THE ENTRY (`ensureLink`) * pnpm does NOT recreate a `link:` dependency that was removed behind its back. * Measured on this machine: once the profile manifest says * `link:…/dsh-plugin-git-commit-push` and the lockfile agrees, `pnpm install` * answers "Already up to date" and leaves the missing entry missing — and * `pnpm install --force` does the same (it reuses the importer state; it does not * re-validate the link). That made the FIRST version of this module destructive: * re-running `setup.ps1` unlinked a perfectly good junction, pnpm declined to * rebuild it, and the profile silently lost the plugin. Two consequences, both * implemented here: * * 1. a healthy link that already points at the checkout being installed is * KEPT (`clearStaleEntry(…, expectedTarget)` returns `kept`), which is what * makes re-running the installer a no-op instead of a removal; * 2. after `pnpm install`, the installer calls `ensureLink`, which fills the * hole with exactly the entry pnpm itself creates for `link:` (a junction * on Windows, a symlink elsewhere). It only ever fills a hole: anything * already present is reported, never replaced. * * Both installers (setup.ps1, setup.sh) call THIS file rather than each carrying * their own copy of the logic, for the same reason they both call * `lib/profile-edit.mjs`: a second hand-written implementation would drift, and * this is the step that must never delete the wrong directory. * * Usage: * node lib/profile-link.mjs --profile-dir --package [--expected-target ] * node lib/profile-link.mjs --profile-dir --package --ensure-link * * Prints one stable token on stdout — `absent`, `kept`, `removed-link`, * `removed-directory`, `created`, `present`, or `present-link` — so the calling * script can relay what happened. Exit code 1 means the target path was refused * as unsafe and NOTHING was removed or created. */ import { lstatSync, mkdirSync, realpathSync, rmSync, symlinkSync, unlinkSync } from 'node:fs' import { join, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' /** Parse `--flag value` pairs without pulling in a dependency. */ function parseArgs(argv) { const args = {} for (let index = 0; index < argv.length; index += 1) { const token = argv[index] if (!token.startsWith('--')) continue const key = token.slice(2) const next = argv[index + 1] if (next === undefined || next.startsWith('--')) { args[key] = true } else { args[key] = next index += 1 } } return args } /** * Whether `child` is strictly inside `parent`. * * Windows paths are compared case-insensitively; the check exists so that a * malformed `--package` can never turn this script into a remover for something * outside the profile's own `node_modules`. */ function isInside(parent, child) { const fold = value => (process.platform === 'win32' ? value.toLowerCase() : value) const base = fold(resolve(parent)) const full = fold(resolve(child)) return full.startsWith(base.endsWith(sep) ? base : base + sep) } /** * Resolve two paths to the same real location, or say they differ. * * `realpathSync` is what answers "does this link point at the checkout I am * installing?" — a junction's own `lstat` says nothing about its target. A * DANGLING link throws here, which is the right answer: its target no longer * exists, so it can never be the checkout being installed and must be cleared. */ function sameRealPath(a, b) { try { const fold = value => (process.platform === 'win32' ? value.toLowerCase() : value) return fold(realpathSync(a)) === fold(realpathSync(b)) } catch { return false } } /** Validate the package name and resolve the entry this module may touch. */ function entryFor(profileDir, packageName) { if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(packageName)) { throw new Error(`refusing to touch "${packageName}": a package name must be a single path segment`) } const nodeModules = join(profileDir, 'node_modules') const target = join(nodeModules, packageName) if (!isInside(nodeModules, target)) { throw new Error(`refusing to touch ${target}: it is not inside ${nodeModules}`) } return { nodeModules, target } } /** * Remove the profile's `node_modules/` entry when it is in the way. * * @param {string} profileDir absolute profile directory * @param {string} packageName a bare package name (no path separators) * @param {string} [expectedTarget] the checkout being installed; a link already * pointing at it is LEFT ALONE (see the `kept` token and the module doc) * @returns {'absent' | 'kept' | 'removed-link' | 'removed-directory'} */ export function clearStaleEntry(profileDir, packageName, expectedTarget) { const { target } = entryFor(profileDir, packageName) let stats try { // lstat, never stat: the question is what the ENTRY is, not what it points at. stats = lstatSync(target) } catch (error) { if (error?.code === 'ENOENT') return 'absent' throw error } if (stats.isSymbolicLink()) { // Already the link this install wants: removing it would be pure loss, since // pnpm does not rebuild a `link:` entry it believes it already installed. if (expectedTarget !== undefined && sameRealPath(target, expectedTarget)) return 'kept' // A `link:` install (a symlink on macOS/Linux, a junction on Windows) that is // dangling or points at another checkout. unlink removes the entry itself; // the checkout it points at is untouched. unlinkSync(target) return 'removed-link' } if (stats.isDirectory()) { rmSync(target, { recursive: true, force: true, maxRetries: 3 }) return 'removed-directory' } rmSync(target, { force: true }) return 'removed-directory' } /** * Make sure the profile's `node_modules/` entry exists, and link it to * `target` when it does not. * * This exists because pnpm will not do it: with the manifest and lockfile * already correct, `pnpm install` reports "Already up to date" even when the * `link:` entry is gone, and `--force` behaves the same (measured). The entry * created here is the very one pnpm creates for `link:` — a junction on Windows * (no elevation needed, unlike a file symlink) and a directory symlink * elsewhere. * * It only ever FILLS A HOLE. Anything already present is reported and left * exactly as it is; deciding that an entry must go is `clearStaleEntry`'s job. * * @param {string} profileDir absolute profile directory * @param {string} packageName a bare package name (no path separators) * @param {string} target the checkout to link to * @returns {'created' | 'present' | 'present-link'} */ export function ensureLink(profileDir, packageName, target) { const { nodeModules, target: dest } = entryFor(profileDir, packageName) try { const stats = lstatSync(dest) return stats.isSymbolicLink() ? 'present-link' : 'present' } catch (error) { if (error?.code !== 'ENOENT') throw error } mkdirSync(nodeModules, { recursive: true }) symlinkSync(resolve(target), dest, process.platform === 'win32' ? 'junction' : 'dir') return 'created' } /* c8 ignore start -- CLI wiring; the exported helper above is what the tests drive */ const invokedDirectly = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href if (invokedDirectly) { const args = parseArgs(process.argv.slice(2)) const profileDir = typeof args['profile-dir'] === 'string' ? args['profile-dir'] : undefined const packageName = typeof args.package === 'string' ? args.package : undefined if (profileDir === undefined || packageName === undefined) { console.error('usage: node profile-link.mjs --profile-dir --package [--expected-target | --ensure-link ]') process.exit(2) } try { if (typeof args['ensure-link'] === 'string') { console.log(ensureLink(profileDir, packageName, args['ensure-link'])) } else { const expected = typeof args['expected-target'] === 'string' ? args['expected-target'] : undefined console.log(clearStaleEntry(profileDir, packageName, expected)) } } catch (error) { console.error(`profile-link: ${error.message}`) process.exit(1) } } /* c8 ignore stop */