From b139721cf7260201481bb4569465b790d436d565 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Tue, 1 Sep 2026 00:41:53 -0700 Subject: [PATCH 1/3] fix(plugins): expose generation versions and mark suspects --- build/safe-mode.html | 4 +- .../generations/projection.mjs | 131 +++++++++++++----- .../dsh-desktop-market-installer/index.js | 52 ++++++- src/main/index.ts | 8 ++ src/main/safe-mode.ts | 28 +++- src/main/state/generation-migration.ts | 7 +- test/generation-boundary.test.js | 25 +++- test/generation-migration.test.ts | 7 +- test/generation-projection.test.ts | 67 ++++++++- test/safe-mode.test.ts | 29 +++- 10 files changed, 301 insertions(+), 57 deletions(-) diff --git a/build/safe-mode.html b/build/safe-mode.html index efceada3..fe8d6e83 100644 --- a/build/safe-mode.html +++ b/build/safe-mode.html @@ -14,7 +14,7 @@ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --canvas: #f7f7f8; --surface: #fff; --soft: #f5f6f7; --ink: #18181b; --muted: #71717a; --line: #e4e4e7; --strong: #d4d4d8; - --button: #18181b; --button-ink: #fff; --danger: #c33b38; --success: #267a4a; + --button: #18181b; --button-ink: #fff; --danger: #c33b38; --warning: #a45b00; --success: #267a4a; } * { box-sizing: border-box; } [hidden] { display: none !important; } @@ -48,6 +48,7 @@ .plugin-title { display:flex; flex-wrap:wrap; align-items:baseline; gap:4px; } .plugin-name { overflow-wrap: anywhere; font-family: ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size: 13px; font-weight: 600; } .plugin-status { color:var(--danger); font-size:11px; font-weight:650; } + .plugin-status.warning { color:var(--warning); } .plugin-action { margin-top:4px; font-size:11px; font-weight:620; } .issue-group { border-top:1px solid var(--line); background:var(--surface); } .issue-group:first-child { border-top:0; } @@ -201,6 +202,7 @@

