diff --git a/dsh-plugin-desktop/scripts/verify-cli-runtime.mjs b/dsh-plugin-desktop/scripts/verify-cli-runtime.mjs index 272ca88141..eeb91d5c32 100644 --- a/dsh-plugin-desktop/scripts/verify-cli-runtime.mjs +++ b/dsh-plugin-desktop/scripts/verify-cli-runtime.mjs @@ -1,7 +1,7 @@ /** Headless artifact smoke for the Electron-backed dsh and pnpm command entries. */ import { spawnSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -10,6 +10,8 @@ import { installDesktopPnpmRuntime } from '../lib/desktop-runtime-environment.js const packageRoot = new URL('../', import.meta.url) const desktopCli = fileURLToPath(new URL('lib/desktop-cli.js', packageRoot)) +const dshAppBootPackage = fileURLToPath(new URL('node_modules/@deepseek-ai/dsh-app-boot/', packageRoot)) +const dshPackage = fileURLToPath(new URL('node_modules/@deepseek-ai/dsh/', packageRoot)) const pnpmCli = fileURLToPath(new URL('node_modules/pnpm/bin/pnpm.mjs', packageRoot)) const dshVersion = JSON.parse(readFileSync(new URL('node_modules/@deepseek-ai/dsh/package.json', packageRoot), 'utf8')).version const pnpmVersion = JSON.parse(readFileSync(new URL('node_modules/pnpm/package.json', packageRoot), 'utf8')).version @@ -39,25 +41,31 @@ function assertNoRunnerEnvironment(label, env) { } } -function verifyResult(label, result, expectedVersion) { +function verifyResult(label, result, expectedOutput) { if (result.error !== undefined) throw result.error if (result.status !== 0) { throw new Error(`${label} artifact smoke exited ${String(result.status)}: ${result.stderr.trim()}`) } - if (result.stdout.trim() !== expectedVersion) { - throw new Error(`${label} artifact smoke returned ${JSON.stringify(result.stdout.trim())} instead of ${JSON.stringify(expectedVersion)}`) + if (expectedOutput === undefined) return + const output = result.stdout.trim() + const matches = expectedOutput instanceof RegExp + ? expectedOutput.test(output) + : output === expectedOutput + if (!matches) { + throw new Error(`${label} artifact smoke returned ${JSON.stringify(output)} instead of matching ${String(expectedOutput)}`) } } -function runElectronEntry(label, nodeArgs, entry, expectedVersion) { +function runElectronEntry(label, nodeArgs, entry, args, expectedOutput, extraEnvironment = {}) { const env = cleanEnvironment() + Object.assign(env, extraEnvironment) env.ELECTRON_RUN_AS_NODE = '1' - const result = spawnSync(electronPath, [...nodeArgs, entry, '--version'], { + const result = spawnSync(electronPath, [...nodeArgs, entry, ...args], { encoding: 'utf8', env, shell: false, }) - verifyResult(label, result, expectedVersion) + verifyResult(label, result, expectedOutput) } function environmentValue(env, name) { @@ -148,5 +156,30 @@ function runPackagedPnpmShim() { } } -runElectronEntry('dsh', ['--expose-internals'], desktopCli, dshVersion) +function runFlatProfileDshEntry() { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-flat-cli-smoke-')) + const desktopPackage = join(root, 'node_modules', 'dsh-plugin-desktop') + const linkedAppBootPackage = join(root, 'node_modules', '@deepseek-ai', 'dsh-app-boot') + const linkedDshPackage = join(root, 'node_modules', '@deepseek-ai', 'dsh') + try { + mkdirSync(join(root, 'node_modules', '@deepseek-ai'), { recursive: true }) + cpSync(fileURLToPath(new URL('lib/', packageRoot)), join(desktopPackage, 'lib'), { recursive: true }) + cpSync(fileURLToPath(new URL('package.json', packageRoot)), join(desktopPackage, 'package.json')) + symlinkSync(dshAppBootPackage, linkedAppBootPackage, process.platform === 'win32' ? 'junction' : 'dir') + symlinkSync(dshPackage, linkedDshPackage, process.platform === 'win32' ? 'junction' : 'dir') + runElectronEntry( + 'flat profile dsh plugin help', + ['--expose-internals'], + join(desktopPackage, 'lib', 'desktop-cli.js'), + ['plugin', '--help'], + undefined, + { DSH_DESKTOP_DEFAULT_PROFILE: 'desktop' }, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +runElectronEntry('dsh', ['--expose-internals'], desktopCli, ['--version'], dshVersion) +runFlatProfileDshEntry() runPackagedPnpmShim() diff --git a/dsh-plugin-desktop/src/packaged-runtime-path.ts b/dsh-plugin-desktop/src/packaged-runtime-path.ts index 924d080c91..253b52ce41 100644 --- a/dsh-plugin-desktop/src/packaged-runtime-path.ts +++ b/dsh-plugin-desktop/src/packaged-runtime-path.ts @@ -1,6 +1,37 @@ /** Physical-path helpers for dependencies Electron Builder removes from app.asar. */ -import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' + +function resolvePackageRoot(moduleUrl: string, packageName: string): string { + const resolve = createRequire(moduleUrl).resolve + try { + return dirname(resolve(`${packageName}/package.json`)) + } catch (manifestCause) { + let directory: string + try { + directory = dirname(resolve(packageName)) + } catch { + throw manifestCause + } + while (true) { + try { + const manifest: unknown = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) + if (manifest !== null + && typeof manifest === 'object' + && (manifest as { name?: unknown }).name === packageName) { + return directory + } + } catch { + // Continue toward the filesystem root until the owning package is found. + } + const parent = dirname(directory) + if (parent === directory) throw manifestCause + directory = parent + } + } +} /** * Map one logical ASAR path to the sibling physical unpacked tree. @@ -12,9 +43,9 @@ export function unpackedAsarPath(filename: string): string { } /** - * Resolve one dependency entry beside a built desktop module. + * Resolve one dependency entry from a built desktop module. * @param moduleUrl - URL of a module emitted below the package's `lib` directory. - * @param dependencyEntry - path below packaged `node_modules`. + * @param dependencyEntry - package subpath resolved through Node module lookup. * @returns a physical path suitable for Node execution and profile symlinks. */ export function packagedDependencyPath(moduleUrl: string, dependencyEntry: string): string { @@ -22,9 +53,15 @@ export function packagedDependencyPath(moduleUrl: string, dependencyEntry: strin if (dependencyEntry.length === 0 || dependencyEntry.startsWith('/') || dependencyEntry.includes('\\') - || segments.some(segment => segment.length === 0 || segment === '.' || segment === '..')) { + || segments.some(segment => segment.length === 0 || segment === '.' || segment === '..') + || (segments[0]?.startsWith('@') === true && segments.length < 2)) { throw new Error('dsh-plugin-desktop: packaged dependency entry must be a relative POSIX path') } - const logicalPath = fileURLToPath(new URL(`../node_modules/${dependencyEntry}`, moduleUrl)) + const packageSegments = segments[0]?.startsWith('@') === true ? 2 : 1 + const packageName = segments.slice(0, packageSegments).join('/') + const logicalPath = join( + resolvePackageRoot(moduleUrl, packageName), + ...segments.slice(packageSegments), + ) return unpackedAsarPath(logicalPath) } diff --git a/dsh-plugin-desktop/tests/desktop-cli.spec.ts b/dsh-plugin-desktop/tests/desktop-cli.spec.ts index cefb69422a..9bb479d4f4 100644 --- a/dsh-plugin-desktop/tests/desktop-cli.spec.ts +++ b/dsh-plugin-desktop/tests/desktop-cli.spec.ts @@ -1,3 +1,5 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { describe, expect, it, vi } from 'vitest' @@ -81,17 +83,67 @@ describe('packaged dsh bootstrap', () => { expect(unpackedAsarPath('/Applications/DSH Desktop.app/Contents/Resources/app.asar/package.json')) .toBe('/Applications/DSH Desktop.app/Contents/Resources/app.asar.unpacked/package.json') expect(unpackedAsarPath('/workspace/node_modules/pkg')).toBe('/workspace/node_modules/pkg') - const moduleUrl = pathToFileURL(join(process.cwd(), 'app.asar', 'lib', 'desktop-cli.js')).href - expect(packagedDependencyPath(moduleUrl, '@deepseek-ai/dsh/lib/bin.js')).toBe(join( - process.cwd(), - 'app.asar.unpacked', - 'node_modules', - '@deepseek-ai', - 'dsh', - 'lib', - 'bin.js', - )) expect(() => packagedDependencyPath(import.meta.url, '../outside.js')) .toThrow('relative POSIX path') }) + + it('maps a resolved ASAR dependency to its physical unpacked path', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-asar-profile-')) + const desktopLib = join(root, 'app.asar', 'lib') + const dshPackage = join(root, 'app.asar', 'node_modules', '@deepseek-ai', 'dsh') + try { + mkdirSync(desktopLib, { recursive: true }) + mkdirSync(join(dshPackage, 'lib'), { recursive: true }) + writeFileSync(join(dshPackage, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh', + type: 'module', + })) + writeFileSync(join(dshPackage, 'lib', 'bin.js'), '') + + const moduleUrl = pathToFileURL(join(desktopLib, 'desktop-cli.js')).href + expect(packagedDependencyPath(moduleUrl, '@deepseek-ai/dsh/lib/bin.js')).toBe(join( + realpathSync(root), + 'app.asar.unpacked', + 'node_modules', + '@deepseek-ai', + 'dsh', + 'lib', + 'bin.js', + )) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('resolves the DSH entry from a pnpm profile with flat package dependencies', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-flat-profile-')) + const desktopLib = join(root, 'node_modules', 'dsh-plugin-desktop', 'lib') + const dshPackage = join(root, 'node_modules', '@deepseek-ai', 'dsh') + const dshEntry = join(dshPackage, 'lib', 'bin.js') + const pnpmPackage = join(root, 'node_modules', 'pnpm') + const pnpmEntry = join(pnpmPackage, 'bin', 'pnpm.mjs') + try { + mkdirSync(desktopLib, { recursive: true }) + mkdirSync(join(dshPackage, 'lib'), { recursive: true }) + mkdirSync(join(pnpmPackage, 'bin'), { recursive: true }) + writeFileSync(join(dshPackage, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh', + type: 'module', + })) + writeFileSync(dshEntry, '') + writeFileSync(join(pnpmPackage, 'package.json'), JSON.stringify({ + name: 'pnpm', + exports: { '.': './package.json' }, + })) + writeFileSync(pnpmEntry, '') + + const moduleUrl = pathToFileURL(join(desktopLib, 'desktop-cli.js')).href + expect(packagedDependencyPath(moduleUrl, '@deepseek-ai/dsh/lib/bin.js')) + .toBe(join(realpathSync(root), 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')) + expect(packagedDependencyPath(moduleUrl, 'pnpm/bin/pnpm.mjs')) + .toBe(join(realpathSync(root), 'node_modules', 'pnpm', 'bin', 'pnpm.mjs')) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) })