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)
+ })
})
+