title.appendChild(name) 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') title.appendChild(status) } const action = document.createElement('span'); action.className = 'plugin-action'; action.textContent = String(plugin.actionLabel || '') diff --git a/packages/dsh-desktop-market-installer/generations/projection.mjs b/packages/dsh-desktop-market-installer/generations/projection.mjs index 91460f11..5a2b962d 100644 --- a/packages/dsh-desktop-market-installer/generations/projection.mjs +++ b/packages/dsh-desktop-market-installer/generations/projection.mjs @@ -23,12 +23,11 @@ import { resolveEnabledGenerations } from './registry.mjs' * consistency check, recovery, and inventory — all of which read this * contract — agree with what is actually linked. * - * Generation plugins go in `bundles` but never in `dependencies`. `bundles` - * is what `resolveBundleDir` reads, and it resolves through the symlink this - * projector writes. `dependencies` is what `pnpm install` acts on — listing a - * generation there makes the shared-tree repair try to install it into - * `node_modules` over the symlink, which is the exact Windows rename-over- - * existing that the generation model exists to avoid. + * Generation plugins appear in `dependencies` at their installed version so + * dsh-market can present them as ordinary installed releases. pnpm is kept on + * the immutable local generation by a root override under `pnpm.overrides`; + * the internal `link:` path therefore never leaks into the market-facing + * dependency map. * * The projection is derived, never authored. Losing it costs a reprojection, * not a repair. @@ -40,6 +39,9 @@ const IN_BOX_BUNDLES = new Set(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-a /** Substring that marks a symlink target as one this projector wrote. */ const GENERATION_LINK_MARKER = join('profiles', '.generations', 'live') +/** Versioned marker for the manifest fields owned by this derived projection. */ +const PROJECTION_VERSION = 1 + function profileDir(dshHome, profile = 'web') { return join(dshHome, 'profiles', profile) } @@ -233,20 +235,20 @@ export async function projectGenerations(dshHome, profile = 'web') { const linked = [] const linkSpecs = new Map() + const projected = new Map() for (const [pluginName, generation] of enabled) { const target = targets.get(pluginName) if (target === undefined) throw new Error(`Enabled generation target was not prevalidated: ${pluginName}`) await ensureDirLink(join(modulesDir, pluginName), target) linked.push(pluginName) - // A `link:` spec is what makes the market's readInstalled() (which reads - // `dependencies`) see the plugin, while telling `pnpm install` the target - // is already a local directory to symlink — never something to fetch or - // rename over. + projected.set(pluginName, generation) + // This path belongs only to pnpm's root override. The market reads the + // ordinary dependency version written below and never sees it. linkSpecs.set(pluginName, `link:${relative(dir, target).split('\\').join('/')}`) } - const unlinked = await pruneStaleGenerationLinks(modulesDir, enabled) - const bundles = await syncProfileManifest(dir, enabled, linkSpecs, manifestState) + const unlinked = await pruneStaleGenerationLinks(modulesDir, projected) + const bundles = await syncProfileManifest(dir, projected, linkSpecs, manifestState) return { linked, unlinked, bundles } } @@ -288,30 +290,64 @@ async function pruneStaleGenerationLinks(modulesDir, enabled) { } /** - * Rewrite `dsh.profile.bundles` and `dependencies` so both the app-boot - * contract and the market's `readInstalled()` agree with the projection. - * In-box bundles keep their place at the front; the enabled generations - * follow, and each is also a `link:` dependency pointing at its generation. + * Rewrite the app-boot bundle list, the market-facing dependency versions, + * and pnpm's private generation overrides. A small marker records the fields + * Desktop owns so a disabled generation can be removed after a crash without + * guessing whether an ordinary version dependency belongs to the user. */ async function syncProfileManifest(dir, enabled, linkSpecs, manifestState) { const manifestPath = join(dir, 'package.json') const { current, manifest } = manifestState - // Real pnpm dependencies the profile already had (dshmarket, anything from - // the old shared-tree path) carry through unchanged. Each enabled generation - // becomes a `link:` dependency: the market sees it as installed, and - // `pnpm install` treats a `link:` target as an existing local directory to - // symlink rather than something to fetch or rename a directory over. + const previousProjection = manifest.dsh?.desktop?.generationProjection + const previousPlugins = previousProjection?.version === PROJECTION_VERSION && + typeof previousProjection.plugins === 'object' && previousProjection.plugins !== null + ? previousProjection.plugins + : {} + + // Real profile dependencies carry through unchanged. Previous generation + // entries are removed using the explicit ownership marker, then enabled + // generations are written back at their actual installed versions. const currentDeps = manifest.dependencies ?? {} - const dependencies = {} - for (const [name, spec] of Object.entries(currentDeps)) { - // Drop a generation `link:` dep whose plugin is no longer enabled; the - // enabled ones are re-added from linkSpecs below. - if (typeof spec === 'string' && spec.includes('.generations/live/')) continue - if (!enabled.has(name)) dependencies[name] = spec + const dependencies = { ...currentDeps } + for (const name of Object.keys(previousPlugins)) { + delete dependencies[name] + } + // Migrate the pre-version-projection shape even when it predates the marker. + for (const [name, spec] of Object.entries(dependencies)) { + if (typeof spec === 'string' && spec.includes('.generations/live/')) delete dependencies[name] } - for (const [name, spec] of linkSpecs) { - dependencies[name] = spec + + const currentOverrides = manifest.pnpm?.overrides ?? {} + const overrides = { ...currentOverrides } + for (const [name, state] of Object.entries(previousPlugins)) { + if (state?.previousOverride?.present && typeof state.previousOverride.value === 'string') { + overrides[name] = state.previousOverride.value + } else { + delete overrides[name] + } + } + // Clean up an old managed override even if the marker was lost. + for (const [name, spec] of Object.entries(overrides)) { + if (typeof spec === 'string' && spec.includes('.generations/live/')) delete overrides[name] + } + + const projectedPlugins = {} + for (const [name, generation] of enabled) { + const previous = previousPlugins[name] + const currentOverride = currentOverrides[name] + const previousOverride = previous?.previousOverride ?? ( + typeof currentOverride === 'string' && !currentOverride.includes('.generations/live/') + ? { present: true, value: currentOverride } + : { present: false } + ) + dependencies[name] = generation.version + overrides[name] = linkSpecs.get(name) + projectedPlugins[name] = { + generationId: generation.id, + visibleVersion: generation.version, + previousOverride + } } // Bundle entries that survive: in-box bundles, plus any kept dependency that @@ -323,23 +359,44 @@ async function syncProfileManifest(dir, enabled, linkSpecs, manifestState) { IN_BOX_BUNDLES.has(name) ) for (const name of Object.keys(dependencies)) { - if (declaredBundles.includes(name) || linkSpecs.has(name)) continue + if (declaredBundles.includes(name) || enabled.has(name)) continue if (await declaresBundle(join(dir, 'node_modules', name))) declaredBundles.push(name) } const pluginNames = [...enabled.keys()].sort() const bundles = [...declaredBundles, ...pluginNames] + const desktop = { + ...(manifest.dsh?.desktop ?? {}) + } + if (Object.keys(projectedPlugins).length > 0) { + desktop.generationProjection = { + version: PROJECTION_VERSION, + plugins: projectedPlugins + } + } else { + delete desktop.generationProjection + } + const pnpm = { + ...(manifest.pnpm ?? {}) + } + if (Object.keys(overrides).length > 0) pnpm.overrides = overrides + else delete pnpm.overrides + const dsh = { + ...manifest.dsh, + profile: { + ...(manifest.dsh?.profile ?? {}), + bundles + } + } + if (Object.keys(desktop).length > 0) dsh.desktop = desktop + else delete dsh.desktop const next = { ...manifest, dependencies, - dsh: { - ...manifest.dsh, - profile: { - ...(manifest.dsh?.profile ?? {}), - bundles - } - } + dsh } + if (Object.keys(pnpm).length > 0) next.pnpm = pnpm + else delete next.pnpm const body = `${JSON.stringify(next, undefined, 2)}\n` // Only touch the file when it actually changes. The projection runs every diff --git a/packages/dsh-desktop-market-installer/index.js b/packages/dsh-desktop-market-installer/index.js index 854c5676..7db2fc3e 100644 --- a/packages/dsh-desktop-market-installer/index.js +++ b/packages/dsh-desktop-market-installer/index.js @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process' -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { chmod, copyFile, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { homedir } from 'node:os' @@ -11,6 +11,7 @@ import { PassThrough } from 'node:stream' import { installGeneration } from './generations/installer.mjs' import { projectGenerations } from './generations/projection.mjs' import { + disableGeneration, listGenerations, readDesired, withRegistryLock, @@ -376,6 +377,34 @@ function validatePluginOperation(args, invokingDir) { } } +function removalTarget(args) { + if (args[0] !== 'remove') return undefined + return args.slice(1).find((argument) => !argument.startsWith('-')) +} + +/** + * dsh-market routes ordinary removals through `runPlugin`. Generation plugins + * must instead update desired.json, otherwise the next projection resurrects + * the dependency the CLI just removed. The marker is written by the projector + * into an otherwise market-transparent manifest field. + */ +export function projectedGenerationRemoval(args, home = dshHome()) { + const target = removalTarget(args) + if (target === undefined) return undefined + try { + const manifest = JSON.parse(readFileSync(join(profileDirectory(home), 'package.json'), 'utf8')) + const plugins = manifest.dsh?.desktop?.generationProjection?.plugins + if (typeof plugins === 'object' && plugins !== null && Object.hasOwn(plugins, target)) { + return target + } + // Compatibility with profiles projected by an earlier Desktop build. + const spec = manifest.dependencies?.[target] + return typeof spec === 'string' && spec.includes('.generations/live/') ? target : undefined + } catch { + return undefined + } +} + export function createDesktopPnpmService(options) { const { binDirectory, @@ -483,6 +512,27 @@ export function createDesktopPnpmService(options) { if (signal?.aborted) throw signal.reason ?? new Error('The package operation was aborted.') if (active) throw new Error('Another desktop pnpm operation is already running.') + const generationRemoval = projectedGenerationRemoval(args, home) + if (generationRemoval !== undefined) { + const handle = asHandle(async ({ write, isCancelled }) => + withRegistryLock(home, async () => { + if (isCancelled()) return { exitCode: 1, message: 'The package operation was aborted.' } + write(`Disabling ${generationRemoval} generation…`) + await disableGeneration(home, generationRemoval) + const projection = await projectGenerations(home) + write(`enabled: ${projection.linked.join(', ')}`) + return { exitCode: 0 } + }) + ) + active = handle + signal?.addEventListener('abort', handle.cancel, { once: true }) + void handle.done.finally(() => { + signal?.removeEventListener('abort', handle.cancel) + if (active === handle) active = undefined + }) + return handle + } + void cleanStaleTemporaryDirectories(home).catch(() => undefined) const child = spawnProcess( diff --git a/src/main/index.ts b/src/main/index.ts index 0a07ba57..3ff01c4a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -174,6 +174,7 @@ let profileBootConfirmationTimer: NodeJS.Timeout | undefined let profileRendererHealthAt = 0 let profileBootNavigationVersion = 0 let profileBootConfirmationComplete = false +let safeModeSuspectedPlugins: string[] = [] // A renderer that crashes (render-process-gone) and reloads that fails the // same way produces a permanent black window the user has to close by hand. // The cooldown keeps reloads from stacking up when the underlying crash @@ -1187,6 +1188,7 @@ function launchHarness(): Promise { } } } + if (runtime.snapshot().phase === 'ready') safeModeSuspectedPlugins = [] })().finally(() => { harnessLaunchOperation = undefined }) @@ -1623,6 +1625,7 @@ async function showPluginRecovery(options?: { shell.showItemInFolder(join(app.getPath('logs'), 'harness.log')) continue } else if (action === 'safe-mode') { + safeModeSuspectedPlugins = [...new Set(detection.plugins)] takePendingFrontendPluginRecovery() queueMicrotask(() => void showSafeMode().catch(showUnexpectedError)) return @@ -1654,6 +1657,7 @@ async function showRuntimeFailure(snapshot: RuntimeSnapshot): Promise { async function waitForSafeModeAction(options: { plugins: readonly string[] + suspectedPlugins: readonly string[] issues: readonly ProfileCompatibilityIssue[] backups: Awaited>['backups'] recoveryLocked: boolean @@ -1704,6 +1708,7 @@ async function waitForSafeModeAction(options: { const model = buildSafeModeViewModel({ locale: harnessLocale(), plugins: options.plugins, + suspectedPlugins: options.suspectedPlugins, issues: options.issues, backups: options.backups, recoveryLocked: options.recoveryLocked, @@ -1943,6 +1948,7 @@ async function showSafeModeManager(initial?: { const backupRestoreLocked = recoveryLocked || !removalLedgerReadable const action = await waitForSafeModeAction({ plugins: installed, + suspectedPlugins: safeModeSuspectedPlugins, issues: compatibility.issues, backups: removalBackups.backups, recoveryLocked, @@ -2134,6 +2140,8 @@ async function showSafeModeManager(initial?: { if (!removal.disabled) failedPlugins.push(plugin) else if (removal.pending) pendingPlugins.push(plugin) } + const disabledPlugins = new Set(selectedPlugins.filter((plugin) => !failedPlugins.includes(plugin))) + safeModeSuspectedPlugins = safeModeSuspectedPlugins.filter((plugin) => !disabledPlugins.has(plugin)) const failed = repairFailures + failedPlugins.length notice = pendingPlugins.length > 0 ? isChinese diff --git a/src/main/safe-mode.ts b/src/main/safe-mode.ts index b50163ff..52bc11ad 100644 --- a/src/main/safe-mode.ts +++ b/src/main/safe-mode.ts @@ -24,8 +24,10 @@ export interface SafeModeIssueGroupViewModel { export interface SafeModePluginViewModel { name: string statusLabel?: string + statusTone?: 'warning' | 'danger' actionLabel: string incompatible: boolean + suspected: boolean } export interface SafeModeBackupViewModel { @@ -80,6 +82,7 @@ export function shouldStartInSafeMode(argv: readonly string[]): boolean { export function buildSafeModeViewModel(options: { locale: SafeModeLocale plugins: readonly string[] + suspectedPlugins?: readonly string[] issues?: readonly ProfileCompatibilityIssue[] backups?: readonly { removalId: string @@ -141,19 +144,34 @@ export function buildSafeModeViewModel(options: { }) const pluginIssues = issues.filter((issue) => issue.resolution === 'disable-plugin') const incompatiblePlugins = new Set(pluginIssues.map((issue) => issue.target)) + const suspectedPlugins = new Set(options.suspectedPlugins ?? []) const plugins = [...new Set([ ...options.plugins, - ...incompatiblePlugins - ])] + ...incompatiblePlugins, + ...suspectedPlugins + ])].sort((left, right) => Number(suspectedPlugins.has(right)) - Number(suspectedPlugins.has(left))) const pluginItems = plugins.map((name): SafeModePluginViewModel => { const incompatible = incompatiblePlugins.has(name) + const suspected = suspectedPlugins.has(name) + const labels = [ + ...(suspected + ? [options.locale === 'zh' ? '本次启动日志推断' : 'inferred from this startup log'] + : []), + ...(incompatible + ? [options.locale === 'zh' ? '版本不兼容' : 'version incompatible'] + : []) + ] return { name, - statusLabel: incompatible - ? options.locale === 'zh' ? '(版本不兼容)' : '(version incompatible)' + statusLabel: labels.length > 0 + ? options.locale === 'zh' + ? `(${labels.join(',')})` + : `(${labels.join(', ')})` : undefined, + statusTone: incompatible ? 'danger' : suspected ? 'warning' : undefined, actionLabel: options.locale === 'zh' ? '卸载插件' : 'Remove plugin', - incompatible + incompatible, + suspected } }) const groups = new Map() diff --git a/src/main/state/generation-migration.ts b/src/main/state/generation-migration.ts index 59cdf277..2abdb6c0 100644 --- a/src/main/state/generation-migration.ts +++ b/src/main/state/generation-migration.ts @@ -678,9 +678,10 @@ export async function migrateProfileToGenerations(deps: MigrationDeps): Promise< await snapshotProfile(dshHome, note, previousDesired, plan.fingerprint) // Trim the manifest to the shared-tree packages and drop the lockfile, then - // let projection add the generations back as `link:` deps + bundles and - // write the symlinks — all before the rebuild, so `pnpm install` sees the - // final manifest and its `.install-complete` fingerprint matches. + // let projection add the generations back as visible version deps, private + // pnpm overrides, bundles, and symlinks — all before the rebuild, so + // `pnpm install` sees the final manifest and its `.install-complete` + // fingerprint matches. await rewriteManifest(dshHome) const existingDesired = await readDesired(dshHome) await writeDesired(dshHome, [...new Set([...existingDesired, ...generationIds])]) diff --git a/test/generation-boundary.test.js b/test/generation-boundary.test.js index 007cfc97..f098776b 100644 --- a/test/generation-boundary.test.js +++ b/test/generation-boundary.test.js @@ -105,7 +105,30 @@ describe('the market install boundary', () => { const manifest = JSON.parse(await readFile(join(home, 'profiles', 'web', 'package.json'), 'utf8')) expect(manifest.dsh.profile.bundles).toContain('demo-plugin') - expect(manifest.dependencies['demo-plugin']).toMatch(/^link:/u) // generation is a link: dep + expect(manifest.dependencies['demo-plugin']).toBe('9.9.9') + expect(manifest.pnpm.overrides['demo-plugin']).toMatch(/^link:/u) + }) + + it('routes a market removal through desired.json instead of the shared profile CLI', async () => { + const home = await freshHome() + const svc = service(home, stubGenerationInstall('demo-plugin', '9.9.9')) + await drainHandle( + svc.runExternalMarketPluginInstall( + ['add', 'demo-plugin@9.9.9'], + join(home, 'profiles', 'web') + ) + ) + + const result = await drainHandle( + svc.runPlugin(['remove', '--workspace-root', 'demo-plugin'], join(home, 'profiles', 'web')) + ) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Disabling demo-plugin generation') + expect(await readDesired(home)).toEqual([]) + 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() }) it('replaces an earlier generation of the same plugin', async () => { diff --git a/test/generation-migration.test.ts b/test/generation-migration.test.ts index 703dadd2..7983e094 100644 --- a/test/generation-migration.test.ts +++ b/test/generation-migration.test.ts @@ -134,8 +134,11 @@ describe('one-time profile migration to generations', () => { const manifest = JSON.parse( await readFile(join(home, 'profiles', 'web', 'package.json'), 'utf8') ) - // dependencies keep only the shared-tree package - expect(manifest.dependencies.dshmarket).toBe('^1.35.0'); expect(manifest.dependencies['dsh-vision-router']).toMatch(/^link:/u) + // generation dependencies expose installed versions while pnpm resolves + // them through private local overrides. + expect(manifest.dependencies.dshmarket).toBe('^1.35.0') + expect(manifest.dependencies['dsh-vision-router']).toBe('2.0.1') + expect(manifest.pnpm.overrides['dsh-vision-router']).toMatch(/^link:/u) // bundles carry the in-box set plus the migrated plugin names expect(manifest.dsh.profile.bundles).toEqual([ '@deepseek-ai/dsh-base', diff --git a/test/generation-projection.test.ts b/test/generation-projection.test.ts index c418a724..3655a59b 100644 --- a/test/generation-projection.test.ts +++ b/test/generation-projection.test.ts @@ -1,7 +1,10 @@ import { existsSync } from 'node:fs' +import { execFile } from 'node:child_process' import { lstat, mkdir, mkdtemp, readFile, readlink, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' +import { promisify } from 'node:util' import { afterEach, describe, expect, it, vi } from 'vitest' import { projectGenerations } from '../packages/dsh-desktop-market-installer/generations/projection' import { @@ -36,6 +39,8 @@ vi.mock('node:fs/promises', async (importOriginal) => { }) describe('generation projection onto the app-boot contract', () => { + const execFileAsync = promisify(execFile) + const pnpmEntry = join(dirname(createRequire(import.meta.url).resolve('pnpm')), 'bin', 'pnpm.cjs') const homes: string[] = [] async function freshHome(): Promise { @@ -116,7 +121,12 @@ describe('generation projection onto the app-boot contract', () => { '@linxin666/dsh-pet', 'dsh-better-sidebar' ]) - expect(manifest.dependencies['dsh-better-sidebar']).toMatch(/^link:/u) // generations are link: deps + expect(manifest.dependencies['dsh-better-sidebar']).toBe('0.17.1') + expect(manifest.pnpm.overrides['dsh-better-sidebar']).toMatch(/^link:/u) + expect(manifest.dsh.desktop.generationProjection.plugins['dsh-better-sidebar']).toMatchObject({ + generationId: 'sidebar+0.17.1+aaaa', + visibleVersion: '0.17.1' + }) expect(manifest.dependencies.dshmarket).toBe('^1.35.0') }) @@ -140,7 +150,58 @@ describe('generation projection onto the app-boot contract', () => { await readFile(join(home, 'profiles', 'web', 'package.json'), 'utf8') ) expect(manifest.dsh.profile.bundles).not.toContain('plugin-b') - expect(manifest.dependencies['plugin-b']).toBeUndefined() // unlinked plugin dropped from deps + expect(manifest.dependencies['plugin-b']).toBeUndefined() + expect(manifest.pnpm?.overrides?.['plugin-b']).toBeUndefined() + expect(manifest.dsh.desktop?.generationProjection?.plugins?.['plugin-b']).toBeUndefined() + }) + + it('restores a profile-owned pnpm override after the generation is disabled', async () => { + const home = await freshHome() + await ensureRegistryDirectories(home) + await initProfile(home) + const manifestPath = join(home, 'profiles', 'web', 'package.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + manifest.pnpm = { overrides: { 'plugin-a': '1.0.1', unrelated: '3.0.0' } } + await writeFile(manifestPath, JSON.stringify(manifest)) + await fakeGeneration(home, 'a+1+x', 'plugin-a', '1.0.0') + + await writeDesired(home, ['a+1+x']) + await projectGenerations(home) + let projected = JSON.parse(await readFile(manifestPath, 'utf8')) + expect(projected.pnpm.overrides['plugin-a']).toMatch(/^link:/u) + expect(projected.pnpm.overrides.unrelated).toBe('3.0.0') + + await writeDesired(home, []) + await projectGenerations(home) + projected = JSON.parse(await readFile(manifestPath, 'utf8')) + expect(projected.pnpm.overrides['plugin-a']).toBe('1.0.1') + expect(projected.pnpm.overrides.unrelated).toBe('3.0.0') + }) + + it('keeps pnpm installs on the local generation while the manifest exposes a version', async () => { + const home = await freshHome() + await ensureRegistryDirectories(home) + const dir = join(home, 'profiles', 'web') + await mkdir(dir, { recursive: true }) + await writeFile( + join(dir, 'package.json'), + JSON.stringify({ name: 'dsh-profile-web', private: true, dependencies: {}, dsh: { profile: { bundles: [] } } }) + ) + await fakeGeneration(home, 'a+1+x', 'plugin-a', '1.0.0') + await writeDesired(home, ['a+1+x']) + await projectGenerations(home) + + await execFileAsync( + process.execPath, + [pnpmEntry, 'install', '--ignore-scripts', '--no-frozen-lockfile', '--offline'], + { cwd: dir } + ) + + const manifest = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) + expect(manifest.dependencies['plugin-a']).toBe('1.0.0') + const installed = JSON.parse(await readFile(join(dir, 'node_modules', 'plugin-a', 'package.json'), 'utf8')) + expect(installed.version).toBe('1.0.0') + expect(await readFile(join(dir, 'pnpm-lock.yaml'), 'utf8')).toContain('link:../.generations/live/a+1+x') }) it('never touches a real pnpm-managed directory in node_modules', async () => { diff --git a/test/safe-mode.test.ts b/test/safe-mode.test.ts index 3677d536..f6567fed 100644 --- a/test/safe-mode.test.ts +++ b/test/safe-mode.test.ts @@ -26,8 +26,8 @@ describe('Safe Mode', () => { expect(model.summary).toContain('但不会删除插件') expect(model.plugins).toEqual(['plugin-a', '@example/plugin-b']) expect(model.pluginItems).toEqual([ - { name: 'plugin-a', actionLabel: '卸载插件', incompatible: false }, - { name: '@example/plugin-b', actionLabel: '卸载插件', incompatible: false } + { name: 'plugin-a', actionLabel: '卸载插件', incompatible: false, suspected: false }, + { name: '@example/plugin-b', actionLabel: '卸载插件', incompatible: false, suspected: false } ]) expect(model.safetyNote).toBe('工作区、会话、模型配置和未选中的插件不会被删除。') }) @@ -80,8 +80,10 @@ describe('Safe Mode', () => { expect(model.pluginItems[0]).toEqual({ name: 'dsh-dream-skin', statusLabel: '(版本不兼容)', + statusTone: 'danger', actionLabel: '卸载插件', - incompatible: true + incompatible: true, + suspected: false }) expect(model.issueGroups).toEqual([]) expect(model.restartLabel).toBe('退出安全模式并重启') @@ -108,7 +110,7 @@ describe('Safe Mode', () => { }] }) expect(model.pluginItems).toEqual([ - { name: 'plugin-a', actionLabel: '卸载插件', incompatible: false } + { name: 'plugin-a', actionLabel: '卸载插件', incompatible: false, suspected: false } ]) expect(model.issueGroups[0]).toMatchObject({ name: 'Profile 核心依赖', @@ -215,6 +217,23 @@ describe('Safe Mode', () => { expect(model.backupSummary).toContain('只允许重试') }) + it('puts harness-log suspects first and marks them without preselecting removal', () => { + const model = buildSafeModeViewModel({ + locale: 'zh', + plugins: ['plugin-a', 'plugin-b'], + suspectedPlugins: ['plugin-b'] + }) + expect(model.plugins).toEqual(['plugin-b', 'plugin-a']) + expect(model.pluginItems[0]).toEqual({ + name: 'plugin-b', + statusLabel: '(本次启动日志推断)', + statusTone: 'warning', + actionLabel: '卸载插件', + incompatible: false, + suspected: true + }) + }) + it('ships a selectable management page with no remote content', async () => { const html = await readFile('build/safe-mode.html', 'utf8') expect(html).toContain('id="items"') @@ -223,6 +242,7 @@ describe('Safe Mode', () => { expect(html).toContain('model.issueGroups') expect(html).toContain('model.pluginItems') expect(html).toContain('plugin.statusLabel') + expect(html).toContain("plugin.statusTone === 'warning'") expect(html.match(/
{ expect(main).toContain("ipcMain.handle('safe-mode:exit', async") expect(main).toContain("return { ok: false, blocked: true }") expect(main).toContain('safeModeManagerWindow') + expect(main).toContain('safeModeSuspectedPlugins = [...new Set(detection.plugins)]') expect(main).toContain('modal: true') expect(main).toContain('assertTrustedSafeModeManagerEvent(event)') expect(main).toContain('`处理完成:修复 ${repaired} 项,卸载 ${selectedPlugins.length} 个插件。`') From 8d903c50d0f97515d9f7c7865876f8539ee4f7da Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Tue, 1 Sep 2026 04:21:25 -0700 Subject: [PATCH 2/3] fix(plugins): persist failed legacy reconciliation --- src/main/state/plugin-removal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/state/plugin-removal.ts b/src/main/state/plugin-removal.ts index cb9fc020..77e470f3 100644 --- a/src/main/state/plugin-removal.ts +++ b/src/main/state/plugin-removal.ts @@ -2067,7 +2067,7 @@ export async function removePluginSafely(options: PluginRemovalOptions): Promise })) if (!reconciliation.ok) { const detail = `profile rebuild failed: ${reconciliation.detail ?? 'unknown error'}` - entry = await markPending(options.dshHome, entry, [detail]).catch(() => entry) + entry = await markFailure(options.dshHome, entry, 'cleanup-pending', [detail]).catch(() => entry) return { pluginName: options.pluginName, disabled: true, From 1bbc21159111407a18a85b5e9df65da3682892bd Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Tue, 1 Sep 2026 06:10:12 -0700 Subject: [PATCH 3/3] fix(recovery): identify route and pending plugin failures --- src/main/runtime/harness-runtime.ts | 30 +++++++++++++- test/plugin-recovery-detection.test.ts | 56 ++++++++++++++++++++++++++ test/runtime.test.ts | 27 +++++++++++++ 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/harness-runtime.ts b/src/main/runtime/harness-runtime.ts index 233a8fe2..0ea3ca15 100644 --- a/src/main/runtime/harness-runtime.ts +++ b/src/main/runtime/harness-runtime.ts @@ -653,10 +653,15 @@ function extractPluginReferences( accepts: (value: string) => boolean ): string[] { const plugins = new Set() + const attemptLogs = latestHarnessAttemptLogs(logLines) + const hasDuplicatePrefixRoute = attemptLogs.some((line) => + line.startsWith('[stderr] ') && /duplicate prefix route ["'][^"']+["']/i.test(line) + ) - for (const line of latestHarnessAttemptLogs(logLines)) { + for (const line of attemptLogs) { if (!line.startsWith('[stderr] ')) continue const text = line.slice(8) + const bootFailureLines = text.split(/\r?\n/).map((value) => value.trim()) // Loader failures are nested (for example the internal `cordis:include` // entry wrapping a third-party bundle). Collect every entry in the chain; @@ -682,7 +687,28 @@ function extractPluginReferences( plugins.add(m5[1].trim()) } - const bootFailureLines = text.split(/\r?\n/).map((value) => value.trim()) + for (const candidate of bootFailureLines) { + const pendingEntry = candidate.match( + /^((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*):\s*pending\s*\(waiting for service:\s*[^)]+\)\s*$/i + ) + if (pendingEntry?.[1] && accepts(pendingEntry[1])) { + plugins.add(pendingEntry[1].trim()) + } + } + + // Some Harness errors do not include the loader wrapper that normally + // names the bundle. For duplicate routes, the first profile stack frame is + // still direct ownership evidence. Restrict stack extraction to that + // failure class so unrelated warnings cannot turn into removal suspects. + if (hasDuplicatePrefixRoute) { + for (const match of text.matchAll( + /[\\/]profiles[\\/][^\\/\s]+[\\/]node_modules[\\/]((?:@[^\\/\s]+[\\/])?[^\\/\s)]+)/gi + )) { + const candidate = match[1]?.replace(/\\/g, '/') + if (candidate && accepts(candidate)) plugins.add(candidate.trim()) + } + } + const bootFailureTitle = bootFailureLines.findIndex((value) => value === 'Failed to load plugins') if (bootFailureTitle >= 0) { for (const candidate of bootFailureLines.slice(bootFailureTitle + 1)) { diff --git a/test/plugin-recovery-detection.test.ts b/test/plugin-recovery-detection.test.ts index 46bfc2f3..39186ccf 100644 --- a/test/plugin-recovery-detection.test.ts +++ b/test/plugin-recovery-detection.test.ts @@ -200,4 +200,60 @@ describe('plugin recovery detection', () => { expect(detection.plugins).toEqual(['dynamic-plugin']) }) + + it('identifies a configured plugin from a duplicate-route profile stack', async () => { + await writeFile( + profilePackageJsonPath(testDir), + JSON.stringify({ + dependencies: { 'dsh-checkpoint-diff': '^1.0.0' }, + dsh: { + profile: { + bundles: [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + 'dsh-checkpoint-diff' + ] + } + } + }) + ) + + const detection = await detectPluginRecovery({ + dshHome: testDir, + initialLogs: [ + '[stderr] [harness-node] DSH entry failed: Error: webserver: duplicate prefix route "/checkpoint-diff"', + '[stderr] at Fiber. (file:///C:/Users/Administrator/AppData/Roaming/dsh-desktop/harness/profiles/web/node_modules/dsh-checkpoint-diff/index.mjs:191:29)' + ] + }) + + expect(detection.plugins).toEqual(['dsh-checkpoint-diff']) + }) + + it('identifies a configured scoped plugin waiting for a missing service', async () => { + await writeFile( + profilePackageJsonPath(testDir), + JSON.stringify({ + dependencies: { '@xmanrui/dsh-im': '^1.0.0' }, + dsh: { + profile: { + bundles: [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-web-app', + '@xmanrui/dsh-im' + ] + } + } + }) + ) + + const detection = await detectPluginRecovery({ + dshHome: testDir, + initialLogs: [ + '[stderr] [harness-node] DSH entry failed: Error: dsh: plugin tree failed to load: dsh: 1 entry did not activate', + '[stderr] @xmanrui/dsh-im: pending (waiting for service: apiProxy)' + ] + }) + + expect(detection.plugins).toEqual(['@xmanrui/dsh-im']) + }) }) diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 9639a67d..ad9cb763 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -390,6 +390,33 @@ describe('offending plugin extraction', () => { expect(extractOffendingPlugin(logs)).toBe('@linxin666/dsh-web-ui-all') }) + it('extracts a plugin from a Windows profile stack after a duplicate route failure', () => { + const logs = [ + '[stderr] [harness-node] DSH entry failed: Error: webserver: duplicate prefix route "/checkpoint-diff"', + '[stderr] at Proxy.register (file:///D:/Program%20Files/DSH%20Desktop/resources/app/node_modules/@deepseek-ai/dsh-host-webserver/lib/index.js:178:36)', + '[stderr] at Fiber. (file:///C:/Users/Administrator/AppData/Roaming/dsh-desktop/harness/profiles/web/node_modules/dsh-checkpoint-diff/index.mjs:191:29)' + ] + expect(extractPluginFailureReferences(logs)).toEqual(['dsh-checkpoint-diff']) + expect(extractOffendingPlugins(logs)).toEqual(['dsh-checkpoint-diff']) + }) + + it('does not treat an unrelated profile stack as duplicate-route ownership evidence', () => { + const logs = [ + '[stderr] [harness-node] DSH entry failed: Error: unrelated core failure', + '[stderr] at run (file:///C:/Users/Administrator/AppData/Roaming/dsh-desktop/harness/profiles/web/node_modules/unrelated-plugin/index.mjs:10:2)' + ] + expect(extractPluginFailureReferences(logs)).toEqual([]) + }) + + it('extracts a scoped entry waiting for a missing service', () => { + const logs = [ + '[stderr] [harness-node] DSH entry failed: Error: dsh: plugin tree failed to load: dsh: 1 entry did not activate', + '[stderr] @xmanrui/dsh-im: pending (waiting for service: apiProxy)' + ] + expect(extractPluginFailureReferences(logs)).toEqual(['@xmanrui/dsh-im']) + expect(extractOffendingPlugins(logs)).toEqual(['@xmanrui/dsh-im']) + }) + it('extracts the third-party plugin nested under the internal include entry', () => { const logs = [ '[stderr] [harness-node] DSH entry failed: Error: dsh: plugin tree failed to load: failed to apply loader entry include (cordis:include): failed to apply loader entry db-connector (dsh-db-connector): cannot get property "commands" without inject'