diff --git a/build/profile-esm-resolver.mjs b/build/profile-esm-resolver.mjs new file mode 100644 index 00000000..b58250cc --- /dev/null +++ b/build/profile-esm-resolver.mjs @@ -0,0 +1,42 @@ +import { pathToFileURL } from 'node:url' + +let profileNodeModules = [] + +export function initialize(data) { + if (data?.profileNodeModules) { + profileNodeModules = data.profileNodeModules + } +} + +export async function resolve(specifier, context, nextResolve) { + if (profileNodeModules.length === 0) { + return nextResolve(specifier, context) + } + + const isBareSpecifier = !specifier.startsWith('.') && + !specifier.startsWith('/') && + !specifier.startsWith('file://') && + !specifier.startsWith('node:') && + !specifier.includes('://') + + if (!isBareSpecifier) { + return nextResolve(specifier, context) + } + + try { + return await nextResolve(specifier, context) + } catch (defaultError) { + for (const nodeModulesDir of profileNodeModules) { + const syntheticParent = pathToFileURL(nodeModulesDir + '/.resolve-anchor/anchor.js').href + try { + return await nextResolve(specifier, { + ...context, + parentURL: syntheticParent + }) + } catch { + // try next profile + } + } + throw defaultError + } +} diff --git a/build/profile-module-paths.mjs b/build/profile-module-paths.mjs new file mode 100644 index 00000000..0e72f46e --- /dev/null +++ b/build/profile-module-paths.mjs @@ -0,0 +1,49 @@ +import { readdirSync, existsSync } from 'node:fs' +import { createRequire, register } from 'node:module' +import { join, delimiter, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const dshHome = process.env.DSH_HOME +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +function collectProfileNodeModules(home) { + if (!home) return [] + const profilesDir = join(home, 'profiles') + try { + return readdirSync(profilesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(profilesDir, entry.name, 'node_modules')) + .filter((dir) => existsSync(dir)) + } catch { + return [] + } +} + +const profileNodeModules = collectProfileNodeModules(dshHome) + +if (profileNodeModules.length > 0) { + const paths = profileNodeModules.join(delimiter) + if (process.env.NODE_PATH) { + process.env.NODE_PATH = process.env.NODE_PATH + delimiter + paths + } else { + process.env.NODE_PATH = paths + } + + try { + const require = createRequire(import.meta.url) + const Module = require('node:module') + if (Module.globalPaths) { + for (const dir of profileNodeModules) { + if (!Module.globalPaths.includes(dir)) { + Module.globalPaths.push(dir) + } + } + } + } catch { + // Best effort for CJS fallback + } + + const resolverUrl = pathToFileURL(join(__dirname, 'profile-esm-resolver.mjs')).href + register(resolverUrl, { data: { profileNodeModules } }) +} diff --git a/package.json b/package.json index 07c2c546..b5e712cc 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,14 @@ "from": "build/harness-node-entry.mjs", "to": "harness-node-entry.mjs" }, + { + "from": "build/profile-module-paths.mjs", + "to": "profile-module-paths.mjs" + }, + { + "from": "build/profile-esm-resolver.mjs", + "to": "profile-esm-resolver.mjs" + }, { "from": "build/dsh-desktop.patch.yml", "to": "dsh-desktop.patch.yml" diff --git a/src/main/index.ts b/src/main/index.ts index d118507e..43be14d1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -457,6 +457,7 @@ async function bootstrap(): Promise { dshEntryPath: dshEntryPath(), nodeExecutablePath: bundledNodePath(), nodeEntryPath: harnessNodeEntryPath(), + profileModulePathsPath: desktopResourcePath('profile-module-paths.mjs'), dshPatchPath: desktopResourcePath('dsh-desktop.patch.yml'), dshHome: join(app.getPath('userData'), 'harness'), logPath: join(app.getPath('logs'), 'harness.log'), diff --git a/src/main/runtime/harness-runtime.ts b/src/main/runtime/harness-runtime.ts index 7555bdbb..db3d4f08 100644 --- a/src/main/runtime/harness-runtime.ts +++ b/src/main/runtime/harness-runtime.ts @@ -9,6 +9,7 @@ export interface HarnessRuntimeOptions { dshEntryPath: string nodeExecutablePath: string nodeEntryPath: string + profileModulePathsPath: string dshPatchPath: string dshHome: string logPath: string @@ -36,7 +37,8 @@ export function buildHarnessSpawnOptions( launchDirectory: string, dshHome: string, platform: NodeJS.Platform = process.platform, - environment: NodeJS.ProcessEnv = process.env + environment: NodeJS.ProcessEnv = process.env, + profileModulePathsPath?: string ): SpawnOptionsWithoutStdio { const { ELECTRON_RUN_AS_NODE: _runAsNode, ...parentEnvironment } = environment const pathKey = platform === 'win32' ? 'Path' : 'PATH' @@ -47,6 +49,7 @@ export function buildHarnessSpawnOptions( ...parentEnvironment, DSH_HOME: dshHome, NO_COLOR: '1', + ...(profileModulePathsPath ? { DSH_DESKTOP_PROFILE_MODULE_PATHS: profileModulePathsPath } : {}), [pathKey]: environment[pathKey] ?? environment.PATH ?? '' }, stdio: ['pipe', 'pipe', 'pipe'], @@ -58,10 +61,12 @@ export function buildNodeArguments( nodeEntryPath: string, dshEntryPath: string, port: number, - patchPath?: string + patchPath?: string, + profileModulePathsPath?: string ): string[] { return [ '--expose-internals', + ...(profileModulePathsPath ? ['--import', profileModulePathsPath] : []), nodeEntryPath, dshEntryPath, ...buildHarnessArguments(port, patchPath) @@ -121,7 +126,8 @@ export class HarnessRuntime { this.options.nodeEntryPath, this.options.dshEntryPath, port, - this.options.dshPatchPath + this.options.dshPatchPath, + this.options.profileModulePathsPath ) const startupTimeoutMs = this.options.startupTimeoutMs ?? (process.platform === 'win32' ? 120_000 : 45_000) @@ -136,7 +142,7 @@ export class HarnessRuntime { child = this.options.launchProcess( this.options.nodeExecutablePath, args, - buildHarnessSpawnOptions(launchDirectory, this.options.dshHome) + buildHarnessSpawnOptions(launchDirectory, this.options.dshHome, undefined, undefined, this.options.profileModulePathsPath) ) } catch (error) { const message = error instanceof Error ? error.message : String(error) diff --git a/test/profile-module-paths.test.ts b/test/profile-module-paths.test.ts new file mode 100644 index 00000000..7605bba3 --- /dev/null +++ b/test/profile-module-paths.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { mkdir, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { pathToFileURL } from 'node:url' + +describe('Profile module paths resolution', () => { + it('resolves bare specifiers from profile node_modules via synthetic parent', async () => { + const testDir = join(tmpdir(), `dsh-desktop-resolver-${Date.now()}`) + const profileNodeModules = join(testDir, 'profiles', 'web', 'node_modules') + const packageDir = join(profileNodeModules, 'test-profile-pkg') + + await mkdir(packageDir, { recursive: true }) + await writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ name: 'test-profile-pkg', version: '1.0.0', main: 'index.js', type: 'module' }) + ) + await writeFile(join(packageDir, 'index.js'), 'export const value = 42;\n') + + try { + const resolverUrl = pathToFileURL(join(process.cwd(), 'build', 'profile-esm-resolver.mjs')).href + const mod = await import(resolverUrl) + + mod.initialize({ profileNodeModules: [profileNodeModules] }) + + const fakeParent = 'file:///app/somewhere/module.js' + const nextResolve = (specifier: string, _context: unknown) => { + if (specifier.includes('test-profile-pkg')) { + return Promise.resolve({ url: pathToFileURL(join(packageDir, 'index.js')).href, shortCircuit: true }) + } + return Promise.reject(new Error(`not found: ${specifier}`)) + } + + const result = await mod.resolve( + 'test-profile-pkg', + { parentURL: fakeParent, conditions: ['import'] }, + nextResolve + ) + + expect(result.url).toContain('test-profile-pkg') + expect(result.url).toContain('index.js') + } finally { + await rm(testDir, { recursive: true, force: true }) + } + }) + + it('passes through non-bare specifiers unchanged', async () => { + const resolverUrl = pathToFileURL(join(process.cwd(), 'build', 'profile-esm-resolver.mjs')).href + const mod = await import(resolverUrl) + + mod.initialize({ profileNodeModules: ['/fake/profile/node_modules'] }) + + const fakeParent = 'file:///app/module.js' + let capturedSpecifier = '' + const nextResolve = (specifier: string, _context: unknown) => { + capturedSpecifier = specifier + return Promise.resolve({ url: `file:///resolved/${specifier}`, shortCircuit: true }) + } + + await mod.resolve('./relative.js', { parentURL: fakeParent }, nextResolve) + expect(capturedSpecifier).toBe('./relative.js') + + await mod.resolve('/absolute/path.js', { parentURL: fakeParent }, nextResolve) + expect(capturedSpecifier).toBe('/absolute/path.js') + + await mod.resolve('node:path', { parentURL: fakeParent }, nextResolve) + expect(capturedSpecifier).toBe('node:path') + + await mod.resolve('file:///some/file.js', { parentURL: fakeParent }, nextResolve) + expect(capturedSpecifier).toBe('file:///some/file.js') + }) +}) diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 816548ab..8f5d182a 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -56,6 +56,27 @@ describe('Harness launch contract', () => { Path: 'windows-path' } }) + expect(options.env).not.toHaveProperty('DSH_DESKTOP_PROFILE_MODULE_PATHS') + }) + + it('passes profile module paths env when provided', () => { + const options = buildHarnessSpawnOptions( + 'C:\\Users\\tester\\AppData\\Roaming\\dsh-desktop\\launch-root', + 'C:\\Users\\tester\\AppData\\Roaming\\dsh-desktop\\harness', + 'win32', + { + ELECTRON_RUN_AS_NODE: '1', + PATH: 'fallback-path', + Path: 'windows-path' + }, + 'C:\\app\\profile-module-paths.mjs' + ) + + expect(options.env).toMatchObject({ + DSH_HOME: 'C:\\Users\\tester\\AppData\\Roaming\\dsh-desktop\\harness', + DSH_DESKTOP_PROFILE_MODULE_PATHS: 'C:\\app\\profile-module-paths.mjs', + NO_COLOR: '1' + }) expect(options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE') }) @@ -81,6 +102,32 @@ describe('Harness launch contract', () => { ]) }) + it('injects profile module paths via --import when provided', () => { + expect( + buildNodeArguments( + 'C:\\app\\harness-node-entry.mjs', + 'C:\\app\\dsh\\lib\\bin.js', + 43127, + 'C:\\app\\dsh-desktop.patch.yml', + 'C:\\app\\profile-module-paths.mjs' + ) + ).toEqual([ + '--expose-internals', + '--import', + 'C:\\app\\profile-module-paths.mjs', + 'C:\\app\\harness-node-entry.mjs', + 'C:\\app\\dsh\\lib\\bin.js', + 'web', + '--patch', + 'C:\\app\\dsh-desktop.patch.yml', + '--host', + '127.0.0.1', + '--port', + '43127' + ]) + }) + + it('makes native Windows termination codes diagnosable', () => { expect(formatExitCode(4294930435)).toContain( '0xFFFF7003, Crashpad handler unavailable'