diff --git a/build/plugin-recovery.html b/build/plugin-recovery.html
index 6f665ccf..72f761dc 100644
--- a/build/plugin-recovery.html
+++ b/build/plugin-recovery.html
@@ -220,6 +220,56 @@
.button.primary:hover { border-color: var(--button-hover); background: var(--button-hover); }
.button[disabled] { opacity: 0.64; cursor: wait; transform: none; }
+ [hidden] { display: none !important; }
+
+ .plugin-upgrade-btn {
+ margin-left: auto;
+ padding: 4px 10px;
+ border: 1px solid rgba(22, 163, 74, 0.28);
+ border-radius: 8px;
+ color: #15803d;
+ background: rgba(22, 163, 74, 0.08);
+ cursor: pointer;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-size: 12px;
+ font-weight: 550;
+ line-height: 1.4;
+ white-space: nowrap;
+ transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
+ }
+ .plugin-upgrade-btn:hover {
+ background: rgba(22, 163, 74, 0.16);
+ border-color: rgba(22, 163, 74, 0.4);
+ }
+ .plugin-upgrade-btn:active {
+ transform: translateY(1px);
+ }
+ .plugin-upgrade-btn:disabled {
+ opacity: 0.6;
+ cursor: wait;
+ transform: none;
+ }
+ :root[data-theme="dark"] .plugin-upgrade-btn {
+ color: #4ade80;
+ background: rgba(74, 222, 128, 0.12);
+ border-color: rgba(74, 222, 128, 0.25);
+ }
+ :root[data-theme="dark"] .plugin-upgrade-btn:hover {
+ background: rgba(74, 222, 128, 0.2);
+ border-color: rgba(74, 222, 128, 0.45);
+ }
+ @media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) .plugin-upgrade-btn {
+ color: #4ade80;
+ background: rgba(74, 222, 128, 0.12);
+ border-color: rgba(74, 222, 128, 0.25);
+ }
+ :root:not([data-theme="light"]) .plugin-upgrade-btn:hover {
+ background: rgba(74, 222, 128, 0.2);
+ border-color: rgba(74, 222, 128, 0.45);
+ }
+ }
+
.advanced {
margin-top: 24px;
border-top: 1px solid var(--line);
@@ -365,6 +415,8 @@
+
+
@@ -412,7 +464,6 @@
setText('reason-title', model.reasonTitle)
setText('reason-detail', model.reasonDetail)
setText('safety-note', model.safetyNote)
- setText('primary', model.primaryLabel)
setText('advanced-label', model.advancedLabel)
setText('launch-directory-label', model.launchDirectoryLabel)
setText('launch-directory', model.launchDirectory || '—')
@@ -420,6 +471,18 @@
setText('technical', model.rawError)
setText('show-log', model.logLabel)
setText('quit', model.quitLabel)
+ const safeModeBtn = document.getElementById('safe-mode')
+ if (safeModeBtn) {
+ safeModeBtn.textContent = model.safeModeLabel || (model.locale === 'zh' ? '进入安全模式' : 'Enter Safe Mode')
+ safeModeBtn.addEventListener('click', () => {
+ safeModeBtn.disabled = true
+ primary.disabled = true
+ if (secondary) secondary.disabled = true
+ document.querySelectorAll('.plugin-upgrade-btn').forEach((btn) => { btn.disabled = true })
+ safeModeBtn.textContent = model.locale === 'zh' ? '正在进入安全模式…' : 'Entering Safe Mode…'
+ navigate('safe-mode')
+ })
+ }
const progress = document.getElementById('progress')
if (model.progress) {
@@ -445,6 +508,29 @@
name.className = 'plugin-name'
name.textContent = String(plugin)
item.append(mark, name)
+
+ const candidate = model.upgradeCandidate
+ const candidateName = candidate
+ ? (candidate.packageName.startsWith('@')
+ ? candidate.packageName.slice(candidate.packageName.indexOf('/') + 1)
+ : candidate.packageName)
+ : undefined
+
+ if (candidate && (candidateName === plugin || model.plugins.length === 1)) {
+ const upgradeBtn = document.createElement('button')
+ upgradeBtn.type = 'button'
+ upgradeBtn.className = 'plugin-upgrade-btn plugin-upgrade'
+ const versionStr = candidate.targetVersion.startsWith('v')
+ ? candidate.targetVersion
+ : `v${candidate.targetVersion}`
+ upgradeBtn.textContent = model.upgradeHint || (model.locale === 'zh' ? '该插件有新的兼容版本' : 'A compatible update is available')
+ upgradeBtn.title = model.locale === 'zh' ? `可升级至 ${versionStr}` : `Can upgrade to ${versionStr}`
+ upgradeBtn.addEventListener('click', () => {
+ primary.click()
+ })
+ item.appendChild(upgradeBtn)
+ }
+
pluginList.appendChild(item)
}
}
@@ -457,10 +543,36 @@
}
}
const primary = document.getElementById('primary')
+ const secondary = document.getElementById('secondary')
+
+ if (model.upgradeCandidate) {
+ setText('primary', model.upgradeLabel)
+ setText('secondary', model.uninstallLabel || (model.locale === 'zh' ? '仍要卸载此插件' : 'Uninstall plugin instead'))
+ secondary.hidden = false
+ secondary.addEventListener('click', () => {
+ secondary.disabled = true
+ primary.disabled = true
+ if (safeModeBtn) safeModeBtn.disabled = true
+ document.querySelectorAll('.plugin-upgrade-btn').forEach((btn) => { btn.disabled = true })
+ secondary.textContent = model.locale === 'zh' ? '正在卸载…' : 'Removing…'
+ navigate('uninstall')
+ })
+ } else {
+ setText('primary', model.primaryLabel)
+ }
+
primary.addEventListener('click', () => {
primary.disabled = true
- primary.textContent = model.primaryBusyLabel
- navigate(model.canUninstall ? 'uninstall' : 'safe-mode')
+ if (secondary) secondary.disabled = true
+ if (safeModeBtn) safeModeBtn.disabled = true
+ document.querySelectorAll('.plugin-upgrade-btn').forEach((btn) => { btn.disabled = true })
+ if (model.upgradeCandidate) {
+ primary.textContent = model.upgradeBusyLabel || (model.locale === 'zh' ? '正在升级…' : 'Upgrading…')
+ navigate('upgrade')
+ } else {
+ primary.textContent = model.primaryBusyLabel
+ navigate(model.canUninstall ? 'uninstall' : 'safe-mode')
+ }
})
document.getElementById('show-log').addEventListener('click', () => navigate('show-log'))
document.getElementById('quit').addEventListener('click', () => navigate('quit'))
diff --git a/build/safe-mode.html b/build/safe-mode.html
index 09bceedf..649df522 100644
--- a/build/safe-mode.html
+++ b/build/safe-mode.html
@@ -75,7 +75,17 @@
.button:hover { background: var(--soft); }
.button.primary { border-color: var(--button); color: var(--button-ink); background: var(--button); }
.button.danger { color:var(--danger); border-color:rgba(195,59,56,.35); }
+ .button.success { color:var(--success); border-color:rgba(38,122,74,.35); }
+ .button.success:hover { background:rgba(38,122,74,.08); }
.button[disabled] { opacity: .55; cursor: default; }
+ .plugin-item { display:flex; align-items:center; justify-content:space-between; border-top:1px solid var(--line); background:var(--surface); }
+ .plugin-item:first-child { border-top:0; }
+ .plugin-item .plugin { flex:1 1 auto; min-width:0; }
+ .plugin-upgrade-btn { margin-right:14px; min-height:28px; padding:4px 10px; border:1px solid var(--success); border-radius:8px; color:var(--success); background:transparent; cursor:pointer; font-size:11px; font-weight:650; white-space:nowrap; flex:0 0 auto; }
+ .plugin-upgrade-btn:hover { background:rgba(38,122,74,.09); }
+ .plugin-upgrade-btn[disabled] { opacity:.55; cursor:wait; }
+ .plugin-status.success { color:var(--success); }
+ .plugin-version { margin-left:4px; color:var(--muted); font-size:11px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
.footer { display: flex; justify-content: flex-end; padding: 0 32px 18px; }
.quit { border: 0; color: var(--muted); background: transparent; cursor: pointer; font-size: 12px; font-weight: 600; }
:root[data-theme="dark"] { --canvas:#141416; --surface:#202023; --soft:#29292d; --ink:#f2f2f4; --muted:#a5a7ad; --line:#3a3a3f; --strong:#4a4a50; --button:#f4f4f5; --button-ink:#111217; --danger:#ee7772; --success:#78d59a; }
@@ -101,6 +111,7 @@
+
@@ -136,6 +147,7 @@
const list = document.getElementById('items')
const empty = document.getElementById('empty')
const apply = document.getElementById('apply')
+ const upgradeAll = document.getElementById('upgrade-all')
const recoveryOpen = document.getElementById('recovery-open')
const agent = document.getElementById('agent')
const restart = document.getElementById('restart')
@@ -145,6 +157,10 @@
apply.hidden = true
recoveryOpen.hidden = false
}
+ if (model.upgradeAllLabel && model.upgradeReadyCount > 0 && model.recoveryLocked !== true) {
+ upgradeAll.hidden = false
+ upgradeAll.textContent = model.upgradeAllLabel
+ }
const pluginCheckboxes = []
const issueCheckboxes = []
for (const group of Array.isArray(model.issueGroups) ? model.issueGroups : []) {
@@ -175,20 +191,48 @@
: (Array.isArray(model.plugins) ? model.plugins : []).map((name) => ({ name }))
for (const plugin of pluginItems) {
const item = document.createElement('li')
+ item.className = 'plugin-item'
const label = document.createElement('label'); label.className = 'plugin'
const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.value = String(plugin.name)
const copy = document.createElement('span'); copy.className = 'plugin-copy'
const title = document.createElement('span'); title.className = 'plugin-title'
const name = document.createElement('span'); name.className = 'plugin-name'; name.textContent = String(plugin.name)
title.appendChild(name)
+ if (plugin.installedVersion) {
+ const ver = document.createElement('span')
+ ver.className = 'plugin-version'
+ ver.textContent = `v${plugin.installedVersion}`
+ title.appendChild(ver)
+ }
if (plugin.statusLabel) {
const status = document.createElement('span'); status.className = 'plugin-status'; status.textContent = String(plugin.statusLabel)
if (plugin.statusTone === 'warning') status.classList.add('warning')
+ else if (plugin.statusTone === 'success') status.classList.add('success')
title.appendChild(status)
}
const action = document.createElement('span'); action.className = 'plugin-action'; action.textContent = String(plugin.actionLabel || '')
copy.append(title, action)
- label.append(checkbox, copy); item.appendChild(label); list.appendChild(item); pluginCheckboxes.push(checkbox)
+ label.append(checkbox, copy); item.appendChild(label)
+
+ if (plugin.upgradeButtonLabel) {
+ const upgradeBtn = document.createElement('button')
+ upgradeBtn.type = 'button'
+ upgradeBtn.className = 'plugin-upgrade-btn'
+ upgradeBtn.textContent = String(plugin.upgradeButtonLabel)
+ upgradeBtn.addEventListener('click', (e) => {
+ e.stopPropagation()
+ upgradeBtn.disabled = true
+ upgradeBtn.textContent = model.locale === 'zh' ? '正在升级…' : 'Upgrading…'
+ apply.disabled = true
+ if (upgradeAll) upgradeAll.disabled = true
+ restart.disabled = true
+ agent.disabled = true
+ void window.dshSafeMode.action('upgrade', { plugins: [plugin.name] })
+ })
+ item.appendChild(upgradeBtn)
+ }
+
+ list.appendChild(item); pluginCheckboxes.push(checkbox)
}
const allCheckboxes = [...issueCheckboxes, ...pluginCheckboxes]
if (allCheckboxes.length === 0) empty.classList.add('visible')
@@ -202,13 +246,24 @@
})
if (plugins.length === 0 && issues.length === 0) return
apply.disabled = true; apply.textContent = model.applyBusyLabel; agent.disabled = true; restart.disabled = true
+ if (upgradeAll) upgradeAll.disabled = true
void window.dshSafeMode.action('apply', { plugins, issues })
})
+ upgradeAll.addEventListener('click', () => {
+ const targets = pluginItems.filter((p) => p.upgradeReady).map((p) => p.name)
+ if (targets.length === 0) return
+ upgradeAll.disabled = true
+ upgradeAll.textContent = model.upgradeAllBusyLabel || (model.locale === 'zh' ? '正在升级…' : 'Upgrading…')
+ apply.disabled = true
+ restart.disabled = true
+ agent.disabled = true
+ void window.dshSafeMode.action('upgrade', { plugins: targets })
+ })
recoveryOpen.addEventListener('click', () => { recoveryOpen.disabled = true; void window.dshSafeMode.action('recovery-open', {}) })
- agent.addEventListener('click', () => { agent.disabled = true; agent.textContent = model.agentBusyLabel; apply.disabled = true; restart.disabled = true; void window.dshSafeMode.action('agent', {}) })
+ agent.addEventListener('click', () => { agent.disabled = true; agent.textContent = model.agentBusyLabel; apply.disabled = true; restart.disabled = true; if (upgradeAll) upgradeAll.disabled = true; void window.dshSafeMode.action('agent', {}) })
restart.addEventListener('click', () => {
if (model.restartConfirm && !window.confirm(String(model.restartConfirm))) return
- restart.disabled = true; restart.textContent = model.restartBusyLabel; apply.disabled = true; agent.disabled = true; void window.dshSafeMode.action('restart', {})
+ restart.disabled = true; restart.textContent = model.restartBusyLabel; apply.disabled = true; agent.disabled = true; if (upgradeAll) upgradeAll.disabled = true; void window.dshSafeMode.action('restart', {})
})
document.getElementById('quit').addEventListener('click', () => void window.dshSafeMode.action('quit', {}))
diff --git a/packages/dsh-desktop-market-installer/generations/projection.d.ts b/packages/dsh-desktop-market-installer/generations/projection.d.ts
index 05aff11a..3549e62f 100644
--- a/packages/dsh-desktop-market-installer/generations/projection.d.ts
+++ b/packages/dsh-desktop-market-installer/generations/projection.d.ts
@@ -12,6 +12,7 @@ export interface PublishedGenerationManifest {
export function projectGenerations(dshHome: string, profile?: string): Promise
export function publishGenerationManifest(
dshHome: string,
- profile?: string
+ profile?: string,
+ options?: { syncBundles?: boolean }
): Promise
export function exposeMissingGenerationLinks(dshHome: string, profile?: string): Promise
diff --git a/packages/dsh-desktop-market-installer/generations/projection.mjs b/packages/dsh-desktop-market-installer/generations/projection.mjs
index 846586c5..898d5dd9 100644
--- a/packages/dsh-desktop-market-installer/generations/projection.mjs
+++ b/packages/dsh-desktop-market-installer/generations/projection.mjs
@@ -245,14 +245,15 @@ export async function projectGenerations(dshHome, profile = 'web') {
* Publish the desired generation set for inventory and the next launch without
* touching the active Profile's node_modules. Market operations run inside the
* live Harness, so replacing even a junction there recreates the Windows
- * rename conflict generations are meant to avoid. The cold-start projector
- * materializes these links after Harness has stopped.
+ * rename conflict generations are meant to avoid. A removal may still update
+ * the manifest's bundle list: that only changes what the *next* Harness boot
+ * composes, while leaving the current process and its links intact.
*/
-export async function publishGenerationManifest(dshHome, profile = 'web') {
+export async function publishGenerationManifest(dshHome, profile = 'web', { syncBundles = false } = {}) {
const { dir, manifestState, enabled, linkSpecs } =
await prepareGenerationProjection(dshHome, profile)
const bundles = await syncProfileManifest(dir, enabled, linkSpecs, manifestState, {
- syncBundles: false
+ syncBundles
})
return { plugins: [...enabled.keys()], bundles }
}
diff --git a/packages/dsh-desktop-market-installer/index.js b/packages/dsh-desktop-market-installer/index.js
index 9e06719b..6dd30bc0 100644
--- a/packages/dsh-desktop-market-installer/index.js
+++ b/packages/dsh-desktop-market-installer/index.js
@@ -541,7 +541,13 @@ export function createDesktopPnpmService(options) {
if (isCancelled()) return { exitCode: 1, message: 'The package operation was aborted.' }
write(`Disabling ${generationRemoval} generation for the next restart…`)
await disableGeneration(home, generationRemoval)
- const published = await publishGenerationManifest(home)
+ // Do not replace the active link while Harness is running, but do
+ // remove this bundle from the next boot's composition now. Waiting
+ // for startup projection leaves an uninstalled plugin live forever
+ // when an unrelated migration preflight is deferred.
+ const published = await publishGenerationManifest(home, MARKET_PROFILE, {
+ syncBundles: true
+ })
write(`staged for next restart: ${published.plugins.join(', ')}`)
return { exitCode: 0 }
})
diff --git a/src/main/index.ts b/src/main/index.ts
index 729cfbbf..87b60ff2 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -30,7 +30,7 @@ import {
clearProfileInstallMarker,
markProfileInstallComplete
} from './state/profile-install-marker'
-import { inspectProfileConsistency } from './state/profile-consistency'
+import { healProfileBundles, inspectProfileConsistency } from './state/profile-consistency'
import {
disableProfilePlugins,
inspectProfileCompatibility,
@@ -122,6 +122,15 @@ import {
} from '../shared/desktop-menu'
import { buildPluginRecoveryViewModel } from './plugin-recovery-view'
import { buildSafeModeViewModel, shouldStartInSafeMode } from './safe-mode'
+import {
+ checkupAllProfilePlugins,
+ evaluatePluginMarketCompatibility,
+ readBundledDshVersion,
+ readInstalledPluginVersion,
+ type PluginHealthReport,
+ type PluginUpgradeCandidate
+} from './state/plugin-market-check'
+import { upgradePluginToGeneration } from './state/plugin-upgrade'
import { aboutDetail, bundledHarnessVersion } from './version-info'
import { windowsMenuViewBounds } from './windows-menu-view'
import { shouldKeepRunningInBackground } from './close-to-tray'
@@ -130,9 +139,10 @@ import {
shouldReloadAfterMainWindowRendererLoss
} from './main-window-recovery'
-type PluginRecoveryAction = 'uninstall' | 'show-log' | 'quit' | 'restart' | 'refresh' | 'safe-mode'
+type PluginRecoveryAction = 'uninstall' | 'upgrade' | 'show-log' | 'quit' | 'restart' | 'refresh' | 'safe-mode'
type SafeModeAction =
| { type: 'apply'; plugins: string[]; issues: string[] }
+ | { type: 'upgrade'; plugins: string[] }
| { type: 'recovery-open' }
| { type: 'backup-open'; removalId: string }
| { type: 'backup-restore'; removalId: string }
@@ -143,6 +153,7 @@ type SafeModeAction =
const PLUGIN_RECOVERY_ACTIONS = new Set([
'uninstall',
+ 'upgrade',
'show-log',
'quit',
'restart',
@@ -1019,6 +1030,10 @@ async function showSplash(): Promise {
*/
async function reportProfileConsistency(dshHome: string): Promise {
try {
+ const healed = await healProfileBundles(dshHome)
+ if (healed.length > 0) {
+ runtime.note(`[desktop] auto-composed ${healed.length} missing bundle(s): ${healed.join(', ')}`)
+ }
const findings = await inspectProfileConsistency(dshHome)
const store = await inspectStoreConsistency(dshHome)
if (store) findings.push(store)
@@ -1525,6 +1540,7 @@ async function waitForPluginRecoveryAction(options: {
plugins: readonly string[]
removedPlugins: readonly string[]
notice?: string
+ upgradeCandidate?: PluginUpgradeCandidate
}): Promise {
const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : createWindow()
const state = buildPluginRecoveryViewModel({
@@ -1588,6 +1604,8 @@ async function showPluginRecovery(options?: {
return true
}
+ const attemptedUpgrades = new Map()
+
try {
while (!quitting) {
const snapshot = runtime.snapshot()
@@ -1603,6 +1621,37 @@ async function showPluginRecovery(options?: {
appendPluginRecoveryDetectionLog(detection.plugins)
waitForRendererEvidence = false
if (applyPendingFrontendEvidence()) continue
+
+ let upgradeCandidate: PluginUpgradeCandidate | undefined
+ if (detection.plugins.length === 1) {
+ const targetPlugin = detection.plugins[0]!
+ try {
+ const installedVersion = await readInstalledPluginVersion(dshHome, targetPlugin)
+ const runtimeVersion =
+ (await readBundledDshVersion(join(app.getAppPath(), 'node_modules'))) || '0.1.2-alpha.1'
+ const check = await evaluatePluginMarketCompatibility({
+ packageName: targetPlugin,
+ installedVersion,
+ currentRuntimeVersion: runtimeVersion,
+ hasLocalIssue: true,
+ locale: harnessLocale()
+ })
+ if (
+ check.upgradeReady &&
+ check.upgradeVersion &&
+ attemptedUpgrades.get(targetPlugin) !== check.upgradeVersion
+ ) {
+ upgradeCandidate = {
+ packageName: targetPlugin,
+ targetVersion: check.upgradeVersion,
+ installedVersion
+ }
+ }
+ } catch (error) {
+ runtime.note(`[plugin-recovery] market check failed for ${targetPlugin}: ${String(error)}`)
+ }
+ }
+
const action = await waitForPluginRecoveryAction({
snapshot: {
...snapshot,
@@ -1611,13 +1660,59 @@ async function showPluginRecovery(options?: {
},
plugins: detection.plugins,
removedPlugins,
- notice
+ notice,
+ upgradeCandidate
})
notice = undefined
if (action === 'refresh') {
applyPendingFrontendEvidence()
continue
+ } else if (action === 'upgrade' && upgradeCandidate) {
+ await runtime.stop()
+ const upgradeResult = await upgradePluginToGeneration({
+ dshHome,
+ pluginName: upgradeCandidate.packageName,
+ targetVersion: upgradeCandidate.targetVersion,
+ nodeExecutablePath: bundledNodePath(),
+ pnpmEntryPath: bundledPnpmEntryPath(),
+ note: (line) => runtime.note(line)
+ })
+ attemptedUpgrades.set(upgradeCandidate.packageName, upgradeCandidate.targetVersion)
+
+ if (!upgradeResult.ok) {
+ notice = isChinese
+ ? `升级 ${upgradeCandidate.packageName} 到 v${upgradeCandidate.targetVersion} 失败:${upgradeResult.detail ?? '未知错误'}。您可以重试或卸载该插件。`
+ : `Failed to upgrade ${upgradeCandidate.packageName} to v${upgradeCandidate.targetVersion}: ${upgradeResult.detail ?? 'unknown error'}. You may retry or uninstall.`
+ continue
+ }
+
+ const compatibility = await inspectProfileCompatibility(
+ dshHome,
+ join(app.getAppPath(), 'node_modules')
+ )
+ const blockingIssues = compatibility.issues.filter((issue) => issue.severity === 'blocking')
+ if (blockingIssues.length > 0) {
+ runtime.note(
+ `[plugin-recovery] normal mode remains blocked by ${blockingIssues.length} ` +
+ `profile compatibility issue${blockingIssues.length === 1 ? '' : 's'} after upgrade`
+ )
+ notice = isChinese
+ ? `已升级至 v${upgradeCandidate.targetVersion},但 Profile 仍有 ${blockingIssues.length} 项兼容问题。` +
+ '为避免再次进入空白界面,请进入安全模式继续处理。'
+ : `Upgraded to v${upgradeCandidate.targetVersion}, but ${blockingIssues.length} blocking profile compatibility ` +
+ `issue${blockingIssues.length === 1 ? ' remains' : 's remain'}. ` +
+ 'Continue in Safe Mode to avoid another blank normal window.'
+ continue
+ }
+
+ await launchHarness()
+ if (applyPendingFrontendEvidence()) continue
+ if (runtime.snapshot().phase === 'ready') {
+ schedulePluginRecoverySessionReset()
+ return
+ }
+ continue
} else if (action === 'uninstall' && detection.plugins.length > 0) {
// The normal web Harness may still have the failing plugin imported.
// macOS permits renaming an open directory, but Windows does not; stop
@@ -1726,6 +1821,7 @@ async function waitForSafeModeAction(options: {
plugins: readonly string[]
suspectedPlugins: readonly string[]
issues: readonly ProfileCompatibilityIssue[]
+ healthReports?: readonly PluginHealthReport[]
backups: Awaited>['backups']
recoveryLocked: boolean
backupRestoreLocked: boolean
@@ -1777,6 +1873,7 @@ async function waitForSafeModeAction(options: {
plugins: options.plugins,
suspectedPlugins: options.suspectedPlugins,
issues: options.issues,
+ healthReports: options.healthReports,
backups: options.backups,
recoveryLocked: options.recoveryLocked,
backupRestoreLocked: options.backupRestoreLocked,
@@ -2001,6 +2098,24 @@ async function showSafeModeManager(initial?: {
noticeTone ??= 'error'
}
const installed = [...new Set([...active, ...pendingRemovals])]
+ let healthReports: PluginHealthReport[] | undefined
+ if (installed.length > 0 && !recoveryLocked) {
+ try {
+ const incompatiblePluginNames = compatibility.issues
+ .filter((issue) => issue.resolution === 'disable-plugin')
+ .map((issue) => issue.target)
+ healthReports = await checkupAllProfilePlugins({
+ plugins: installed,
+ dshHome,
+ bundledNodeModulesPath: join(app.getAppPath(), 'node_modules'),
+ incompatiblePlugins: [...new Set([...safeModeSuspectedPlugins, ...incompatiblePluginNames])],
+ locale: harnessLocale()
+ })
+ } catch (error) {
+ runtime.note(`[safe-mode] plugin market health checkup failed: ${String(error)}`)
+ }
+ }
+
const allowedRestoreId = recoveryLocked &&
!migrationRecoveryLocked &&
removalLedgerReadable &&
@@ -2017,6 +2132,7 @@ async function showSafeModeManager(initial?: {
plugins: installed,
suspectedPlugins: safeModeSuspectedPlugins,
issues: compatibility.issues,
+ healthReports,
backups: removalBackups.backups,
recoveryLocked,
backupRestoreLocked,
@@ -2167,6 +2283,57 @@ async function showSafeModeManager(initial?: {
return
}
+ if (action.type === 'upgrade') {
+ if (await refreshMigrationRecoveryLock(dshHome)) {
+ notice = isChinese ? 'Profile 恢复事务完成前禁止升级插件。' : 'Plugins cannot be upgraded until the recovery transaction completes.'
+ noticeTone = 'error'
+ continue
+ }
+ const reportsByPkg = new Map((healthReports ?? []).map((r) => [r.packageName, r]))
+ const targets = action.plugins.filter((pkg) => {
+ const report = reportsByPkg.get(pkg)
+ return report?.upgradeReady && report.upgradeVersion
+ })
+
+ if (targets.length === 0) {
+ notice = isChinese ? '所选插件没有可升级的兼容版本。' : 'No compatible upgrade candidate found for selected plugins.'
+ noticeTone = 'error'
+ continue
+ }
+
+ let upgradedCount = 0
+ const failedPackages: string[] = []
+ for (const pkg of targets) {
+ const report = reportsByPkg.get(pkg)!
+ const res = await upgradePluginToGeneration({
+ dshHome,
+ pluginName: pkg,
+ targetVersion: report.upgradeVersion!,
+ nodeExecutablePath: bundledNodePath(),
+ pnpmEntryPath: bundledPnpmEntryPath(),
+ note: (line) => runtime.note(line)
+ })
+ if (res.ok) {
+ upgradedCount++
+ } else {
+ failedPackages.push(pkg)
+ }
+ }
+
+ if (failedPackages.length === 0) {
+ notice = isChinese
+ ? `成功升级 ${upgradedCount} 个插件。`
+ : `Successfully upgraded ${upgradedCount} plugin${upgradedCount === 1 ? '' : 's'}.`
+ noticeTone = 'success'
+ } else {
+ notice = isChinese
+ ? `成功升级 ${upgradedCount} 个插件,${failedPackages.length} 个升级失败(${failedPackages.join('、')})。`
+ : `Upgraded ${upgradedCount} plugin${upgradedCount === 1 ? '' : 's'}; ${failedPackages.length} failed (${failedPackages.join(', ')}).`
+ noticeTone = 'error'
+ }
+ continue
+ }
+
if (await refreshMigrationRecoveryLock(dshHome)) {
notice = isChinese ? 'Profile 恢复事务完成前禁止修改正常 Profile。' : 'The normal Profile is locked until the recovery transaction completes.'
noticeTone = 'error'
@@ -2522,6 +2689,7 @@ async function bootstrap(): Promise {
!safeModeManagerVisible ||
(
action !== 'apply' &&
+ action !== 'upgrade' &&
action !== 'recovery-open' &&
action !== 'backup-open' &&
action !== 'backup-restore' &&
@@ -2535,7 +2703,7 @@ async function bootstrap(): Promise {
}
await refreshMigrationRecoveryLock(join(app.getPath('userData'), 'harness'))
if (
- (action === 'apply' || action === 'backup-delete') && profileRecoveryLocked()
+ (action === 'apply' || action === 'upgrade' || action === 'backup-delete') && profileRecoveryLocked()
) return { ok: false }
if (action === 'apply') {
if (typeof selection !== 'object' || selection === null) return { ok: false }
@@ -2549,6 +2717,16 @@ async function bootstrap(): Promise {
return { ok: false }
}
resolveSafeModeAction({ type: 'apply', plugins, issues })
+ } else if (action === 'upgrade') {
+ if (typeof selection !== 'object' || selection === null) return { ok: false }
+ const { plugins } = selection as { plugins?: unknown }
+ if (
+ !Array.isArray(plugins) ||
+ !plugins.every((plugin) => typeof plugin === 'string')
+ ) {
+ return { ok: false }
+ }
+ resolveSafeModeAction({ type: 'upgrade', plugins })
} else if (
action === 'backup-open' ||
action === 'backup-restore' ||
diff --git a/src/main/plugin-recovery-view.ts b/src/main/plugin-recovery-view.ts
index 334706bf..ddb8e357 100644
--- a/src/main/plugin-recovery-view.ts
+++ b/src/main/plugin-recovery-view.ts
@@ -2,6 +2,12 @@ import type { RuntimeSnapshot } from '../shared/contracts'
export type PluginRecoveryLocale = 'en' | 'zh'
+export interface PluginRecoveryUpgradeCandidate {
+ packageName: string
+ targetVersion: string
+ installedVersion?: string
+}
+
export interface PluginRecoveryViewModel {
locale: PluginRecoveryLocale
brand: string
@@ -17,6 +23,11 @@ export interface PluginRecoveryViewModel {
safetyNote: string
primaryLabel: string
primaryBusyLabel: string
+ upgradeCandidate?: PluginRecoveryUpgradeCandidate
+ upgradeLabel?: string
+ upgradeBusyLabel?: string
+ upgradeHint?: string
+ uninstallLabel?: string
logLabel: string
advancedLabel: string
errorLabel: string
@@ -24,6 +35,7 @@ export interface PluginRecoveryViewModel {
launchDirectory?: string
rawError: string
quitLabel: string
+ safeModeLabel: string
canUninstall: boolean
}
@@ -146,8 +158,9 @@ export function buildPluginRecoveryViewModel(options: {
removedPlugins: readonly string[]
locale: PluginRecoveryLocale
notice?: string
+ upgradeCandidate?: PluginRecoveryUpgradeCandidate
}): PluginRecoveryViewModel {
- const { snapshot, locale, notice } = options
+ const { snapshot, locale, notice, upgradeCandidate } = options
const pluginPackages = [...new Set(options.plugins)]
const plugins = pluginPackages.map(displayPluginName)
const removedPlugins = [...new Set(options.removedPlugins)].map(displayPluginName)
@@ -179,6 +192,15 @@ export function buildPluginRecoveryViewModel(options: {
? multiple ? `卸载这 ${plugins.length} 个插件并继续检测` : '卸载此插件并继续检测'
: '进入安全模式',
primaryBusyLabel: canUninstall ? '正在处理并重新检测…' : '正在进入安全模式…',
+ upgradeCandidate,
+ upgradeLabel: upgradeCandidate
+ ? '升级插件并重启'
+ : undefined,
+ upgradeBusyLabel: upgradeCandidate ? '正在升级…' : undefined,
+ upgradeHint: upgradeCandidate
+ ? '该插件有新的兼容版本'
+ : undefined,
+ uninstallLabel: upgradeCandidate ? '仍要卸载此插件' : undefined,
logLabel: '打开 Harness 日志',
advancedLabel: '查看技术详情',
errorLabel: '错误信息',
@@ -186,6 +208,7 @@ export function buildPluginRecoveryViewModel(options: {
launchDirectory: snapshot.launchDirectory,
rawError: snapshot.message,
quitLabel: '退出 DSH Desktop',
+ safeModeLabel: '进入安全模式',
canUninstall
}
}
@@ -213,6 +236,15 @@ export function buildPluginRecoveryViewModel(options: {
? multiple ? `Remove these ${plugins.length} plugins and continue` : 'Remove this plugin and continue'
: 'Enter Safe Mode',
primaryBusyLabel: canUninstall ? 'Removing and checking again…' : 'Entering Safe Mode…',
+ upgradeCandidate,
+ upgradeLabel: upgradeCandidate
+ ? 'Upgrade plugin and restart'
+ : undefined,
+ upgradeBusyLabel: upgradeCandidate ? 'Upgrading…' : undefined,
+ upgradeHint: upgradeCandidate
+ ? 'A compatible update is available'
+ : undefined,
+ uninstallLabel: upgradeCandidate ? 'Uninstall this plugin instead' : undefined,
logLabel: 'Open Harness log',
advancedLabel: 'View technical details',
errorLabel: 'Error details',
@@ -220,6 +252,7 @@ export function buildPluginRecoveryViewModel(options: {
launchDirectory: snapshot.launchDirectory,
rawError: snapshot.message,
quitLabel: 'Quit DSH Desktop',
+ safeModeLabel: 'Enter Safe Mode',
canUninstall
}
}
diff --git a/src/main/safe-mode.ts b/src/main/safe-mode.ts
index 354f2e3a..1fd15f8a 100644
--- a/src/main/safe-mode.ts
+++ b/src/main/safe-mode.ts
@@ -1,4 +1,5 @@
import type { ProfileCompatibilityIssue } from './state/profile-compatibility'
+import type { PluginHealthReport, PluginHealthStatus } from './state/plugin-market-check'
export type SafeModeLocale = 'en' | 'zh'
@@ -24,10 +25,17 @@ export interface SafeModeIssueGroupViewModel {
export interface SafeModePluginViewModel {
name: string
statusLabel?: string
- statusTone?: 'warning' | 'danger'
+ statusTone?: 'warning' | 'danger' | 'success'
actionLabel: string
incompatible: boolean
suspected: boolean
+ healthStatus?: PluginHealthStatus
+ healthLabel?: string
+ installedVersion?: string
+ latestVersion?: string
+ upgradeReady?: boolean
+ upgradeVersion?: string
+ upgradeButtonLabel?: string
}
export interface SafeModeBackupViewModel {
@@ -73,6 +81,9 @@ export interface SafeModeViewModel {
quitLabel: string
notice?: string
noticeTone?: 'success' | 'error'
+ upgradeAllLabel?: string
+ upgradeAllBusyLabel?: string
+ upgradeReadyCount: number
}
export function shouldStartInSafeMode(argv: readonly string[]): boolean {
@@ -84,6 +95,7 @@ export function buildSafeModeViewModel(options: {
plugins: readonly string[]
suspectedPlugins?: readonly string[]
issues?: readonly ProfileCompatibilityIssue[]
+ healthReports?: readonly PluginHealthReport[]
backups?: readonly {
removalId: string
pluginName: string
@@ -150,9 +162,13 @@ export function buildSafeModeViewModel(options: {
...incompatiblePlugins,
...suspectedPlugins
])].sort((left, right) => Number(suspectedPlugins.has(right)) - Number(suspectedPlugins.has(left)))
+ const healthReportByPlugin = new Map(
+ (options.healthReports ?? []).map((report) => [report.packageName, report])
+ )
const pluginItems = plugins.map((name): SafeModePluginViewModel => {
const incompatible = incompatiblePlugins.has(name)
const suspected = suspectedPlugins.has(name)
+ const report = healthReportByPlugin.get(name)
const labels = [
...(suspected
? [options.locale === 'zh' ? '本次启动日志推断' : 'inferred from this startup log']
@@ -161,6 +177,22 @@ export function buildSafeModeViewModel(options: {
? [options.locale === 'zh' ? '版本不兼容' : 'version incompatible']
: [])
]
+ if (report?.healthLabel && !incompatible && !suspected) {
+ labels.push(report.healthLabel)
+ }
+ const statusTone = incompatible
+ ? 'danger'
+ : suspected
+ ? 'warning'
+ : report?.upgradeReady
+ ? 'success'
+ : undefined
+ const upgradeButtonLabel = report?.upgradeReady && report.upgradeVersion
+ ? options.locale === 'zh'
+ ? `升级至 v${report.upgradeVersion}`
+ : `Upgrade to v${report.upgradeVersion}`
+ : undefined
+
return {
name,
statusLabel: labels.length > 0
@@ -168,12 +200,20 @@ export function buildSafeModeViewModel(options: {
? `(${labels.join(',')})`
: `(${labels.join(', ')})`
: undefined,
- statusTone: incompatible ? 'danger' : suspected ? 'warning' : undefined,
+ statusTone,
actionLabel: options.locale === 'zh' ? '卸载插件' : 'Remove plugin',
incompatible,
- suspected
+ suspected,
+ ...(report?.healthStatus !== undefined ? { healthStatus: report.healthStatus } : {}),
+ ...(report?.healthLabel !== undefined ? { healthLabel: report.healthLabel } : {}),
+ ...(report?.installedVersion !== undefined ? { installedVersion: report.installedVersion } : {}),
+ ...(report?.latestVersion !== undefined ? { latestVersion: report.latestVersion } : {}),
+ ...(report?.upgradeReady !== undefined ? { upgradeReady: report.upgradeReady } : {}),
+ ...(report?.upgradeVersion !== undefined ? { upgradeVersion: report.upgradeVersion } : {}),
+ ...(upgradeButtonLabel !== undefined ? { upgradeButtonLabel } : {})
}
})
+ const upgradeReadyCount = pluginItems.filter((item) => item.upgradeReady).length
const groups = new Map()
for (const issue of issues.filter((issue) => issue.resolution !== 'disable-plugin')) {
const id = issue.groupId ?? `${issue.resolution}:${issue.target}`
@@ -308,7 +348,12 @@ export function buildSafeModeViewModel(options: {
: undefined,
quitLabel: '退出 DSH Desktop',
notice: options.notice,
- noticeTone: options.noticeTone
+ noticeTone: options.noticeTone,
+ upgradeAllLabel: upgradeReadyCount > 0
+ ? `一键升级 ${upgradeReadyCount} 个已适配插件`
+ : undefined,
+ upgradeAllBusyLabel: '正在批量升级…',
+ upgradeReadyCount
}
}
@@ -345,6 +390,11 @@ export function buildSafeModeViewModel(options: {
: undefined,
quitLabel: 'Quit DSH Desktop',
notice: options.notice,
- noticeTone: options.noticeTone
+ noticeTone: options.noticeTone,
+ upgradeAllLabel: upgradeReadyCount > 0
+ ? `Upgrade ${upgradeReadyCount} compatible plugin${upgradeReadyCount === 1 ? '' : 's'}`
+ : undefined,
+ upgradeAllBusyLabel: 'Upgrading plugins…',
+ upgradeReadyCount
}
}
diff --git a/src/main/state/plugin-market-check.ts b/src/main/state/plugin-market-check.ts
new file mode 100644
index 00000000..557f9955
--- /dev/null
+++ b/src/main/state/plugin-market-check.ts
@@ -0,0 +1,471 @@
+import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { profilePackageJsonPath } from './plugin-recovery'
+
+export interface NpmPackageManifest {
+ name: string
+ version: string
+ dependencies?: Record
+ optionalDependencies?: Record
+ peerDependencies?: Record
+ peerDependenciesMeta?: Record
+ engines?: Record
+ dsh?: {
+ bundle?: {
+ patch?: string
+ }
+ client?: {
+ platform?: string
+ inject?: string[]
+ }
+ minVersion?: string
+ }
+}
+
+export type PluginHealthStatus =
+ | 'up-to-date'
+ | 'upgrade-available'
+ | 'incompatible-fixed-in-latest'
+ | 'incompatible-no-fix'
+ | 'checking'
+ | 'check-failed'
+
+export interface PluginHealthReport {
+ packageName: string
+ installedVersion?: string
+ latestVersion?: string
+ healthStatus: PluginHealthStatus
+ healthLabel: string
+ upgradeReady: boolean
+ upgradeVersion?: string
+ detail?: string
+}
+
+export interface PluginUpgradeCandidate {
+ packageName: string
+ targetVersion: string
+ installedVersion?: string
+}
+
+export const DEFAULT_NPM_REGISTRY = 'https://registry.npmmirror.com'
+export const FALLBACK_NPM_REGISTRY = 'https://registry.npmjs.org'
+export const DEFAULT_MARKET_CHECK_TIMEOUT_MS = 2_500
+
+/**
+ * Parsed Semver version.
+ */
+export interface SemverVersion {
+ major: number
+ minor: number
+ patch: number
+ prerelease: Array
+}
+
+const SEMVER_PATTERN =
+ /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
+
+export function parseSemver(input: string): SemverVersion | null {
+ if (typeof input !== 'string') return null
+ const trimmed = input.trim()
+ const match = SEMVER_PATTERN.exec(trimmed)
+ if (!match) return null
+
+ const major = Number(match[1])
+ const minor = Number(match[2])
+ const patch = Number(match[3])
+ const prerelease = match[4]
+ ? match[4].split('.').map((part) => (/^\d+$/.test(part) ? Number(part) : part))
+ : []
+
+ return { major, minor, patch, prerelease }
+}
+
+export function compareSemver(aStr: string, bStr: string): number {
+ const a = parseSemver(aStr)
+ const b = parseSemver(bStr)
+ if (!a && !b) return aStr.localeCompare(bStr)
+ if (!a) return -1
+ if (!b) return 1
+
+ if (a.major !== b.major) return a.major > b.major ? 1 : -1
+ if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1
+ if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1
+
+ // When one has prerelease and other does not, version without prerelease is greater
+ if (a.prerelease.length === 0 && b.prerelease.length > 0) return 1
+ if (a.prerelease.length > 0 && b.prerelease.length === 0) return -1
+ if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0
+
+ const len = Math.max(a.prerelease.length, b.prerelease.length)
+ for (let i = 0; i < len; i += 1) {
+ const aPart = a.prerelease[i]
+ const bPart = b.prerelease[i]
+ if (aPart === undefined) return -1
+ if (bPart === undefined) return 1
+ if (aPart === bPart) continue
+
+ const aNum = typeof aPart === 'number'
+ const bNum = typeof bPart === 'number'
+ if (aNum && !bNum) return -1
+ if (!aNum && bNum) return 1
+ return aPart > bPart ? 1 : -1
+ }
+
+ return 0
+}
+
+/**
+ * Check whether a version satisfies a comparator: e.g. "^0.1.2", ">=0.1.0", "~1.0.0", "*", "0.1.2-alpha.1".
+ */
+export function satisfiesComparator(versionStr: string, comparator: string): boolean {
+ const comp = comparator.trim()
+ if (!comp || comp === '*' || comp === 'x' || comp === 'X') return true
+
+ const v = parseSemver(versionStr)
+ if (!v) return false
+
+ // Handle caret ^
+ if (comp.startsWith('^')) {
+ const target = comp.slice(1).trim()
+ const t = parseSemver(target)
+ if (!t) return false
+
+ // Prerelease versions only satisfy ranges that have the same [major, minor, patch] tuple with a prerelease
+ if (v.prerelease.length > 0) {
+ if (t.prerelease.length === 0 || v.major !== t.major || v.minor !== t.minor || v.patch !== t.patch) {
+ return false
+ }
+ }
+
+ // Must be >= target
+ if (compareSemver(versionStr, target) < 0) return false
+
+ // Next breaking bump
+ if (t.major > 0) {
+ return v.major === t.major
+ }
+ if (t.minor > 0) {
+ return v.major === 0 && v.minor === t.minor
+ }
+ return v.major === 0 && v.minor === 0 && v.patch === t.patch
+ }
+
+ // Handle tilde ~
+ if (comp.startsWith('~')) {
+ const target = comp.slice(1).trim()
+ const t = parseSemver(target)
+ if (!t) return false
+ if (v.prerelease.length > 0) {
+ if (t.prerelease.length === 0 || v.major !== t.major || v.minor !== t.minor || v.patch !== t.patch) {
+ return false
+ }
+ }
+ if (compareSemver(versionStr, target) < 0) return false
+ return v.major === t.major && v.minor === t.minor
+ }
+
+ if (comp.startsWith('>=')) {
+ const target = comp.slice(2).trim()
+ return compareSemver(versionStr, target) >= 0
+ }
+ if (comp.startsWith('>')) {
+ const target = comp.slice(1).trim()
+ return compareSemver(versionStr, target) > 0
+ }
+ if (comp.startsWith('<=')) {
+ const target = comp.slice(2).trim()
+ return compareSemver(versionStr, target) <= 0
+ }
+ if (comp.startsWith('<')) {
+ const target = comp.slice(1).trim()
+ return compareSemver(versionStr, target) < 0
+ }
+ if (comp.startsWith('=')) {
+ const target = comp.slice(1).trim()
+ return compareSemver(versionStr, target) === 0
+ }
+
+ // Exact version
+ return compareSemver(versionStr, comp) === 0
+}
+
+/**
+ * Check whether a version satisfies a semver range: e.g. ">=0.1.0 <0.2.0" or "^0.1.0 || ^0.2.0".
+ */
+export function satisfiesRange(versionStr: string, range: string): boolean {
+ if (!range || range.trim() === '*' || range.trim() === '') return true
+
+ // Multiple alternatives separated by ||
+ const alternatives = range.split('||').map((alt) => alt.trim()).filter(Boolean)
+ if (alternatives.length === 0) return true
+
+ return alternatives.some((alt) => {
+ // AND conditions separated by whitespace
+ const parts = alt.split(/\s+/).filter(Boolean)
+ return parts.every((part) => satisfiesComparator(versionStr, part))
+ })
+}
+
+// In-memory cache for package metadata to avoid repeated network hits
+const manifestCache = new Map()
+const CACHE_TTL_MS = 5 * 60 * 1000
+
+export async function fetchPluginManifestFromRegistry(
+ packageName: string,
+ options?: {
+ registry?: string
+ timeoutMs?: number
+ fetchFn?: typeof fetch
+ }
+): Promise {
+ const cached = manifestCache.get(packageName)
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
+ return cached.manifest
+ }
+
+ const registries = [
+ options?.registry || DEFAULT_NPM_REGISTRY,
+ FALLBACK_NPM_REGISTRY
+ ]
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_MARKET_CHECK_TIMEOUT_MS
+ const fetchImpl = options?.fetchFn ?? fetch
+
+ for (const registry of registries) {
+ try {
+ const url = `${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`
+ const controller = new AbortController()
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
+ const res = await fetchImpl(url, {
+ signal: controller.signal,
+ headers: {
+ accept: 'application/json',
+ 'user-agent': 'dsh-desktop'
+ }
+ }).finally(() => clearTimeout(timer))
+
+ if (res.ok) {
+ const data = (await res.json()) as NpmPackageManifest
+ if (data && typeof data.version === 'string') {
+ manifestCache.set(packageName, { manifest: data, timestamp: Date.now() })
+ return data
+ }
+ }
+ } catch {
+ // Try next registry
+ }
+ }
+
+ manifestCache.set(packageName, { manifest: null, timestamp: Date.now() })
+ return null
+}
+
+export function clearManifestCache(): void {
+ manifestCache.clear()
+}
+
+/**
+ * Inspect whether a remote manifest is compatible with current DSH runtime.
+ */
+export function inferPluginRuntimeCompatibility(
+ manifest: NpmPackageManifest,
+ currentRuntimeVersion: string
+): { isCompatible: boolean; reason?: string } {
+ // 1. Check peerDependencies for @deepseek-ai/* packages (e.g. @deepseek-ai/dsh, @deepseek-ai/dsh-agent, etc.)
+ const peers = manifest.peerDependencies ?? {}
+ for (const [peerPkg, peerRange] of Object.entries(peers)) {
+ if (peerPkg.startsWith('@deepseek-ai/') && peerPkg !== '@deepseek-ai/cordis') {
+ if (peerRange && !satisfiesRange(currentRuntimeVersion, peerRange)) {
+ return {
+ isCompatible: false,
+ reason: `Declares peer ${peerPkg} (${peerRange}), incompatible with runtime ${currentRuntimeVersion}`
+ }
+ }
+ }
+ }
+
+ // 2. Check engines.dsh
+ const engineDsh = manifest.engines?.dsh || manifest.dsh?.minVersion
+ if (engineDsh && !satisfiesRange(currentRuntimeVersion, engineDsh)) {
+ return {
+ isCompatible: false,
+ reason: `Requires engine DSH (${engineDsh}), incompatible with runtime ${currentRuntimeVersion}`
+ }
+ }
+
+ // 3. Check for deprecated packages in dependencies (e.g. dsh-host-apiproxy)
+ const deps = { ...(manifest.dependencies ?? {}), ...(manifest.optionalDependencies ?? {}) }
+ if (Object.keys(deps).includes('@deepseek-ai/dsh-host-apiproxy')) {
+ return {
+ isCompatible: false,
+ reason: 'Requires deprecated module @deepseek-ai/dsh-host-apiproxy'
+ }
+ }
+
+ return { isCompatible: true }
+}
+
+export async function readBundledDshVersion(bundledNodeModulesPath: string): Promise {
+ try {
+ const raw = await readFile(join(bundledNodeModulesPath, '@deepseek-ai', 'dsh', 'package.json'), 'utf8')
+ const manifest = JSON.parse(raw) as { version?: string }
+ return manifest.version
+ } catch {
+ return undefined
+ }
+}
+
+export async function readInstalledPluginVersion(
+ dshHome: string,
+ pluginName: string
+): Promise {
+ try {
+ const manifestPath = profilePackageJsonPath(dshHome)
+ const profileDir = join(manifestPath, '..')
+ const pkgPath = join(profileDir, 'node_modules', ...pluginName.split('/'), 'package.json')
+ const raw = await readFile(pkgPath, 'utf8')
+ const manifest = JSON.parse(raw) as { version?: string }
+ return manifest.version
+ } catch {
+ return undefined
+ }
+}
+
+/**
+ * Evaluate single plugin for upgrade readiness.
+ */
+export async function evaluatePluginMarketCompatibility(options: {
+ packageName: string
+ installedVersion?: string
+ currentRuntimeVersion: string
+ registry?: string
+ timeoutMs?: number
+ fetchFn?: typeof fetch
+ hasLocalIssue?: boolean
+ locale?: 'zh' | 'en'
+}): Promise {
+ const {
+ packageName,
+ installedVersion,
+ currentRuntimeVersion,
+ hasLocalIssue = false,
+ locale = 'zh'
+ } = options
+ const isZh = locale === 'zh'
+
+ const manifest = await fetchPluginManifestFromRegistry(packageName, {
+ registry: options.registry,
+ timeoutMs: options.timeoutMs,
+ fetchFn: options.fetchFn
+ })
+
+ if (!manifest) {
+ return {
+ packageName,
+ installedVersion,
+ healthStatus: 'check-failed',
+ healthLabel: isZh ? '未能连接市场检查' : 'Market check unavailable',
+ upgradeReady: false,
+ detail: isZh ? '网络超时或市场暂无此插件' : 'Network timeout or package not found in market'
+ }
+ }
+
+ const latestVersion = manifest.version
+ const compatibility = inferPluginRuntimeCompatibility(manifest, currentRuntimeVersion)
+ const isNewer = installedVersion ? compareSemver(latestVersion, installedVersion) > 0 : false
+
+ if (hasLocalIssue) {
+ if (isNewer && compatibility.isCompatible) {
+ return {
+ packageName,
+ installedVersion,
+ latestVersion,
+ healthStatus: 'incompatible-fixed-in-latest',
+ healthLabel: isZh
+ ? `不兼容(最新版 v${latestVersion} 已适配)`
+ : `Incompatible (v${latestVersion} is compatible)`,
+ upgradeReady: true,
+ upgradeVersion: latestVersion,
+ detail: isZh
+ ? `最新版 v${latestVersion} 已适配当前 DSH Runtime (${currentRuntimeVersion}),推荐升级`
+ : `Latest version v${latestVersion} supports current runtime (${currentRuntimeVersion}), upgrade recommended`
+ }
+ }
+ return {
+ packageName,
+ installedVersion,
+ latestVersion,
+ healthStatus: 'incompatible-no-fix',
+ healthLabel: isZh ? '当前版本与最新版均不兼容' : 'Incompatible (no compatible update in market)',
+ upgradeReady: false,
+ detail: compatibility.reason ?? (isZh ? '市场最新版本仍未声明适配当前 Runtime' : 'Latest version is still not compatible')
+ }
+ }
+
+ if (isNewer) {
+ if (compatibility.isCompatible) {
+ return {
+ packageName,
+ installedVersion,
+ latestVersion,
+ healthStatus: 'upgrade-available',
+ healthLabel: isZh ? `发现新版本 v${latestVersion}` : `Update available v${latestVersion}`,
+ upgradeReady: true,
+ upgradeVersion: latestVersion,
+ detail: isZh ? `可升级至 v${latestVersion}` : `Can upgrade to v${latestVersion}`
+ }
+ }
+ return {
+ packageName,
+ installedVersion,
+ latestVersion,
+ healthStatus: 'up-to-date',
+ healthLabel: isZh ? '已是最新兼容版本' : 'Up to date (compatible)',
+ upgradeReady: false,
+ detail: isZh ? '市场有新版,但与当前 Runtime 暂不兼容' : 'Newer version in market is not compatible with current runtime'
+ }
+ }
+
+ return {
+ packageName,
+ installedVersion,
+ latestVersion,
+ healthStatus: 'up-to-date',
+ healthLabel: isZh ? '已是最新版' : 'Up to date',
+ upgradeReady: false
+ }
+}
+
+/**
+ * Run health checkup on all installed plugins in parallel.
+ */
+export async function checkupAllProfilePlugins(options: {
+ plugins: string[]
+ dshHome: string
+ bundledNodeModulesPath: string
+ incompatiblePlugins?: string[]
+ registry?: string
+ timeoutMs?: number
+ fetchFn?: typeof fetch
+ locale?: 'zh' | 'en'
+}): Promise {
+ const currentRuntimeVersion = (await readBundledDshVersion(options.bundledNodeModulesPath)) || '0.1.2-alpha.1'
+ const incompatibleSet = new Set(options.incompatiblePlugins ?? [])
+
+ const reports = await Promise.all(
+ options.plugins.map(async (plugin) => {
+ const installedVersion = await readInstalledPluginVersion(options.dshHome, plugin)
+ return evaluatePluginMarketCompatibility({
+ packageName: plugin,
+ installedVersion,
+ currentRuntimeVersion,
+ hasLocalIssue: incompatibleSet.has(plugin),
+ registry: options.registry,
+ timeoutMs: options.timeoutMs,
+ fetchFn: options.fetchFn,
+ locale: options.locale
+ })
+ })
+ )
+
+ return reports
+}
diff --git a/src/main/state/plugin-recovery.ts b/src/main/state/plugin-recovery.ts
index 3fd7ccb2..d1ff663c 100644
--- a/src/main/state/plugin-recovery.ts
+++ b/src/main/state/plugin-recovery.ts
@@ -1,6 +1,6 @@
import { existsSync } from 'node:fs'
-import { lstat, readFile, readdir, rm, writeFile } from 'node:fs/promises'
-import { dirname, join, resolve } from 'node:path'
+import { lstat, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
+import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { parse } from 'yaml'
import { removeTree } from './remove-tree'
import { bundleEntryIds, prunePatchLayer } from './patch-layer'
@@ -91,18 +91,75 @@ function configuredProfilePlugins(manifest: ProfileManifest): string[] {
return plugins
}
+/**
+ * A generation uninstall can be interrupted after its dependency was removed
+ * but before projection removes its bundle and node_modules link. Only expose
+ * that residue to Safe Mode when the link resolves to the generation registry
+ * and its metadata names the same plugin. A bundle entry by itself is not
+ * enough: it may be a user-maintained profile customization.
+ */
+export async function isStaleGenerationBundle(
+ dshHome: string,
+ packageName: string
+): Promise {
+ const packagePath = join(dshHome, 'profiles', 'web', 'node_modules', packageName)
+ const generationsDirectory = resolve(dshHome, 'profiles', '.generations', 'live')
+
+ try {
+ if (!(await lstat(packagePath)).isSymbolicLink()) return false
+ const resolvedPackagePath = await realpath(packagePath)
+ const insideGenerations = relative(generationsDirectory, resolvedPackagePath)
+ if (
+ insideGenerations === '' ||
+ insideGenerations === '..' ||
+ insideGenerations.startsWith(`..${sep}`) ||
+ isAbsolute(insideGenerations)
+ ) return false
+
+ const segments = insideGenerations.split(sep)
+ const packageSegments = packageName.split('/')
+ if (
+ segments.length !== packageSegments.length + 2 ||
+ segments[1] !== 'node_modules' ||
+ !packageSegments.every((segment, index) => segments[index + 2] === segment)
+ ) return false
+
+ const generation = JSON.parse(
+ await readFile(join(generationsDirectory, segments[0]!, 'generation.json'), 'utf8')
+ ) as { pluginName?: unknown }
+ return generation.pluginName === packageName
+ } catch {
+ return false
+ }
+}
+
/**
* User-installed bundle packages that Safe Mode can manage without starting
- * Harness. Reading both dependencies and bundles avoids presenting transitive
- * packages as plugins, or offering to remove a package that is not active in
- * the profile.
+ * Harness. Normally a package must occur in both dependencies and bundles to
+ * avoid exposing transitive packages. The exception is a proven generation
+ * residue: its dependency is gone, while an active profile link still points
+ * into the generation registry. Including it lets Safe Mode finish an
+ * interrupted uninstall without treating arbitrary bundle entries as plugins.
*/
export async function listInstalledProfilePlugins(dshHome: string): Promise {
try {
const manifest = JSON.parse(
await readFile(profilePackageJsonPath(dshHome), 'utf8')
) as ProfileManifest
- const plugins = configuredProfilePlugins(manifest)
+ const dependencies = manifest.dependencies ?? {}
+ const configuredPlugins = configuredProfilePlugins(manifest)
+ const residualCandidates = (manifest.dsh?.profile?.bundles ?? []).filter(
+ (bundle) => isThirdPartyPackageName(bundle) && !(bundle in dependencies)
+ )
+ const residualPlugins = await Promise.all(
+ residualCandidates.map(async (bundle) =>
+ await isStaleGenerationBundle(dshHome, bundle) ? bundle : undefined
+ )
+ )
+ const plugins = [...new Set([
+ ...configuredPlugins,
+ ...residualPlugins.filter((plugin): plugin is string => plugin !== undefined)
+ ])]
const modulesDirectory = join(dshHome, 'profiles', 'web', 'node_modules')
const entries = await Promise.all(
plugins.map(async (name, index) => {
diff --git a/src/main/state/plugin-upgrade.ts b/src/main/state/plugin-upgrade.ts
new file mode 100644
index 00000000..9ffe9bfd
--- /dev/null
+++ b/src/main/state/plugin-upgrade.ts
@@ -0,0 +1,70 @@
+import {
+ installGeneration,
+ type GenerationInstallResult
+} from 'dsh-desktop-market-installer/generations/installer'
+import { projectGenerations } from 'dsh-desktop-market-installer/generations/projection'
+import {
+ listGenerations,
+ readDesired,
+ withRegistryLock,
+ writeDesired
+} from 'dsh-desktop-market-installer/generations/registry'
+
+export interface PluginUpgradeOptions {
+ dshHome: string
+ pluginName: string
+ targetVersion: string
+ nodeExecutablePath: string
+ pnpmEntryPath: string
+ note?: (line: string) => void
+}
+
+export interface PluginUpgradeResult {
+ ok: boolean
+ detail?: string
+}
+
+/**
+ * Install the target version of a plugin as an immutable generation and project
+ * it into the web profile, replacing any older generation of that plugin.
+ */
+export async function upgradePluginToGeneration(
+ options: PluginUpgradeOptions
+): Promise {
+ const { dshHome, pluginName, targetVersion, nodeExecutablePath, pnpmEntryPath, note } = options
+ const spec = `${pluginName}@${targetVersion}`
+
+ return withRegistryLock(dshHome, async () => {
+ note?.(`[plugin-upgrade] installing ${spec} as a generation…`)
+
+ const install: GenerationInstallResult = await installGeneration({
+ dshHome,
+ pluginSpec: spec,
+ nodeExecutablePath,
+ pnpmEntryPath,
+ onTrace: (line) => note?.(`[plugin-upgrade] ${line}`)
+ })
+
+ if (!install.ok || !install.generation) {
+ const detail = install.detail ?? 'generation installation failed'
+ note?.(`[plugin-upgrade] failed to install ${spec}: ${detail}`)
+ return { ok: false, detail }
+ }
+
+ const [desired, generations] = await Promise.all([
+ readDesired(dshHome),
+ listGenerations(dshHome)
+ ])
+ const byId = new Map(generations.map((g) => [g.id, g]))
+ const kept = desired.filter((id) => {
+ const g = byId.get(id)
+ return g === undefined || g.pluginName !== install.generation!.pluginName
+ })
+
+ await writeDesired(dshHome, [...kept, install.generation.id])
+ await projectGenerations(dshHome)
+
+ note?.(`[plugin-upgrade] successfully upgraded ${pluginName} to v${targetVersion} (${install.generation.id})`)
+ return { ok: true }
+ })
+}
diff --git a/src/main/state/profile-consistency.ts b/src/main/state/profile-consistency.ts
index 5a1b6d15..8de63779 100644
--- a/src/main/state/profile-consistency.ts
+++ b/src/main/state/profile-consistency.ts
@@ -156,3 +156,50 @@ export async function inspectProfileConsistency(dshHome: string): Promise {
+ const manifestPath = profilePackageJsonPath(dshHome)
+ let manifestText: string
+ let manifest: ProfileManifest & { dsh?: { profile?: { bundles?: string[] }; [key: string]: unknown }; [key: string]: unknown }
+ try {
+ manifestText = await readFile(manifestPath, 'utf8')
+ manifest = JSON.parse(manifestText)
+ } catch {
+ return []
+ }
+
+ const nodeModulesPath = join(dirname(manifestPath), 'node_modules')
+ const currentBundles = manifest.dsh?.profile?.bundles ?? []
+ const bundleSet = new Set(currentBundles)
+ const dependencies = Object.keys(manifest.dependencies ?? {})
+ const healed: string[] = []
+
+ for (const dependency of dependencies) {
+ if (bundleSet.has(dependency)) continue
+ const { installed, bundle } = await inspectPackage(nodeModulesPath, dependency)
+ if (installed && bundle) {
+ currentBundles.push(dependency)
+ bundleSet.add(dependency)
+ healed.push(dependency)
+ }
+ }
+
+ if (healed.length > 0) {
+ try {
+ if (!manifest.dsh) manifest.dsh = {}
+ if (!manifest.dsh.profile) manifest.dsh.profile = {}
+ manifest.dsh.profile.bundles = currentBundles
+ const { writeFile } = await import('node:fs/promises')
+ await writeFile(manifestPath, `${JSON.stringify(manifest, undefined, 2)}\n`, 'utf8')
+ } catch {
+ // Best-effort auto-healing; never crash startup if write fails
+ }
+ }
+
+ return healed
+}
diff --git a/test/generation-boundary.test.js b/test/generation-boundary.test.js
index 3c7b36f7..847b09e0 100644
--- a/test/generation-boundary.test.js
+++ b/test/generation-boundary.test.js
@@ -144,7 +144,9 @@ describe('the market install boundary', () => {
const manifest = JSON.parse(await readFile(join(home, 'profiles', 'web', 'package.json'), 'utf8'))
expect(manifest.dependencies['demo-plugin']).toBeUndefined()
expect(manifest.pnpm?.overrides?.['demo-plugin']).toBeUndefined()
- expect(manifest.dsh.profile.bundles).toContain('demo-plugin')
+ // Uninstall immediately removes the plugin from the next Harness boot's
+ // composition. Its active link remains intact until the process stops.
+ expect(manifest.dsh.profile.bundles).not.toContain('demo-plugin')
expect(await readlink(link)).toBe(activeTarget)
await projectGenerations(home)
diff --git a/test/plugin-market-check.test.ts b/test/plugin-market-check.test.ts
new file mode 100644
index 00000000..0f86482b
--- /dev/null
+++ b/test/plugin-market-check.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from 'vitest'
+import {
+ compareSemver,
+ inferPluginRuntimeCompatibility,
+ parseSemver,
+ satisfiesComparator,
+ satisfiesRange,
+ evaluatePluginMarketCompatibility,
+ type NpmPackageManifest
+} from '../src/main/state/plugin-market-check'
+
+describe('plugin-market-check', () => {
+ it('parses and compares semver correctly', () => {
+ expect(parseSemver('1.2.3')).toEqual({
+ major: 1,
+ minor: 2,
+ patch: 3,
+ prerelease: []
+ })
+ expect(parseSemver('0.1.2-alpha.4')).toEqual({
+ major: 0,
+ minor: 1,
+ patch: 2,
+ prerelease: ['alpha', 4]
+ })
+ expect(compareSemver('1.0.0', '1.0.1')).toBe(-1)
+ expect(compareSemver('1.2.0', '1.1.9')).toBe(1)
+ expect(compareSemver('0.1.2', '0.1.2-alpha.4')).toBe(1)
+ expect(compareSemver('0.1.2-alpha.1', '0.1.2-alpha.4')).toBe(-1)
+ })
+
+ it('evaluates semver comparators and ranges', () => {
+ expect(satisfiesComparator('0.1.2', '^0.1.0')).toBe(true)
+ expect(satisfiesComparator('0.2.0', '^0.1.0')).toBe(false)
+ expect(satisfiesComparator('1.2.3', '^1.0.0')).toBe(true)
+ expect(satisfiesComparator('2.0.0', '^1.0.0')).toBe(false)
+ expect(satisfiesComparator('0.1.5', '~0.1.2')).toBe(true)
+ expect(satisfiesComparator('0.2.0', '~0.1.2')).toBe(false)
+ expect(satisfiesRange('0.1.2-alpha.4', '^0.1.0 || ^0.1.2-0')).toBe(true)
+ expect(satisfiesRange('0.1.2', '>=0.1.0 <0.2.0')).toBe(true)
+ expect(satisfiesRange('0.2.5', '>=0.1.0 <0.2.0')).toBe(false)
+ })
+
+ it('infers runtime compatibility for manifests', () => {
+ const compatibleManifest: NpmPackageManifest = {
+ name: 'example-plugin',
+ version: '1.2.0',
+ peerDependencies: {
+ '@deepseek-ai/dsh': '^0.1.2-0'
+ }
+ }
+ expect(inferPluginRuntimeCompatibility(compatibleManifest, '0.1.2-rc.1').isCompatible).toBe(true)
+
+ const incompatibleManifest: NpmPackageManifest = {
+ name: 'legacy-plugin',
+ version: '1.0.0',
+ peerDependencies: {
+ '@deepseek-ai/dsh': '^0.1.1'
+ }
+ }
+ expect(inferPluginRuntimeCompatibility(incompatibleManifest, '0.1.2-rc.1').isCompatible).toBe(false)
+
+ const deprecatedDepManifest: NpmPackageManifest = {
+ name: 'deprecated-dep-plugin',
+ version: '1.1.0',
+ dependencies: {
+ '@deepseek-ai/dsh-host-apiproxy': '^0.1.1'
+ }
+ }
+ expect(inferPluginRuntimeCompatibility(deprecatedDepManifest, '0.1.2').isCompatible).toBe(false)
+
+ const subPackagePeerManifest: NpmPackageManifest = {
+ name: 'dsh-better-sidebar',
+ version: '0.17.1',
+ peerDependencies: {
+ '@deepseek-ai/dsh-agent': '^0.1.0-rc.8',
+ '@deepseek-ai/cordis': '^4.0.1'
+ }
+ }
+ expect(inferPluginRuntimeCompatibility(subPackagePeerManifest, '0.1.2-rc.1').isCompatible).toBe(false)
+ expect(inferPluginRuntimeCompatibility(subPackagePeerManifest, '0.1.0-rc.9').isCompatible).toBe(true)
+ })
+
+ it('evaluates plugin market compatibility for upgrade candidates', async () => {
+ const mockManifest: NpmPackageManifest = {
+ name: 'test-plugin',
+ version: '2.0.0',
+ peerDependencies: {
+ '@deepseek-ai/dsh': '^0.1.2'
+ }
+ }
+
+ const mockFetch = async () =>
+ new Response(JSON.stringify(mockManifest), {
+ status: 200,
+ headers: { 'content-type': 'application/json' }
+ })
+
+ const report = await evaluatePluginMarketCompatibility({
+ packageName: 'test-plugin',
+ installedVersion: '1.0.0',
+ currentRuntimeVersion: '0.1.2',
+ hasLocalIssue: true,
+ fetchFn: mockFetch as unknown as typeof fetch,
+ locale: 'zh'
+ })
+
+ expect(report.healthStatus).toBe('incompatible-fixed-in-latest')
+ expect(report.upgradeReady).toBe(true)
+ expect(report.upgradeVersion).toBe('2.0.0')
+ })
+})
diff --git a/test/plugin-recovery-html.test.ts b/test/plugin-recovery-html.test.ts
index ceb3b6a0..972103c0 100644
--- a/test/plugin-recovery-html.test.ts
+++ b/test/plugin-recovery-html.test.ts
@@ -20,4 +20,10 @@ describe('plugin recovery page', () => {
expect(html).toContain('id="show-log"')
expect(html).toContain('id="quit"')
})
+
+ it('renders concise upgrade indicator directly on the plugin item without emojis', () => {
+ expect(html).not.toContain('💡')
+ expect(html).not.toContain('id="upgrade-card"')
+ expect(html).toContain('plugin-upgrade')
+ })
})
diff --git a/test/plugin-recovery-view.test.ts b/test/plugin-recovery-view.test.ts
index 0b5ab77c..32155e56 100644
--- a/test/plugin-recovery-view.test.ts
+++ b/test/plugin-recovery-view.test.ts
@@ -104,4 +104,22 @@ describe('plugin recovery view model', () => {
expect(html).toContain("navigate('show-log')")
expect(html).not.toContain('id="restart"')
})
+
+ it('configures upgrade candidate when a compatible update is available', () => {
+ const model = buildPluginRecoveryViewModel({
+ snapshot: failedSnapshot(),
+ plugins: ['plugin-a'],
+ removedPlugins: [],
+ locale: 'zh',
+ upgradeCandidate: {
+ packageName: 'plugin-a',
+ targetVersion: '2.0.0',
+ installedVersion: '1.0.0'
+ }
+ })
+ expect(model.upgradeCandidate?.targetVersion).toBe('2.0.0')
+ expect(model.upgradeLabel).toBe('升级插件并重启')
+ expect(model.upgradeHint).toBe('该插件有新的兼容版本')
+ expect(model.uninstallLabel).toBe('仍要卸载此插件')
+ })
})
diff --git a/test/plugin-recovery.test.ts b/test/plugin-recovery.test.ts
index 95c7961c..340b8761 100644
--- a/test/plugin-recovery.test.ts
+++ b/test/plugin-recovery.test.ts
@@ -1,5 +1,5 @@
import { existsSync } from 'node:fs'
-import { mkdir, readFile, rm, utimes, writeFile } from 'node:fs/promises'
+import { mkdir, readFile, rm, symlink, utimes, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { parse, stringify } from 'yaml'
@@ -82,6 +82,60 @@ describe('plugin-recovery', () => {
await expect(listInstalledProfilePlugins(join(testDir, 'missing'))).resolves.toEqual([])
})
+ it('lists a generation-linked bundle left behind by an interrupted uninstall', async () => {
+ await writeFile(
+ profilePackageJsonPath(testDir),
+ JSON.stringify({
+ dependencies: { '@deepseek-ai/dsh-base': '0.1.0' },
+ dsh: {
+ profile: {
+ bundles: ['@deepseek-ai/dsh-base', '@example/stale-generation', 'plain-bundle-entry']
+ }
+ }
+ })
+ )
+ const generationDirectory = join(
+ testDir,
+ 'profiles',
+ '.generations',
+ 'live',
+ 'example+stale-generation+1.0.0'
+ )
+ const generationPackage = join(
+ generationDirectory,
+ 'node_modules',
+ '@example',
+ 'stale-generation'
+ )
+ await mkdir(generationPackage, { recursive: true })
+ await writeFile(
+ join(generationDirectory, 'generation.json'),
+ JSON.stringify({ pluginName: '@example/stale-generation' })
+ )
+ await writeFile(
+ join(generationPackage, 'package.json'),
+ JSON.stringify({ name: '@example/stale-generation' })
+ )
+ const profilePackage = join(
+ testDir,
+ 'profiles',
+ 'web',
+ 'node_modules',
+ '@example',
+ 'stale-generation'
+ )
+ await mkdir(join(profilePackage, '..'), { recursive: true })
+ await symlink(
+ generationPackage,
+ profilePackage,
+ process.platform === 'win32' ? 'junction' : 'dir'
+ )
+
+ await expect(listInstalledProfilePlugins(testDir)).resolves.toEqual([
+ '@example/stale-generation'
+ ])
+ })
+
it('lists the most recently installed profile plugin first', async () => {
await writeFile(
profilePackageJsonPath(testDir),
diff --git a/test/profile-consistency.test.ts b/test/profile-consistency.test.ts
index a97493ea..6fc12a78 100644
--- a/test/profile-consistency.test.ts
+++ b/test/profile-consistency.test.ts
@@ -1,8 +1,8 @@
-import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
-import { inspectProfileConsistency } from '../src/main/state/profile-consistency'
+import { healProfileBundles, inspectProfileConsistency } from '../src/main/state/profile-consistency'
describe('profile consistency', () => {
const homes: string[] = []
@@ -110,4 +110,33 @@ describe('profile consistency', () => {
homes.push(home)
await expect(inspectProfileConsistency(home)).resolves.toEqual([])
})
+
+ it('auto-heals uncomposed bundles into manifest dsh.profile.bundles', async () => {
+ const { home, modules } = await profileHome({
+ dependencies: { 'dsh-better-sidebar': '^1.0.0', 'dsh-dream-skin': '^1.0.0' },
+ dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } }
+ })
+ await install(modules, 'dsh-better-sidebar', true)
+ await install(modules, 'dsh-dream-skin', true)
+
+ // Before heal, consistency reports they are installed but not composed
+ const beforeFindings = await inspectProfileConsistency(home)
+ expect(beforeFindings).toContain('dsh-better-sidebar is installed and declares a bundle, but is not composed')
+ expect(beforeFindings).toContain('dsh-dream-skin is installed and declares a bundle, but is not composed')
+
+ // Heal
+ const healed = await healProfileBundles(home)
+ expect(healed).toEqual(['dsh-better-sidebar', 'dsh-dream-skin'])
+
+ // After heal, consistency reports clean
+ await expect(inspectProfileConsistency(home)).resolves.toEqual([])
+
+ // Check package.json
+ const manifest = JSON.parse(await readFile(join(home, 'profiles', 'web', 'package.json'), 'utf8'))
+ expect(manifest.dsh.profile.bundles).toEqual([
+ '@deepseek-ai/dsh-base',
+ 'dsh-better-sidebar',
+ 'dsh-dream-skin'
+ ])
+ })
})
diff --git a/test/safe-mode.test.ts b/test/safe-mode.test.ts
index 4f5f8780..9fd9ab2d 100644
--- a/test/safe-mode.test.ts
+++ b/test/safe-mode.test.ts
@@ -349,4 +349,39 @@ describe('Safe Mode', () => {
await rm(dshHome, { recursive: true, force: true })
}
})
+
+ it('enriches plugin items with health reports and upgrade candidates', () => {
+ const model = buildSafeModeViewModel({
+ locale: 'zh',
+ plugins: ['plugin-a', 'plugin-b'],
+ healthReports: [
+ {
+ packageName: 'plugin-a',
+ installedVersion: '1.0.0',
+ latestVersion: '2.0.0',
+ healthStatus: 'incompatible-fixed-in-latest',
+ healthLabel: '不兼容(最新版 v2.0.0 已适配)',
+ upgradeReady: true,
+ upgradeVersion: '2.0.0'
+ },
+ {
+ packageName: 'plugin-b',
+ installedVersion: '1.2.0',
+ latestVersion: '1.2.0',
+ healthStatus: 'up-to-date',
+ healthLabel: '已是最新版',
+ upgradeReady: false
+ }
+ ]
+ })
+ expect(model.upgradeReadyCount).toBe(1)
+ expect(model.upgradeAllLabel).toBe('一键升级 1 个已适配插件')
+ const itemA = model.pluginItems.find((p) => p.name === 'plugin-a')
+ expect(itemA?.upgradeReady).toBe(true)
+ expect(itemA?.upgradeVersion).toBe('2.0.0')
+ expect(itemA?.upgradeButtonLabel).toBe('升级至 v2.0.0')
+ const itemB = model.pluginItems.find((p) => p.name === 'plugin-b')
+ expect(itemB?.upgradeReady).toBe(false)
+ expect(itemB?.upgradeButtonLabel).toBeUndefined()
+ })
})