diff --git a/build/dsh-desktop.patch.yml b/build/dsh-desktop.patch.yml index 34c24083..a1947757 100644 --- a/build/dsh-desktop.patch.yml +++ b/build/dsh-desktop.patch.yml @@ -18,6 +18,7 @@ # The desktop shell has the real lifecycle authority, so keep that behavior off # whenever the optional plugin is present in the composed profile. - id: dsh-market + inject: [desktopProfiles] config: profile: web allowRestart: false diff --git a/packages/dsh-desktop-market-installer/index.js b/packages/dsh-desktop-market-installer/index.js index 9e12e2f3..20c1c0ae 100644 --- a/packages/dsh-desktop-market-installer/index.js +++ b/packages/dsh-desktop-market-installer/index.js @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs' import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { homedir } from 'node:os' -import { delimiter, dirname, join, resolve } from 'node:path' +import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path' export const RECOMMENDED_MARKET_VERSION = '1.9.0' export const MARKET_PACKAGE = 'dshmarket' @@ -16,7 +16,7 @@ const OPERATION_TIMEOUT_MS = 15 * 60 * 1000 const MAX_LOG_BYTES = 32 * 1024 export const name = 'dsh-desktop-market-installer' -export const inject = ['webServer'] +export const inject = [] function dshHome() { return process.env.DSH_HOME || join(homedir(), '.dsh') @@ -164,6 +164,137 @@ export async function ensurePnpmShim(home = dshHome()) { return directory } +function processPath(environment) { + return ( + (process.platform === 'win32' ? environment.Path : environment.PATH) ?? + environment.PATH ?? + environment.Path ?? + '' + ) +} + +export function buildPnpmEnvironment( + binDirectory, + environment = process.env, + executablePath = process.execPath +) { + const result = { ...environment } + for (const key of Object.keys(result)) { + if (key.toUpperCase() === 'ELECTRON_RUN_AS_NODE') delete result[key] + } + + const seen = new Set() + const paths = [binDirectory, dirname(executablePath), ...processPath(environment).split(delimiter)] + .map((entry) => entry.trim()) + .filter((entry) => { + if (!entry) return false + const identity = process.platform === 'win32' ? entry.toLowerCase() : entry + if (seen.has(identity)) return false + seen.add(identity) + return true + }) + const value = paths.join(delimiter) + result.PATH = value + if (process.platform === 'win32') result.Path = value + result.CI = 'true' + result.NO_COLOR = '1' + return result +} + +export function createDesktopProfilesService(home = dshHome()) { + const current = Object.freeze({ + name: MARKET_PROFILE, + dir: profileDirectory(home) + }) + return Object.freeze({ + current, + list: () => [current], + select: async (name) => { + if (name !== MARKET_PROFILE) { + throw new Error(`DSH Desktop only exposes the ${MARKET_PROFILE} profile.`) + } + } + }) +} + +function validatePluginOperation(args, invokingDir) { + if (!Array.isArray(args) || args.length === 0) { + throw new Error('Desktop pnpm requires at least one plugin argument.') + } + if (args.some((argument) => typeof argument !== 'string' || !argument || argument.includes('\0'))) { + throw new Error('Desktop pnpm arguments must be non-empty strings without NUL.') + } + if (typeof invokingDir !== 'string' || !isAbsolute(invokingDir) || invokingDir.includes('\0')) { + throw new Error('Desktop pnpm requires an absolute invoking directory without NUL.') + } +} + +export function createDesktopPnpmService(options) { + const { + binDirectory, + dshEntryPath = resolveDshEntry(), + executablePath = process.execPath, + environment = process.env, + spawnProcess = spawn + } = options + let active + let closed = false + + const runPlugin = (args, invokingDir, signal) => { + validatePluginOperation(args, invokingDir) + if (closed) throw new Error('The DSH Desktop pnpm service has been disposed.') + if (signal?.aborted) throw signal.reason ?? new Error('The package operation was aborted.') + if (active) throw new Error('Another desktop pnpm operation is already running.') + + const child = spawnProcess( + executablePath, + [dshEntryPath, 'plugin', '--profile', MARKET_PROFILE, ...args], + { + cwd: invokingDir, + env: buildPnpmEnvironment(binDirectory, environment, executablePath), + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + detached: process.platform !== 'win32' + } + ) + const cancel = () => killProcessTree(child) + const done = new Promise((resolveDone, rejectDone) => { + child.once('error', rejectDone) + child.once('close', (exitCode, exitSignal) => { + resolveDone({ exitCode, signal: exitSignal }) + }) + }) + const handle = { + stdout: child.stdout, + stderr: child.stderr, + done, + cancel + } + active = handle + + const abort = () => cancel() + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted) abort() + const release = () => { + signal?.removeEventListener('abort', abort) + if (active === handle) active = undefined + } + void done.then(release, release) + return handle + } + + return Object.freeze({ + runPlugin, + async dispose() { + closed = true + const operation = active + if (!operation) return + operation.cancel() + await operation.done.catch(() => undefined) + } + }) +} + export function resolveDshEntry(argv = process.argv) { const entry = argv[1] if (!entry || !/[/\\]bin\.js$/u.test(entry)) { @@ -195,7 +326,7 @@ async function atomicWrite(path, contents) { } function killProcessTree(child) { - if (!child || child.exitCode !== null) return + if (!child || child.exitCode !== null || !child.pid) return if (process.platform === 'win32') { spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, @@ -210,16 +341,59 @@ function killProcessTree(child) { } } -export function apply(ctx) { +export async function apply(ctx) { const home = dshHome() const directory = profileDirectory(home) const manifestPath = join(directory, 'package.json') - let activeChild let operationPromise let phase = 'idle' let detail let restartRequired = false + // These generation-scoped services are the supported Desktop integration + // boundary consumed by dsh-market 1.6+. The market therefore never probes + // or provisions a system package manager and all mutations stay on the + // packaged Node/pnpm pair. + const binDirectory = await ensurePnpmShim(home) + const desktopProfiles = createDesktopProfilesService(home) + const desktopPnpm = createDesktopPnpmService({ binDirectory }) + ctx.provide('desktopProfiles', desktopProfiles) + ctx.provide('desktopPnpm', desktopPnpm) + ctx.effect(() => () => desktopPnpm.dispose(), 'dsh-desktop-market-installer: desktop pnpm') + + const runProfileCommand = async (args, action) => { + const handle = desktopPnpm.runPlugin(args, directory) + let output = '' + const append = (chunk) => { + output = `${output}${chunk.toString('utf8')}`.slice(-MAX_LOG_BYTES) + const lines = output.trim().split(/\r?\n/u) + detail = lines.at(-1)?.slice(0, 800) + } + handle.stdout.on('data', append) + handle.stderr.on('data', append) + + let timedOut = false + const timer = setTimeout(() => { + timedOut = true + handle.cancel() + }, OPERATION_TIMEOUT_MS) + + try { + const exit = await handle.done + if (timedOut) throw new Error(`${action} timed out after 15 minutes.`) + if (exit.exitCode !== 0) { + throw new Error( + detail || + `${action} exited with ${exit.signal ? `signal ${exit.signal}` : `code ${exit.exitCode}`}.` + ) + } + } finally { + clearTimeout(timer) + handle.stdout.off('data', append) + handle.stderr.off('data', append) + } + } + const status = async () => { const installation = await readMarketInstallation(home) if (phase === 'installing' || phase === 'uninstalling') { @@ -274,7 +448,6 @@ export function apply(ctx) { return } - await ensurePnpmShim(home) await mkdir(directory, { recursive: true }) let manifestSnapshot try { @@ -283,50 +456,11 @@ export function apply(ctx) { if (error?.code !== 'ENOENT') throw error } - const pathKey = process.platform === 'win32' ? 'Path' : 'PATH' - const envPath = process.env[pathKey] ?? process.env.PATH ?? process.env.Path ?? '' - const child = spawn(process.execPath, buildInstallArguments(), { - cwd: directory, - env: { - ...process.env, - PATH: envPath, - Path: envPath, - CI: 'true', - NO_COLOR: '1' - }, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - detached: process.platform !== 'win32' - }) - activeChild = child - - let output = '' - const append = (chunk) => { - output = `${output}${chunk.toString('utf8')}`.slice(-MAX_LOG_BYTES) - const lines = output.trim().split(/\r?\n/u) - detail = lines.at(-1)?.slice(0, 800) - } - child.stdout.on('data', append) - child.stderr.on('data', append) - - let timedOut = false - const timer = setTimeout(() => { - timedOut = true - killProcessTree(child) - }, OPERATION_TIMEOUT_MS) - try { - const exit = await new Promise((resolveExit, rejectExit) => { - child.once('error', rejectExit) - child.once('exit', (code, signal) => resolveExit({ code, signal })) - }) - if (timedOut) throw new Error('Installation timed out after 15 minutes.') - if (exit.code !== 0) { - throw new Error( - detail || - `The installer exited with ${exit.signal ? `signal ${exit.signal}` : `code ${exit.code}`}.` - ) - } + await runProfileCommand( + ['add', '--save-exact', `${MARKET_PACKAGE}@${RECOMMENDED_MARKET_VERSION}`], + 'Installation' + ) const installed = await readMarketInstallation(home) if (!installed.installedVersion) { @@ -342,9 +476,6 @@ export function apply(ctx) { phase = 'error' detail = error instanceof Error ? error.message : String(error) ctx.logger.warn(error instanceof Error ? error : new Error(detail)) - } finally { - clearTimeout(timer) - if (activeChild === child) activeChild = undefined } } @@ -359,7 +490,6 @@ export function apply(ctx) { return } - await ensurePnpmShim(home) let manifestSnapshot try { manifestSnapshot = await readFile(manifestPath, 'utf8') @@ -367,50 +497,8 @@ export function apply(ctx) { if (error?.code !== 'ENOENT') throw error } - const pathKey = process.platform === 'win32' ? 'Path' : 'PATH' - const envPath = process.env[pathKey] ?? process.env.PATH ?? process.env.Path ?? '' - const child = spawn(process.execPath, buildUninstallArguments(), { - cwd: directory, - env: { - ...process.env, - PATH: envPath, - Path: envPath, - CI: 'true', - NO_COLOR: '1' - }, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - detached: process.platform !== 'win32' - }) - activeChild = child - - let output = '' - const append = (chunk) => { - output = `${output}${chunk.toString('utf8')}`.slice(-MAX_LOG_BYTES) - const lines = output.trim().split(/\r?\n/u) - detail = lines.at(-1)?.slice(0, 800) - } - child.stdout.on('data', append) - child.stderr.on('data', append) - - let timedOut = false - const timer = setTimeout(() => { - timedOut = true - killProcessTree(child) - }, OPERATION_TIMEOUT_MS) - try { - const exit = await new Promise((resolveExit, rejectExit) => { - child.once('error', rejectExit) - child.once('exit', (code, signal) => resolveExit({ code, signal })) - }) - if (timedOut) throw new Error('Uninstallation timed out after 15 minutes.') - if (exit.code !== 0) { - throw new Error( - detail || - `The uninstaller exited with ${exit.signal ? `signal ${exit.signal}` : `code ${exit.code}`}.` - ) - } + await runProfileCommand(['remove', MARKET_PACKAGE], 'Uninstallation') const removed = await readMarketInstallation(home) if (removed.dependency || removed.installedVersion) { @@ -426,14 +514,11 @@ export function apply(ctx) { phase = 'error' detail = error instanceof Error ? error.message : String(error) ctx.logger.warn(error instanceof Error ? error : new Error(detail)) - } finally { - clearTimeout(timer) - if (activeChild === child) activeChild = undefined } } - ctx.effect(() => { - const disposeStatus = ctx.webServer.register({ + ctx.inject(['webServer'], (webCtx) => webCtx.effect(() => { + const disposeStatus = webCtx.webServer.register({ kind: 'exact', path: STATUS_PATH, handler: async (req, res) => { @@ -444,7 +529,7 @@ export function apply(ctx) { sendJson(res, 200, await status()) } }) - const disposeInstall = ctx.webServer.register({ + const disposeInstall = webCtx.webServer.register({ kind: 'exact', path: INSTALL_PATH, handler: async (req, res) => { @@ -471,7 +556,7 @@ export function apply(ctx) { .catch((error) => { phase = 'error' detail = error instanceof Error ? error.message : String(error) - ctx.logger.warn(error instanceof Error ? error : new Error(detail)) + webCtx.logger.warn(error instanceof Error ? error : new Error(detail)) }) .finally(() => { operationPromise = undefined @@ -479,7 +564,7 @@ export function apply(ctx) { sendJson(res, 202, await status()) } }) - const disposeUninstall = ctx.webServer.register({ + const disposeUninstall = webCtx.webServer.register({ kind: 'exact', path: UNINSTALL_PATH, handler: async (req, res) => { @@ -508,7 +593,7 @@ export function apply(ctx) { .catch((error) => { phase = 'error' detail = error instanceof Error ? error.message : String(error) - ctx.logger.warn(error instanceof Error ? error : new Error(detail)) + webCtx.logger.warn(error instanceof Error ? error : new Error(detail)) }) .finally(() => { operationPromise = undefined @@ -521,14 +606,7 @@ export function apply(ctx) { disposeUninstall() disposeInstall() disposeStatus() - killProcessTree(activeChild) await operationPromise?.catch(() => undefined) } - }, 'dsh-desktop-market-installer: fixed package routes') - - ctx.effect(() => { - void ensurePnpmShim(home).catch((error) => { - ctx.logger.warn(error instanceof Error ? error : new Error(String(error))) - }) - }, 'dsh-desktop-market-installer: packaged pnpm shim') + }, 'dsh-desktop-market-installer: fixed package routes')) } diff --git a/test/market-installer.test.js b/test/market-installer.test.js index 64226c30..146e9569 100644 --- a/test/market-installer.test.js +++ b/test/market-installer.test.js @@ -8,8 +8,11 @@ import { RECOMMENDED_MARKET_VERSION, STATUS_PATH, UNINSTALL_PATH, + buildPnpmEnvironment, buildInstallArguments, buildUninstallArguments, + createDesktopPnpmService, + createDesktopProfilesService, ensurePnpmShim, isTrustedRequest, readMarketInstallation, @@ -66,6 +69,92 @@ describe('desktop plugin market installer', () => { } }) + it('exposes the active Desktop profile without inferring it from argv', async () => { + const home = join('C:\\Users\\tester', 'AppData', 'Roaming', 'dsh-desktop', 'harness') + const profiles = createDesktopProfilesService(home) + + expect(profiles.current).toEqual({ + name: 'web', + dir: join(home, 'profiles', 'web') + }) + expect(profiles.list()).toEqual([profiles.current]) + await expect(profiles.select('web')).resolves.toBeUndefined() + await expect(profiles.select('other')).rejects.toThrow('only exposes the web profile') + }) + + it('runs plugin mutations through one packaged pnpm operation boundary', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-pnpm-service-')) + const binDirectory = join(root, '.desktop-bin') + const fakeDshEntry = join(root, 'fake-dsh.mjs') + await mkdir(binDirectory, { recursive: true }) + await writeFile( + fakeDshEntry, + [ + "process.stdout.write(JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd(), path: process.env.PATH }))", + "await new Promise((resolve) => setTimeout(resolve, Number(process.env.DSH_DESKTOP_TEST_DELAY_MS ?? '0')))" + ].join('\n'), + 'utf8' + ) + + const environment = { + ...process.env, + DSH_DESKTOP_TEST_DELAY_MS: '80', + ELECTRON_RUN_AS_NODE: '1' + } + const service = createDesktopPnpmService({ + binDirectory, + dshEntryPath: fakeDshEntry, + executablePath: process.execPath, + environment + }) + const handle = service.runPlugin(['remove', 'example-plugin'], root) + expect(() => service.runPlugin(['install'], root)).toThrow( + 'Another desktop pnpm operation is already running.' + ) + + let stdout = '' + handle.stdout.on('data', (chunk) => { + stdout += chunk.toString('utf8') + }) + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + const invocation = JSON.parse(stdout) + expect(invocation.args).toEqual([ + 'plugin', + '--profile', + 'web', + 'remove', + 'example-plugin' + ]) + expect(invocation.cwd).toBe(root) + expect(invocation.path.split(process.platform === 'win32' ? ';' : ':')[0]).toBe( + binDirectory + ) + expect(buildPnpmEnvironment(binDirectory, environment)).not.toHaveProperty( + 'ELECTRON_RUN_AS_NODE' + ) + + const next = service.runPlugin(['install'], root) + await expect(next.done).resolves.toEqual({ exitCode: 0, signal: null }) + await service.dispose() + expect(() => service.runPlugin(['install'], root)).toThrow('has been disposed') + }) + + it('rejects a package operation that was already aborted', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-pnpm-abort-')) + const controller = new AbortController() + controller.abort(new Error('cancelled before start')) + const service = createDesktopPnpmService({ + binDirectory: join(root, '.desktop-bin'), + dshEntryPath: join(root, 'unused-dsh-entry.mjs'), + executablePath: process.execPath + }) + + expect(() => service.runPlugin(['install'], root, controller.signal)).toThrow( + 'cancelled before start' + ) + await service.dispose() + }) + it('reports both the requested dependency and installed package version', async () => { const home = await mkdtemp(join(tmpdir(), 'dsh-market-status-')) const profile = join(home, 'profiles', 'web') @@ -132,6 +221,7 @@ describe('desktop plugin market installer', () => { '只会移除 dsh-market。通过插件市场安装的其他插件将继续保留。' ) expect(desktopPatch).toContain('name: dsh-desktop-market-installer') + expect(desktopPatch).toContain('inject: [desktopProfiles]') expect(desktopPatch).toContain('allowRestart: false') expect(preload).toContain("restartHarness: (): Promise<{ ok: boolean }>") })