From 4420cac71e5364ba6d2dbd12143935e180fc1f05 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Thu, 20 Aug 2026 19:32:26 +0800 Subject: [PATCH] fix(recovery): auto-prune missing profile bundles and support arbitrary Windows install paths --- build/installer.nsh | 19 +++++++++ build/plugin-recovery.html | 10 +++++ src/main/index.ts | 6 ++- src/main/plugin-recovery-view.ts | 6 +++ src/main/state/plugin-recovery.ts | 67 +++++++++++++++++++++++++++++ test/plugin-recovery-html.test.ts | 1 + test/plugin-recovery-view.test.ts | 4 ++ test/plugin-recovery.test.ts | 71 +++++++++++++++++++++++++++++++ 8 files changed, 183 insertions(+), 1 deletion(-) diff --git a/build/installer.nsh b/build/installer.nsh index 09b0e7b5..e6aed052 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -59,5 +59,24 @@ ${NSD_SetText} $DshDirectoryEdit $3 StrCpy $DshDirectoryNormalizationActive "0" FunctionEnd + + ; Auto-create the installation directory tree before install begins. + ; This allows users to type any path (e.g. D:\dsh-desktop) directly + ; without needing to pre-create parent folders first. + !define MUI_PAGE_CUSTOMFUNCTION_LEAVE DshEnsureInstDirExists + + Function DshEnsureInstDirExists + CreateDirectory "$INSTDIR" + FunctionEnd + + ; Accept any directory path the user types, even if it does not exist yet. + ; Without this override NSIS rejects non-existent paths before the user + ; can click Next. + !macro preInit + !macroend + Function .onVerifyInstDir + ; Always pass — we create the directory in DshEnsureInstDirExists. + FunctionEnd !endif !endif + diff --git a/build/plugin-recovery.html b/build/plugin-recovery.html index a6421dd2..09692901 100644 --- a/build/plugin-recovery.html +++ b/build/plugin-recovery.html @@ -365,6 +365,7 @@

+

@@ -413,6 +414,7 @@

