refactor(desktop): drop link-era profile cleanup

Remove cleanProfileCorePackages and migrateDesktopProfileLinks with their specs; applyRelease keeps runtime validation, settings migration, and profile creation. A regression pins that installed packages, declarations, legacy state, and the lockfile survive a launch.
This commit is contained in:
imccyu committed 2026-09-19 13:29:13 +08:00
1 parent a311386983
commit 938b62c0aa
7 files changed
+30 -388

No files matched your search

+1 -1
View File
@@ -326,7 +326,7 @@ async function main(): Promise<void> {
startup ??= (async () => {
await navigateMain(applicationUrl)
await backend.start(async () => {
await manager.applyRelease(app.isPackaged)
await manager.applyRelease()
})
if (backend.host !== undefined) updateJournal?.action('workspace-ready')
// The existing Web document resumes through the boot IPC response.
-90
View File
@@ -1,90 +0,0 @@
/** Remove application-owned packages left in external plugin profiles before production boot. */
import { existsSync, lstatSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { dump, load } from 'js-yaml'
import { DESKTOP_PACKAGE_SET_FILE, readDesktopCorePackageSet } from './core-package-set.ts'
/** Enables the temporary production-profile cleanup; remove this module and its caller together. */
const CLEAN_PROFILE_CORE_PACKAGES = true
function object(value: unknown): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('desktop profile cleanup: expected an object')
}
return value as Record<string, unknown>
}
function stat(path: string): ReturnType<typeof lstatSync> | undefined {
try { return lstatSync(path) } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return undefined
}
}
function remove(path: string): boolean {
const entry = stat(path)
if (entry === undefined) return false
if (entry.isSymbolicLink()) unlinkSync(path)
else rmSync(path, { recursive: entry.isDirectory() })
return true
}
function requireDirectory(path: string): void {
const entry = stat(path)
if (entry !== undefined && (!entry.isDirectory() || entry.isSymbolicLink())) {
throw new Error(`desktop profile cleanup: package parent is not a real directory: ${path}`)
}
}
function prune(value: Record<string, unknown>, field: string, names: ReadonlySet<string>): boolean {
if (value[field] === undefined) return false
const entries = object(value[field])
let changed = false
for (const name of names) {
if (!Object.hasOwn(entries, name)) continue
Reflect.deleteProperty(entries, name)
changed = true
}
return changed
}
/**
* Delete current and recorded Desktop core packages, their declarations, and stale lockfile resolutions.
* Managed links are unlinked without touching their targets; unrelated plugins and profile configuration survive.
* The caller holds the profile lock and must not have started the Host. Development launches do nothing.
* @param profile - Absolute Desktop profile directory.
* @param packageNames - Package names from the verified application runtime descriptor.
* @param production - Whether this launch uses the packaged application.
*/
export function cleanProfileCorePackages(profile: string, packageNames: readonly string[], production: boolean): void {
// oxlint-disable-next-line typescript/no-unnecessary-condition -- release cleanup has an explicit source switch.
if (!CLEAN_PROFILE_CORE_PACKAGES || !production || !existsSync(profile)) return
const names = new Set(packageNames)
const recordPath = join(profile, DESKTOP_PACKAGE_SET_FILE)
if (existsSync(recordPath)) {
for (const entry of readDesktopCorePackageSet(profile).packages) names.add(entry.name)
}
const roots = [join(profile, 'node_modules')]
for (const root of roots) {
requireDirectory(root)
for (const name of names) requireDirectory(dirname(join(root, name)))
}
const manifestPath = join(profile, 'package.json')
const manifest = existsSync(manifestPath) ? object(JSON.parse(readFileSync(manifestPath, 'utf8'))) : undefined
const workspacePath = join(profile, 'pnpm-workspace.yaml')
const workspace = existsSync(workspacePath) ? object(load(readFileSync(workspacePath, 'utf8'))) : undefined
let manifestChanged = false
if (manifest !== undefined) {
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
manifestChanged = prune(manifest, field, names) || manifestChanged
}
if (manifest.pnpm !== undefined) manifestChanged = prune(object(manifest.pnpm), 'overrides', names) || manifestChanged
}
const workspaceChanged = workspace !== undefined && prune(workspace, 'overrides', names)
const packageResidue = roots.some(root => [...names].some(name => stat(join(root, name)) !== undefined))
if (manifestChanged || workspaceChanged || packageResidue) remove(join(profile, 'pnpm-lock.yaml'))
if (manifestChanged) writeFileSync(manifestPath, `${JSON.stringify(manifest, undefined, 2)}\n`)
if (workspaceChanged) writeFileSync(workspacePath, dump(workspace))
for (const root of roots) for (const name of names) remove(join(root, name))
}
-56
View File
@@ -1,56 +0,0 @@
/** One-time removal of package links recorded by Desktop profile management. */
import { lstatSync, readFileSync, readlinkSync, unlinkSync } from 'node:fs'
import { dirname, isAbsolute, join, resolve } from 'node:path'
/** Legacy Desktop package-link ownership file. */
export const DESKTOP_PROFILE_STATE = 'desktop-runtime-state.json'
interface DesktopPackageLink {
readonly name: string
readonly target: string
}
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function stat(path: string): ReturnType<typeof lstatSync> | undefined {
try { return lstatSync(path) } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return undefined
}
}
/**
* Remove recorded links still owned by Desktop and delete their legacy state file.
* Installed directories, changed links, and links beneath redirected package directories are retained.
* @param profile - Desktop profile directory.
*/
export function migrateDesktopProfileLinks(profile: string): void {
const statePath = join(profile, DESKTOP_PROFILE_STATE)
if (stat(statePath) === undefined) return
const value: unknown = JSON.parse(readFileSync(statePath, 'utf8'))
if (!record(value) || !Array.isArray(value.links)) throw new Error('desktop profile: invalid legacy package links')
const links = value.links.map((link: unknown): DesktopPackageLink => {
if (!record(link) || typeof link.name !== 'string' || !PACKAGE_NAME.test(link.name)
|| typeof link.target !== 'string' || !isAbsolute(link.target)) {
throw new Error('desktop profile: invalid legacy package link')
}
return { name: link.name, target: link.target }
})
const modules = join(profile, 'node_modules')
if (stat(modules)?.isDirectory() === true) {
for (const link of links) {
const path = join(modules, link.name)
if (dirname(path) !== modules && stat(dirname(path))?.isDirectory() !== true) continue
if (stat(path)?.isSymbolicLink() === true
&& resolve(dirname(path), readlinkSync(path)) === resolve(link.target)) {
unlinkSync(path)
}
}
}
unlinkSync(statePath)
}
+2 -7
View File
@@ -25,8 +25,6 @@ import { readDesktopRuntime } from './runtime-tree.ts'
import {
initProfile, PROFILE_TEMPLATES, sanitizeProfile, type ProfileTemplate,
} from '@deepseek-ai/dsh-app-boot'
import { migrateDesktopProfileLinks } from './profile-packages.ts'
import { cleanProfileCorePackages } from './profile-core-cleanup.ts'
const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime'
const DSH_PACKAGE = '@deepseek-ai/dsh'
@@ -81,14 +79,11 @@ export class DesktopProjectManager {
/**
* Load application metadata and prepare the external plugin profile without installing packages.
* @param production - Remove application-owned profile packages before packaged Host startup.
*/
async applyRelease(production = false): Promise<void> {
async applyRelease(): Promise<void> {
await this.withLock(() => {
const descriptor = readDesktopRuntime(this.runtime.dsh)
cleanProfileCorePackages(this.paths.profile, descriptor.sharedPackages.map(entry => entry.name), production)
readDesktopRuntime(this.runtime.dsh)
migrateProfileSettings(this.paths.profile)
migrateDesktopProfileLinks(this.paths.profile)
createPluginProfile(this.paths.profile)
})
}
@@ -1,102 +0,0 @@
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { load } from 'js-yaml'
import { cleanProfileCorePackages } from '../src/profile-core-cleanup.ts'
const roots: string[] = []
const core = '@deepseek-ai/dsh-web-app'
const extra = '@deepseek-ai/optional-plugin'
function fixture(): string {
const root = mkdtempSync(join(tmpdir(), 'desktop-core-cleanup-'))
roots.push(root)
mkdirSync(join(root, 'node_modules', core), { recursive: true })
mkdirSync(join(root, 'node_modules', extra), { recursive: true })
writeFileSync(join(root, 'package.json'), JSON.stringify({
dependencies: { [core]: 'file:./desktop-packages/web.tgz', [extra]: '1.0.0' },
optionalDependencies: { [core]: '1.0.0' },
pnpm: { overrides: { [core]: '1.0.0', third: '2.0.0' } },
dsh: { profile: { bundles: [core, extra] } },
}))
writeFileSync(join(root, 'pnpm-workspace.yaml'), `nodeLinker: hoisted\noverrides:\n '${core}': file:./desktop-packages/web.tgz\n third: 2.0.0\n`)
writeFileSync(join(root, 'pnpm-lock.yaml'), 'old resolutions\n')
writeFileSync(join(root, 'cordis.patch.yml'), '[]\n')
return root
}
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
it('removes core packages and reinstall declarations while preserving optional plugins and configuration', () => {
const root = fixture()
cleanProfileCorePackages(root, [core], true)
expect(existsSync(join(root, 'node_modules', core))).toBe(false)
expect(existsSync(join(root, 'node_modules', extra))).toBe(true)
expect(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))).toEqual({
dependencies: { [extra]: '1.0.0' }, optionalDependencies: {},
pnpm: { overrides: { third: '2.0.0' } }, dsh: { profile: { bundles: [core, extra] } },
})
expect(load(readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8'))).toEqual({ nodeLinker: 'hoisted', overrides: { third: '2.0.0' } })
expect(readFileSync(join(root, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
expect(existsSync(join(root, 'pnpm-lock.yaml'))).toBe(false)
writeFileSync(join(root, 'pnpm-lock.yaml'), 'new plugin resolutions\n')
cleanProfileCorePackages(root, [core], true)
expect(readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8')).toBe('new plugin resolutions\n')
})
it('does not clean development profiles', () => {
const root = fixture()
const before = readFileSync(join(root, 'package.json'), 'utf8')
cleanProfileCorePackages(root, [core], false)
expect(existsSync(join(root, 'node_modules', core))).toBe(true)
expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(before)
expect(existsSync(join(root, 'pnpm-lock.yaml'))).toBe(true)
})
it('unlinks core packages without deleting their target, including dangling links', () => {
const root = fixture()
const target = join(root, 'development-package')
mkdirSync(target)
writeFileSync(join(target, 'sentinel'), 'keep')
rmSync(join(root, 'node_modules', core), { recursive: true })
symlinkSync(target, join(root, 'node_modules', core), 'junction')
cleanProfileCorePackages(root, [core], true)
expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('keep')
symlinkSync(target, join(root, 'node_modules', core), 'junction')
rmSync(target, { recursive: true })
cleanProfileCorePackages(root, [core], true)
expect(() => lstatSync(join(root, 'node_modules', core))).toThrow()
})
it('uses the old package inventory to remove retired core names', () => {
const root = fixture()
const names = ['@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host', core].sort()
writeFileSync(join(root, 'desktop-packages.json'), JSON.stringify({ schemaVersion: 1, packages: names.map((name, index) => ({
name, version: '0.1.2', file: `${index}.tgz`, bytes: 1, integrity: 'sha512-YQ==',
})) }))
cleanProfileCorePackages(root, [], true)
expect(existsSync(join(root, 'node_modules', core))).toBe(false)
expect(existsSync(join(root, 'node_modules', extra))).toBe(true)
})
it('rejects invalid metadata before deleting packages or declarations', () => {
const root = fixture()
writeFileSync(join(root, 'desktop-packages.json'), '{"schemaVersion":1,"packages":[{"name":"../../outside"}]}')
const before = readFileSync(join(root, 'package.json'), 'utf8')
expect(() => { cleanProfileCorePackages(root, [core], true) }).toThrow('invalid package record')
expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe(before)
expect(existsSync(join(root, 'node_modules', core))).toBe(true)
})
it('refuses redirected package parents without deleting their contents', () => {
const root = fixture()
const target = join(root, 'external-scope')
mkdirSync(join(target, 'dsh-web-app'), { recursive: true })
rmSync(join(root, 'node_modules', '@deepseek-ai'), { recursive: true })
symlinkSync(target, join(root, 'node_modules', '@deepseek-ai'), 'junction')
expect(() => { cleanProfileCorePackages(root, [core], true) }).toThrow('not a real directory')
expect(existsSync(join(target, 'dsh-web-app'))).toBe(true)
})
-126
View File
@@ -1,126 +0,0 @@
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, rmdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { DESKTOP_PROFILE_STATE, migrateDesktopProfileLinks } from '../src/profile-packages.ts'
const roots: string[] = []
const symlinks: string[] = []
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'desktop-profile-migration-'))
roots.push(root)
const profile = join(root, 'profile')
const target = join(root, 'runtime-package')
mkdirSync(join(profile, 'node_modules'), { recursive: true })
mkdirSync(target)
return { root, profile, target }
}
function link(target: string, path: string): void {
mkdirSync(dirname(path), { recursive: true })
symlinkSync(target, path, process.platform === 'win32' ? 'junction' : 'dir')
symlinks.push(path)
}
function writeState(profile: string, links: unknown): void {
writeFileSync(join(profile, DESKTOP_PROFILE_STATE), JSON.stringify({
schemaVersion: 'obsolete', runtimeId: null, version: 8, lockHash: false, links,
}))
}
afterEach(() => {
for (const path of symlinks.splice(0).reverse()) {
try {
if (lstatSync(path).isSymbolicLink()) unlinkSync(path)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
}
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
it('removes recorded links and state without interpreting obsolete runtime fields', () => {
const { profile, target } = fixture()
const packagePath = join(profile, 'node_modules', '@deepseek-ai/cordis')
link(target, packagePath)
writeFileSync(join(target, 'package.json'), '{"name":"@deepseek-ai/cordis"}')
writeState(profile, [{ name: '@deepseek-ai/cordis', target }])
migrateDesktopProfileLinks(profile)
expect(existsSync(packagePath)).toBe(false)
expect(existsSync(join(profile, DESKTOP_PROFILE_STATE))).toBe(false)
expect(readFileSync(join(target, 'package.json'), 'utf8')).toContain('@deepseek-ai/cordis')
expect(() => { migrateDesktopProfileLinks(profile) }).not.toThrow()
})
it('removes an owned broken link without following its target', () => {
const { profile, target } = fixture()
const packagePath = join(profile, 'node_modules', 'plugin')
link(target, packagePath)
writeState(profile, [{ name: 'plugin', target }])
rmdirSync(target)
migrateDesktopProfileLinks(profile)
expect(() => lstatSync(packagePath)).toThrow()
expect(existsSync(join(profile, DESKTOP_PROFILE_STATE))).toBe(false)
})
it('preserves a pnpm-installed directory replacing a recorded link', () => {
const { profile, target } = fixture()
const packagePath = join(profile, 'node_modules', 'plugin')
mkdirSync(packagePath)
writeFileSync(join(packagePath, 'package.json'), '{"name":"plugin","version":"2.0.0"}')
writeState(profile, [{ name: 'plugin', target }])
migrateDesktopProfileLinks(profile)
expect(readFileSync(join(packagePath, 'package.json'), 'utf8')).toContain('2.0.0')
expect(existsSync(join(profile, DESKTOP_PROFILE_STATE))).toBe(false)
})
it('preserves changed and unrecorded package links', () => {
const { root, profile, target } = fixture()
const replacement = join(root, 'user-package')
mkdirSync(replacement)
const changed = join(profile, 'node_modules', 'changed')
const unrecorded = join(profile, 'node_modules', 'unrecorded')
link(replacement, changed)
link(target, unrecorded)
const changedTarget = readlinkSync(changed)
const unrecordedTarget = readlinkSync(unrecorded)
writeState(profile, [{ name: 'changed', target }])
migrateDesktopProfileLinks(profile)
expect(readlinkSync(changed)).toBe(changedTarget)
expect(readlinkSync(unrecorded)).toBe(unrecordedTarget)
})
it('leaves an uninitialized profile untouched', () => {
const { root } = fixture()
const missingProfile = join(root, 'absent-profile')
migrateDesktopProfileLinks(missingProfile)
expect(existsSync(missingProfile)).toBe(false)
})
it.each(['../../outside', '../outside', '@scope/../../outside', '..\\outside'])('rejects escaping package name %s before removing any links', (name) => {
const { root, profile, target } = fixture()
const packagePath = join(profile, 'node_modules', 'owned')
const outside = join(root, 'outside')
link(target, packagePath)
link(target, outside)
writeState(profile, [{ name: 'owned', target }, { name, target }])
expect(() => { migrateDesktopProfileLinks(profile) }).toThrow('invalid legacy package link')
expect(lstatSync(packagePath).isSymbolicLink()).toBe(true)
expect(lstatSync(outside).isSymbolicLink()).toBe(true)
expect(existsSync(join(profile, DESKTOP_PROFILE_STATE))).toBe(true)
})
it.each(['node_modules', 'node_modules/@scope'])('preserves package links beneath redirected %s', (parent) => {
const { root, profile, target } = fixture()
const external = join(root, 'external')
mkdirSync(external)
const packagePath = join(external, 'plugin')
link(target, packagePath)
if (parent === 'node_modules') rmdirSync(join(profile, 'node_modules'))
link(external, join(profile, parent))
writeState(profile, [{ name: parent === 'node_modules' ? 'plugin' : '@scope/plugin', target }])
migrateDesktopProfileLinks(profile)
expect(lstatSync(packagePath).isSymbolicLink()).toBe(true)
expect(existsSync(join(profile, DESKTOP_PROFILE_STATE))).toBe(false)
})
+27 -6
View File
@@ -43,16 +43,37 @@ afterEach(() => {
})
describe('desktop external plugin profile', () => {
it('cleans application packages only when preparing a production launch', async () => {
it('preserves installed packages, profile state, and the lockfile when preparing a launch', async () => {
const { manager } = setup()
await manager.applyRelease()
seedPlugin(manager)
const profile = manager.paths.profile
const name = '@deepseek-ai/dsh-web-app'
const path = join(manager.paths.profile, 'node_modules', name)
const path = join(profile, 'node_modules', name)
mkdirSync(path, { recursive: true })
await manager.applyRelease()
expect(existsSync(path)).toBe(true)
await manager.applyRelease(true)
expect(existsSync(path)).toBe(false)
writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0' }))
const manifestPath = join(profile, 'package.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { dependencies: Record<string, string> }
manifest.dependencies[name] = '1.0.0'
writeFileSync(manifestPath, JSON.stringify(manifest))
writeFileSync(join(profile, 'desktop-runtime-state.json'), JSON.stringify({ links: [] }))
writeFileSync(join(profile, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n')
const files = [
'package.json',
'pnpm-workspace.yaml',
'desktop-runtime-state.json',
'pnpm-lock.yaml',
`node_modules/${name}/package.json`,
'node_modules/plugin/package.json',
'node_modules/plugin/bundle.yml',
]
const before = files.map(file => readFileSync(join(profile, file), 'utf8'))
await expect(manager.applyRelease()).resolves.toBeUndefined()
expect(files.map(file => readFileSync(join(profile, file), 'utf8'))).toEqual(before)
expect(lstatSync(path).isDirectory()).toBe(true)
expect(lstatSync(join(profile, 'node_modules/plugin')).isDirectory()).toBe(true)
})
it('reuses plugin files without scanning manifests and can disable them', async () => {
const { manager } = setup()