From b96959ecf44edadcabb01c0c0ba6ff747ae8cf1c Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 29 Sep 2026 10:51:33 +0800 Subject: [PATCH 1/3] feat(plugins): show where a bundle comes from on its page The bundle page ends with a Source section: the spec that installs the bundle elsewhere, as `pnpm add` accepts it, or Built in for an official bundle the installation ships, followed by its current version. The Host's listBundles carries the new `source` field for profile dependencies: git addresses and URLs as recorded, file:/link: paths made absolute against the profile, and registry ranges, tags, or aliases after the package name. --- .../expected/plugin-config/bundle.expected.md | 6 +++ .../plugin-manager/exports-en.expected.md | 6 +++ .../plugin-manager/exports.expected.md | 6 +++ apps/web/tests/plugin-config.e2e.ts | 4 +- apps/web/tests/plugin-manager.e2e.ts | 8 +++- docs/event-producer-consumer.md | 6 +-- docs/event-producer-consumer.zh.md | 6 +-- docs/subsystems/boot.md | 4 +- docs/subsystems/boot.zh.md | 4 +- packages/boot/plugin-manager/README.i18n.yaml | 4 +- packages/boot/plugin-manager/README.md | 2 +- packages/boot/plugin-manager/README.zh.md | 2 +- packages/boot/plugin-manager/src/index.ts | 16 ++++---- .../boot/plugin-manager/src/install-spec.ts | 20 +++++++++- packages/boot/plugin-manager/src/types.ts | 2 + .../boot/plugin-manager/tests/manager.spec.ts | 26 ++++++++++-- .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 +- .../client/ui-plugin-manager/README.zh.md | 2 +- .../src/client/PluginManagerPage.module.css | 40 +++++++++++++++++++ .../src/client/PluginManagerPage.tsx | 34 +++++++++++++++- .../ui-plugin-manager/src/client/locales.ts | 8 ++++ .../src/client/manager-store.ts | 3 ++ .../tests/components.client.spec.tsx | 16 ++++++++ .../tests/manager-store.client.spec.ts | 3 +- .../extensions/tool-cordis/src/api-catalog.ts | 4 +- 26 files changed, 200 insertions(+), 38 deletions(-) diff --git a/apps/web/tests/expected/plugin-config/bundle.expected.md b/apps/web/tests/expected/plugin-config/bundle.expected.md index ff95faa173..f71e0dad28 100644 --- a/apps/web/tests/expected/plugin-config/bundle.expected.md +++ b/apps/web/tests/expected/plugin-config/bundle.expected.md @@ -14,6 +14,12 @@ - code: fixture-live-client - text: 运行中 - switch "启用组件 @fixture/live-client" [checked] +- heading "来源信息" [level=4] +- term: 代码来源 +- definition: + - code: file:{{fixtures}}/fixture-live-client +- term: 当前版本 +- definition: 0.0.1 - region "夹具区块": - heading "夹具区块" [level=4] - paragraph: 来自夹具的区块内容 diff --git a/apps/web/tests/expected/plugin-manager/exports-en.expected.md b/apps/web/tests/expected/plugin-manager/exports-en.expected.md index 74cc1b4c15..3b3706b8f0 100644 --- a/apps/web/tests/expected/plugin-manager/exports-en.expected.md +++ b/apps/web/tests/expected/plugin-manager/exports-en.expected.md @@ -22,3 +22,9 @@ - text: "@fixture/bundle/review" - code: fixture-review - text: "Off" +- heading "Source" [level=4] +- term: Installed from +- definition: + - code: file:{{fixtures}}/fixture-bundle +- term: Current version +- definition: 0.0.1 diff --git a/apps/web/tests/expected/plugin-manager/exports.expected.md b/apps/web/tests/expected/plugin-manager/exports.expected.md index 0512893d3e..2a86287511 100644 --- a/apps/web/tests/expected/plugin-manager/exports.expected.md +++ b/apps/web/tests/expected/plugin-manager/exports.expected.md @@ -23,3 +23,9 @@ - code: fixture-review - code: "@fixture/bundle/review" - text: 已关闭 +- heading "来源信息" [level=4] +- term: 代码来源 +- definition: + - code: file:{{fixtures}}/fixture-bundle +- term: 当前版本 +- definition: 0.0.1 diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 9a140bcf46..e7c29735b5 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -317,7 +317,9 @@ describe('web e2e: plugin configuration pages', () => { expect(await bundlePage.getByRole('button', { name: '夹具操作' }).count()).toBe(1) expect(await bundlePage.locator('[data-live-badge="bundle"]').textContent()).toBe('夹具标签') expect(await bundlePage.getByRole('region', { name: '夹具区块' }).getByText('来自夹具的区块内容').count()).toBe(1) - const bundleSnapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) + const bundleSnapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, { + replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']], + }) await compareOrRefreshGolden(BUNDLE_EXPECTED, bundleSnapshot, MODE) await configure.click() diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index b89279d173..dcf7ae246b 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -547,14 +547,18 @@ describe('web e2e: plugin manager', () => { expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(1) await panel.getByText('Registry description for the fixture bundle.', { exact: true }).first().waitFor() expect([...scaffold.ctx.loader.entries()].some(entry => entry.options.name.startsWith('@fixture/bundle'))).toBe(false) - await compareOrRefreshGolden(EXPORTS_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE) + await compareOrRefreshGolden(EXPORTS_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, { + replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']], + }), MODE) try { await setLanguage('en') await search.getByText('File Search', { exact: true }).waitFor() expect(await review.getByText('@fixture/bundle/review', { exact: true }).count()).toBeGreaterThan(0) expect(await search.getByText('Search package introduction.', { exact: true }).count()).toBe(1) expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(0) - await compareOrRefreshGolden(EXPORTS_EN_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE) + await compareOrRefreshGolden(EXPORTS_EN_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, { + replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']], + }), MODE) expect(await panel.locator('[data-plugin-name]').textContent()).toBe('@fixture/bundle') } finally { await setLanguage('zh') diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9af81e3523..4721d79846 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -56,9 +56,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:245`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:251`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:258`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:471`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 3f5f5e710f..e4e11ccd96 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -58,9 +58,9 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:245`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:251`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:258`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:471`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 0739170597..4dd92e2224 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -111,8 +111,8 @@ Manage profile files and apply their declared reload lifecycle. /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected. - * @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections, - * whether the installation offers the bundle, and removal availability. + * @returns Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional + * display metadata, activation selections, whether the installation offers the bundle, and removal availability. */ @Remote listBundles(): Promise diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 64ff36d412..d31b0648cc 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -111,8 +111,8 @@ Manage profile files and apply their declared reload lifecycle. /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected. - * @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections, - * whether the installation offers the bundle, and removal availability. + * @returns Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional + * display metadata, activation selections, whether the installation offers the bundle, and removal availability. */ @Remote listBundles(): Promise diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 034b9efee0..b95d40d15f 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: b8d7061ee289b2a0 - zh: 49aba7df4761fb23 + en: 02e58b7db5ea342c + zh: d0a2d0e0255e241b /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 920c0acb3b9feaf3 zh: ba6e832a256880a7 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index ca470bcd3a..9427801c61 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -41,7 +41,7 @@ A plugin toggle updates only `disabled` in the last matching override in the pro A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable. -`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. The `plugin_manager` tool omits UI display metadata from list results. +`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded, a `file:` or `link:` path made absolute against the profile, and a registry range, tag, or alias after the package name; a bundle the installation supplies has none. The `plugin_manager` tool omits UI display metadata from list results. `inspect(spec, options)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same proxy and authentication settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form and the `host` it is fetched from. The answer carries the name, version, description, whether the package declares a bundle, and the `registry` that answered, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`, with the `registries` asked. A caller's `signal` or `inspectTimeoutMs` ends the lookup. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 24f9351aa6..280d30af3e 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" 已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。 -`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。 +`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录原样给出,`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,注册表版本范围、标签或别名接在包名的 `@` 之后;安装提供的组合包没有该字段。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。 `inspect(spec, options)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的代理与认证设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式和它被拉取的 `host`。答复携带名称、版本、描述、该包是否声明组合包,以及作答的 `registry`,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`,并附上问过的 `registries`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index ea8a94f548..834fce2ac4 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -21,7 +21,7 @@ import type {} from '@deepseek-ai/dsh-hmr' import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot' import { bundleManifest, readProfileRegistry, registryArguments, runProfilePnpm, saveManifest, viewProfilePackage } from './operations.ts' import { classifyInstallFailure } from './install-failure.ts' -import { InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts' +import { dependencySpec, InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts' import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } from './registry.ts' import { writePluginEnabled } from './patch.ts' import { incompatiblePlugin, ManagementFailure } from './failure.ts' @@ -273,20 +273,22 @@ export class PluginManager extends TypertRemoteService { /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected. - * @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections, - * whether the installation offers the bundle, and removal availability. + * @returns Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional + * display metadata, activation selections, whether the installation offers the bundle, and removal availability. */ @Remote listBundles(): Promise { const manifest = readProfileManifest('dsh', this.profile.dir) const exemptions = readProfileVersionExemptions(this.profile.dir) const selected = manifest.dsh?.profile?.bundles ?? [] - const dependencies = Object.keys(manifest.dependencies ?? {}) + const recorded = manifest.dependencies ?? {} + const dependencies = Object.keys(recorded) const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest const names = [...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {})])] const bundles: BundleInfo[] = [] for (const name of names) { const installed = dependencies.includes(name) + const source = installed ? { source: dependencySpec(name, recorded[name] as string, this.profile.dir) } : {} const optional = OPTIONAL_BUNDLES.includes(name) const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name) const enabled = selected.includes(name) @@ -294,7 +296,7 @@ export class PluginManager extends TypertRemoteService { try { const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor) if (info === undefined) { - if (enabled) bundles.push({ name, enabled, installed, optional, removable: removable && readOnlyReason === undefined, + if (enabled) bundles.push({ name, ...source, enabled, installed, optional, removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: { code: 'not-bundle' }, rows: [], overrides: [] }) continue } @@ -305,12 +307,12 @@ export class PluginManager extends TypertRemoteService { bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }), ...(info.description === undefined || info.description === '' ? {} : { description: info.description }), ...meta === undefined ? {} : { meta }, - enabled, installed, optional, removable: removable && readOnlyReason === undefined, + ...source, enabled, installed, optional, removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), ...this.declaredRows(name, info) }) } catch (error) { if (enabled || installed) { - bundles.push({ name, enabled, installed, optional, removable: removable && readOnlyReason === undefined, + bundles.push({ name, ...source, enabled, installed, optional, removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: managementError(error), rows: [], overrides: [] }) } } diff --git a/packages/boot/plugin-manager/src/install-spec.ts b/packages/boot/plugin-manager/src/install-spec.ts index 3ef4615584..4edcce9313 100644 --- a/packages/boot/plugin-manager/src/install-spec.ts +++ b/packages/boot/plugin-manager/src/install-spec.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-plugin-manager/install-spec */ -import { isAbsolute } from 'node:path' +import { isAbsolute, resolve } from 'node:path' /** * One spec read into its form and the parts an inspection needs. A git spec @@ -89,3 +89,21 @@ export function parseInstallSpec(raw: string): ParsedInstallSpec { if (range === '') throw invalid(spec, 'a version after @ must not be empty') return range === undefined ? { kind: 'registry', spec, name } : { kind: 'registry', spec, name, range } } + +/** A recorded dependency value pnpm resolves outside the registry: a protocol other than a registry alias, or an scp-like git address. */ +const NON_REGISTRY_VALUE = /^(?!(?:npm|workspace|catalog):)[a-z][a-z0-9+.-]*:|^git@/i + +/** + * The spec `pnpm add` accepts for a dependency the profile manifest records. A git address, URL, or other + * non-registry protocol is the recorded value; a `file:` or `link:` path, which pnpm records relative to the + * profile, becomes absolute; a registry range, tag, or alias follows the dependency name after `@`. + * @param name - the dependency name. + * @param recorded - the value the profile manifest records for it. + * @param profileDir - the profile directory a relative local path is resolved against. + * @returns the installable spec. + */ +export function dependencySpec(name: string, recorded: string, profileDir: string): string { + const local = /^(file|link):(.*)$/s.exec(recorded) + if (local !== null) return `${local[1]}:${resolve(profileDir, local[2] as string)}` + return NON_REGISTRY_VALUE.test(recorded) ? recorded : `${name}@${recorded}` +} diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index b87f231e60..89a0f7da7c 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -55,6 +55,8 @@ export interface BundleInfo { enabled: boolean /** Whether the profile's own dependencies hold the package; false for a bundle the dsh installation supplies. */ installed: boolean + /** Present when `installed`: the profile dependency as a spec `pnpm add` accepts, with local paths made absolute. */ + source?: string /** * Whether the installation ships the bundle for the person to switch on: named by the launcher's `OPTIONAL_BUNDLES`, * held by the installation's dependencies, selected by no shipped template, and never removable. diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 81acd6ddf8..d1147f6500 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -4,7 +4,7 @@ import { realpath } from 'node:fs/promises' import { once } from 'node:events' import { createServer } from 'node:http' import type { Socket } from 'node:net' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { fileURLToPath, pathToFileURL } from 'node:url' import { execa } from 'execa' @@ -301,7 +301,7 @@ it('lists bundle versions and current-profile plugin targets', async () => { rows: [{ rowId: 'manager', moduleName: 'cordis:manager', entryId: 'include:manager' }], overrides: [], }, { - name: 'extra', version: '1.0.0', enabled: true, installed: true, optional: false, removable: true, + name: 'extra', version: '1.0.0', source: 'extra@1.0.0', enabled: true, installed: true, optional: false, removable: true, meta: { title: 'extra' }, rows: [{ rowId: 'managed', moduleName: pathToFileURL(join(dir, 'node_modules', 'extra', 'plugin.mjs')).href, entryId: 'include:managed' }], overrides: [], }, @@ -323,7 +323,7 @@ it('describes a bundle by its manifest and patch: one-liner, rows without a live writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) const moduleName = pathToFileURL(join(dir, 'node_modules', 'described', 'plugin.mjs')).href expect((await manager.listBundles()).find(row => row.name === 'described')).toEqual({ - name: 'described', version: '2.0.0', description: 'Describes itself.', enabled: false, installed: true, optional: false, removable: true, + name: 'described', version: '2.0.0', description: 'Describes itself.', source: 'described@2.0.0', enabled: false, installed: true, optional: false, removable: true, meta: { title: 'described', description: 'Describes itself.' }, rows: [{ rowId: 'described-row', moduleName }], overrides: ['managed'], }) @@ -336,6 +336,24 @@ it('describes a bundle by its manifest and patch: one-liner, rows without a live expect((await manager.listBundles()).find(row => row.name === 'described')?.rows).toEqual([{ rowId: 'described-row', moduleName }]) }) +it('names where each installed bundle comes from as a spec pnpm installs', async () => { + const { manager, dir, bundle } = await fixture() + const recorded: Record = { + extra: '^1.0.0', tagged: 'latest', aliased: 'npm:@acme/aliased@2', github: 'github:someone/dsh-plugin#v1', + ssh: 'git@github.com:someone/dsh-plugin.git', tarball: 'https://cdn.example.com/dsh-x-1.0.0.tgz', + relative: 'file:../plugins/relative', linked: 'link:/plugins/linked', + } + for (const name of Object.keys(recorded)) bundle(name, []) + const manifest = readProfileManifest('test', dir) + manifest.dependencies = recorded + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + expect(Object.fromEntries((await manager.listBundles()).map(row => [row.name, row.source]))).toEqual({ + core: undefined, extra: 'extra@^1.0.0', tagged: 'tagged@latest', aliased: 'aliased@npm:@acme/aliased@2', + github: 'github:someone/dsh-plugin#v1', ssh: 'git@github.com:someone/dsh-plugin.git', tarball: 'https://cdn.example.com/dsh-x-1.0.0.tgz', + relative: `file:${resolve(dir, '../plugins/relative')}`, linked: `link:${resolve('/plugins/linked')}`, + }) +}) + it.each(['native', 'runtime'] as const)('reads a disabled bundle and its independent exported plugins without running them with %s resolution', async (mode) => { const { ctx, manager, dir, bundle, profile } = await fixture('startup') bundle('localized', [{ id: 'first', name: 'local-child/first' }, { id: 'second', name: 'local-child/second' }]) @@ -416,7 +434,7 @@ it.each([ writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) expect((await manager.listBundles()).find(row => row.name === 'unnamed')).toEqual({ - name: 'unnamed', version: '1.0.0', enabled: false, installed: true, optional: false, removable: true, + name: 'unnamed', version: '1.0.0', source: 'unnamed@1.0.0', enabled: false, installed: true, optional: false, removable: true, rows: [], overrides: [], ...expected, }) }) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 29acda2d0f..cae7cfe160 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -21,8 +21,8 @@ en: 20e7310ab6867389 zh: eed2957381abc0bd /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle: - en: abc90531efa889d5 - zh: 0562570f85e69c35 + en: 2d29970dcd0b2836 + zh: 73f6b519546d2b6b /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-one-row-of-a-bundle: en: 620c6a3ee20848bd zh: a9429f047a12bb4c diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index c075e53cf0..2dbe9b7eef 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -49,7 +49,7 @@ When the Host attributes a network failure or timeout to a GitHub address and of ### Switching a bundle -A bundle's page shows its full package name under the title, the spec that installs it elsewhere. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards and counts even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins. +A bundle's page shows its full package name under the title. Its **Source** section, after the rows, names the spec that installs it elsewhere, as `pnpm add` accepts it, or **Built in** for an official bundle the installation ships, then its current version. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards and counts even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins. ### Switching one row of a bundle diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 0d8e0f5afd..58007b183d 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -49,7 +49,7 @@ Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时 ### 切换一个组合包 -组合包页面在标题下方显示完整包名,也就是在别处安装它所需的 spec。组合包开关改变其层选择。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片与数量中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。 +组合包页面在标题下方显示完整包名。组件列表之后的「来源信息」给出在别处安装它所需的 spec(`pnpm add` 接受的形式),安装随附的官方组合包显示「内置」,随后是当前版本。组合包开关改变其层选择。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片与数量中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。 ### 切换组合包里的一行 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index f2818ba19a..99cd6401d2 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -1252,6 +1252,46 @@ label.registryOption { color: var(--dsw-alias-label-secondary); } +/* A bundle's source facts: label at the start, value at the end, one ruled line each. */ +.facts { + margin: 0; +} + +.facts div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 44px; + padding: 8px 2px; + border-bottom: 0.5px solid var(--dsw-alias-border-l2); +} + +.facts dt { + flex: none; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); +} + +.facts dd { + min-width: 0; + margin: 0; + font-size: 13px; + line-height: 20px; + text-align: end; + overflow-wrap: anywhere; +} + +.facts code { + padding: 2px 6px; + border-radius: 4px; + background: var(--dsw-alias-bg-module-platform); + box-decoration-break: clone; + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12px; +} + /* The way into a package's page. */ .cardArrow { flex: none; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 92d06906f0..fd9b594d57 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -269,6 +269,34 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: { ) } +/** + * Where a bundle comes from: the spec that installs it elsewhere, or built in + * for an official one the installation ships, and its version. A selected + * bundle that neither the profile nor the installation holds has no section. + */ +function SourceSection({ pkg, t }: { readonly pkg: PackageView; readonly t: Translate }): ReactNode { + if (pkg.source === undefined && !pkg.optional) return null + return ( +
+

{t('sourceTitle')}

+
+
+
{t('sourceSpec')}
+
{pkg.source === undefined ? t('sourceBuiltIn') : {pkg.source}}
+
+ {pkg.version === undefined + ? null + : ( +
+
{t('sourceVersion')}
+
{pkg.version}
+
+ )} +
+
+ ) +} + /** * A bundle's enable switch on its card and its page: locked, saying why, for * one the Host protects; off and locked for one it cannot read. @@ -536,9 +564,10 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot, form }: { * One package's page: the crumb back to the list; its icon with its switch * and, for a package the profile installed, uninstall; its title beside its * version tag, its beta tag, and its problem tag; the package name the title - * stands for, which is what installs it elsewhere; its one-liner; the Host's + * stands for; its one-liner; the Host's * problem when it reports one; the configuration the bundle registered for - * itself; and its rows with their switches and configure controls. + * itself; its rows with their switches and configure controls; and where it + * comes from. */ function PackageDetail({ pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot, @@ -620,6 +649,7 @@ function PackageDetail({ toggle={pkg.enabled ? { busy: row => busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined} configure={configure} /> + {renderSlot('plugins.detail.section', { subject })} diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 71678ea52b..0400aca1e2 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -30,6 +30,10 @@ export const zh = { metadataError: '包元信息错误:{error}', versionTag: 'v{version}', partsLabel: '包含的组件', + sourceTitle: '来源信息', + sourceSpec: '代码来源', + sourceBuiltIn: '内置', + sourceVersion: '当前版本', partsEmpty: '这个插件包不包含任何组件。', partsCountTotal: '共 {count} 个', partsCountRunning: '{count} 运行中', @@ -227,6 +231,10 @@ export const en = { metadataError: 'Package metadata error: {error}', versionTag: 'v{version}', partsLabel: 'Components', + sourceTitle: 'Source', + sourceSpec: 'Installed from', + sourceBuiltIn: 'Built in', + sourceVersion: 'Current version', partsEmpty: 'This plugin pack contains no components.', partsCountTotal: '{count} total', partsCountRunning: '{count} running', diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 2f319d01ea..c87690b767 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -85,6 +85,8 @@ export interface PackageView { readonly meta?: PluginLocalizedMeta /** Whether the profile's own dependencies hold the package; false for a bundle the installation supplies. */ readonly installed: boolean + /** Present when `installed`: the profile dependency as a spec `pnpm add` accepts. */ + readonly source?: string /** Whether the installation ships the bundle for the person to switch on: official, off until selected, never removable. */ readonly optional: boolean /** Whether the bundle is in the profile's layer list. */ @@ -424,6 +426,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]): enabled: bundle.enabled, rows, ...bundle.version === undefined ? {} : { version: bundle.version }, + ...bundle.source === undefined ? {} : { source: bundle.source }, ...bundle.description === undefined ? {} : { description: bundle.description }, ...bundle.meta === undefined ? {} : { meta: bundle.meta }, ...bundle.readOnlyReason === undefined ? {} : { readOnlyReason: bundle.readOnlyReason }, diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 63fa70a35a..2b14b5afc4 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -201,6 +201,22 @@ describe('PluginManagerPage', () => { expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')).not.toBeNull() }) + it('says where a bundle comes from: the spec that installs it, or built in, with its version', () => { + const b = renderTab({ packages: [ + pkg({ source: 'github:someone/dsh-better-sidebar' }), + { name: 'dsh-official', installed: false, optional: true, enabled: false, rows: [] }, + { name: 'dsh-missing', installed: false, optional: false, enabled: true, error: { code: 'unknown-plugin' }, rows: [] }, + ] }) + const facts = (): string[] => [...document.querySelectorAll('[data-plugin-source] dt, [data-plugin-source] dd')].map(node => node.textContent) + act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) }) + expect(within(document.querySelector('[data-plugin-source]') as HTMLElement).getByRole('heading').textContent).toBe(en.sourceTitle) + expect(facts()).toEqual([en.sourceSpec, 'github:someone/dsh-better-sidebar', en.sourceVersion, '0.16.0']) + act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-official' }) }) + expect(facts()).toEqual([en.sourceSpec, en.sourceBuiltIn]) + act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-missing' }) }) + expect(document.querySelector('[data-plugin-source]')).toBeNull() + }) + it('preserves the requested bundle through StrictMode effect replay and page remounts', () => { const b = renderTab({ packages: [pkg()] }) b.unmount() diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 895a4feccd..f46b48ac7e 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -19,6 +19,7 @@ const BUNDLE: BundleInfo = { description: 'A sidebar.', enabled: false, installed: true, + source: 'dsh-better-sidebar@^0.16.0', optional: false, removable: true, rows: [{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY }, { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme' }], @@ -115,7 +116,7 @@ it('hands a custom page the shared configuration form of its entry', () => { describe('packageView', () => { it('joins a bundle with the entries its rows run as', () => { expect(packageView(BUNDLE, PLUGINS)).toEqual({ - name: 'dsh-better-sidebar', version: '0.16.0', description: 'A sidebar.', + name: 'dsh-better-sidebar', version: '0.16.0', description: 'A sidebar.', source: 'dsh-better-sidebar@^0.16.0', installed: true, optional: false, enabled: false, rows: [ { rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY, enabled: true, phase: 'active' }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index e419f6edd9..b43dc0dd6c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1656,7 +1656,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: '@Remote listBundles(): Promise', description: 'Read the profile\'s installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.', parameters: [], - returns: 'Package versions, manifest descriptions, rows, optional display metadata, activation selections, whether the installation offers the bundle, and removal availability.', + returns: 'Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional display metadata, activation selections, whether the installation offers the bundle, and removal availability.', }, { signature: '@Remote async registries(): Promise', @@ -4701,7 +4701,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BundleInfo', - declaration: 'export interface BundleInfo {\n name: string;\n version?: string;\n meta?: PluginLocalizedMeta;\n description?: string;\n enabled: boolean;\n installed: boolean;\n optional: boolean;\n removable: boolean;\n readOnlyReason?: ReadOnlyReason;\n error?: ManagementError;\n rows: BundleRowInfo[];\n overrides: string[];\n}', + declaration: 'export interface BundleInfo {\n name: string;\n version?: string;\n meta?: PluginLocalizedMeta;\n description?: string;\n enabled: boolean;\n installed: boolean;\n source?: string;\n optional: boolean;\n removable: boolean;\n readOnlyReason?: ReadOnlyReason;\n error?: ManagementError;\n rows: BundleRowInfo[];\n overrides: string[];\n}', }, { name: 'BundleRowInfo', From 6d1e12b237649b97e7ddd4ae519fce21a67931ff Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 29 Sep 2026 12:07:56 +0800 Subject: [PATCH 2/3] fix(plugins): keep credentials and shadowed copies out of bundle sources Address the first ds-review-bot round on the bundle Source section: - strip the user information of http(s) and git+http(s) URLs so a private Git URL's token reaches neither the page nor the plugin_manager tool; - expand `~` in file:/link: paths to the home directory as pnpm does; - write jsr: aliases after the dependency name, like npm: aliases; - give no source for a profile dependency the installation also supplies, whose installation copy is the one that loads; - label the English row "Code source" to match the Chinese copy and hold for built-in bundles, and sync the subsystem page, READMEs, CSS comment, and tool test. --- .../plugin-manager/exports-en.expected.md | 2 +- docs/event-producer-consumer.md | 6 ++--- docs/event-producer-consumer.zh.md | 6 ++--- docs/subsystems/boot.i18n.yaml | 4 ++-- docs/subsystems/boot.md | 2 +- docs/subsystems/boot.zh.md | 2 +- packages/boot/plugin-manager/README.i18n.yaml | 4 ++-- packages/boot/plugin-manager/README.md | 2 +- packages/boot/plugin-manager/README.zh.md | 2 +- packages/boot/plugin-manager/src/index.ts | 3 ++- .../boot/plugin-manager/src/install-spec.ts | 19 ++++++++++----- packages/boot/plugin-manager/src/types.ts | 5 +++- .../boot/plugin-manager/tests/manager.spec.ts | 24 ++++++++++++------- .../boot/plugin-manager/tests/tools.spec.ts | 4 ++-- .../src/client/PluginManagerPage.module.css | 4 ++-- .../src/client/PluginManagerPage.tsx | 7 +++--- .../ui-plugin-manager/src/client/locales.ts | 2 +- 17 files changed, 58 insertions(+), 40 deletions(-) diff --git a/apps/web/tests/expected/plugin-manager/exports-en.expected.md b/apps/web/tests/expected/plugin-manager/exports-en.expected.md index 3b3706b8f0..5ab1afeccb 100644 --- a/apps/web/tests/expected/plugin-manager/exports-en.expected.md +++ b/apps/web/tests/expected/plugin-manager/exports-en.expected.md @@ -23,7 +23,7 @@ - code: fixture-review - text: "Off" - heading "Source" [level=4] -- term: Installed from +- term: Code source - definition: - code: file:{{fixtures}}/fixture-bundle - term: Current version diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4721d79846..f0bf0eec36 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -56,9 +56,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:245`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:251`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:258`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:248`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:254`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:261`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:471`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e4e11ccd96..03db59efd1 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -58,9 +58,9 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:245`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:251`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:258`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:248`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:254`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:261`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:471`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index cc1d189e3f..3b36b08a50 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -6,5 +6,5 @@ en: 962b4398c9052959 zh: 14471c340a9f6e60 /profile-management/management-records: - en: c49daf51d1468b37 - zh: f4964a074ca9002f + en: 6295412615ed2d56 + zh: f02b40d4ad5fc7e4 diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 4dd92e2224..dc118bc5c7 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -10,7 +10,7 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `PluginInfo` carries module identity, effective enablement, fiber phase and optional display `meta`, plus a unique `patchId` or a `readOnlyReason`. -`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability and optional resolution error. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time. +`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability, optional resolution error, and, for a bundle the profile's own dependency supplies, `source`: that dependency as a spec `pnpm add` accepts. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time. `InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `approvedBuilds` grants persistent script permission to the supplied pending package names before installation. `registry` names the registry asked first; absent, the configured one. diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index d31b0648cc..22d8fe2378 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -10,7 +10,7 @@ `PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。 -`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性及可选的解析错误。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 +`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性、可选的解析错误;由 profile 自身依赖提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 `InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。 diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index b95d40d15f..bea031538a 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: 02e58b7db5ea342c - zh: d0a2d0e0255e241b + en: 22d07e11dba5e843 + zh: b7d548b6a3f70b98 /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 920c0acb3b9feaf3 zh: ba6e832a256880a7 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 9427801c61..a9f4a6b2a9 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -41,7 +41,7 @@ A plugin toggle updates only `disabled` in the last matching override in the pro A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable. -`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded, a `file:` or `link:` path made absolute against the profile, and a registry range, tag, or alias after the package name; a bundle the installation supplies has none. The `plugin_manager` tool omits UI display metadata from list results. +`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the credentials of an http(s) URL, a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata from list results. `inspect(spec, options)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same proxy and authentication settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form and the `host` it is fetched from. The answer carries the name, version, description, whether the package declares a bundle, and the `registry` that answered, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`, with the `registries` asked. A caller's `signal` or `inspectTimeoutMs` ends the lookup. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 280d30af3e..912982ed6d 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" 已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。 -`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录原样给出,`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,注册表版本范围、标签或别名接在包名的 `@` 之后;安装提供的组合包没有该字段。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。 +`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 中的凭据;`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。 `inspect(spec, options)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的代理与认证设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式和它被拉取的 `host`。答复携带名称、版本、描述、该包是否声明组合包,以及作答的 `registry`,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`,并附上问过的 `registries`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 834fce2ac4..b63273b956 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -288,9 +288,10 @@ export class PluginManager extends TypertRemoteService { const bundles: BundleInfo[] = [] for (const name of names) { const installed = dependencies.includes(name) - const source = installed ? { source: dependencySpec(name, recorded[name] as string, this.profile.dir) } : {} const optional = OPTIONAL_BUNDLES.includes(name) const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name) + // Bundle resolution reads the installation first, so a profile dependency it also supplies is not the loaded copy. + const source = removable ? { source: dependencySpec(name, recorded[name] as string, this.profile.dir) } : {} const enabled = selected.includes(name) const readOnlyReason = this.protectsManager(name) ? 'management-required' as const : undefined try { diff --git a/packages/boot/plugin-manager/src/install-spec.ts b/packages/boot/plugin-manager/src/install-spec.ts index 4edcce9313..59b9ce55f7 100644 --- a/packages/boot/plugin-manager/src/install-spec.ts +++ b/packages/boot/plugin-manager/src/install-spec.ts @@ -4,6 +4,7 @@ * @module @deepseek-ai/dsh-plugin-manager/install-spec */ +import { homedir } from 'node:os' import { isAbsolute, resolve } from 'node:path' /** @@ -90,13 +91,16 @@ export function parseInstallSpec(raw: string): ParsedInstallSpec { return range === undefined ? { kind: 'registry', spec, name } : { kind: 'registry', spec, name, range } } -/** A recorded dependency value pnpm resolves outside the registry: a protocol other than a registry alias, or an scp-like git address. */ -const NON_REGISTRY_VALUE = /^(?!(?:npm|workspace|catalog):)[a-z][a-z0-9+.-]*:|^git@/i +/** A recorded dependency value pnpm resolves outside a registry: a protocol other than a registry alias, or an scp-like git address. */ +const NON_REGISTRY_VALUE = /^(?!(?:npm|jsr|workspace|catalog):)[a-z][a-z0-9+.-]*:|^git@/i +/** The user information of an http(s) URL, which holds a password or access token when present. */ +const HTTP_CREDENTIALS = /^((?:git\+)?https?:\/\/)[^/@]*@/i /** * The spec `pnpm add` accepts for a dependency the profile manifest records. A git address, URL, or other - * non-registry protocol is the recorded value; a `file:` or `link:` path, which pnpm records relative to the - * profile, becomes absolute; a registry range, tag, or alias follows the dependency name after `@`. + * non-registry protocol is the recorded value without the credentials of an http(s) URL; a `file:` or `link:` + * path, which pnpm records relative to the profile or with `~` for the home directory, becomes absolute; a + * registry range, tag, or alias follows the dependency name after `@`. * @param name - the dependency name. * @param recorded - the value the profile manifest records for it. * @param profileDir - the profile directory a relative local path is resolved against. @@ -104,6 +108,9 @@ const NON_REGISTRY_VALUE = /^(?!(?:npm|workspace|catalog):)[a-z][a-z0-9+.-]*:|^g */ export function dependencySpec(name: string, recorded: string, profileDir: string): string { const local = /^(file|link):(.*)$/s.exec(recorded) - if (local !== null) return `${local[1]}:${resolve(profileDir, local[2] as string)}` - return NON_REGISTRY_VALUE.test(recorded) ? recorded : `${name}@${recorded}` + if (local !== null) { + const path = (local[2] as string).replace(/^~(?=$|[\\/])/, () => homedir()) + return `${local[1]}:${resolve(profileDir, path)}` + } + return NON_REGISTRY_VALUE.test(recorded) ? recorded.replace(HTTP_CREDENTIALS, '$1') : `${name}@${recorded}` } diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 89a0f7da7c..de74865beb 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -55,7 +55,10 @@ export interface BundleInfo { enabled: boolean /** Whether the profile's own dependencies hold the package; false for a bundle the dsh installation supplies. */ installed: boolean - /** Present when `installed`: the profile dependency as a spec `pnpm add` accepts, with local paths made absolute. */ + /** + * Present for a profile dependency the installation does not also supply: the spec `pnpm add` accepts, with local + * paths made absolute and http(s) credentials removed. + */ source?: string /** * Whether the installation ships the bundle for the person to switch on: named by the launcher's `OPTIONAL_BUNDLES`, diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index d1147f6500..164ce038a6 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -5,7 +5,7 @@ import { once } from 'node:events' import { createServer } from 'node:http' import type { Socket } from 'node:net' import { join, resolve } from 'node:path' -import { tmpdir } from 'node:os' +import { homedir, tmpdir } from 'node:os' import { fileURLToPath, pathToFileURL } from 'node:url' import { execa } from 'execa' import type { Context } from '@deepseek-ai/cordis' @@ -337,20 +337,28 @@ it('describes a bundle by its manifest and patch: one-liner, rows without a live }) it('names where each installed bundle comes from as a spec pnpm installs', async () => { - const { manager, dir, bundle } = await fixture() + const { manager, dir, bundle, profile } = await fixture() const recorded: Record = { - extra: '^1.0.0', tagged: 'latest', aliased: 'npm:@acme/aliased@2', github: 'github:someone/dsh-plugin#v1', - ssh: 'git@github.com:someone/dsh-plugin.git', tarball: 'https://cdn.example.com/dsh-x-1.0.0.tgz', - relative: 'file:../plugins/relative', linked: 'link:/plugins/linked', + extra: '^1.0.0', tagged: 'latest', aliased: 'npm:@acme/aliased@2', jsr: 'jsr:@acme/jsr@^1', github: 'github:someone/dsh-plugin#v1', + ssh: 'git@github.com:someone/dsh-plugin.git', sshUrl: 'git+ssh://git@github.com/someone/dsh-plugin.git', + tarball: 'https://cdn.example.com/dsh-x-1.0.0.tgz', token: 'https://ghp_secret@cdn.example.com/dsh-x-1.0.0.tgz', + password: 'git+https://someone:secret@git.example.com/someone/dsh-plugin.git#main', + relative: 'file:../plugins/relative', linked: 'link:/plugins/linked', home: 'file:~/plugins/home', + shadowed: '1.0.0', } for (const name of Object.keys(recorded)) bundle(name, []) const manifest = readProfileManifest('test', dir) manifest.dependencies = recorded writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + // The installation's own copy of a bundle is the one that loads, so the profile's dependency on it names nothing. + writeFileSync(profile.installAnchor, JSON.stringify({ name: 'installation', dependencies: { shadowed: '1.0.0' } })) expect(Object.fromEntries((await manager.listBundles()).map(row => [row.name, row.source]))).toEqual({ - core: undefined, extra: 'extra@^1.0.0', tagged: 'tagged@latest', aliased: 'aliased@npm:@acme/aliased@2', - github: 'github:someone/dsh-plugin#v1', ssh: 'git@github.com:someone/dsh-plugin.git', tarball: 'https://cdn.example.com/dsh-x-1.0.0.tgz', - relative: `file:${resolve(dir, '../plugins/relative')}`, linked: `link:${resolve('/plugins/linked')}`, + core: undefined, extra: 'extra@^1.0.0', tagged: 'tagged@latest', aliased: 'aliased@npm:@acme/aliased@2', jsr: 'jsr@jsr:@acme/jsr@^1', + github: 'github:someone/dsh-plugin#v1', ssh: 'git@github.com:someone/dsh-plugin.git', sshUrl: 'git+ssh://git@github.com/someone/dsh-plugin.git', + tarball: 'https://cdn.example.com/dsh-x-1.0.0.tgz', token: 'https://cdn.example.com/dsh-x-1.0.0.tgz', + password: 'git+https://git.example.com/someone/dsh-plugin.git#main', + relative: `file:${resolve(dir, '../plugins/relative')}`, linked: `link:${resolve(dir, '/plugins/linked')}`, + home: `file:${resolve(homedir(), 'plugins/home')}`, shadowed: undefined, }) }) diff --git a/packages/boot/plugin-manager/tests/tools.spec.ts b/packages/boot/plugin-manager/tests/tools.spec.ts index 021df877a6..e18b5acce8 100644 --- a/packages/boot/plugin-manager/tests/tools.spec.ts +++ b/packages/boot/plugin-manager/tests/tools.spec.ts @@ -195,10 +195,10 @@ it('keeps UI translation metadata out of model-facing plugin and bundle lists', entries: [{ entryId: 'include:plugin', enabled: true }], total: 1, nextOffset: null, }) manager.listBundles.mockImplementationOnce(async () => [{ - name: 'bundle', enabled: true, meta, rows: [{ rowId: 'plugin', moduleName: 'plugin', meta }], + name: 'bundle', source: 'bundle@^1.0.0', enabled: true, meta, rows: [{ rowId: 'plugin', moduleName: 'plugin', meta }], }]) expect(JSON.parse(resultText(await call({ action: 'list_bundles' })))).toEqual({ - entries: [{ name: 'bundle', enabled: true, rows: [{ rowId: 'plugin', moduleName: 'plugin' }] }], total: 1, nextOffset: null, + entries: [{ name: 'bundle', source: 'bundle@^1.0.0', enabled: true, rows: [{ rowId: 'plugin', moduleName: 'plugin' }] }], total: 1, nextOffset: null, }) }) diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 99cd6401d2..d13c10bca7 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -1201,8 +1201,8 @@ label.registryOption { line-height: 22px; } -/* The package or row identity under the title: the exact spec that installs the - package elsewhere, where a title of its own or a shortened name hides it. */ +/* The package or row identity under the title, where a title of its own or a + shortened name hides it. */ .detailName { margin: 0; font-size: 12px; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index fd9b594d57..9fe9d099b5 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -564,10 +564,9 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot, form }: { * One package's page: the crumb back to the list; its icon with its switch * and, for a package the profile installed, uninstall; its title beside its * version tag, its beta tag, and its problem tag; the package name the title - * stands for; its one-liner; the Host's - * problem when it reports one; the configuration the bundle registered for - * itself; its rows with their switches and configure controls; and where it - * comes from. + * stands for; its one-liner; the Host's problem when it reports one; the + * configuration the bundle registered for itself; its rows with their switches + * and configure controls; and where it comes from. */ function PackageDetail({ pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot, diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 0400aca1e2..4e646d22b6 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -232,7 +232,7 @@ export const en = { versionTag: 'v{version}', partsLabel: 'Components', sourceTitle: 'Source', - sourceSpec: 'Installed from', + sourceSpec: 'Code source', sourceBuiltIn: 'Built in', sourceVersion: 'Current version', partsEmpty: 'This plugin pack contains no components.', From 9b79f97010449fc6b397771c8ddc8cb2cb4f7221 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 29 Sep 2026 13:27:43 +0800 Subject: [PATCH 3/3] fix(plugins): keep aliases and whole user information in bundle sources Address the second ds-review-bot round on the bundle Source section: - remove an http(s) URL's user information up to its last `@`, so a username containing `@` no longer leaves the password behind; - keep the dependency name before a local or git spec when the package's own manifest name differs, so reinstalling restores the alias; - accept scp-like git addresses with any user name; - show Built in for a profile dependency whose loaded copy the installation supplies, instead of hiding the section; - align the PackageView, subsystem page, and README wording with the shadowing rule and the kept query string, and assert the source of not-bundle and unreadable bundles. --- docs/subsystems/boot.i18n.yaml | 4 +-- docs/subsystems/boot.md | 2 +- docs/subsystems/boot.zh.md | 2 +- packages/boot/plugin-manager/README.i18n.yaml | 4 +-- packages/boot/plugin-manager/README.md | 2 +- packages/boot/plugin-manager/README.zh.md | 2 +- packages/boot/plugin-manager/src/index.ts | 13 ++++++---- .../boot/plugin-manager/src/install-spec.ts | 25 ++++++++++++------- packages/boot/plugin-manager/src/types.ts | 2 +- .../boot/plugin-manager/tests/manager.spec.ts | 14 +++++++---- .../src/client/PluginManagerPage.tsx | 7 +++--- .../src/client/manager-store.ts | 2 +- .../tests/components.client.spec.tsx | 4 +++ 13 files changed, 51 insertions(+), 32 deletions(-) diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 3b36b08a50..4d5b515bea 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -6,5 +6,5 @@ en: 962b4398c9052959 zh: 14471c340a9f6e60 /profile-management/management-records: - en: 6295412615ed2d56 - zh: f02b40d4ad5fc7e4 + en: 6929d1001684c024 + zh: 06d3d3f6eea848ec diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index dc118bc5c7..142ea14b59 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -10,7 +10,7 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `PluginInfo` carries module identity, effective enablement, fiber phase and optional display `meta`, plus a unique `patchId` or a `readOnlyReason`. -`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability, optional resolution error, and, for a bundle the profile's own dependency supplies, `source`: that dependency as a spec `pnpm add` accepts. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time. +`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability, optional resolution error, and, for a bundle the profile's own dependency supplies and the installation does not, `source`: that dependency as a spec `pnpm add` accepts. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time. `InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `approvedBuilds` grants persistent script permission to the supplied pending package names before installation. `registry` names the registry asked first; absent, the configured one. diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 22d8fe2378..4495ccbd73 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -10,7 +10,7 @@ `PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。 -`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性、可选的解析错误;由 profile 自身依赖提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 +`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性、可选的解析错误;由 profile 自身依赖提供、且安装不提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 `InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。 diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index bea031538a..7b503ceb6a 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: 22d07e11dba5e843 - zh: b7d548b6a3f70b98 + en: d0f7d42dffe6d3c7 + zh: b4bf81958cda876d /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 920c0acb3b9feaf3 zh: ba6e832a256880a7 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index a9f4a6b2a9..eabc124c92 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -41,7 +41,7 @@ A plugin toggle updates only `disabled` in the last matching override in the pro A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable. -`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the credentials of an http(s) URL, a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata from list results. +`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the user information of an http(s) URL (a query string stays as recorded), a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name, as is any spec of a package installed under another name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata from list results. `inspect(spec, options)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same proxy and authentication settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form and the `host` it is fetched from. The answer carries the name, version, description, whether the package declares a bundle, and the `registry` that answered, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`, with the `registries` asked. A caller's `signal` or `inspectTimeoutMs` ends the lookup. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 912982ed6d..c715052d8f 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" 已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。 -`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 中的凭据;`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。 +`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 的 user information(查询字符串保持原样);`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后,以别名安装的包的 spec 也是如此。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。 `inspect(spec, options)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的代理与认证设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式和它被拉取的 `host`。答复携带名称、版本、描述、该包是否声明组合包,以及作答的 `registry`,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`,并附上问过的 `registries`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index b63273b956..10ca06c160 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -290,14 +290,17 @@ export class PluginManager extends TypertRemoteService { const installed = dependencies.includes(name) const optional = OPTIONAL_BUNDLES.includes(name) const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name) - // Bundle resolution reads the installation first, so a profile dependency it also supplies is not the loaded copy. - const source = removable ? { source: dependencySpec(name, recorded[name] as string, this.profile.dir) } : {} + // Bundle resolution reads the installation first, so a profile dependency the installation manifest also names, + // like one it forbids removing, is not the loaded copy. + const sourceOf = (packageName?: string): { source?: string } => + removable ? { source: dependencySpec(name, recorded[name] as string, this.profile.dir, packageName) } : {} const enabled = selected.includes(name) const readOnlyReason = this.protectsManager(name) ? 'management-required' as const : undefined try { const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor) if (info === undefined) { - if (enabled) bundles.push({ name, ...source, enabled, installed, optional, removable: removable && readOnlyReason === undefined, + if (enabled) bundles.push({ name, ...sourceOf(), enabled, installed, optional, + removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: { code: 'not-bundle' }, rows: [], overrides: [] }) continue } @@ -308,12 +311,12 @@ export class PluginManager extends TypertRemoteService { bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }), ...(info.description === undefined || info.description === '' ? {} : { description: info.description }), ...meta === undefined ? {} : { meta }, - ...source, enabled, installed, optional, removable: removable && readOnlyReason === undefined, + ...sourceOf(info.name), enabled, installed, optional, removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), ...this.declaredRows(name, info) }) } catch (error) { if (enabled || installed) { - bundles.push({ name, ...source, enabled, installed, optional, removable: removable && readOnlyReason === undefined, + bundles.push({ name, ...sourceOf(), enabled, installed, optional, removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: managementError(error), rows: [], overrides: [] }) } } diff --git a/packages/boot/plugin-manager/src/install-spec.ts b/packages/boot/plugin-manager/src/install-spec.ts index 59b9ce55f7..4b76f1925e 100644 --- a/packages/boot/plugin-manager/src/install-spec.ts +++ b/packages/boot/plugin-manager/src/install-spec.ts @@ -92,25 +92,32 @@ export function parseInstallSpec(raw: string): ParsedInstallSpec { } /** A recorded dependency value pnpm resolves outside a registry: a protocol other than a registry alias, or an scp-like git address. */ -const NON_REGISTRY_VALUE = /^(?!(?:npm|jsr|workspace|catalog):)[a-z][a-z0-9+.-]*:|^git@/i -/** The user information of an http(s) URL, which holds a password or access token when present. */ -const HTTP_CREDENTIALS = /^((?:git\+)?https?:\/\/)[^/@]*@/i +const NON_REGISTRY_VALUE = /^(?!(?:npm|jsr|workspace|catalog):)[a-z][a-z0-9+.-]*:|^[^@/:\s]+@[^:/\s]+:/i +/** The user information of an http(s) URL, up to the last `@` before the path, which holds a password or access token when present. */ +const HTTP_USER_INFO = /^((?:git\+)?https?:\/\/)[^/]*@/i /** * The spec `pnpm add` accepts for a dependency the profile manifest records. A git address, URL, or other - * non-registry protocol is the recorded value without the credentials of an http(s) URL; a `file:` or `link:` - * path, which pnpm records relative to the profile or with `~` for the home directory, becomes absolute; a - * registry range, tag, or alias follows the dependency name after `@`. + * non-registry protocol is the recorded value without the user information of an http(s) URL, whose query string + * is kept; a `file:` or `link:` path, which pnpm records relative to the profile or with `~` for the home + * directory, becomes absolute. A registry range, tag, or alias follows the dependency name after `@`, as does a + * non-registry value installed under a name other than the package's own. * @param name - the dependency name. * @param recorded - the value the profile manifest records for it. * @param profileDir - the profile directory a relative local path is resolved against. + * @param packageName - the name the installed package's manifest declares, when it could be read. * @returns the installable spec. */ -export function dependencySpec(name: string, recorded: string, profileDir: string): string { +export function dependencySpec(name: string, recorded: string, profileDir: string, packageName = name): string { const local = /^(file|link):(.*)$/s.exec(recorded) + let spec: string if (local !== null) { const path = (local[2] as string).replace(/^~(?=$|[\\/])/, () => homedir()) - return `${local[1]}:${resolve(profileDir, path)}` + spec = `${local[1]}:${resolve(profileDir, path)}` + } else if (NON_REGISTRY_VALUE.test(recorded)) { + spec = recorded.replace(HTTP_USER_INFO, '$1') + } else { + return `${name}@${recorded}` } - return NON_REGISTRY_VALUE.test(recorded) ? recorded.replace(HTTP_CREDENTIALS, '$1') : `${name}@${recorded}` + return packageName === name ? spec : `${name}@${spec}` } diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index de74865beb..dbc20d9598 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -57,7 +57,7 @@ export interface BundleInfo { installed: boolean /** * Present for a profile dependency the installation does not also supply: the spec `pnpm add` accepts, with local - * paths made absolute and http(s) credentials removed. + * paths made absolute and the user information of an http(s) URL removed. */ source?: string /** diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 164ce038a6..fa8b926bae 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -241,7 +241,7 @@ it.each(['missing package', 'invalid manifest', 'not a bundle', 'missing patch', }) expect([...ctx.loader.entries()].some(entry => entry.id === 'include:managed')).toBe(false) expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ - enabled: true, error: { code: failure === 'not a bundle' ? 'not-bundle' : 'operation-error' }, rows: [], + enabled: true, source: 'extra@1.0.0', error: { code: failure === 'not a bundle' ? 'not-bundle' : 'operation-error' }, rows: [], }) expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' }) expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core']) @@ -340,13 +340,16 @@ it('names where each installed bundle comes from as a spec pnpm installs', async const { manager, dir, bundle, profile } = await fixture() const recorded: Record = { extra: '^1.0.0', tagged: 'latest', aliased: 'npm:@acme/aliased@2', jsr: 'jsr:@acme/jsr@^1', github: 'github:someone/dsh-plugin#v1', - ssh: 'git@github.com:someone/dsh-plugin.git', sshUrl: 'git+ssh://git@github.com/someone/dsh-plugin.git', + ssh: 'git@github.com:someone/dsh-plugin.git', deploy: 'deploy@git.corp:team/dsh-plugin.git', + sshUrl: 'git+ssh://git@github.com/someone/dsh-plugin.git', email: 'git+http://user@example.com:secret@git.example.com/repo.git', tarball: 'https://cdn.example.com/dsh-x-1.0.0.tgz', token: 'https://ghp_secret@cdn.example.com/dsh-x-1.0.0.tgz', password: 'git+https://someone:secret@git.example.com/someone/dsh-plugin.git#main', relative: 'file:../plugins/relative', linked: 'link:/plugins/linked', home: 'file:~/plugins/home', - shadowed: '1.0.0', + aliasLocal: 'file:../plugins/original', shadowed: '1.0.0', } for (const name of Object.keys(recorded)) bundle(name, []) + // Installed under a name other than its own, the package keeps that name only when the spec carries it. + writeFileSync(join(dir, 'node_modules', 'aliasLocal', 'package.json'), JSON.stringify({ name: 'original', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })) const manifest = readProfileManifest('test', dir) manifest.dependencies = recorded writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) @@ -354,11 +357,12 @@ it('names where each installed bundle comes from as a spec pnpm installs', async writeFileSync(profile.installAnchor, JSON.stringify({ name: 'installation', dependencies: { shadowed: '1.0.0' } })) expect(Object.fromEntries((await manager.listBundles()).map(row => [row.name, row.source]))).toEqual({ core: undefined, extra: 'extra@^1.0.0', tagged: 'tagged@latest', aliased: 'aliased@npm:@acme/aliased@2', jsr: 'jsr@jsr:@acme/jsr@^1', - github: 'github:someone/dsh-plugin#v1', ssh: 'git@github.com:someone/dsh-plugin.git', sshUrl: 'git+ssh://git@github.com/someone/dsh-plugin.git', + github: 'github:someone/dsh-plugin#v1', ssh: 'git@github.com:someone/dsh-plugin.git', deploy: 'deploy@git.corp:team/dsh-plugin.git', + sshUrl: 'git+ssh://git@github.com/someone/dsh-plugin.git', email: 'git+http://git.example.com/repo.git', tarball: 'https://cdn.example.com/dsh-x-1.0.0.tgz', token: 'https://cdn.example.com/dsh-x-1.0.0.tgz', password: 'git+https://git.example.com/someone/dsh-plugin.git#main', relative: `file:${resolve(dir, '../plugins/relative')}`, linked: `link:${resolve(dir, '/plugins/linked')}`, - home: `file:${resolve(homedir(), 'plugins/home')}`, shadowed: undefined, + home: `file:${resolve(homedir(), 'plugins/home')}`, aliasLocal: `aliasLocal@file:${resolve(dir, '../plugins/original')}`, shadowed: undefined, }) }) diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 9fe9d099b5..b5ed263122 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -271,11 +271,12 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: { /** * Where a bundle comes from: the spec that installs it elsewhere, or built in - * for an official one the installation ships, and its version. A selected - * bundle that neither the profile nor the installation holds has no section. + * for one whose loaded copy the installation supplies, and its version. A + * selected bundle that neither the profile nor the installation holds has no + * section. */ function SourceSection({ pkg, t }: { readonly pkg: PackageView; readonly t: Translate }): ReactNode { - if (pkg.source === undefined && !pkg.optional) return null + if (pkg.source === undefined && !pkg.installed && !pkg.optional) return null return (

{t('sourceTitle')}

diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index c87690b767..0841ef440a 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -85,7 +85,7 @@ export interface PackageView { readonly meta?: PluginLocalizedMeta /** Whether the profile's own dependencies hold the package; false for a bundle the installation supplies. */ readonly installed: boolean - /** Present when `installed`: the profile dependency as a spec `pnpm add` accepts. */ + /** Present for a profile dependency the installation does not also supply: the spec `pnpm add` accepts. */ readonly source?: string /** Whether the installation ships the bundle for the person to switch on: official, off until selected, never removable. */ readonly optional: boolean diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 2b14b5afc4..06089013e6 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -205,6 +205,7 @@ describe('PluginManagerPage', () => { const b = renderTab({ packages: [ pkg({ source: 'github:someone/dsh-better-sidebar' }), { name: 'dsh-official', installed: false, optional: true, enabled: false, rows: [] }, + { name: 'dsh-shadowed', installed: true, optional: false, enabled: true, rows: [] }, { name: 'dsh-missing', installed: false, optional: false, enabled: true, error: { code: 'unknown-plugin' }, rows: [] }, ] }) const facts = (): string[] => [...document.querySelectorAll('[data-plugin-source] dt, [data-plugin-source] dd')].map(node => node.textContent) @@ -213,6 +214,9 @@ describe('PluginManagerPage', () => { expect(facts()).toEqual([en.sourceSpec, 'github:someone/dsh-better-sidebar', en.sourceVersion, '0.16.0']) act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-official' }) }) expect(facts()).toEqual([en.sourceSpec, en.sourceBuiltIn]) + // A profile dependency the installation also supplies loads the installation's copy. + act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-shadowed' }) }) + expect(facts()).toEqual([en.sourceSpec, en.sourceBuiltIn]) act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-missing' }) }) expect(document.querySelector('[data-plugin-source]')).toBeNull() })