setText('reason-detail', model.reasonDetail) setText('safety-note', model.safetyNote) setText('primary', model.primaryLabel) + setText('restart', model.restartLabel) setText('advanced-label', model.advancedLabel) setText('launch-directory-label', model.launchDirectoryLabel) setText('launch-directory', model.launchDirectory || '—') @@ -457,11 +459,19 @@

} } const primary = document.getElementById('primary') + const restart = document.getElementById('restart') primary.addEventListener('click', () => { primary.disabled = true primary.textContent = model.primaryBusyLabel + restart.disabled = true navigate(model.canUninstall ? 'uninstall' : 'show-log') }) + restart.addEventListener('click', () => { + restart.disabled = true + restart.textContent = model.restartBusyLabel + primary.disabled = true + navigate('restart') + }) document.getElementById('show-log').addEventListener('click', () => navigate('show-log')) document.getElementById('quit').addEventListener('click', () => navigate('quit')) diff --git a/src/main/index.ts b/src/main/index.ts index 2ca78e92..f400870c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -23,6 +23,7 @@ import { import { secureWindow } from './security' import { ensureLaunchRoot } from './state/launch-root' import { + pruneMissingProfileBundles, resetPluginProfile, uninstallPluginFromProfile } from './state/plugin-recovery' @@ -53,7 +54,8 @@ type PluginRecoveryAction = 'uninstall' | 'show-log' | 'quit' | 'restart' | 'ref const PLUGIN_RECOVERY_ACTIONS = new Set([ 'uninstall', 'show-log', - 'quit' + 'quit', + 'restart' ]) let mainWindow: BrowserWindow | undefined @@ -445,6 +447,8 @@ function launchHarness(): Promise { if (harnessLaunchOperation) return harnessLaunchOperation harnessLaunchOperation = (async () => { + const dshHome = join(app.getPath('userData'), 'harness') + await pruneMissingProfileBundles(dshHome).catch(() => false) await showSplash() await runtime.start(launchDirectory) })().finally(() => { diff --git a/src/main/plugin-recovery-view.ts b/src/main/plugin-recovery-view.ts index 08deca0d..d2b3bdb6 100644 --- a/src/main/plugin-recovery-view.ts +++ b/src/main/plugin-recovery-view.ts @@ -17,6 +17,8 @@ export interface PluginRecoveryViewModel { safetyNote: string primaryLabel: string primaryBusyLabel: string + restartLabel: string + restartBusyLabel: string logLabel: string advancedLabel: string errorLabel: string @@ -179,6 +181,8 @@ export function buildPluginRecoveryViewModel(options: { ? multiple ? `卸载这 ${plugins.length} 个插件并继续检测` : '卸载此插件并继续检测' : '打开 Harness 日志', primaryBusyLabel: canUninstall ? '正在处理并重新检测…' : '正在打开日志…', + restartLabel: '重启 Harness', + restartBusyLabel: '正在重启…', logLabel: '打开 Harness 日志', advancedLabel: '查看技术详情', errorLabel: '错误信息', @@ -213,6 +217,8 @@ export function buildPluginRecoveryViewModel(options: { ? multiple ? `Remove these ${plugins.length} plugins and continue` : 'Remove this plugin and continue' : 'Open Harness log', primaryBusyLabel: canUninstall ? 'Removing and checking again…' : 'Opening log…', + restartLabel: 'Restart Harness', + restartBusyLabel: 'Restarting…', logLabel: 'Open Harness log', advancedLabel: 'View technical details', errorLabel: 'Error details', diff --git a/src/main/state/plugin-recovery.ts b/src/main/state/plugin-recovery.ts index 1c778e9d..e075eecc 100644 --- a/src/main/state/plugin-recovery.ts +++ b/src/main/state/plugin-recovery.ts @@ -466,8 +466,75 @@ export async function resetPluginProfile( } } + // Remove stale pnpm-lock.yaml so pnpm regenerates a clean hoisted dependency graph + const lockfilePath = join(dshHome, 'profiles', 'web', 'pnpm-lock.yaml') + if (existsSync(lockfilePath)) { + await rm(lockfilePath, { force: true }).catch(() => undefined) + } + + return modified + } catch { + return false + } +} + +/** + * Automatically inspects the web profile manifest before launch. + * If any third-party bundle listed in `dsh.profile.bundles` is missing from `node_modules`, + * prunes it from `bundles` and `dependencies` to prevent Harness from failing with + * "cannot resolve profile bundle". + */ +export async function pruneMissingProfileBundles(dshHome: string): Promise { + const manifestPath = profilePackageJsonPath(dshHome) + if (!existsSync(manifestPath)) return false + + const profileDirectory = dirname(manifestPath) + const nodeModulesPath = join(profileDirectory, 'node_modules') + + try { + const raw = await readFile(manifestPath, 'utf8') + const manifest = JSON.parse(raw) as ProfileManifest + let modified = false + + if (manifest.dsh?.profile?.bundles) { + const origBundles = manifest.dsh.profile.bundles + const prunedBundles = origBundles.filter((bundle) => { + if (!isThirdPartyPackageName(bundle)) return true + const bundleDir = join(nodeModulesPath, bundle) + const exists = existsSync(bundleDir) + if (!exists) { + modified = true + } + return exists + }) + + if (modified) { + manifest.dsh.profile.bundles = prunedBundles + } + } + + if (manifest.dependencies) { + for (const dep of Object.keys(manifest.dependencies)) { + if (!isThirdPartyPackageName(dep)) continue + const depDir = join(nodeModulesPath, dep) + if (!existsSync(depDir)) { + delete manifest.dependencies[dep] + modified = true + } + } + } + + if (modified) { + await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8') + const lockfilePath = join(profileDirectory, 'pnpm-lock.yaml') + if (existsSync(lockfilePath)) { + await rm(lockfilePath, { force: true }).catch(() => undefined) + } + } + return modified } catch { return false } } + diff --git a/test/plugin-recovery-html.test.ts b/test/plugin-recovery-html.test.ts index b4952505..6473c6ad 100644 --- a/test/plugin-recovery-html.test.ts +++ b/test/plugin-recovery-html.test.ts @@ -14,6 +14,7 @@ describe('plugin recovery page', () => { it('retains access to diagnostics and exit actions', () => { expect(html).toContain('class="decision-row"') + expect(html).toContain('id="restart"') expect(html.indexOf('id="primary"')).toBeLessThan(html.indexOf('id="safety-note"')) expect(html).toContain('id="advanced-label"') expect(html).toContain('id="show-log"') diff --git a/test/plugin-recovery-view.test.ts b/test/plugin-recovery-view.test.ts index 7ecdb314..de272fd1 100644 --- a/test/plugin-recovery-view.test.ts +++ b/test/plugin-recovery-view.test.ts @@ -57,6 +57,8 @@ describe('plugin recovery view model', () => { expect(model.plugins).toEqual(['plugin-a', 'plugin-b']) expect(model.primaryLabel).toBe('卸载这 2 个插件并继续检测') expect(model.canUninstall).toBe(true) + expect(model.restartLabel).toBe('重启 Harness') + expect(model.restartBusyLabel).toBe('正在重启…') expect(model).not.toHaveProperty('status') expect(model.advancedLabel).toBe('查看技术详情') }) @@ -92,5 +94,7 @@ describe('plugin recovery view model', () => { }) expect(model.canUninstall).toBe(false) expect(model.primaryLabel).toBe('Open Harness log') + expect(model.restartLabel).toBe('Restart Harness') + expect(model.restartBusyLabel).toBe('Restarting…') }) }) diff --git a/test/plugin-recovery.test.ts b/test/plugin-recovery.test.ts index f153e6ee..f7c67673 100644 --- a/test/plugin-recovery.test.ts +++ b/test/plugin-recovery.test.ts @@ -5,6 +5,7 @@ import { parse, stringify } from 'yaml' import { isThirdPartyPackageName, profilePackageJsonPath, + pruneMissingProfileBundles, resetPluginProfile, resolveProfileRecoveryPlugins, uninstallPluginFromProfile @@ -627,4 +628,74 @@ describe('plugin-recovery', () => { ) ).resolves.toEqual([]) }) + + it('prunes missing third-party bundles and broken dependencies while preserving core and installed packages', async () => { + const pkgPath = profilePackageJsonPath(testDir) + const existingPluginDir = join(testDir, 'profiles', 'web', 'node_modules', 'dsh-existing-plugin') + await mkdir(existingPluginDir, { recursive: true }) + await writeFile(join(existingPluginDir, 'package.json'), JSON.stringify({ name: 'dsh-existing-plugin' })) + + const lockfilePath = join(testDir, 'profiles', 'web', 'pnpm-lock.yaml') + await writeFile(lockfilePath, 'lockfile-content') + + await writeFile( + pkgPath, + JSON.stringify({ + dependencies: { + 'dsh-existing-plugin': '^1.0.0', + 'dsh-full-remote': '^1.0.0' + }, + dsh: { + profile: { + bundles: [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + 'dshmarket', + 'dsh-existing-plugin', + 'dsh-full-remote' + ] + } + } + }) + ) + + const modified = await pruneMissingProfileBundles(testDir) + expect(modified).toBe(true) + + const updated = JSON.parse(await readFile(pkgPath, 'utf8')) + expect(updated.dependencies).toEqual({ + 'dsh-existing-plugin': '^1.0.0' + }) + expect(updated.dsh.profile.bundles).toEqual([ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + 'dshmarket', + 'dsh-existing-plugin' + ]) + }) + + it('leaves clean profile manifests unmodified when pruning missing bundles', async () => { + const pkgPath = profilePackageJsonPath(testDir) + await writeFile( + pkgPath, + JSON.stringify({ + dependencies: { + dshmarket: '1.16.0' + }, + dsh: { + profile: { + bundles: [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + 'dshmarket' + ] + } + } + }) + ) + + const modified = await pruneMissingProfileBundles(testDir) + expect(modified).toBe(false) + }) }) +