Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/dsh-desktop-market-installer/generations/installer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,11 @@ export async function installGeneration(options) {
const sourceCopy = join(stagingDir, 'source', pluginName.replace(/^@/u, '').replace(/[/\\]/gu, '+'))
await mkdir(join(stagingDir, 'source'), { recursive: true })
await cp(options.sourceDirectory, sourceCopy, { recursive: true, dereference: true })
installSpec = `file:${sourceCopy}`
// pnpm resolves file: specs against the staging project. On Windows an
// absolute `file:C:\...` is treated as a relative suffix, which produced
// doubled absolute paths across drives and junction spellings; the
// project-relative file:./ form is portable across both (#563).
installSpec = `file:./${relative(stagingDir, sourceCopy).replace(/\\/gu, '/')}`
}
trace(`installing ${options.sourceSpec ?? pluginSpec} into staging`)
// A git subpackage can declare a pnpm version different from its workspace
Expand Down Expand Up @@ -560,7 +564,7 @@ async function resolveNonRootPackage(requireFromPackage, dependency) {

/** Verify every installed package's required runtime dependency stays in an allowed closure. */
export async function verifyGenerationPeers(dshHome, generation) {
const { createRequire } = await import('node:module')
const { createRequire, isBuiltin } = await import('node:module')
const generationRoot = await realpath(generation.directory)
const closure = await realpath(installationClosureDir(dshHome)).catch(
() => installationClosureDir(dshHome)
Expand Down Expand Up @@ -625,6 +629,10 @@ export async function verifyGenerationPeers(dshHome, generation) {
!Object.hasOwn(dependencies, dependency) &&
!Object.hasOwn(optionalDependencies, dependency)
if (optionalPeerOnly && !isHostSingleton(dependency)) continue
// Node builtins are not files to look up; treating their bare ids as
// paths produced false "resolves outside the generation and installation
// closure" rejections (#563).
if (isBuiltin(dependency)) continue
let resolved
try {
resolved = requireFromPackage.resolve(dependency)
Expand Down
60 changes: 60 additions & 0 deletions test/generation-installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,66 @@ describe('the generation installer', () => {
expect(result.problems).toContain('broken does not resolve from the generation or installation closure')
})

it('hands pnpm a project-relative file: spec for local sources (#563)', async () => {
const home = await freshHome()
const source = join(home, 'local-source')
await mkdir(source, { recursive: true })
await writeFile(join(source, 'package.json'), JSON.stringify({ name: 'local-shape', version: '1.0.0' }))
const specs: string[] = []
const spawnProcess = (_executable: string, args: string[], spawnOptions: { cwd: string }) => {
specs.push(String(args[2]))
const listeners: Record<string, (value?: unknown) => void> = {}
const stream = { on: () => stream }
const child = {
stdout: stream,
stderr: stream,
once: (event: string, listener: (value?: unknown) => void) => {
listeners[event] = listener
return child
},
kill: () => undefined
}
void (async () => {
const pkg = join(spawnOptions.cwd, 'node_modules', 'local-shape')
await mkdir(pkg, { recursive: true })
await writeFile(join(pkg, 'package.json'), JSON.stringify({ name: 'local-shape', version: '1.0.0' }))
await writeFile(join(spawnOptions.cwd, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
listeners.close?.(0)
})()
return child
}
const result = await installGeneration({
dshHome: home,
pluginSpec: 'file:local-source',
expectedPluginName: 'local-shape',
sourceDirectory: source,
nodeExecutablePath: 'node',
pnpmEntryPath: 'pnpm',
spawnProcess
})
expect(result.ok).toBe(true)
// The absolute `file:${sourceCopy}` form is read as a relative suffix by
// pnpm on Windows and produced doubled absolute paths across drives and
// junction spellings; the spec must stay project-relative and portable.
expect(specs).toEqual(['file:./source/local-shape'])
})

it('skips Node builtin dependencies instead of resolving them as files (#563)', async () => {
const home = await freshHome()
const directory = join(home, 'profiles', '.generations', 'live', 'builtin-deps')
const modules = join(directory, 'node_modules')
for (const [name, fields] of Object.entries({
'root-plugin': { dependencies: { 'node:fs': '*', 'node:crypto': '*', ordinary: '*' } },
ordinary: { main: 'index.js' }
})) {
await mkdir(join(modules, name), { recursive: true })
await writeFile(join(modules, name, 'package.json'), JSON.stringify({ name, ...fields }))
}
await writeFile(join(modules, 'ordinary', 'index.js'), 'module.exports = {}')
const generation = { id: 'builtin-deps', pluginName: 'root-plugin', version: '1.0.0', directory }
expect(await verifyGenerationPeers(home, generation)).toEqual({ ok: true, problems: [] })
})

it('fails validation when a required ordinary dependency is missing', async () => {
const home = await freshHome()
const directory = join(home, 'profiles', '.generations', 'live', 'missing-dependency')
Expand Down