diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 10ac24a9d9..16608f51ba 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -326,7 +326,7 @@ async function main(): Promise { 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. diff --git a/apps/desktop/src/profile-core-cleanup.ts b/apps/desktop/src/profile-core-cleanup.ts deleted file mode 100644 index c75d7c8136..0000000000 --- a/apps/desktop/src/profile-core-cleanup.ts +++ /dev/null @@ -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 { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new Error('desktop profile cleanup: expected an object') - } - return value as Record -} - -function stat(path: string): ReturnType | 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, field: string, names: ReadonlySet): 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)) -} diff --git a/apps/desktop/src/profile-packages.ts b/apps/desktop/src/profile-packages.ts deleted file mode 100644 index bf9d98e0a3..0000000000 --- a/apps/desktop/src/profile-packages.ts +++ /dev/null @@ -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 { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function stat(path: string): ReturnType | 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) -} diff --git a/apps/desktop/src/project-manager.ts b/apps/desktop/src/project-manager.ts index af7804e1a5..9e01caa98f 100644 --- a/apps/desktop/src/project-manager.ts +++ b/apps/desktop/src/project-manager.ts @@ -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 { + async applyRelease(): Promise { 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) }) } diff --git a/apps/desktop/tests/profile-core-cleanup.spec.ts b/apps/desktop/tests/profile-core-cleanup.spec.ts deleted file mode 100644 index 08d1e727c4..0000000000 --- a/apps/desktop/tests/profile-core-cleanup.spec.ts +++ /dev/null @@ -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) -}) diff --git a/apps/desktop/tests/profile-packages.spec.ts b/apps/desktop/tests/profile-packages.spec.ts deleted file mode 100644 index 2233a0128b..0000000000 --- a/apps/desktop/tests/profile-packages.spec.ts +++ /dev/null @@ -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) -}) diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts index f86e3f2f16..5f4059c1c9 100644 --- a/apps/desktop/tests/project-manager.spec.ts +++ b/apps/desktop/tests/project-manager.spec.ts @@ -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 } + 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()