diff --git a/package.json b/package.json index afcc8cc9e..c73950149 100644 --- a/package.json +++ b/package.json @@ -327,6 +327,7 @@ "**/spawn-helper", "**/@vscode/ripgrep-*/bin/rg", "node_modules/@deepseek-ai/libreoffice-kit-*/**/*", + "node_modules/pnpm/**/*", "node_modules/sherpa-onnx-*/**/*" ], "npmRebuild": false, diff --git a/packages/dsh-desktop-market-installer/index.js b/packages/dsh-desktop-market-installer/index.js index 0e9ae6efb..4230aec5f 100644 --- a/packages/dsh-desktop-market-installer/index.js +++ b/packages/dsh-desktop-market-installer/index.js @@ -185,13 +185,30 @@ function shellQuote(value) { return `'${String(value).replaceAll("'", `'\\''`)}'` } +/** + * The physical copy of a packaged pnpm path. The archive member spelling is + * readable only through Electron's asar-aware fs; the shims and node-mode + * children that execute pnpm see `app.asar` as a regular file and the OS + * answers ENOTDIR. The build unpacks pnpm (asarUnpack), so rewrite the + * archive segment to its `app.asar.unpacked` sibling — development installs + * carry no archive segment and pass through unchanged. + */ +function unpackedPnpmPath(path) { + const separator = path.includes('\\') && !path.includes('/') ? '\\' : '/' + const segments = path.split(/[\\/]/u) + const archiveAt = segments.lastIndexOf('app.asar') + if (archiveAt === -1) return path + segments[archiveAt] = 'app.asar.unpacked' + return segments.join(separator) +} + export function resolvePnpmEntry(requireFrom = import.meta.url) { const require = createRequire(requireFrom) // pnpm intentionally maps its package root export to package.json instead of // exporting the package.json subpath. Resolving the root therefore gives us // the stable package anchor without reaching through an unexported path. const manifest = require.resolve('pnpm') - const root = dirname(manifest) + const root = unpackedPnpmPath(dirname(manifest)) const candidates = [join(root, 'bin', 'pnpm.cjs'), join(root, 'bin', 'pnpm.mjs')] const entry = candidates.find((candidate) => existsSync(candidate)) if (!entry) throw new Error('The packaged pnpm entry was not found.') diff --git a/src/main/bundled-pnpm.ts b/src/main/bundled-pnpm.ts new file mode 100644 index 000000000..e9dfc3251 --- /dev/null +++ b/src/main/bundled-pnpm.ts @@ -0,0 +1,39 @@ +import { join } from 'node:path' + +/** + * The physical copy of a bundled runtime path. + * + * The packaged app keeps its node_modules inside app.asar; the Electron + * runtime reads the archive, but pnpm is executed by node-mode children (the + * Harness hand-off and the `.desktop-bin` shims) that cannot read archive + * members — the OS answers `spawn ENOTDIR` because `app.asar` is a regular + * file. The build therefore unpacks pnpm (asarUnpack) and every executable + * entry must resolve to the sibling `app.asar.unpacked` copy, the rule + * node-pty, ripgrep, and the Office engine already follow. Paths outside an + * archive (development installs) are returned unchanged. + */ +export function unpackedBundledPath(path: string): string { + const separator = path.includes('\\') && !path.includes('/') ? '\\' : '/' + const segments = path.split(/[\\/]/u) + const archiveAt = segments.lastIndexOf('app.asar') + if (archiveAt === -1) return path + segments[archiveAt] = 'app.asar.unpacked' + return segments.join(separator) +} + +/** + * The packaged pnpm entry for a runtime root (`app.getAppPath()` when + * packaged). The entry must be the unpacked physical copy — its consumers + * spawn it in node mode — so candidates are tested against the physical + * filesystem; when nothing exists there the unpacked spelling is still + * returned, which downstream resolution reports as a missing entry instead + * of a cryptic spawn failure. + */ +export function bundledPnpmEntryFrom( + runtimeRoot: string, + exists: (path: string) => boolean = () => true +): string { + const bin = unpackedBundledPath(join(runtimeRoot, 'node_modules', 'pnpm', 'bin')) + const candidates = [join(bin, 'pnpm.cjs'), join(bin, 'pnpm.mjs')] + return candidates.find((candidate) => exists(candidate)) ?? join(bin, 'pnpm.cjs') +} diff --git a/src/main/index.ts b/src/main/index.ts index f1f2c8554..79f5a4bd6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,5 @@ import { isMemoryExit } from './resource-diagnostics' +import { bundledPnpmEntryFrom } from './bundled-pnpm' import { ProcessRecovery } from './process-recovery' import { ApplicationRollbackFlow } from './application-rollback-flow' import { developmentEnvironment } from './development-environment' @@ -680,9 +681,9 @@ function bundledPnpmRunnerPath(): string { } function bundledPnpmEntryPath(): string { - const root = join(bundledRuntimeRoot(), 'node_modules', 'pnpm', 'bin') - const candidates = [join(root, 'pnpm.cjs'), join(root, 'pnpm.mjs')] - return candidates.find((candidate) => existsSync(candidate)) ?? join(root, 'pnpm.cjs') + // pnpm runs in node-mode children that cannot read app.asar members, so it + // is unpacked at build time and the entry must be the physical copy. + return bundledPnpmEntryFrom(bundledRuntimeRoot(), existsSync) } function harnessNodeEntryPath(): string { diff --git a/test/bundled-pnpm.test.ts b/test/bundled-pnpm.test.ts new file mode 100644 index 000000000..a1d4896a6 --- /dev/null +++ b/test/bundled-pnpm.test.ts @@ -0,0 +1,32 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { bundledPnpmEntryFrom, unpackedBundledPath } from '../src/main/bundled-pnpm' + +describe('bundled pnpm entry', () => { + it('rewrites archive members to their unpacked physical copies', () => { + expect(unpackedBundledPath(join('/Applications/DSH Desktop.app/Contents/Resources', 'app.asar', 'node_modules', 'pnpm', 'bin', 'pnpm.cjs'))) + .toBe(join('/Applications/DSH Desktop.app/Contents/Resources', 'app.asar.unpacked', 'node_modules', 'pnpm', 'bin', 'pnpm.cjs')) + expect(unpackedBundledPath('C:\\Program Files\\DSH Desktop\\resources\\app.asar\\node_modules\\pnpm\\bin\\pnpm.cjs')) + .toBe('C:\\Program Files\\DSH Desktop\\resources\\app.asar.unpacked\\node_modules\\pnpm\\bin\\pnpm.cjs') + }) + + it('rewrites the archive root itself and leaves non-archive paths alone', () => { + expect(unpackedBundledPath(join('/opt/app', 'app.asar'))) + .toBe(join('/opt/app', 'app.asar.unpacked')) + expect(unpackedBundledPath(join('/workspace', 'node_modules', 'pnpm', 'bin', 'pnpm.cjs'))) + .toBe(join('/workspace', 'node_modules', 'pnpm', 'bin', 'pnpm.cjs')) + }) + + it('resolves the packaged entry from the unpacked copy, never the archive member', () => { + const resources = '/Applications/DSH Desktop.app/Contents/Resources' + const unpacked = join(resources, 'app.asar.unpacked', 'node_modules', 'pnpm', 'bin', 'pnpm.cjs') + expect(bundledPnpmEntryFrom(join(resources, 'app.asar'), (candidate) => candidate === unpacked)).toBe(unpacked) + // A build without the unpacked copy keeps the unpacked spelling so the + // miss is reported at resolution, not as a spawn failure. + expect(bundledPnpmEntryFrom(join(resources, 'app.asar'), () => false)) + .toBe(unpacked) + // Development installs are unchanged. + const devEntry = join('/workspace', 'node_modules', 'pnpm', 'bin', 'pnpm.cjs') + expect(bundledPnpmEntryFrom('/workspace', () => true)).toBe(devEntry) + }) +}) diff --git a/test/market-installer.test.js b/test/market-installer.test.js index 4a81acc9d..f551aefdf 100644 --- a/test/market-installer.test.js +++ b/test/market-installer.test.js @@ -58,6 +58,22 @@ describe('desktop plugin market installer', () => { expect(resolvePnpmEntry()).toMatch(/node_modules[/\\]pnpm[/\\]bin[/\\]pnpm\.(c|m)js$/u) }) + it('resolves the packaged pnpm entry to the unpacked copy node-mode children can execute', async () => { + const base = await mkdtemp(join(tmpdir(), 'dsh-pnpm-')) + const archivePackage = join(base, 'app.asar', 'node_modules', 'pnpm') + const unpackedPackage = join(base, 'app.asar.unpacked', 'node_modules', 'pnpm') + for (const root of [archivePackage, unpackedPackage]) { + await mkdir(join(root, 'bin'), { recursive: true }) + await writeFile(join(root, 'package.json'), '{"name":"pnpm","main":"package.json"}', 'utf8') + await writeFile(join(root, 'bin', 'pnpm.cjs'), '', 'utf8') + } + // require.resolve('pnpm') from inside the archive returns the package.json + // anchor; the entry must be the physical app.asar.unpacked sibling. + const requireFrom = pathToFileURL(join(archivePackage, 'loader.js')).href + const expected = join(await realpath(unpackedPackage), 'bin', 'pnpm.cjs') + expect(resolvePnpmEntry(requireFrom)).toBe(expected) + }) + it('removes only Desktop’s exact legacy pnpm settings and preserves sensitive config bytes', () => { const npmrc = [ '# user configuration',