diff --git a/.gitignore b/.gitignore index 149ad8a2..9180d8c2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ build/icon-1024.png .env .env.* !.env.example +.coaligne/ +.coaligneignore + diff --git a/build/plugin-recovery.html b/build/plugin-recovery.html index dc9af93a..a6421dd2 100644 --- a/build/plugin-recovery.html +++ b/build/plugin-recovery.html @@ -450,7 +450,11 @@

} const navigate = (action) => { - location.href = `dsh-recovery://${action}` + if (window.dshRecovery && typeof window.dshRecovery.action === 'function') { + void window.dshRecovery.action(action) + } else { + location.href = `dsh-recovery://${action}` + } } const primary = document.getElementById('primary') primary.addEventListener('click', () => { diff --git a/src/main/index.ts b/src/main/index.ts index ccbdaf95..2e241123 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process' import { join } from 'node:path' -import { existsSync, readFileSync } from "node:fs" +import { appendFileSync, existsSync, readFileSync } from 'node:fs' import { parse } from 'yaml' import { app, @@ -13,11 +13,22 @@ import { type IpcMainInvokeEvent, type MessageBoxOptions } from 'electron' -import { extractFailureCause, extractOffendingPlugins, HarnessRuntime } from './runtime/harness-runtime' +import { + extractDuplicateLoaderEntryId, + extractFailureCause, + extractPluginFailureReferences, + extractSlotConflictName, + HarnessRuntime +} from './runtime/harness-runtime' +import { removeProfilePluginWithDsh } from './runtime/profile-plugin-command' import { LanMobileBridge } from './mobile/lan-mobile-bridge' import { secureWindow } from './security' import { ensureLaunchRoot } from './state/launch-root' -import { resetPluginProfile, uninstallPluginFromProfile } from './state/plugin-recovery' +import { + resetPluginProfile, + resolveProfileRecoveryPlugins, + uninstallPluginFromProfile +} from './state/plugin-recovery' import { isAbortedNavigationError, shouldLoadHarnessUrl } from './window-navigation' import { checkForUpdates, @@ -53,6 +64,45 @@ let failureRecoveryVisible = false let harnessLaunchOperation: Promise | undefined let pluginRecoveryActionResolver: ((action: PluginRecoveryAction) => void) | undefined let mainWindowNavigationVersion = 0 +let rendererPluginFailureLogs: string[] = [] +let pluginRecoveryRemovedPlugins: string[] = [] +let pluginRecoveryResetTimer: ReturnType | undefined + +function cancelPluginRecoverySessionReset(): void { + if (pluginRecoveryResetTimer) clearTimeout(pluginRecoveryResetTimer) + pluginRecoveryResetTimer = undefined +} + +function schedulePluginRecoverySessionReset(): void { + cancelPluginRecoverySessionReset() + pluginRecoveryResetTimer = setTimeout(() => { + pluginRecoveryResetTimer = undefined + pluginRecoveryRemovedPlugins = [] + // Keep the chain alive long enough for slower Windows machines to finish + // rendering a frontend plugin failure after the backend reports ready. + }, 60_000) +} + +function appendRendererPluginRecoveryLog(logs: readonly string[]): void { + if (logs.length === 0) return + + try { + const evidence = logs + .slice(-50) + .join('\n') + .slice(-20_000) + .split(/\r?\n/) + .map((line) => `[renderer] ${line}`) + .join('\n') + appendFileSync( + join(app.getPath('logs'), 'harness.log'), + `\n[desktop] frontend plugin recovery ${new Date().toISOString()}\n${evidence}\n`, + 'utf8' + ) + } catch (error) { + console.warn('[desktop] failed to persist frontend plugin recovery evidence', error) + } +} function isDevelopmentBuild(): boolean { if (!app.isPackaged) return true @@ -164,6 +214,12 @@ function bundledNodePath(): string { return join(app.getAppPath(), 'node_modules', 'node', 'bin', executable) } +function bundledPnpmEntryPath(): string { + const root = join(app.getAppPath(), 'node_modules', 'pnpm', 'bin') + const candidates = [join(root, 'pnpm.cjs'), join(root, 'pnpm.mjs')] + return candidates.find((candidate) => existsSync(candidate)) ?? join(root, 'pnpm.cjs') +} + function harnessNodeEntryPath(): string { return app.isPackaged ? join(process.resourcesPath, 'harness-node-entry.mjs') @@ -290,6 +346,15 @@ function createWindow(): BrowserWindow { event.preventDefault() window.setTitle('') }) + window.webContents.on('console-message', (details) => { + if (details.level !== 'error') return + const sourceUrl = details.sourceId || window.webContents.getURL() + if (!sourceUrl.startsWith('http://127.0.0.1:')) return + const message = details.message.trim() + if (!message) return + rendererPluginFailureLogs.push(`[stderr] ${message}`) + rendererPluginFailureLogs = rendererPluginFailureLogs.slice(-50) + }) installPluginRecoveryNavigation(window) secureWindow(window) installContextMenu(window, harnessLocale) @@ -305,6 +370,7 @@ async function openHarness(url: string): Promise { const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : createWindow() if (shouldLoadHarnessUrl(window.webContents.getURL(), url)) { const navigationVersion = ++mainWindowNavigationVersion + rendererPluginFailureLogs = [] window.webContents.stop() try { await window.loadURL(url) @@ -511,21 +577,36 @@ function showUnexpectedError(error: unknown): void { dialog.showErrorBox('DSH Desktop encountered an error', message) } -async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise { +async function showPluginRecovery(options?: { + message?: string + logs?: readonly string[] +}): Promise { if (failureRecoveryVisible || quitting) return failureRecoveryVisible = true const dshHome = join(app.getPath('userData'), 'harness') const isChinese = harnessLocale() === 'zh' - const removedPlugins: string[] = [] + cancelPluginRecoverySessionReset() + const removedPlugins = pluginRecoveryRemovedPlugins let notice: string | undefined try { - while (!quitting && runtime.snapshot().phase === 'failed') { - snapshot = runtime.snapshot() - const offendingPlugins = extractOffendingPlugins(snapshot.logs) + while (!quitting) { + let snapshot = runtime.snapshot() + const logs = options?.logs ?? snapshot.logs + const message = options?.message ?? snapshot.message + const offendingPlugins = await resolveProfileRecoveryPlugins( + dshHome, + extractPluginFailureReferences(logs), + extractDuplicateLoaderEntryId(logs), + extractSlotConflictName(logs), + removedPlugins + ) const action = await waitForPluginRecoveryAction({ - snapshot, + snapshot: { + ...snapshot, + message: message || snapshot.message + }, plugins: offendingPlugins, removedPlugins, notice @@ -535,7 +616,24 @@ async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise { if (action === 'uninstall' && offendingPlugins.length > 0) { const failedPlugins: string[] = [] for (const plugin of offendingPlugins) { - const removed = await uninstallPluginFromProfile(dshHome, plugin) + const removed = await uninstallPluginFromProfile(dshHome, plugin, async (pluginName) => { + const result = await removeProfilePluginWithDsh( + { + dshHome, + dshEntryPath: dshEntryPath(), + nodeExecutablePath: bundledNodePath(), + pnpmEntryPath: bundledPnpmEntryPath(), + environment: process.env + }, + pluginName + ) + if (!result.ok) { + console.warn( + `[plugin-recovery] Failed to remove ${pluginName}: ${result.detail ?? 'unknown error'}` + ) + } + return result.ok + }) if (removed) { if (!removedPlugins.includes(plugin)) removedPlugins.push(plugin) } else { @@ -555,8 +653,18 @@ async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise { : `These plugins could not be removed: ${failedPlugins.join(', ')}` } await launchHarness() + if (runtime.snapshot().phase === 'ready') { + schedulePluginRecoverySessionReset() + return + } + continue } else if (action === 'restart') { await launchHarness() + if (runtime.snapshot().phase === 'ready') { + schedulePluginRecoverySessionReset() + return + } + continue } else if (action === 'show-log') { shell.showItemInFolder(join(app.getPath('logs'), 'harness.log')) continue @@ -564,9 +672,6 @@ async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise { app.quit() return } - - if (runtime.snapshot().phase !== 'failed') return - snapshot = runtime.snapshot() } } catch (error) { showUnexpectedError(error) @@ -575,6 +680,10 @@ async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise { } } +async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise { + await showPluginRecovery({ message: snapshot.message, logs: snapshot.logs }) +} + function installMenu(): void { const isChinese = app.getLocale().toLowerCase().startsWith('zh') const checkForUpdatesLabel = isChinese @@ -773,6 +882,27 @@ async function bootstrap(): Promise { ipcMain.handle('harness:show-log', () => { shell.showItemInFolder(join(app.getPath('logs'), 'harness.log')) }) + ipcMain.removeHandler('harness:open-recovery') + ipcMain.handle('harness:open-recovery', async (event, frontendErrorMessage?: unknown) => { + assertTrustedMainWindowEvent(event) + const message = typeof frontendErrorMessage === 'string' ? frontendErrorMessage : undefined + const logs = [ + ...rendererPluginFailureLogs, + ...(message ? [`[stderr] ${message}`] : []) + ] + appendRendererPluginRecoveryLog(logs) + void showPluginRecovery({ message, logs }) + return { ok: true } + }) + ipcMain.removeHandler('recovery:action') + ipcMain.handle('recovery:action', (event, action: unknown) => { + assertTrustedMainWindowEvent(event) + if (typeof action === 'string' && PLUGIN_RECOVERY_ACTIONS.has(action as PluginRecoveryAction)) { + resolvePluginRecoveryAction(action as PluginRecoveryAction) + return { ok: true } + } + return { ok: false } + }) ipcMain.removeHandler('harness:reset-plugins') ipcMain.handle('harness:reset-plugins', async (event, pluginName?: unknown) => { assertTrustedMainWindowEvent(event) diff --git a/src/main/plugin-recovery-view.ts b/src/main/plugin-recovery-view.ts index e4924131..08deca0d 100644 --- a/src/main/plugin-recovery-view.ts +++ b/src/main/plugin-recovery-view.ts @@ -32,6 +32,11 @@ interface FailureDescription { detail: string } +function displayPluginName(packageName: string): string { + if (!packageName.startsWith('@')) return packageName + return packageName.slice(packageName.indexOf('/') + 1) +} + function latestAttemptText(logs: readonly string[]): string { let startIndex = -1 for (let index = logs.length - 1; index >= 0; index -= 1) { @@ -61,6 +66,19 @@ export function describePluginFailure( detail: `The startup log shows that ${duplicateRoute} was registered more than once, so Harness could not continue.` } } + + if (/duplicate loader entry id/i.test(text)) { + const entryId = text.match(/duplicate loader entry id:\s*([^\s]+)/i)?.[1] + return locale === 'zh' + ? { + title: '插件注册了重复的服务组件', + detail: `启动日志显示组件 ${entryId ? `"${entryId}"` : ''} 被重复定义,插件之间存在加载冲突,因此 Harness 无法继续启动。` + } + : { + title: 'A plugin registered a duplicate service component', + detail: `The startup log shows that component ${entryId ? `"${entryId}"` : ''} was registered more than once due to a plugin conflict.` + } + } if (/cannot resolve profile bundle/i.test(text)) { return locale === 'zh' @@ -86,6 +104,19 @@ export function describePluginFailure( } } + if (/single slot\s+["'][^"']+["']\s+already has a registration/i.test(text)) { + const slotName = text.match(/single slot\s+["']([^"']+)["']/i)?.[1] + return locale === 'zh' + ? { + title: '插件存在界面插槽冲突', + detail: `检测到界面插槽 ${slotName ? `"${slotName}"` : ''} 存在重复注册,多个第三方插件试图占用相同的界面组件,导致前端无法正常渲染。` + } + : { + title: 'A plugin has a UI slot conflict', + detail: `UI slot ${slotName ? `"${slotName}"` : ''} has duplicate registrations from conflicting plugins.` + } + } + if (/failed to import loader entry/i.test(text)) { return locale === 'zh' ? { @@ -117,8 +148,9 @@ export function buildPluginRecoveryViewModel(options: { notice?: string }): PluginRecoveryViewModel { const { snapshot, locale, notice } = options - const plugins = [...new Set(options.plugins)] - const removedPlugins = [...new Set(options.removedPlugins)] + const pluginPackages = [...new Set(options.plugins)] + const plugins = pluginPackages.map(displayPluginName) + const removedPlugins = [...new Set(options.removedPlugins)].map(displayPluginName) const canUninstall = plugins.length > 0 const description = describePluginFailure(snapshot.logs, locale) const multiple = plugins.length > 1 diff --git a/src/main/runtime/harness-runtime.ts b/src/main/runtime/harness-runtime.ts index d0c3567e..65fcba14 100644 --- a/src/main/runtime/harness-runtime.ts +++ b/src/main/runtime/harness-runtime.ts @@ -316,9 +316,28 @@ export function extractFailureCause(logLines: readonly string[]): string | undef return undefined } -const CORE_BUNDLES = new Set(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) +const CORE_BUNDLES = new Set(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dshmarket']) +const PACKAGE_REFERENCE_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i -export function extractOffendingPlugins(logLines: readonly string[]): string[] { +function isPackageReference(value: string): boolean { + const candidate = value.trim() + if (!candidate || candidate.includes(':')) return false + return PACKAGE_REFERENCE_PATTERN.test(candidate) +} + +function isActionablePluginReference(value: string): boolean { + const candidate = value.trim() + return ( + isPackageReference(candidate) && + !CORE_BUNDLES.has(candidate) && + !candidate.startsWith('@deepseek-ai/') + ) +} + +function extractPluginReferences( + logLines: readonly string[], + accepts: (value: string) => boolean +): string[] { const plugins = new Set() for (const line of latestHarnessAttemptLogs(logLines)) { @@ -326,34 +345,72 @@ export function extractOffendingPlugins(logLines: readonly string[]): string[] { const text = line.slice(8) const m1 = text.match(/failed to apply loader entry [^\s]+ \((@[^)]+|[^)]+)\)/i) - if (m1 && m1[1] && !CORE_BUNDLES.has(m1[1].trim())) { + if (m1 && m1[1] && accepts(m1[1])) { plugins.add(m1[1].trim()) } const m2 = text.match(/cannot resolve profile bundle ["']([^"']+)["']/i) - if (m2 && m2[1] && !CORE_BUNDLES.has(m2[1].trim())) { + if (m2 && m2[1] && accepts(m2[1])) { plugins.add(m2[1].trim()) } const m3 = text.match(/profile bundle ["']([^"']+)["'] declares no dsh\.bundle/i) - if (m3 && m3[1] && !CORE_BUNDLES.has(m3[1].trim())) { + if (m3 && m3[1] && accepts(m3[1])) { plugins.add(m3[1].trim()) } const m4 = text.match(/failed to import loader entry [^\s]+ \((@[^)]+|[^)]+)\)/i) - if (m4 && m4[1] && !CORE_BUNDLES.has(m4[1].trim())) { + if (m4 && m4[1] && accepts(m4[1])) { plugins.add(m4[1].trim()) } const m5 = text.match(/plugin\(s\) failed to load:\s*([a-zA-Z0-9@/_-]+)/i) - if (m5 && m5[1] && !CORE_BUNDLES.has(m5[1].trim())) { + if (m5 && m5[1] && accepts(m5[1])) { plugins.add(m5[1].trim()) } + + const bootFailureLines = text.split(/\r?\n/).map((value) => value.trim()) + const bootFailureTitle = bootFailureLines.findIndex((value) => value === 'Failed to load plugins') + if (bootFailureTitle >= 0) { + for (const candidate of bootFailureLines.slice(bootFailureTitle + 1)) { + if (accepts(candidate)) plugins.add(candidate) + } + } } return [...plugins] } +export function extractPluginFailureReferences(logLines: readonly string[]): string[] { + return extractPluginReferences(logLines, isPackageReference) +} + +export function extractOffendingPlugins(logLines: readonly string[]): string[] { + return extractPluginReferences(logLines, isActionablePluginReference) +} + +export function extractDuplicateLoaderEntryId( + logLines: readonly string[] +): string | undefined { + for (const line of latestHarnessAttemptLogs(logLines)) { + if (!line.startsWith('[stderr] ')) continue + const match = line.slice(8).match(/duplicate loader entry id:\s*["']?([^\s"']+)["']?/i) + if (match?.[1]) return match[1].trim() + } + return undefined +} + +export function extractSlotConflictName( + logLines: readonly string[] +): string | undefined { + for (const line of latestHarnessAttemptLogs(logLines)) { + if (!line.startsWith('[stderr] ')) continue + const match = line.slice(8).match(/single slot\s+["']([^"']+)["']\s+already has a registration/i) + if (match?.[1]) return match[1].trim() + } + return undefined +} + export function extractOffendingPlugin(logLines: readonly string[]): string | undefined { return extractOffendingPlugins(logLines)[0] } diff --git a/src/main/runtime/profile-plugin-command.ts b/src/main/runtime/profile-plugin-command.ts new file mode 100644 index 00000000..3bbbc1c7 --- /dev/null +++ b/src/main/runtime/profile-plugin-command.ts @@ -0,0 +1,192 @@ +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { chmod, mkdir, writeFile } from 'node:fs/promises' +import { delimiter, dirname, join } from 'node:path' + +const PROFILE = 'web' +const OPERATION_TIMEOUT_MS = 15 * 60 * 1000 +const MAX_OUTPUT_BYTES = 32 * 1024 + +export interface ProfilePluginCommandOptions { + dshHome: string + dshEntryPath: string + nodeExecutablePath: string + pnpmEntryPath: string + environment?: NodeJS.ProcessEnv +} + +export interface ProfilePluginCommandResult { + ok: boolean + detail?: string +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +export function buildProfilePluginRemoveArguments( + dshEntryPath: string, + pluginName: string +): string[] { + return [dshEntryPath, 'plugin', '--profile', PROFILE, 'remove', pluginName] +} + +export async function ensureProfilePnpmShim(options: ProfilePluginCommandOptions): Promise { + const directory = join(options.dshHome, '.desktop-bin') + await mkdir(directory, { recursive: true }) + + if (process.platform === 'win32') { + await writeFile( + join(directory, 'pnpm.cmd'), + `@chcp 65001 >nul\r\n@echo off\r\n"${options.nodeExecutablePath}" "${options.pnpmEntryPath}" %*\r\n`, + 'utf8' + ) + await writeFile( + join(directory, 'node.cmd'), + `@chcp 65001 >nul\r\n@echo off\r\n"${options.nodeExecutablePath}" %*\r\n`, + 'utf8' + ) + } else { + const pnpmPath = join(directory, 'pnpm') + await writeFile( + pnpmPath, + `#!/bin/sh\nexec ${shellQuote(options.nodeExecutablePath)} ${shellQuote(options.pnpmEntryPath)} "$@"\n`, + { encoding: 'utf8', mode: 0o755 } + ) + await chmod(pnpmPath, 0o755) + const nodePath = join(directory, 'node') + await writeFile( + nodePath, + `#!/bin/sh\nexec ${shellQuote(options.nodeExecutablePath)} "$@"\n`, + { encoding: 'utf8', mode: 0o755 } + ) + await chmod(nodePath, 0o755) + } + + return directory +} + +export function buildProfilePluginCommandEnvironment( + environment: NodeJS.ProcessEnv, + shimDirectory: string, + nodeExecutablePath: string +): NodeJS.ProcessEnv { + const result = { ...environment } + delete result.ELECTRON_RUN_AS_NODE + + const currentPath = + (process.platform === 'win32' ? result.Path : result.PATH) ?? + result.PATH ?? + result.Path ?? + '' + const parts = currentPath.split(delimiter).filter(Boolean) + const additions = [shimDirectory, dirname(nodeExecutablePath)].filter( + (directory) => !parts.includes(directory) + ) + const nextPath = [...additions, currentPath].filter(Boolean).join(delimiter) + result.PATH = nextPath + if (process.platform === 'win32') result.Path = nextPath + result.DSH_HOME = result.DSH_HOME ?? '' + result.CI = 'true' + result.NO_COLOR = '1' + return result +} + +function killProcessTree(child: ReturnType): void { + if (child.exitCode !== null || !child.pid) return + if (process.platform === 'win32') { + spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + windowsHide: true, + stdio: 'ignore' + }).unref() + return + } + try { + process.kill(-child.pid, 'SIGTERM') + } catch { + child.kill('SIGTERM') + } +} + +export async function removeProfilePluginWithDsh( + options: ProfilePluginCommandOptions, + pluginName: string +): Promise { + const requiredPaths = [ + options.dshEntryPath, + options.nodeExecutablePath, + options.pnpmEntryPath + ] + if (requiredPaths.some((path) => !existsSync(path))) { + return { ok: false, detail: 'The bundled DSH, Node.js, or pnpm runtime was not found.' } + } + + const profileDirectory = join(options.dshHome, 'profiles', PROFILE) + if (!existsSync(profileDirectory)) { + return { ok: false, detail: 'The web profile directory was not found.' } + } + + try { + const shimDirectory = await ensureProfilePnpmShim(options) + const environment = buildProfilePluginCommandEnvironment( + options.environment ?? process.env, + shimDirectory, + options.nodeExecutablePath + ) + environment.DSH_HOME = options.dshHome + + const child = spawn( + options.nodeExecutablePath, + buildProfilePluginRemoveArguments(options.dshEntryPath, pluginName), + { + cwd: profileDirectory, + env: environment, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + detached: process.platform !== 'win32' + } + ) + + let output = '' + const append = (chunk: Buffer | string): void => { + output = `${output}${chunk.toString()}`.slice(-MAX_OUTPUT_BYTES) + } + 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<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => resolve({ code, signal })) + } + ) + if (timedOut) { + return { ok: false, detail: 'Plugin removal timed out after 15 minutes.' } + } + if (exit.code !== 0) { + const detail = output.trim().split(/\r?\n/u).at(-1)?.slice(0, 800) + return { + ok: false, + detail: + detail || + `Plugin removal exited with ${exit.signal ? `signal ${exit.signal}` : `code ${exit.code}`}.` + } + } + return { ok: true } + } finally { + clearTimeout(timer) + } + } catch (error) { + return { + ok: false, + detail: error instanceof Error ? error.message : String(error) + } + } +} diff --git a/src/main/security-policy.ts b/src/main/security-policy.ts index 062a7efe..96186834 100644 --- a/src/main/security-policy.ts +++ b/src/main/security-policy.ts @@ -12,7 +12,8 @@ function isHarnessUrl(rawUrl: string): boolean { export function isTrustedAppUrl(rawUrl: string): boolean { try { - if (new URL(rawUrl).protocol === 'file:') return true + const parsed = new URL(rawUrl) + if (parsed.protocol === 'file:' || parsed.protocol === 'dsh-recovery:') return true } catch { return false } diff --git a/src/main/state/plugin-recovery.ts b/src/main/state/plugin-recovery.ts index 17b54d1d..f9e13247 100644 --- a/src/main/state/plugin-recovery.ts +++ b/src/main/state/plugin-recovery.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs' -import { readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { parse } from 'yaml' export function profilePackageJsonPath(dshHome: string): string { return join(dshHome, 'profiles', 'web', 'package.json') @@ -21,42 +22,255 @@ interface ProfileManifest { } } -export async function uninstallPluginFromProfile( - dshHome: string, - pluginName: string +interface BundleManifest { + dependencies?: Record + optionalDependencies?: Record + dsh?: { + bundle?: { + patch?: string + } + } +} + +interface ProfileLockfile { + importers?: Record< + string, + { + dependencies?: Record + devDependencies?: Record + optionalDependencies?: Record + } + > +} + +export type ProfilePluginRemovalRunner = (pluginName: string) => Promise + +const CORE_BUNDLES = new Set(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dshmarket']) +const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i + +function yamlPackageNamePattern(packageName: string): RegExp { + const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp( + `^\\s*name:\\s*(?:["']${escaped}["']|${escaped})(?:\\s*(?:#.*)?)?$`, + 'm' + ) +} + +export function isThirdPartyPackageName(packageName: string): boolean { + return ( + PACKAGE_NAME_PATTERN.test(packageName) && + !packageName.startsWith('@deepseek-ai/') && + !CORE_BUNDLES.has(packageName) + ) +} + +function configuredProfilePlugins(manifest: ProfileManifest): string[] { + const dependencies = manifest.dependencies ?? {} + const bundles = new Set(manifest.dsh?.profile?.bundles ?? []) + const plugins: string[] = [] + + for (const dep of Object.keys(dependencies)) { + if (bundles.has(dep) && isThirdPartyPackageName(dep)) { + plugins.push(dep) + } + } + + return plugins +} + +async function bundleOwnsPackage( + profileDirectory: string, + bundle: string, + packageName: string ): Promise { + const packageDirectory = join(profileDirectory, 'node_modules', bundle) + + try { + const rawManifest = await readFile(join(packageDirectory, 'package.json'), 'utf8') + const manifest = JSON.parse(rawManifest) as BundleManifest + if ( + packageName in (manifest.dependencies ?? {}) || + packageName in (manifest.optionalDependencies ?? {}) + ) { + return true + } + + const patch = manifest.dsh?.bundle?.patch + if (!patch) return false + const rawPatch = await readFile(resolve(packageDirectory, patch), 'utf8') + return yamlPackageNamePattern(packageName).test(rawPatch) + } catch { + return false + } +} + +function loaderEntryPattern(entryId: string): RegExp { + const escaped = entryId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp( + `^\\s*-\\s+id:\\s*(?:["']${escaped}["']|${escaped})(?:\\s*(?:#.*)?)?$`, + 'm' + ) +} + +async function bundleDeclaresLoaderEntry( + profileDirectory: string, + bundle: string, + entryId: string +): Promise { + const packageDirectory = join(profileDirectory, 'node_modules', bundle) + const packageJsonPath = join(packageDirectory, 'package.json') + + try { + const rawManifest = await readFile(packageJsonPath, 'utf8') + const bundleManifest = JSON.parse(rawManifest) as BundleManifest + const patch = bundleManifest.dsh?.bundle?.patch + if (!patch) return false + + const patchPath = resolve(packageDirectory, patch) + const rawPatch = await readFile(patchPath, 'utf8') + return loaderEntryPattern(entryId).test(rawPatch) + } catch { + return false + } +} + +async function pluginMatchesSlot( + profileDirectory: string, + plugin: string, + slotName: string +): Promise { + const packageDir = join(profileDirectory, 'node_modules', plugin) + const filesToCheck = [ + 'cordis.patch.yml', + 'client.js', + 'lib/client.js', + 'dist/client.js', + 'package.json', + 'index.js', + 'lib/index.js', + 'dist/index.js' + ] + for (const file of filesToCheck) { + try { + const content = await readFile(join(packageDir, file), 'utf8') + if (content.includes(slotName)) return true + } catch {} + } + return false +} + +export async function resolveProfileRecoveryPlugins( + dshHome: string, + detectedPlugins: readonly string[], + duplicateLoaderEntryId?: string, + slotConflictName?: string, + excludedPlugins: readonly string[] = [] +): Promise { const manifestPath = profilePackageJsonPath(dshHome) - if (!existsSync(manifestPath)) return false try { const raw = await readFile(manifestPath, 'utf8') const manifest = JSON.parse(raw) as ProfileManifest - let modified = false + const excludedSet = new Set(excludedPlugins) + const configuredPlugins = configuredProfilePlugins(manifest).filter( + (plugin) => !excludedSet.has(plugin) + ) + const configuredSet = new Set(configuredPlugins) + const profileDirectory = dirname(manifestPath) - if (manifest.dependencies && pluginName in manifest.dependencies) { - delete manifest.dependencies[pluginName] - modified = true + // 1. Match an installed third-party root package directly, or prove that + // a reported sub-package is owned by one configured third-party bundle. + const matchedPlugins = new Set() + for (const detected of detectedPlugins) { + if (!PACKAGE_NAME_PATTERN.test(detected)) continue + if (configuredSet.has(detected)) { + matchedPlugins.add(detected) + continue + } + for (const configured of configuredPlugins) { + if (await bundleOwnsPackage(profileDirectory, configured, detected)) { + matchedPlugins.add(configured) + } + } } + if (matchedPlugins.size === 1) return [...matchedPlugins] - if (manifest.dsh?.profile?.bundles) { - const originalLength = manifest.dsh.profile.bundles.length - manifest.dsh.profile.bundles = manifest.dsh.profile.bundles.filter( - (bundle) => bundle !== pluginName - ) - if (manifest.dsh.profile.bundles.length !== originalLength) { - modified = true + // 2. Duplicate loader entry matching + if (duplicateLoaderEntryId) { + let offendingPlugin: string | undefined + for (const plugin of configuredPlugins) { + if (await bundleDeclaresLoaderEntry(profileDirectory, plugin, duplicateLoaderEntryId)) { + offendingPlugin = plugin + } } + if (offendingPlugin) return [offendingPlugin] } - if (modified) { - await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8') - return true + // 3. Slot conflict matching + if (slotConflictName) { + const slotMatched = new Set() + for (const plugin of configuredPlugins) { + if (await pluginMatchesSlot(profileDirectory, plugin, slotConflictName)) { + slotMatched.add(plugin) + } + } + if (slotMatched.size === 1) return [...slotMatched] } + + // Never guess. A recovery action is only safe when one or more packages + // have direct evidence tying them to the reported failure. + return [] } catch { - return false + return [] } +} - return false +export async function uninstallPluginFromProfile( + dshHome: string, + pluginName: string, + removePlugin?: ProfilePluginRemovalRunner +): Promise { + if (!isThirdPartyPackageName(pluginName) || !removePlugin) return false + + const manifestPath = profilePackageJsonPath(dshHome) + const lockfilePath = join(dirname(manifestPath), 'pnpm-lock.yaml') + const pluginDirectory = join(dirname(manifestPath), 'node_modules', pluginName) + + try { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as ProfileManifest + const configured = + Object.hasOwn(manifest.dependencies ?? {}, pluginName) && + (manifest.dsh?.profile?.bundles ?? []).includes(pluginName) + if (!configured) return false + + const lockfileExisted = existsSync(lockfilePath) + if (!(await removePlugin(pluginName))) return false + + const updatedManifest = JSON.parse(await readFile(manifestPath, 'utf8')) as ProfileManifest + if ( + Object.hasOwn(updatedManifest.dependencies ?? {}, pluginName) || + (updatedManifest.dsh?.profile?.bundles ?? []).includes(pluginName) || + existsSync(pluginDirectory) + ) { + return false + } + + if (lockfileExisted) { + const lockfile = parse(await readFile(lockfilePath, 'utf8')) as ProfileLockfile + const importer = lockfile.importers?.['.'] + if ( + Object.hasOwn(importer?.dependencies ?? {}, pluginName) || + Object.hasOwn(importer?.devDependencies ?? {}, pluginName) || + Object.hasOwn(importer?.optionalDependencies ?? {}, pluginName) + ) { + return false + } + } + + return true + } catch { + return false + } } export async function resetPluginProfile( @@ -65,15 +279,20 @@ export async function resetPluginProfile( ): Promise { const manifestPath = profilePackageJsonPath(dshHome) if (!existsSync(manifestPath)) return false + if (failingPlugin && !isThirdPartyPackageName(failingPlugin)) return false try { const raw = await readFile(manifestPath, 'utf8') const manifest = JSON.parse(raw) as ProfileManifest + let modified = false if (failingPlugin) { const scope = failingPlugin.startsWith('@') ? failingPlugin.split('/')[0] : undefined if (manifest.dependencies) { - delete manifest.dependencies[failingPlugin] + if (failingPlugin in manifest.dependencies) { + delete manifest.dependencies[failingPlugin] + modified = true + } for (const dep of Object.keys(manifest.dependencies)) { if ( failingPlugin.includes(dep) || @@ -81,10 +300,12 @@ export async function resetPluginProfile( (scope && dep.startsWith(scope)) ) { delete manifest.dependencies[dep] + modified = true } } } if (manifest.dsh?.profile?.bundles) { + const origLen = manifest.dsh.profile.bundles.length manifest.dsh.profile.bundles = manifest.dsh.profile.bundles.filter( (b) => b !== failingPlugin && @@ -92,25 +313,64 @@ export async function resetPluginProfile( !b.includes(failingPlugin) && (!scope || !b.startsWith(scope)) ) + if (manifest.dsh.profile.bundles.length !== origLen) { + modified = true + } } } else { - // If no specific plugin given, reset bundles to safe core bundles + // If no specific plugin given, reset to safe core bundles and clean all third-party dependencies const safeBundles = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] if (manifest.dependencies?.dshmarket) safeBundles.push('dshmarket') - if (manifest.dsh?.profile?.bundles) { - manifest.dsh.profile.bundles = safeBundles + manifest.dsh ??= {} + manifest.dsh.profile ??= {} + manifest.dsh.profile.bundles = safeBundles + modified = true + if (manifest.dependencies) { + for (const dep of Object.keys(manifest.dependencies)) { + if (!CORE_BUNDLES.has(dep)) { + delete manifest.dependencies[dep] + modified = true + } + } } } - await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8') + if (modified) { + await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8') + } // Reset cordis.patch.yml to clean state const patchPath = profileCordisPatchPath(dshHome) if (existsSync(patchPath)) { - await writeFile(patchPath, '[]\n', 'utf8') + const patchContent = await readFile(patchPath, 'utf8') + if (patchContent.trim() !== '[]') { + await writeFile(patchPath, '[]\n', 'utf8') + modified = true + } } - return true + // Physically clean plugin files from node_modules to guarantee thorough uninstallation + const nodeModulesPath = join(dshHome, 'profiles', 'web', 'node_modules') + if (existsSync(nodeModulesPath)) { + if (failingPlugin) { + const pluginDir = join(nodeModulesPath, failingPlugin) + await rm(pluginDir, { recursive: true, force: true }).catch(() => undefined) + if (failingPlugin.startsWith('@')) { + const scope = failingPlugin.split('/')[0] + if (scope) { + const scopeDir = join(nodeModulesPath, scope) + try { + const files = await readdir(scopeDir) + if (files.length === 0) { + await rm(scopeDir, { recursive: true, force: true }).catch(() => undefined) + } + } catch {} + } + } + } + } + + return modified } catch { return false } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3b695098..bb26bb3c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,26 +6,15 @@ import { updateMessage, type UpdateLocale } from './update-view' -import { - isPluginLoadError, - extractPluginName, - pluginErrorMessage -} from './plugin-error-view' +import { isPluginLoadError } from './plugin-error-view' import { mountWindowsTitlebar } from './windows-titlebar' const ROOT_ID = 'dsh-desktop-update-root' -const PLUGIN_ERROR_ROOT_ID = 'dsh-desktop-plugin-error-root' const MOBILE_BUTTON_ID = 'dsh-desktop-mobile-button' const locale: UpdateLocale = navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en' let host: HTMLDivElement | undefined let content: HTMLDivElement | undefined -let pluginErrorHost: HTMLDivElement | undefined -let pluginErrorContent: HTMLDivElement | undefined -let activePluginErrorName: string | undefined -let pluginErrorVisible = false -let restartingHarness = false -let resettingHarness = false let currentStatus: UpdateStatus | undefined let dismissedVersion: string | null = null let dismissedTransientPhase: UpdateStatus['phase'] | null = null @@ -34,52 +23,63 @@ let receivedStatusEvent = false let phoneConnected = false let mobileStatusTimer: number | undefined -function checkBootFailureInDom(): void { +let bootFailureTriggered = false +let bootFailureTimer: number | undefined +const pendingBootFailureMessages: string[] = [] + +const BOOT_FAILURE_SETTLE_MS = 400 + +function currentBootFailureText(): string | undefined { const root = document.body || document.documentElement - if (!root) return - const divs = Array.from(root.querySelectorAll('div')) - const failedTitle = divs.find((el) => el.textContent?.trim() === 'Failed to load plugins') - if (!failedTitle || !failedTitle.parentElement) return - - const failedContainer = failedTitle.parentElement - const errorText = failedContainer.textContent ?? '' - const pluginName = extractPluginName(errorText) - - const INJECTED_ID = 'dsh-desktop-boot-recovery-actions' - if (document.getElementById(INJECTED_ID)) return - - const actionsDiv = document.createElement('div') - actionsDiv.id = INJECTED_ID - actionsDiv.style.cssText = [ - 'display:flex', - 'margin-top:20px', - 'justify-content:center', - 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif' - ].join(';') + if (!root) return undefined + + // The package list and loader detail are rendered in separate sibling + // containers on Harness's boot-failure page. Reading only the title's + // parent drops exactly the evidence Desktop needs to identify the second + // conflicting plugin, so capture the full failure page instead. + const text = document.body?.innerText || root.textContent + if (!text?.includes('Failed to load plugins')) return undefined + return text + ?.split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean) + .join('\n') +} - const restartBtn = document.createElement('button') - restartBtn.type = 'button' - restartBtn.textContent = locale === 'zh' ? '重启 Harness' : 'Restart Harness' - restartBtn.style.cssText = [ - 'appearance:none', - 'border:none', - 'background:#4d6bfe', - 'color:#ffffff', - 'padding:9px 22px', - 'border-radius:8px', - 'font-size:13px', - 'font-weight:600', - 'cursor:pointer', - 'box-shadow:0 2px 8px rgba(77,107,254,0.35)' - ].join(';') - restartBtn.addEventListener('click', () => { - restartBtn.disabled = true - restartBtn.textContent = locale === 'zh' ? '正在重启…' : 'Restarting…' - void ipcRenderer.invoke('harness:reset-plugins', pluginName) - }) +function addBootFailureMessage(message: string | undefined): void { + const normalized = message?.trim() + if (!normalized || pendingBootFailureMessages.includes(normalized)) return + pendingBootFailureMessages.push(normalized) +} + +function queueBootFailure(message?: string): void { + if (bootFailureTriggered) return + + addBootFailureMessage(message) + addBootFailureMessage(currentBootFailureText()) + if (pendingBootFailureMessages.length === 0) return + + if (bootFailureTimer !== undefined) window.clearTimeout(bootFailureTimer) + bootFailureTimer = window.setTimeout(() => { + bootFailureTimer = undefined + if (bootFailureTriggered) return + + // The web boot page renders the plugin name and detailed loader error after + // window.error/unhandledrejection fires. Read it one last time before leaving + // the page so recovery receives the richest available diagnostic evidence. + addBootFailureMessage(currentBootFailureText()) + const errorText = pendingBootFailureMessages.join('\n') + if (!errorText) return + + bootFailureTriggered = true + void ipcRenderer.invoke('harness:open-recovery', errorText) + }, BOOT_FAILURE_SETTLE_MS) +} - actionsDiv.appendChild(restartBtn) - failedContainer.appendChild(actionsDiv) +function checkBootFailureInDom(): void { + const errorText = currentBootFailureText() + if (!errorText) return + queueBootFailure(errorText) } const domObserver = new MutationObserver(() => { @@ -146,7 +146,6 @@ function initializeUi(): void { mountWindowsTitlebar({ document, ipcRenderer, locale }) } mount() - mountPluginErrorCard() mountMobileButton() checkBootFailureInDom() domObserver.observe(document.documentElement, { @@ -160,14 +159,16 @@ function initializeUi(): void { window.addEventListener('error', (event) => { const err = event.error ?? event.message if (isPluginLoadError(err)) { - showPluginErrorNotification(extractPluginName(err)) + const errorText = typeof err === 'string' ? err : err instanceof Error ? err.message : String(err) + queueBootFailure(errorText) } }) window.addEventListener('unhandledrejection', (event) => { const reason = event.reason if (isPluginLoadError(reason)) { - showPluginErrorNotification(extractPluginName(reason)) + const errorText = typeof reason === 'string' ? reason : reason instanceof Error ? reason.message : String(reason) + queueBootFailure(errorText) } }) @@ -178,114 +179,12 @@ contextBridge.exposeInMainWorld( }) ) -function mountPluginErrorCard(): void { - if (document.getElementById(PLUGIN_ERROR_ROOT_ID)) return - - pluginErrorHost = document.createElement('div') - pluginErrorHost.id = PLUGIN_ERROR_ROOT_ID - pluginErrorHost.style.cssText = [ - 'position:fixed', - 'right:20px', - 'bottom:20px', - 'z-index:2147483647', - 'display:none', - 'width:min(384px,calc(100vw - 40px))', - 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif' - ].join(';') - - const shadow = pluginErrorHost.attachShadow({ mode: 'closed' }) - const style = document.createElement('style') - style.textContent = styles - pluginErrorContent = document.createElement('div') - shadow.append(style, pluginErrorContent) - document.documentElement.appendChild(pluginErrorHost) - renderPluginError() -} - -function showPluginErrorNotification(pluginName?: string): void { - activePluginErrorName = pluginName || activePluginErrorName - pluginErrorVisible = true - mountPluginErrorCard() - renderPluginError() -} - -function dismissPluginError(): void { - pluginErrorVisible = false - if (pluginErrorHost) { - pluginErrorHost.style.display = 'none' - } -} - -function renderPluginError(): void { - if (!pluginErrorHost || !pluginErrorContent) return - - if (!pluginErrorVisible) { - pluginErrorHost.style.display = 'none' - pluginErrorContent.replaceChildren() - return - } - - pluginErrorHost.style.display = 'block' - const info = pluginErrorMessage(locale, activePluginErrorName) - - const card = element('aside', 'card') - card.setAttribute('aria-live', 'polite') - card.setAttribute('aria-label', info.title) - - const row = element('div', 'row') - const indicator = element('span', restartingHarness || resettingHarness ? 'spinner' : 'dot warning') - indicator.setAttribute('aria-hidden', 'true') - row.appendChild(indicator) - - const body = element('div', 'body') - const title = element('p', 'message') - title.textContent = info.title - body.appendChild(title) - - const detail = element('p', 'detail') - detail.textContent = info.message - body.appendChild(detail) - - const actions = element('div', 'actions') - const restartBtn = button( - restartingHarness - ? locale === 'zh' - ? '正在重启…' - : 'Restarting…' - : locale === 'zh' - ? '重启 Harness' - : 'Restart Harness', - 'primary' - ) - restartBtn.disabled = restartingHarness - restartBtn.addEventListener('click', () => { - restartingHarness = true - renderPluginError() - void ipcRenderer - .invoke('harness:reset-plugins', activePluginErrorName) - .finally(() => { - restartingHarness = false - dismissPluginError() - }) +contextBridge.exposeInMainWorld( + 'dshRecovery', + Object.freeze({ + action: (action: string): Promise<{ ok: boolean }> => ipcRenderer.invoke('recovery:action', action) }) - - const ignoreBtn = button(locale === 'zh' ? '忽略' : 'Dismiss', 'secondary') - ignoreBtn.disabled = restartingHarness - ignoreBtn.addEventListener('click', dismissPluginError) - - actions.append(restartBtn, ignoreBtn) - body.appendChild(actions) - - row.appendChild(body) - - const close = button('×', 'close') - close.setAttribute('aria-label', locale === 'zh' ? '关闭' : 'Close') - close.addEventListener('click', dismissPluginError) - row.appendChild(close) - - card.appendChild(row) - pluginErrorContent.replaceChildren(card) -} +) function mount(): void { if (document.getElementById(ROOT_ID)) return diff --git a/test/plugin-error-view.test.ts b/test/plugin-error-view.test.ts index 35340301..09be218e 100644 --- a/test/plugin-error-view.test.ts +++ b/test/plugin-error-view.test.ts @@ -48,16 +48,18 @@ describe('plugin load error detection and extraction', () => { }) }) -describe('preload wiring for plugin error toast', () => { - it('installs error listeners and exposes restart button', async () => { +describe('preload wiring for plugin error handling', () => { + it('installs error listeners and connects to unified recovery', async () => { const preload = await readFile('src/preload/index.ts', 'utf8') - expect(preload).toContain('dsh-desktop-plugin-error-root') expect(preload).toContain("window.addEventListener('error'") expect(preload).toContain("window.addEventListener('unhandledrejection'") expect(preload).toContain('isPluginLoadError') - expect(preload).toContain("harness:restart") - expect(preload).toContain("harness:reset-plugins") - expect(preload).toContain('mountPluginErrorCard') + expect(preload).toContain('harness:open-recovery') + expect(preload).toContain('checkBootFailureInDom') + expect(preload).toContain('queueBootFailure(errorText)') + expect(preload).toContain('pendingBootFailureMessages.join') + expect(preload).toContain("document.body?.innerText") + expect(preload).toContain("text?.includes('Failed to load plugins')") }) }) diff --git a/test/plugin-recovery-view.test.ts b/test/plugin-recovery-view.test.ts index d6bb406a..7ecdb314 100644 --- a/test/plugin-recovery-view.test.ts +++ b/test/plugin-recovery-view.test.ts @@ -38,7 +38,9 @@ describe('plugin recovery view model', () => { it.each([ ['cannot resolve profile bundle example', '插件没有完整安装'], ['package declares no dsh.bundle', '安装的包不是兼容的 DSH 插件'], - ['failed to import loader entry example', '插件代码加载失败'] + ['failed to import loader entry example', '插件代码加载失败'], + ['duplicate loader entry id: storage', '插件注册了重复的服务组件'], + ['single slot "conversation.hero.workspace.directoryFlow" already has a registration at priority 0', '插件存在界面插槽冲突'] ])('describes known startup failures: %s', (log, expectedTitle) => { expect(describePluginFailure([`[stderr] ${log}`], 'zh').title).toBe(expectedTitle) }) @@ -70,6 +72,17 @@ describe('plugin recovery view model', () => { expect(model.plugins).toEqual(['plugin-b']) }) + it('shows a readable name for a scoped package while recovery keeps its package id', () => { + const model = buildPluginRecoveryViewModel({ + snapshot: failedSnapshot(), + plugins: ['@deepseek-harness-tui/dsh-tui'], + removedPlugins: [], + locale: 'zh' + }) + expect(model.plugins).toEqual(['dsh-tui']) + expect(model.canUninstall).toBe(true) + }) + it('falls back to the log when no plugin can be identified', () => { const model = buildPluginRecoveryViewModel({ snapshot: failedSnapshot(), diff --git a/test/plugin-recovery.test.ts b/test/plugin-recovery.test.ts index aa69e619..f153e6ee 100644 --- a/test/plugin-recovery.test.ts +++ b/test/plugin-recovery.test.ts @@ -1,15 +1,41 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { parse, stringify } from 'yaml' import { + isThirdPartyPackageName, profilePackageJsonPath, resetPluginProfile, + resolveProfileRecoveryPlugins, uninstallPluginFromProfile } from '../src/main/state/plugin-recovery' describe('plugin-recovery', () => { const testDir = join(__dirname, '.temp-plugin-recovery-test') + async function simulateDshPluginRemove(pluginName: string): Promise { + const profileDirectory = join(testDir, 'profiles', 'web') + const manifestPath = join(profileDirectory, 'package.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + delete manifest.dependencies?.[pluginName] + if (manifest.dsh?.profile?.bundles) { + manifest.dsh.profile.bundles = manifest.dsh.profile.bundles.filter( + (bundle: string) => bundle !== pluginName + ) + } + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + + const lockfilePath = join(profileDirectory, 'pnpm-lock.yaml') + const lockfile = parse(await readFile(lockfilePath, 'utf8')) + delete lockfile.importers?.['.']?.dependencies?.[pluginName] + await writeFile(lockfilePath, stringify(lockfile)) + await rm(join(profileDirectory, 'node_modules', pluginName), { + recursive: true, + force: true + }) + return true + } + beforeEach(async () => { await mkdir(join(testDir, 'profiles', 'web'), { recursive: true }) }) @@ -40,8 +66,30 @@ describe('plugin-recovery', () => { } } await writeFile(pkgPath, JSON.stringify(originalPkg, null, 2)) + await mkdir(join(testDir, 'profiles', 'web', 'node_modules', 'dsh-better-sidebar'), { + recursive: true + }) + await writeFile( + join(testDir, 'profiles', 'web', 'pnpm-lock.yaml'), + stringify({ + lockfileVersion: '9.0', + importers: { + '.': { + dependencies: { + 'dsh-better-sidebar': { specifier: '^0.13.1', version: '0.13.1' }, + '@linxin666/dsh-web-ui-all': { specifier: '^0.2.2', version: '0.2.2' }, + dshmarket: { specifier: '1.9.0', version: '1.9.0' } + } + } + } + }) + ) - const success = await uninstallPluginFromProfile(testDir, 'dsh-better-sidebar') + const success = await uninstallPluginFromProfile( + testDir, + 'dsh-better-sidebar', + simulateDshPluginRemove + ) expect(success).toBe(true) const updatedPkg = JSON.parse(await readFile(pkgPath, 'utf8')) @@ -55,6 +103,13 @@ describe('plugin-recovery', () => { 'dshmarket', '@linxin666/dsh-web-ui-all' ]) + const updatedLockfile = parse( + await readFile(join(testDir, 'profiles', 'web', 'pnpm-lock.yaml'), 'utf8') + ) + expect(updatedLockfile.importers['.'].dependencies).toEqual({ + '@linxin666/dsh-web-ui-all': { specifier: '^0.2.2', version: '0.2.2' }, + dshmarket: { specifier: '1.9.0', version: '1.9.0' } + }) }) it('returns false when package.json does not exist', async () => { @@ -82,6 +137,145 @@ describe('plugin-recovery', () => { expect(success).toBe(false) }) + it('does not report success when the lockfile still imports the removed plugin', async () => { + const profileDirectory = join(testDir, 'profiles', 'web') + const pkgPath = profilePackageJsonPath(testDir) + await writeFile( + pkgPath, + JSON.stringify({ + dependencies: { 'stale-lock-plugin': '^1.0.0' }, + dsh: { profile: { bundles: ['stale-lock-plugin'] } } + }) + ) + await writeFile( + join(profileDirectory, 'pnpm-lock.yaml'), + stringify({ + lockfileVersion: '9.0', + importers: { + '.': { + dependencies: { + 'stale-lock-plugin': { specifier: '^1.0.0', version: '1.0.0' } + } + } + } + }) + ) + + const success = await uninstallPluginFromProfile( + testDir, + 'stale-lock-plugin', + async (pluginName) => { + const manifest = JSON.parse(await readFile(pkgPath, 'utf8')) + delete manifest.dependencies[pluginName] + manifest.dsh.profile.bundles = [] + await writeFile(pkgPath, JSON.stringify(manifest)) + return true + } + ) + + expect(success).toBe(false) + }) + + it('never treats Harness core packages as uninstallable third-party packages', async () => { + const pkgPath = profilePackageJsonPath(testDir) + const manifest = { + dependencies: { + '@deepseek-ai/dsh-client-ui-directory-picker-native': '^0.1.0-rc.7', + dshmarket: '1.15.0' + }, + dsh: { + profile: { + bundles: [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + '@deepseek-ai/dsh-client-ui-directory-picker-native', + 'dshmarket' + ] + } + } + } + await writeFile(pkgPath, JSON.stringify(manifest)) + + expect(isThirdPartyPackageName('@deepseek-ai/dsh-client-ui-directory-picker-native')).toBe(false) + expect(isThirdPartyPackageName('dshmarket')).toBe(false) + expect(isThirdPartyPackageName('@linxin666/dsh-web-ui-all')).toBe(true) + await expect( + uninstallPluginFromProfile(testDir, '@deepseek-ai/dsh-client-ui-directory-picker-native') + ).resolves.toBe(false) + expect(JSON.parse(await readFile(pkgPath, 'utf8'))).toEqual(manifest) + }) + + it('maps an internal duplicate loader error to the profile bundle that declared it', async () => { + const pkgPath = profilePackageJsonPath(testDir) + const pluginDirectory = join( + testDir, + 'profiles', + 'web', + 'node_modules', + '@deepseek-harness-tui', + 'dsh-tui' + ) + await mkdir(pluginDirectory, { recursive: true }) + await writeFile( + pkgPath, + JSON.stringify({ + dependencies: { + '@deepseek-harness-tui/dsh-tui': '^0.8.4', + dshmarket: '1.15.0' + }, + dsh: { + profile: { + bundles: [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + 'dshmarket', + '@deepseek-harness-tui/dsh-tui' + ] + } + } + }) + ) + await writeFile( + join(pluginDirectory, 'package.json'), + JSON.stringify({ + name: '@deepseek-harness-tui/dsh-tui', + dsh: { bundle: { patch: './cordis.patch.yml' } } + }) + ) + await writeFile( + join(pluginDirectory, 'cordis.patch.yml'), + '- id: storage\n name: "@deepseek-ai/dsh-storage"\n' + ) + + await expect( + resolveProfileRecoveryPlugins(testDir, [], 'storage') + ).resolves.toEqual(['@deepseek-harness-tui/dsh-tui']) + }) + + it('does not offer or remove a package that is not an active profile bundle', async () => { + const pkgPath = profilePackageJsonPath(testDir) + await writeFile( + pkgPath, + JSON.stringify({ + dependencies: { + 'partial-plugin': '^1.0.0' + }, + dsh: { + profile: { + bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] + } + } + }) + ) + + await expect( + resolveProfileRecoveryPlugins(testDir, ['partial-plugin']) + ).resolves.toEqual([]) + await expect( + uninstallPluginFromProfile(testDir, 'partial-plugin', async () => true) + ).resolves.toBe(false) + }) + it('resets plugin profile by cleaning up specific failing plugin and related packages', async () => { const pkgPath = profilePackageJsonPath(testDir) const originalPkg = { @@ -116,4 +310,321 @@ describe('plugin-recovery', () => { 'dshmarket' ]) }) + + it('resolves root package when a scoped sub-module fails', async () => { + const pkgPath = profilePackageJsonPath(testDir) + const rootPackageDir = join( + testDir, + 'profiles', + 'web', + 'node_modules', + '@linxin666', + 'dsh-web-ui-all' + ) + await mkdir(rootPackageDir, { recursive: true }) + await writeFile( + join(rootPackageDir, 'package.json'), + JSON.stringify({ + name: '@linxin666/dsh-web-ui-all', + dependencies: { + '@linxin666/dsh-client-ui-web-ui-settings': '0.2.2' + } + }) + ) + await writeFile( + pkgPath, + JSON.stringify({ + dependencies: { + '@linxin666/dsh-web-ui-all': '^0.2.2', + '@openviking/dsh-memory-plugin': '^0.1.0', + dshmarket: '1.9.0' + }, + dsh: { + profile: { + bundles: [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + 'dshmarket', + '@linxin666/dsh-web-ui-all', + '@openviking/dsh-memory-plugin' + ] + } + } + }) + ) + + const resolved = await resolveProfileRecoveryPlugins(testDir, [ + '@linxin666/dsh-client-ui-web-ui-settings' + ]) + expect(resolved).toEqual(['@linxin666/dsh-web-ui-all']) + }) + + it('resolves the specific plugin that declared a conflicting UI slot', async () => { + const pkgPath = profilePackageJsonPath(testDir) + const remoteDir = join(testDir, 'profiles', 'web', 'node_modules', 'dsh-full-remote') + const memoryDir = join(testDir, 'profiles', 'web', 'node_modules', '@openviking', 'dsh-memory-plugin') + await mkdir(remoteDir, { recursive: true }) + await mkdir(memoryDir, { recursive: true }) + + await writeFile( + pkgPath, + JSON.stringify({ + dependencies: { + 'dsh-full-remote': '^0.3.4', + '@openviking/dsh-memory-plugin': '^0.1.0', + dshmarket: '1.9.0' + }, + dsh: { + profile: { + bundles: [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + 'dshmarket', + 'dsh-full-remote', + '@openviking/dsh-memory-plugin' + ] + } + } + }) + ) + + await writeFile( + join(remoteDir, 'client.js'), + 'ctx.slot("conversation.hero.workspace.directoryFlow", component);' + ) + await writeFile( + join(memoryDir, 'client.js'), + 'ctx.slot("sidebar.panel", memoryComponent);' + ) + + const resolved = await resolveProfileRecoveryPlugins( + testDir, + ['@deepseek-ai/dsh-client-ui-directory-picker-browse'], + undefined, + 'conversation.hero.workspace.directoryFlow' + ) + expect(resolved).toEqual(['dsh-full-remote']) + }) + + it('maps a failed core entry to the third-party bundle that inserted it', async () => { + const pkgPath = profilePackageJsonPath(testDir) + const remoteDir = join(testDir, 'profiles', 'web', 'node_modules', 'dsh-full-remote') + await mkdir(remoteDir, { recursive: true }) + await writeFile( + join(remoteDir, 'package.json'), + JSON.stringify({ + name: 'dsh-full-remote', + dsh: { bundle: { patch: './cordis.patch.yml' } } + }) + ) + await writeFile( + join(remoteDir, 'cordis.patch.yml'), + "- id: ui-directory-picker-browse\n name: '@deepseek-ai/dsh-client-ui-directory-picker-browse'\n" + ) + await writeFile( + pkgPath, + JSON.stringify({ + dependencies: { + 'dsh-full-remote': '^0.3.4', + 'unrelated-plugin': '^1.0.0' + }, + dsh: { + profile: { + bundles: [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + 'dsh-full-remote', + 'unrelated-plugin' + ] + } + } + }) + ) + + await expect( + resolveProfileRecoveryPlugins(testDir, [ + '@deepseek-ai/dsh-client-ui-directory-picker-browse' + ]) + ).resolves.toEqual(['dsh-full-remote']) + }) + + it('maps the directory-picker frontend failure to the remaining remote bundle', async () => { + const pkgPath = profilePackageJsonPath(testDir) + const remoteDir = join(testDir, 'profiles', 'web', 'node_modules', '@xgone', 'dsh-remote') + await mkdir(remoteDir, { recursive: true }) + await writeFile( + join(remoteDir, 'package.json'), + JSON.stringify({ + name: '@xgone/dsh-remote', + dsh: { bundle: { patch: './cordis.patch.yml' } } + }) + ) + await writeFile( + join(remoteDir, 'cordis.patch.yml'), + [ + '- insert:', + ' - id: directory-picker-browse-ui', + " name: '@deepseek-ai/dsh-client-ui-directory-picker-browse'", + '' + ].join('\n') + ) + await writeFile( + pkgPath, + JSON.stringify({ + dependencies: { + '@xgone/dsh-remote': '^0.2.0', + dshmarket: '1.9.0' + }, + dsh: { + profile: { + bundles: [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + 'dshmarket', + '@xgone/dsh-remote' + ] + } + } + }) + ) + + await expect( + resolveProfileRecoveryPlugins(testDir, [ + '@deepseek-ai/dsh-client-ui-directory-picker-browse' + ]) + ).resolves.toEqual(['@xgone/dsh-remote']) + }) + + it('continues a recovery session by excluding the plugin removed in the previous round', async () => { + const pkgPath = profilePackageJsonPath(testDir) + const fullRemoteDir = join(testDir, 'profiles', 'web', 'node_modules', 'dsh-full-remote') + const xgoneRemoteDir = join( + testDir, + 'profiles', + 'web', + 'node_modules', + '@xgone', + 'dsh-remote' + ) + for (const [directory, name] of [ + [fullRemoteDir, 'dsh-full-remote'], + [xgoneRemoteDir, '@xgone/dsh-remote'] + ] as const) { + await mkdir(directory, { recursive: true }) + await writeFile( + join(directory, 'package.json'), + JSON.stringify({ name, dsh: { bundle: { patch: './cordis.patch.yml' } } }) + ) + await writeFile( + join(directory, 'cordis.patch.yml'), + "- id: directory-picker-browse-ui\n name: '@deepseek-ai/dsh-client-ui-directory-picker-browse'\n" + ) + } + await writeFile( + pkgPath, + JSON.stringify({ + dependencies: { + 'dsh-full-remote': '^0.3.4', + '@xgone/dsh-remote': '^0.2.0' + }, + dsh: { + profile: { + bundles: [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + '@xgone/dsh-remote', + 'dsh-full-remote' + ] + } + } + }) + ) + + await expect( + resolveProfileRecoveryPlugins( + testDir, + ['@deepseek-ai/dsh-client-ui-directory-picker-browse'], + undefined, + 'conversation.hero.workspace.directoryFlow' + ) + ).resolves.toEqual([]) + await expect( + resolveProfileRecoveryPlugins( + testDir, + ['@deepseek-ai/dsh-client-ui-directory-picker-browse'], + undefined, + 'conversation.hero.workspace.directoryFlow', + ['dsh-full-remote'] + ) + ).resolves.toEqual(['@xgone/dsh-remote']) + }) + + it('does not offer every third-party package when the failure has no direct match', async () => { + const pkgPath = profilePackageJsonPath(testDir) + await writeFile( + pkgPath, + JSON.stringify({ + dependencies: { + 'plugin-a': '^1.0.0', + 'plugin-b': '^1.0.0', + dshmarket: '1.15.0' + }, + dsh: { + profile: { + bundles: [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + 'dshmarket', + 'plugin-a', + 'plugin-b' + ] + } + } + }) + ) + + await expect( + resolveProfileRecoveryPlugins(testDir, [ + '@deepseek-ai/dsh-client-ui-directory-picker-native' + ]) + ).resolves.toEqual([]) + }) + + it('does not guess when more than one third-party package directly references a conflicting slot', async () => { + const pkgPath = profilePackageJsonPath(testDir) + const firstDir = join(testDir, 'profiles', 'web', 'node_modules', 'plugin-a') + const secondDir = join(testDir, 'profiles', 'web', 'node_modules', 'plugin-b') + await mkdir(firstDir, { recursive: true }) + await mkdir(secondDir, { recursive: true }) + await writeFile(join(firstDir, 'client.js'), 'slots.register({ name: "sidebar.panel" })') + await writeFile(join(secondDir, 'client.js'), 'slots.register({ name: "sidebar.panel" })') + await writeFile( + pkgPath, + JSON.stringify({ + dependencies: { + 'plugin-a': '^1.0.0', + 'plugin-b': '^1.0.0' + }, + dsh: { + profile: { + bundles: [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + 'plugin-a', + 'plugin-b' + ] + } + } + }) + ) + + await expect( + resolveProfileRecoveryPlugins( + testDir, + ['@deepseek-ai/dsh-client-ui-sidebar'], + undefined, + 'sidebar.panel' + ) + ).resolves.toEqual([]) + }) }) diff --git a/test/profile-plugin-command.test.ts b/test/profile-plugin-command.test.ts new file mode 100644 index 00000000..9a5f3138 --- /dev/null +++ b/test/profile-plugin-command.test.ts @@ -0,0 +1,58 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { removeProfilePluginWithDsh } from '../src/main/runtime/profile-plugin-command' + +describe('profile-plugin-command', () => { + const testDir = join(__dirname, '.temp-profile-plugin-command-test') + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }) + }) + + it('runs the DSH remove command with the bundled pnpm shim on PATH', async () => { + const profileDirectory = join(testDir, 'profiles', 'web') + const reportPath = join(testDir, 'report.json') + const dshEntryPath = join(testDir, 'fake-dsh.mjs') + await mkdir(profileDirectory, { recursive: true }) + await writeFile( + dshEntryPath, + ` + import { spawnSync } from 'node:child_process' + import { writeFileSync } from 'node:fs' + const pnpm = spawnSync('pnpm', ['--version'], { + encoding: 'utf8', + shell: process.platform === 'win32' + }) + writeFileSync(${JSON.stringify(reportPath)}, JSON.stringify({ + argv: process.argv.slice(2), + dshHome: process.env.DSH_HOME, + pnpmVersion: pnpm.stdout?.trim(), + pnpmStatus: pnpm.status, + pnpmError: pnpm.error?.message + })) + process.exit(pnpm.status ?? 1) + `, + 'utf8' + ) + + const result = await removeProfilePluginWithDsh( + { + dshHome: testDir, + dshEntryPath, + nodeExecutablePath: process.execPath, + pnpmEntryPath: join(process.cwd(), 'node_modules', 'pnpm', 'bin', 'pnpm.cjs'), + environment: process.env + }, + '@example/plugin' + ) + + expect(result).toEqual({ ok: true }) + expect(JSON.parse(await readFile(reportPath, 'utf8'))).toEqual({ + argv: ['plugin', '--profile', 'web', 'remove', '@example/plugin'], + dshHome: testDir, + pnpmVersion: '10.34.5', + pnpmStatus: 0 + }) + }) +}) diff --git a/test/runtime.test.ts b/test/runtime.test.ts index c82161be..5ec3a0b1 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -3,9 +3,11 @@ import { buildHarnessArguments, buildHarnessSpawnOptions, buildNodeArguments, + extractDuplicateLoaderEntryId, extractFailureCause, extractOffendingPlugin, extractOffendingPlugins, + extractPluginFailureReferences, formatExitCode, updateReadyStability } from '../src/main/runtime/harness-runtime' @@ -219,11 +221,32 @@ describe('offending plugin extraction', () => { it('ignores core deepseek packages as offending plugins', () => { const logs = [ - '[stderr] [harness-node] DSH entry failed: Error: dsh: plugin tree failed to load: failed to apply loader entry base (@deepseek-ai/dsh-base): some error' + '[stderr] [harness-node] DSH entry failed: Error: dsh: plugin tree failed to load: failed to apply loader entry picker (@deepseek-ai/dsh-client-ui-directory-picker-native): some error' ] expect(extractOffendingPlugin(logs)).toBeUndefined() }) + it('extracts only third-party packages from the frontend boot failure list', () => { + const logs = [ + '[stderr] Failed to load plugins\n@deepseek-ai/dsh-client-ui-directory-picker-native\ndsh-remote\nweb boot: 2 entries did not activate' + ] + expect(extractOffendingPlugins(logs)).toEqual(['dsh-remote']) + expect(extractPluginFailureReferences(logs)).toEqual([ + '@deepseek-ai/dsh-client-ui-directory-picker-native', + 'dsh-remote' + ]) + }) + + it('keeps a failed core entry as ownership evidence without making it uninstallable', () => { + const logs = [ + '[stderr] failed to apply loader entry 43d01328 (@deepseek-ai/dsh-client-ui-directory-picker-browse): single slot "conversation.hero.workspace.directoryFlow" already has a registration at priority 0' + ] + expect(extractPluginFailureReferences(logs)).toEqual([ + '@deepseek-ai/dsh-client-ui-directory-picker-browse' + ]) + expect(extractOffendingPlugins(logs)).toEqual([]) + }) + it('returns undefined when no plugin error is matched', () => { const logs = [ '[stderr] [harness-node] uncaught exception: ReferenceError: x is not defined' @@ -231,6 +254,14 @@ describe('offending plugin extraction', () => { expect(extractOffendingPlugin(logs)).toBeUndefined() }) + it('never treats an internal Cordis loader as an uninstallable plugin', () => { + const logs = [ + '[stderr] [harness-node] DSH entry failed: Error: dsh: plugin tree failed to load: failed to apply loader entry include (cordis:include): duplicate loader entry id: storage' + ] + expect(extractOffendingPlugins(logs)).toEqual([]) + expect(extractDuplicateLoaderEntryId(logs)).toBe('storage') + }) + it('collects multiple unique plugins reported by the same launch', () => { const logs = [ '[desktop] starting 2026-08-19T08:00:00.000Z',