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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 18 additions & 1 deletion packages/dsh-desktop-market-installer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.')
Expand Down
39 changes: 39 additions & 0 deletions src/main/bundled-pnpm.ts
Original file line number Diff line number Diff line change
@@ -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')
}
7 changes: 4 additions & 3 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions test/bundled-pnpm.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
16 changes: 16 additions & 0 deletions test/market-installer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading