diff --git a/packages/boot/app-boot/src/probe.ts b/packages/boot/app-boot/src/probe.ts index e4adeed8d6..6e7fce7320 100644 --- a/packages/boot/app-boot/src/probe.ts +++ b/packages/boot/app-boot/src/probe.ts @@ -8,8 +8,9 @@ */ import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { loadOverlayPatches } from './index.ts' import { readProfileManifest, resolveBundleDir, type ProfileManifest } from './profile.ts' @@ -220,7 +221,12 @@ export async function probePackage(options: ProbeOptions): Promise const hasMain = manifest.main !== undefined || manifest.exports !== undefined const report = await runChild(options, packageDir, hasMain ? options.packageName : '', declaredAddable.map(entry => entry.name)) const harnessCordis = resolveHarnessCordis() - const cordisSameCopy = report.cordis === null || harnessCordis === undefined ? null : report.cordis === harnessCordis + // Compared by package directory, not by entry file: a source launch + // resolves the harness's copy to its TypeScript entry while the child, a + // plain Node process, resolves the same package's built entry. + const cordisSameCopy = report.cordis === null || harnessCordis === undefined + ? null + : cordisPackageDir(report.cordis) === cordisPackageDir(harnessCordis) const kind: PluginProbe['kind'] = declaredBundle !== undefined ? 'bundle' : report.main.isPlugin ? 'plugin' : 'library' const addable: PluginProbeAddable[] = declaredAddable.map((entry) => { // The child reports every declared name; a missing report is a child @@ -275,6 +281,35 @@ function resolveHarnessCordis(): string | undefined { } } +/** + * The real directory of the `@deepseek-ai/cordis` package a resolved entry + * URL belongs to: the nearest ancestor whose manifest carries that name. + * @param url - a `file:` URL of one cordis entry module. + * @returns the package directory with symlinks resolved, or the entry path + * itself when no such manifest is found above it. + */ +function cordisPackageDir(url: string): string { + let dir = dirname(fileURLToPath(url)) + for (;;) { + const manifest = join(dir, 'package.json') + if (existsSync(manifest)) { + let name: unknown + try { + name = (JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown }).name + } catch { + // A manifest Node's resolver did not read — an unrelated ancestor's, + // malformed — is not cordis's; keep climbing. + /* v8 ignore next */ + name = undefined + } + if (name === '@deepseek-ai/cordis') return realpathSync(dir) + } + const parent = dirname(dir) + if (parent === dir) return fileURLToPath(url) + dir = parent + } +} + /** The cache file for one package under a profile. */ function probeCachePath(profileDir: string, packageName: string): string { return join(profileDir, PLUGIN_PROBE_DIR, `${packageName.replaceAll('/', '__')}.json`) diff --git a/packages/boot/app-boot/tests/probe.spec.ts b/packages/boot/app-boot/tests/probe.spec.ts index c8918d73d6..36c6ce32b3 100644 --- a/packages/boot/app-boot/tests/probe.spec.ts +++ b/packages/boot/app-boot/tests/probe.spec.ts @@ -3,9 +3,10 @@ * a child process, and the per-profile cache. */ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, writeFileSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { PLUGIN_PROBE_DIR, probePackage, readProbeCache, writeProbeCache, type PluginProbe } from '../src/index.ts' @@ -120,6 +121,16 @@ describe('probePackage', () => { 'node_modules/@deepseek-ai/cordis/index.js': 'export const Context = class {}\n', }, }, + // A copy whose manifest carries another name still resolves as cordis + // for Node; with no cordis manifest above its entry, the entry itself + // stands for the copy, which is not the harness's. + 'renamed-cordis': { + main: 'export function apply() {}\n', + files: { + 'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify({ name: 'not-cordis', version: '0.0.0', type: 'module', main: './index.js' }), + 'node_modules/@deepseek-ai/cordis/index.js': 'export const Context = class {}\n', + }, + }, }) const broken = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'broken' }) expect(broken.ok).toBe(false) @@ -130,6 +141,21 @@ describe('probePackage', () => { expect(own.cordisSameCopy).toBe(false) expect(own.ok).toBe(false) expect(own.reason).toContain('own copy of @deepseek-ai/cordis') + const renamed = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'renamed-cordis' }) + expect(renamed.cordisSameCopy).toBe(false) + }) + + it('recognizes the harness copy of cordis by its package directory, whichever entry each side resolved', async () => { + // The staged package reaches cordis through a symlink to the harness's + // own package; the child resolves its built entry, this process (under + // the test runner's source aliases) its TypeScript entry. + const { profileDir, installAnchor } = stage({ 'shared-cordis': { main: 'export function apply() {}\n' } }) + const harnessCordis = fileURLToPath(new URL('../../../../vendor/cordis', import.meta.url)) + mkdirSync(join(profileDir, 'node_modules', 'shared-cordis', 'node_modules', '@deepseek-ai'), { recursive: true }) + symlinkSync(harnessCordis, join(profileDir, 'node_modules', 'shared-cordis', 'node_modules', '@deepseek-ai', 'cordis'), 'dir') + const shared = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'shared-cordis' }) + expect(shared.cordisSameCopy).toBe(true) + expect(shared.ok).toBe(true) }) it('probes a package with the minimal manifest and no main export', async () => { diff --git a/packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css b/packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css index b2266945f8..cb500529f5 100644 --- a/packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css +++ b/packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css @@ -430,7 +430,7 @@ display: flex; flex-direction: column; gap: 10px; - min-width: min(520px, 80vw); + min-width: 0; } .installField { diff --git a/packages/client/ui-settings-plugin-manager/src/client/manager-store.ts b/packages/client/ui-settings-plugin-manager/src/client/manager-store.ts index 1fe537b9fd..c0c2ad3157 100644 --- a/packages/client/ui-settings-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-settings-plugin-manager/src/client/manager-store.ts @@ -341,10 +341,12 @@ export class PluginManagerController { if (result.ok) { this.patchInstall({ phase: 'done', installed: result.value.installed }) } else { - // Streamed chunks already show the run; otherwise the Host's captured - // log, or its message when pnpm never started. + // The Host's reason follows whatever streamed: pnpm's captured log when + // no chunk arrived, else the refusal that followed a successful pnpm + // run — a bundle the tree rejected, a probe that refused it. const current = this.getSnapshot().install.log - const log = current === '' ? detailOf(result.error, 'log') ?? result.error.message : current + const reason = detailOf(result.error, 'reason') ?? detailOf(result.error, 'log') ?? result.error.message + const log = current === '' || current.endsWith(reason) ? (current === '' ? reason : current) : `${current}\n${reason}` this.patchInstall({ phase: 'failed', log }) } void this.load() diff --git a/packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts index c07248d914..8b6df128b6 100644 --- a/packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts @@ -288,7 +288,8 @@ describe('PluginManagerController', () => { add: vi.fn() .mockResolvedValueOnce(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'ERR_PNPM' })) .mockResolvedValueOnce(refused('gateway/internal', 'offline')) - .mockResolvedValueOnce(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'ignored' })), + .mockResolvedValueOnce(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'tail' })) + .mockResolvedValueOnce(refused('plugins/enable-failed', 'plugin-manager: x rejected', { packageName: 'x', reason: 'the tree rejected it' })), }) await controller.load() face.openInstall() @@ -303,7 +304,14 @@ describe('PluginManagerController', () => { controller.appendLog({ jobId: 'j', spec: 'x', stream: 'stderr', text: 'streamed' }) await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(3) }) await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) - expect(state().install.log).toBe('streamed') + // The Host's captured log follows what streamed; a refusal after a clean + // pnpm run follows it with its reason. + expect(state().install.log).toBe('streamed\ntail') + face.runInstall() + controller.appendLog({ jobId: 'j', spec: 'x', stream: 'stdout', text: 'Done' }) + await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(4) }) + await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) + expect(state().install.log).toBe('Done\nthe tree rejected it') }) it('drops every late settlement after disposal', async () => {