Merge master into persistence-schema-digest-keys

This commit is contained in:
turtle1999
2026-10-19 19:24:13 +08:00
49 changed files with 2829 additions and 138 deletions
@@ -9,8 +9,8 @@
en: edefff7cd986a509
zh: a270839fa86976a2
/agent-note-guided-plugin-installation/decision:
en: 30f1e636973df06a
zh: 0f874b88a5d4d7f5
en: 5448b9f8aaa9a569
zh: 9024c5328c5da3bd
/agent-note-guided-plugin-installation/alternatives-considered:
en: be64b541f4d81e7c
zh: 8600531c08ab4288
@@ -20,7 +20,7 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
**Cancellation is the manager's, not the signal's.** The dialog generates a request id for each run; the Host streams the run's output and phases under it, and `cancelInstall` answers only after pnpm exited and the files are back. The dialog waits for that answer before offering the spec again; an aborted RPC or a lost connection is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for.
**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added, and the dialog closes and the list scrolls to it. Nothing is enabled before the person has seen what was installed.
**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added; the dialog closes, and the list scrolls to it when its card is on the current page. Nothing is enabled before the person has seen what was installed.
**Outcomes of the moment are toasts.** A change that waits for the next start, one a higher layer overrides, a cancelled run, and a refused action each toast and retire; nothing stays on the page.
@@ -20,7 +20,7 @@ Status: implemented
**取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。
**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭,列表滚动到它。在人看到装了什么之前,不会启用任何东西。
**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭;其卡片在当前页面时,列表滚动到它。在人看到装了什么之前,不会启用任何东西。
**当下的结果是 toast。** 要等下次启动的变更、被更高层覆盖的变更、被取消的运行、被拒绝的操作,各弹一条 toast 后自行消失;页面上不留任何东西。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-14-registry-bundle-updates.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-14-registry-bundle-updates.md
/agent-note-registry-bundle-updates:
en: 9b55699b0d5b3c7b
zh: 729a564af144048d
/agent-note-registry-bundle-updates/problem:
en: 47524f94e08192b5
zh: 335104b6fda38f03
/agent-note-registry-bundle-updates/decision:
en: 9201e6cc6cad6b8d
zh: be2332897037c43a
/agent-note-registry-bundle-updates/alternatives-considered:
en: 299b8e211b9c21bf
zh: 72bd106e96b42605
/agent-note-registry-bundle-updates/consequences:
en: 57c30a10a233c3e0
zh: 87cd51e2df8696bd
/agent-note-registry-bundle-updates/verification:
en: ab3442415bff92a6
zh: 89d477c7d14f6098
@@ -0,0 +1,47 @@
# Agent Note: Registry bundle updates
Status: implemented
English | [中文](2026-10-14-registry-bundle-updates.zh.md)
## Problem
Third-party bundles installed from a registry had no update path. The plugin page offered **Update** only for Official bundles, whose target is the running DSH version, so a person learned of a newer third-party release outside DSH and replaced it by uninstalling and installing again. With HMR an install over an installed package is refused, and a manual removal followed by a failed installation leaves the plugin uninstalled.
## Decision
**Versions are read through `pnpm view` in the profile directory.** `listVersions` runs `pnpm view <name> versions time dist-tags` over the same registry plan as `inspect`, so the `.npmrc` chain, proxy, and authentication that the installation uses also decide what the list shows, and a registry that cannot be reached moves the lookup to the next one. pnpm prints a range lookup as its highest match only, so each listed version takes its own `pnpm view <name>@<version> name version engines peerDependencies deprecated dsh` on the registry that answered. `versionListLimit` bounds how many newer versions are read and `versionLookupConcurrency` bounds the subprocesses in flight. Compatibility reuses `evaluatePluginCompatibility` with the profile's exemptions, so the list and the installation judge peers the same way.
**The Host owns the update and its recovery.** `updateBundle` views the target first and refuses a missing version, one that declares no bundle, or one whose `engines.dsh` or DSH peers reject the running version before anything changes. With HMR it then calls `removeBundle` and `installBundle(name@version, { saveExact, registry, enabled })`; when that installation fails, is cancelled, or installs but cannot start, it removes a target that stayed and installs the removed version again from the registry it came from, else over the configured plan. The reinstallation carries no request id, so a cancellation of the update cannot stop it. Without HMR it installs over the running version, which `installBundle` allows there and reports as `restart-required`. `plugin-manager/update-state` announces each step for the dialog's progress list.
**Eligibility is a listed fact.** `BundleInfo.updatable` marks a third-party profile dependency saved as a registry spec under its own name and not as an `npm:` alias, with a semver version and no plugin management; the page shows the Versions section only for those, and both methods refuse every other bundle. The `plugin_manager` tool, which has no update action, omits the field.
**Updates only move forward.** The page lists versions newer than the installed one and `updateBundle` refuses any other, so an update never asks a plugin to read data a newer version wrote.
## Alternatives considered
**Fetch the abbreviated packument over HTTP.** One request returns every version's `peerDependencies` and `deprecated`, but the Host would have to resolve registry, scope, authentication, and proxy settings from the `.npmrc` chain itself and keep that resolution identical to pnpm's. A list from a registry pnpm would not install from shows versions that cannot be installed.
**`pnpm outdated`.** It reports current, wanted, and latest only, judges no DSH peers, and with exact saves wanted equals current.
**Orchestrate removal and installation in the browser.** The Official update did so. A browser that closes or loses its connection between the two calls leaves the package removed with nobody to reinstall it, and the target check before removal would be a second, bypassable copy of the Host's rule.
**List versions while reading bundles.** `listBundles` is offline; adding registry lookups would make opening the plugin page a network operation with one subprocess per version.
## Consequences
- Checking a package costs one `pnpm view` plus one per listed version, each bounded by `inspectTimeoutMs`.
- A target saved by an update, and a reinstallation after a failed one, are exact versions even when the profile had saved a range; with HMR the bundle joins the end of the selection.
- Removal and installation take the profile lock separately, so another operation can run between them; an installation that rejects, for example on a lock timeout, still triggers the reinstallation.
- A version whose dependencies need a new build-script approval fails to install and is reinstalled at the previous version; its scripts are approved through an ordinary installation.
- A reinstallation that also fails leaves the package uninstalled, and the dialog says so.
## Verification
| Coverage | Location |
|---|---|
| Listing, registry fallback, refusals, compatibility, prereleases, deprecation, and the version limit | `packages/boot/plugin-manager/tests/manager.spec.ts` |
| Update through removal and installation, reinstallation after a failed, cancelled, or rejected target, a failed reinstallation, refusal before any change, removal failure, and the no-HMR path | `packages/boot/plugin-manager/tests/manager.spec.ts` |
| `pnpm view` answer parsing | `packages/boot/plugin-manager/tests/versions.spec.ts` |
| Version checks, update state, and step progress in the store | `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` |
| Versions section, toolbar check, and every update outcome | `packages/client/ui-plugin-manager/tests/components.client.spec.tsx` |
@@ -0,0 +1,47 @@
# Agent Note: 注册表组合包更新
Status: implemented
[English](2026-10-14-registry-bundle-updates.md) | 中文
## 问题
从注册表安装的第三方组合包没有更新途径。插件页只为官方组合包提供 **更新**,其目标是当前运行的 DSH 版本,因此用户只能在 DSH 之外得知第三方新版本,并通过卸载再安装来替换。有 HMR 时,安装到已安装的包之上会被拒绝;手动移除后安装失败,插件就处于未安装状态。
## 决策
**版本通过在 profile 目录中运行 `pnpm view` 读取。** `listVersions` 按与 `inspect` 相同的注册表计划运行 `pnpm view <name> versions time dist-tags`,因此安装所用的 `.npmrc` 链、代理与认证同样决定列表显示什么,连不上的注册表会使查询转向下一个。pnpm 对范围查询只打印最高的匹配版本,因此每个列出的版本在作答的注册表上单独运行一次 `pnpm view <name>@<version> name version engines peerDependencies deprecated dsh`。`versionListLimit` 限制读取的新版本个数,`versionLookupConcurrency` 限制同时运行的子进程个数。兼容性复用 `evaluatePluginCompatibility` 与 profile 的豁免,因此列表与安装以同样方式判断 peer。
**更新及其恢复由 Host 负责。** `updateBundle` 先查询目标版本,在任何改动之前拒绝不存在的版本、未声明组合包的版本,以及 `engines.dsh` 或 DSH peer 拒绝当前运行版本的版本。有 HMR 时,它随后调用 `removeBundle` 与 `installBundle(name@version, { saveExact, registry, enabled })`;该安装失败、被取消,或已安装但无法启动时,先移除仍留下的目标版本,再从被移除版本原来的注册表重新安装它,没有记录时按配置的计划安装。重新安装不带请求 id,因此取消更新无法中止它。没有 HMR 时,它直接安装在运行中的版本之上,`installBundle` 在这种情况下允许这样做并报告 `restart-required`。`plugin-manager/update-state` 通告每一步,供对话框的进度列表使用。
**更新资格是列出的事实。** `BundleInfo.updatable` 标记以自身包名、按注册表 spec 保存(不是 `npm:` 别名)、带 semver 版本且不承载插件管理的第三方 profile 依赖;页面只为这些组合包显示版本区块,两个方法都拒绝其他组合包。没有更新操作的 `plugin_manager` 工具省略该字段。
**更新只向前。** 页面只列出比已安装版本新的版本,`updateBundle` 拒绝其他版本,因此更新不会让插件读取更新版本写入的数据。
## 考虑过的替代方案
**通过 HTTP 获取精简 packument。** 一次请求即可返回每个版本的 `peerDependencies` 与 `deprecated`,但 Host 必须自行从 `.npmrc` 链解析注册表、scope、认证与代理设置,并保持与 pnpm 的解析一致。来自 pnpm 不会从中安装的注册表的列表,会显示无法安装的版本。
**`pnpm outdated`。** 它只报告 current、wanted 与 latest,不判断 DSH peer,而在确切版本保存下 wanted 等于 current。
**在浏览器中编排移除与安装。** 官方组合包的更新就是这样做的。浏览器在两次调用之间关闭或断开连接时,包已被移除且无人重新安装,而移除前的目标检查会成为 Host 规则的第二份、可被绕过的副本。
**在读取组合包时列出版本。** `listBundles` 不联网;加入注册表查询会让打开插件页变成网络操作,且每个版本一个子进程。
## 后果
- 检查一个包需要一次 `pnpm view`,再加上每个列出版本一次,每次都受 `inspectTimeoutMs` 限制。
- 更新保存的目标版本,以及失败后重新安装的版本,都是确切版本,即使 profile 原先保存的是范围;有 HMR 时,组合包排到选中列表末尾。
- 移除与安装分别获取 profile 锁,其他操作可能在两者之间运行;安装被拒绝(例如锁等待超时)时,同样会触发重新安装。
- 依赖需要新的构建脚本批准的版本会安装失败,并重新安装为原版本;其脚本通过普通安装批准。
- 重新安装也失败时,包处于未安装状态,对话框会如实说明。
## 验证
| 覆盖 | 位置 |
|---|---|
| 列表、注册表回退、拒绝、兼容性、预发布、弃用与版本上限 | `packages/boot/plugin-manager/tests/manager.spec.ts` |
| 经移除与安装的更新、目标失败、取消或被拒绝后的重新安装、重新安装失败、改动前的拒绝、移除失败与无 HMR 路径 | `packages/boot/plugin-manager/tests/manager.spec.ts` |
| `pnpm view` 答复解析 | `packages/boot/plugin-manager/tests/versions.spec.ts` |
| store 中的版本检查、更新状态与步骤进度 | `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` |
| 版本区块、工具栏检查与每种更新结果 | `packages/client/ui-plugin-manager/tests/components.client.spec.tsx` |
+5 -1
View File
@@ -2482,7 +2482,7 @@ export interface PlanModeConfig {
## `@deepseek-ai/dsh-plugin-manager`
- `inject`: `loader` · `profileContext`
- `source`: [`packages/boot/plugin-manager/src/index.ts:42`](../packages/boot/plugin-manager/src/index.ts)
- `source`: [`packages/boot/plugin-manager/src/index.ts:45`](../packages/boot/plugin-manager/src/index.ts)
```ts config-catalog
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
@@ -2507,6 +2507,10 @@ export interface Config {
* unless that is npm's own registry or one of these.
*/
fallbackRegistries?: string[]
/** How many of the newest versions above the installed one a version listing reads and returns. */
versionListLimit?: number
/** How many single-version registry lookups a version listing runs at once. */
versionLookupConcurrency?: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-plugin-manager -->
+5 -1
View File
@@ -2484,7 +2484,7 @@ export interface PlanModeConfig {
## `@deepseek-ai/dsh-plugin-manager`
- `inject`: `loader` · `profileContext`
- `source`: [`packages/boot/plugin-manager/src/index.ts:42`](../packages/boot/plugin-manager/src/index.ts)
- `source`: [`packages/boot/plugin-manager/src/index.ts:45`](../packages/boot/plugin-manager/src/index.ts)
```ts config-catalog
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
@@ -2509,6 +2509,10 @@ export interface Config {
* unless that is npm's own registry or one of these.
*/
fallbackRegistries?: string[]
/** How many of the newest versions above the installed one a version listing reads and returns. */
versionListLimit?: number
/** How many single-version registry lookups a version listing runs at once. */
versionLookupConcurrency?: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-plugin-manager -->
+4 -3
View File
@@ -56,9 +56,10 @@ 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), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`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:269`](../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:275`](../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:282`](../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:359`](../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:365`](../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:372`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:379`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:136`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../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`) | - |
+4 -3
View File
@@ -58,9 +58,10 @@
| `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), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`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:269`](../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:275`](../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:282`](../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:359`](../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:365`](../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:372`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:379`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:136`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../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`) | - |
+2 -2
View File
@@ -6,8 +6,8 @@
en: 962b4398c9052959
zh: 14471c340a9f6e60
/profile-management/management-records:
en: a1412da0219c2a09
zh: b6869fd548f4c693
en: 56c269c5f6dc107c
zh: 535fe725b924b1e6
/profile-management/market-records:
en: a28421bc6481b0ed
zh: 73142d6163a20f50
+43 -1
View File
@@ -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.official` identifies project-maintained shipped optional bundles and on-demand catalog entries independently of installation. `availability` identifies a readable installation-provided or profile package, or `missing`; on-demand catalog entries exclude undeclared transitive copies. `installed` records the profile dependency declaration. An on-demand `installTarget` carries the host-derived exact spec and version. `BundleInfo` also 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.
`BundleInfo.official` identifies project-maintained shipped optional bundles and on-demand catalog entries independently of installation. `availability` identifies a readable installation-provided or profile package, or `missing`; on-demand catalog entries exclude undeclared transitive copies. `installed` records the profile dependency declaration. An on-demand `installTarget` carries the host-derived exact spec and version. `BundleInfo` also carries the package name, optional installed version, selected enablement, removal availability, update eligibility (`updatable`), 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.saveExact` passes `--save-exact` to the shared package installer. `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.
@@ -191,6 +191,30 @@ Manage profile files and apply their declared reload lifecycle.
*/
@Remote async inspect(spec: string, options?: InspectOptions, signal?: AbortSignal): Promise<PluginSpecInspection>
/** Read the published versions newer than an installed registry package's version, from the first registry that answers.
* Official bundles follow the DSH version and are not listed.
* @param name Installed profile dependency name.
* @param options The registry asked first; absent, the one the package was installed from in this process, else the configured one.
* @param signal Ends the lookups early.
* @returns At most `versionListLimit` newer versions, newest first, with their tags, deprecation, bundle declaration,
* and DSH compatibility, and the registry that answered; or why no list could be read, with every registry asked.
*/
@Remote async listVersions(name: string, options?: ListVersionsOptions, signal?: AbortSignal): Promise<PluginVersionList>
/**
* Replace an installed registry bundle with a newer published version. The target is checked on the registry first:
* it must exist, declare a bundle, and accept the running DSH version, or nothing changes. With HMR the installed
* version is removed, so its plugins stop, and the target is installed with the same selection; when that
* installation fails or is cancelled, the removed version is installed again from the registry it came from.
* Without HMR the target is installed over the running version and the result reports the restart.
* @param name Installed profile dependency name.
* @param version Exact published version newer than the installed one.
* @param options The registry asked first, as for `listVersions`, and the request id naming the progress and the
* target installation's logs and cancellation.
* @returns The versions, the last step that ran with its result, and the reinstallation when one ran.
*/
@Remote async updateBundle(name: string, version: string, options?: UpdateBundleOptions): Promise<PluginUpdateResult>
/** Persist a plugin entry's desired enablement and apply it on live profiles.
* @param id Loader entry identity returned by listPlugins.
* @param enabled Whether the plugin should run.
@@ -433,6 +457,24 @@ An installation moved between its Host phases. `installing` is announced once pe
Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
<a id="plugin-managerupdate-state--emit"></a>
#### `plugin-manager/update-state` — emit
An update moved to its next step: checking the target, removing the installed version, installing the target, or reinstalling the removed version after the target failed. Announced once per step.
```ts cordis-catalog
/**
* An update moved to its next step: checking the target, removing the installed version, installing the target,
* or reinstalling the removed version after the target failed. Announced once per step.
* @mode emit
* @param progress - the update's request id and step.
*/
'plugin-manager/update-state'(progress: PluginUpdateProgress): void
```
Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
<a id="plugin-market-events"></a>
### `plugin-market/*` events
+43 -1
View File
@@ -10,7 +10,7 @@
`PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。
`BundleInfo.official` 标识项目维护的随附可选组合包和按需目录项,独立于安装状态。`availability` 表示可读取的安装随附包、profile 包或 `missing`;按需目录项不计入未声明的传递依赖副本。`installed` 记录 profile 的依赖声明。按需条目的 `installTarget` 包含 Host 生成的精确 spec 和版本。`BundleInfo` 还包含包名、可选的安装版本、组合层选择状态、删除可用性、可选的解析错误;由 profile 自身依赖提供、且安装不提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。
`BundleInfo.official` 标识项目维护的随附可选组合包和按需目录项,独立于安装状态。`availability` 表示可读取的安装随附包、profile 包或 `missing`;按需目录项不计入未声明的传递依赖副本。`installed` 记录 profile 的依赖声明。按需条目的 `installTarget` 包含 Host 生成的精确 spec 和版本。`BundleInfo` 还包含包名、可选的安装版本、组合层选择状态、删除可用性、更新资格(`updatable`)、可选的解析错误;由 profile 自身依赖提供、且安装不提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。
`InstallBundleOptions.saveExact` 向共享包安装器传入 `--save-exact`。`InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。
@@ -191,6 +191,30 @@ Manage profile files and apply their declared reload lifecycle.
*/
@Remote async inspect(spec: string, options?: InspectOptions, signal?: AbortSignal): Promise<PluginSpecInspection>
/** Read the published versions newer than an installed registry package's version, from the first registry that answers.
* Official bundles follow the DSH version and are not listed.
* @param name Installed profile dependency name.
* @param options The registry asked first; absent, the one the package was installed from in this process, else the configured one.
* @param signal Ends the lookups early.
* @returns At most `versionListLimit` newer versions, newest first, with their tags, deprecation, bundle declaration,
* and DSH compatibility, and the registry that answered; or why no list could be read, with every registry asked.
*/
@Remote async listVersions(name: string, options?: ListVersionsOptions, signal?: AbortSignal): Promise<PluginVersionList>
/**
* Replace an installed registry bundle with a newer published version. The target is checked on the registry first:
* it must exist, declare a bundle, and accept the running DSH version, or nothing changes. With HMR the installed
* version is removed, so its plugins stop, and the target is installed with the same selection; when that
* installation fails or is cancelled, the removed version is installed again from the registry it came from.
* Without HMR the target is installed over the running version and the result reports the restart.
* @param name Installed profile dependency name.
* @param version Exact published version newer than the installed one.
* @param options The registry asked first, as for `listVersions`, and the request id naming the progress and the
* target installation's logs and cancellation.
* @returns The versions, the last step that ran with its result, and the reinstallation when one ran.
*/
@Remote async updateBundle(name: string, version: string, options?: UpdateBundleOptions): Promise<PluginUpdateResult>
/** Persist a plugin entry's desired enablement and apply it on live profiles.
* @param id Loader entry identity returned by listPlugins.
* @param enabled Whether the plugin should run.
@@ -433,6 +457,24 @@ An installation moved between its Host phases. `installing` is announced once pe
Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
<a id="plugin-managerupdate-state--emit"></a>
#### `plugin-manager/update-state` — emit
An update moved to its next step: checking the target, removing the installed version, installing the target, or reinstalling the removed version after the target failed. Announced once per step.
```ts cordis-catalog
/**
* An update moved to its next step: checking the target, removing the installed version, installing the target,
* or reinstalling the removed version after the target failed. Announced once per step.
* @mode emit
* @param progress - the update's request id and step.
*/
'plugin-manager/update-state'(progress: PluginUpdateProgress): void
```
Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts)
<a id="plugin-market-events"></a>
### `plugin-market/*` events
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/plugin-manager-view-fields/guide.md
/:
en: ce2254ec04c7e121
zh: d41be1b049886b05
/viewprofilepackage-takes-the-fields-it-prints:
en: 2b42139fb8291351
zh: 142078b83eec688c
/viewprofilepackage-takes-the-fields-it-prints/change:
en: 72177a0fbf9d18de
zh: 0e689154933a6174
/viewprofilepackage-takes-the-fields-it-prints/migration:
en: 7107ae7573558a60
zh: 17c4428c0e83a842
@@ -0,0 +1,25 @@
---
kind: upgrade-guide
description: "viewProfilePackage from @deepseek-ai/dsh-plugin-manager/operations requires the manifest fields it prints."
---
# `viewProfilePackage` takes the fields it prints
English | [中文](guide.zh.md)
## Change
`viewProfilePackage(dir, spec, options)` from `@deepseek-ai/dsh-plugin-manager/operations` used to print a fixed field list: `name`, `version`, `description`, and `dsh`. `PackageViewOptions` now requires `fields`, the manifest or registry fields `pnpm view` prints, so the same lookup reads version lists and per-version peers. Code that calls `viewProfilePackage` directly no longer type-checks without `fields`, and a JavaScript caller that omits it runs `pnpm view` with no fields.
## Migration
1. Pass the fields the call used to print:
```ts
import { viewProfilePackage } from '@deepseek-ai/dsh-plugin-manager/operations'
declare const dir: string, spec: string, timeoutMs: number
await viewProfilePackage(dir, spec, { fields: ['name', 'version', 'description', 'dsh'], timeoutMs })
```
2. Run your type checker; a missing `fields` property is reported at each call.
@@ -0,0 +1,25 @@
---
kind: upgrade-guide
description: "@deepseek-ai/dsh-plugin-manager/operations 的 viewProfilePackage 需要传入它打印的清单字段。"
---
# `viewProfilePackage` 接收它打印的字段
[English](guide.md) | 中文
## 变更
`@deepseek-ai/dsh-plugin-manager/operations` 的 `viewProfilePackage(dir, spec, options)` 原先打印固定的字段列表:`name`、`version`、`description` 与 `dsh`。`PackageViewOptions` 现在要求 `fields`,即 `pnpm view` 打印的清单或注册表字段,使同一查询可以读取版本列表和各版本的 peer。直接调用 `viewProfilePackage` 的代码缺少 `fields` 时无法通过类型检查,省略它的 JavaScript 调用方会让 `pnpm view` 不带字段运行。
## 迁移
1. 传入调用原先打印的字段:
```ts
import { viewProfilePackage } from '@deepseek-ai/dsh-plugin-manager/operations'
declare const dir: string, spec: string, timeoutMs: number
await viewProfilePackage(dir, spec, { fields: ['name', 'version', 'description', 'dsh'], timeoutMs })
```
2. 运行类型检查;缺少 `fields` 属性的每处调用都会被报告。
+1
View File
@@ -36,6 +36,7 @@ export type {
PackageResult,
PluginChange, PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallFailureKind,
PluginInstallLogChunk, PluginInstallProgress, PluginInstallRequestId, PluginRegistries, PluginSpecInspection,
PluginUpdateProgress, PluginUpdateResult, PluginUpdateStep, PluginVersion, PluginVersionList, RegistryAttempt,
ReadOnlyReason, Registry, RestartReason,
} from '@deepseek-ai/dsh-plugin-manager/types'
export type {} from '@deepseek-ai/dsh-plugin-manager/remote'
@@ -48,6 +48,7 @@ export const API_REMOTE_FORWARDED_EVENTS = [
{ event: 'plugin-manager/changed', mode: 'emit' },
{ event: 'plugin-manager/install-log', mode: 'emit' },
{ event: 'plugin-manager/install-state', mode: 'emit' },
{ event: 'plugin-manager/update-state', mode: 'emit' },
{ event: 'plugin-market/changed', mode: 'emit' },
{ event: 'settings/document-updated', mode: 'emit' },
{ event: 'schedule/changed', mode: 'emit' },
@@ -15,14 +15,14 @@
en: 87679c51aa3693f5
zh: 11684a48a5806afb
/deepseek-ai-dsh-plugin-manager/use-this-package:
en: 95c1b18f8432090a
zh: 213e54a1547a1da1
en: 4a97c164c4eaf933
zh: 8be4ac9f8b9e4d9c
/deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions:
en: 483285658e267dd5
zh: 846bec2daac59a96
/deepseek-ai-dsh-plugin-manager/use-this-package/configuration:
en: d5643a06f300c30a
zh: 9348b6a7676a2849
en: 861333e423369690
zh: 6d16604bd24766d3
/deepseek-ai-dsh-plugin-manager/understand-the-implementation:
en: 495dadf79c48c43c
zh: 9884dd04a5bb49dc
@@ -45,8 +45,8 @@
en: c29f60be7f3edc1a
zh: d9b9388b96ef95cc
/deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work:
en: 6b93fd9851b79a0e
zh: d2cf1982e8cfc718
en: eeeaa73140fa45d7
zh: 12b5c10f0390310e
/deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/failure-behavior:
en: c670eb2330b441e8
zh: 2c8223e03e1e3ef0
+10 -1
View File
@@ -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 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.
`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 and update eligibility from list results.
`listBundles` marks shipped optional bundles and on-demand catalog entries as `official`, independently of package installation. `availability` identifies a readable installation-provided or profile package, or `missing`; on-demand catalog entries exclude undeclared transitive copies. `installed` only records whether the profile declares the dependency. An absent on-demand entry carries embedded localized metadata and a host-derived `installTarget` containing the running DSH version and install spec, without registry access. Profile-declared packages with unreadable files retain their declaration and expose the normal error.
@@ -51,6 +51,10 @@ Published installations offer `package@<running DSH version>`. Source Web and de
Official on-demand installs call the same `installBundle` operation as other bundles. Registry targets use `saveExact: true`; development targets retain the ordinary `link:` dependency. An explicit update removes the installed package and installs the target with the current `enabled` selection; without HMR, a bundle that runs since startup cannot be removed, so the installation goes over it and reports `restart-required`. A failed or cancelled installation after the removal leaves the package uninstalled, and the reinstalled bundle joins the end of the selection. Turning Off only deselects the layer, and Remove uses the existing removal checks. Installation failure restores the profile manifest and lockfile; activation failure after installation can retain the saved dependency and selection. The existing peer-compatibility check still applies, without an additional Official-only version admission rule.
`listVersions(name, options)` reads the published versions newer than an installed bundle that `listBundles` marks `updatable`: a third-party profile dependency saved as a registry spec under its own name, not an `npm:` alias, whose manifest declares a semver version, and that carries no plugin management. One `pnpm view <name> versions time dist-tags` asks the registry plan from `options.registry`, else the registry this process last installed the package from, else the configured one. The newest `versionListLimit` versions above the installed one are then read from the registry that answered, one `pnpm view <name>@<version>` each and at most `versionLookupConcurrency` at a time, because pnpm prints only the highest match of a range. The list orders them newest first with their dist-tags, publication time, prerelease flag, deprecation message, whether they declare a bundle, and an `incompatible` record when their `engines.dsh` or DSH peers reject the running version without an exemption; `omitted` counts the newer versions left out, and `attempts` names every registry asked with what each failed with. A refusal is `not-updatable`, `not-found`, `network`, or `unknown`; one version lookup that fails refuses the whole list. Official bundles follow the DSH version and are not listed.
`updateBundle(name, version, options)` replaces an updatable bundle with an exact newer version; an older version is refused. The target is first viewed over the same plan as `listVersions`, and a version that is missing, declares no bundle, or whose `engines.dsh` or DSH peers reject the running DSH stops the update at `step: 'check'` with the profile unchanged. With HMR the update removes the installed version, so its plugins stop, then installs `name@version` with `saveExact` and the current selection from the registry that answered. When that installation fails, is cancelled, or installs but cannot start, the target is removed if it stayed and the removed version is installed again from the registry it was installed from in this process, else over the configured plan as its removal asks, and reported as `restore`; a reinstallation that also fails leaves the package uninstalled. A removal or installation that rejects, for example on a lock timeout, is reported as that step's failure. Without HMR the target is installed over the running version and the result reports `restart-required`. `plugin-manager/update-state` announces `checking`, `removing`, `installing`, and `restoring` under the caller's `requestId`; the target's installation streams its logs and accepts `cancelInstall` under the same id, while the reinstallation carries none, so a cancellation cannot stop it.
`installBundle` checks GitHub repositories with `git ls-remote` before starting pnpm, using the profile directory and the installer's Git and proxy configuration. `githubConnectionTimeoutMs` defaults to 5000 ms and limits only this check, not package download or builds. The check disables credential helpers and prompts; only network failures and timeouts stop installation, reporting `failedAt: 'spec-host'` with the existing failure kind and diagnostic log. Authentication, repository lookup and other failures are left to pnpm, including its HTTPS-to-SSH fallback. Cancellation and manager disposal stop the check and its descendants. Registry packages, paths, tarballs and other Git hosts skip this check. A reachable repository can still fail during download or bundle validation.
Registries are asked in turn. The plan starts at `options.registry`, else the configured `registry` (`null` is the one pnpm's own configuration names), and continues through `fallbackRegistries` while a registry is unreachable, times out, or answers that it has no such package or version, which a mirror not yet synced does. A registry outside the configured set is asked alone, so a private registry never falls through to a public one; pnpm's own registry counts as part of the set only while what it names, read through `pnpm config get registry` before each plan, is npm's own registry or one of the fallbacks, and is otherwise asked alone as a private one. A registry pnpm's own configuration already names is asked once. The lookup runs `pnpm view` with `--registry` and without pnpm's own retries, so a dead registry is reported within `inspectTimeoutMs` and the next one is asked; pnpm prints its refusal as JSON on stdout, which is read like stderr. The install keeps pnpm's retry settings. `registries()` answers the configured set and what pnpm names, for a picker. The [registry Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md) owns the rationale.
@@ -85,6 +89,8 @@ The CLI exposes `dsh plugin --profile <profile> version-exemptions`, `allow-vers
| `fallbackRegistries` | `['https://registry.npmmirror.com/']` | Registries asked in turn, as http(s) URLs, while the one before is unreachable or holds no copy of the package; pnpm's own registry joins the order only while it names npm's own registry or one of these. |
| `outputBytes` | `16384` | Maximum pnpm diagnostic bytes returned per operation; the full output remains in the returned log path. |
| `lockWaitMs` | `120000` | Maximum time in milliseconds to acquire the profile write lock. |
| `versionListLimit` | `20` | How many of the newest versions above the installed one a version listing reads and returns. |
| `versionLookupConcurrency` | `4` | How many single-version registry lookups a version listing runs at once. |
| `idleTimeoutMs` | `600000` | Maximum time in milliseconds a service package run may capture no output before the manager terminates it; a run with inherited descriptors (`dsh plugin`) is never bound. |
-----
@@ -138,6 +144,9 @@ Tool results append to the transcript. Enabling or disabling other tools can cha
- Web approves the entire displayed pending group; it has no per-package selection.
- pnpm verifies every lockfile tarball URL against its default registry when a command changes the lockfile. After a package was installed from another registry, installing a different package with the default registry, or removing that package after a restart, can fail with `ERR_PNPM_TARBALL_URL_MISMATCH`.
- An update saves the target as an exact version, and a reinstallation after a failed update saves the previous version exactly, even when the profile had saved a range. With HMR the reinstalled bundle joins the end of the selection, which can change which bundle's configuration takes precedence.
- An update runs its removal and its installation as two profile operations; another operation can run between them.
- A newer version whose dependencies need new build-script approval fails to update and is reinstalled at the previous version; approve its scripts through an ordinary installation after removing the package.
- Package replacements require restarting the process without HMR. A package relinked outside Plugin Manager while it is still installed waits for a restart.
- Startup-only profiles cannot remove packages used to start the current process; stop it and use `dsh plugin`.
- The manager cannot disable its own management components, change another profile, or edit an agent preset's composition.
+10 -1
View File
@@ -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 的 user information(查询字符串保持原样);`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后,以别名安装的包的 spec 也是如此。安装提供的组合包没有该字段,即使 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 展示元信息和更新资格。
`listBundles` 将随附可选组合包和按需目录项标为 `official`,与包的安装状态独立。`availability` 表示可读取的安装随附包、profile 包,或 `missing`;按需目录项不计入未声明的传递依赖副本。`installed` 仅表示 profile 是否声明该依赖。尚未安装的按需条目携带内嵌的本地化元信息,以及 Host 生成、包含当前 DSH 版本与安装 spec 的 `installTarget`,无需访问注册表。profile 已声明但文件不可读的包保留依赖声明,并展示原有错误。
@@ -51,6 +51,10 @@ kind: "package-reference"
官方按需安装调用和其他组合包相同的 `installBundle` 操作。注册表目标使用 `saveExact: true`;开发目标保留普通 `link:` 依赖。显式更新先删除已安装的包,再以当前 `enabled` 选择状态安装目标;没有 HMR 时,自启动起运行的组合包无法删除,因此安装直接覆盖它并报告 `restart-required`。删除之后安装失败或被取消时,该包保持未安装,重新安装的组合包排在选择列表末尾。关闭仅取消选择配置层,移除使用原有移除检查。安装失败恢复 profile 清单和锁文件;安装成功后激活失败可能保留已保存的依赖和选择。原有 peer 兼容性检查继续生效,不增加仅针对官方条目的版本准入规则。
`listVersions(name, options)` 读取 `listBundles` 标为 `updatable` 的已安装组合包的新版本:以自身包名、按注册表 spec 保存(不是 `npm:` 别名)的第三方 profile 依赖,清单声明 semver 版本,且不承载插件管理。一次 `pnpm view <name> versions time dist-tags` 按注册表计划询问,计划从 `options.registry` 开始,否则从本进程上次安装该包所用的注册表开始,否则从配置的注册表开始。随后从作答的注册表逐个读取比已安装版本新的最新 `versionListLimit` 个版本,每个版本一次 `pnpm view <name>@<version>`,同时最多 `versionLookupConcurrency` 个,因为 pnpm 对范围只打印最高的匹配版本。列表按从新到旧排列,带有各版本的 dist-tag、发布时间、预发布标记、弃用说明、是否声明组合包,以及在其 `engines.dsh` 或 DSH peer 拒绝当前运行版本且没有豁免时的 `incompatible` 记录;`omitted` 统计未列出的新版本数,`attempts` 列出问过的每个注册表及其失败原因。拒绝为 `not-updatable`、`not-found`、`network` 或 `unknown`;任一版本查询失败都会拒绝整个列表。官方组合包跟随 DSH 版本,不会列出。
`updateBundle(name, version, options)` 把可更新的组合包替换为一个更新的确切版本;更旧的版本会被拒绝。目标版本先按与 `listVersions` 相同的计划查询,版本不存在、未声明组合包,或其 `engines.dsh` 或 DSH peer 拒绝当前 DSH 时,更新停在 `step: 'check'`,profile 保持不变。有 HMR 时,更新先移除已安装版本,使其插件停止,再以 `saveExact` 和当前的选中状态从作答的注册表安装 `name@version`。该安装失败、被取消,或已安装但无法启动时,先移除仍留下的目标版本,再从本进程安装被移除版本所用的注册表重新安装它,没有记录时按配置的计划安装,与移除的做法一致,结果以 `restore` 报告;重新安装也失败时,包处于未安装状态。移除或安装被拒绝(例如锁等待超时)时,报告为该步骤失败。没有 HMR 时,目标版本直接安装在运行中的版本之上,结果报告 `restart-required`。`plugin-manager/update-state` 以调用方的 `requestId` 通告 `checking`、`removing`、`installing` 与 `restoring`;目标版本的安装以同一 id 流式输出日志并接受 `cancelInstall`,重新安装不带 id,因此取消无法中止它。
`installBundle` 在启动 pnpm 前通过 `git ls-remote` 检查 GitHub 仓库,使用 profile 目录及安装器的 Git 与代理配置。`githubConnectionTimeoutMs` 默认为 5000 毫秒,只限制这次检查,不限制包下载或构建。检查禁用凭据助手和认证提示;只有网络失败与超时会停止安装,通过现有失败类型与诊断日志返回,并标记 `failedAt: 'spec-host'`。认证、仓库查找及其他失败继续交给 pnpm,包括其 HTTPS 到 SSH 的回退。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。
注册表按顺序询问。计划从 `options.registry` 开始,否则从配置的 `registry`(`null` 即 pnpm 自身配置指定的那个)开始,并在一个注册表不可达、超时或答复没有这个包或版本(尚未同步的镜像会如此)时继续问 `fallbackRegistries`。配置集合之外的注册表只问它自己,因而私有源永远不会落到公共源;pnpm 自身的注册表只在它指向的地址(每次做计划前经 `pnpm config get registry` 读出)是 npm 官方源或某个备选源时才算集合成员,否则视为私有源只问它自己。pnpm 自身配置已经指向的注册表只问一次。查询以 `--registry` 运行 `pnpm view` 且不带 pnpm 自身的重试,所以死掉的注册表会在 `inspectTimeoutMs` 内报告并转问下一个;pnpm 把拒绝以 JSON 打印在 stdout,读法与 stderr 相同。安装保留 pnpm 的重试设置。`registries()` 把配置集合和 pnpm 指向的地址答复给选择器。[注册表 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md) 拥有理由。
@@ -85,6 +89,8 @@ CLI 提供 `dsh plugin --profile <profile> version-exemptions`、`allow-version
| `fallbackRegistries` | `['https://registry.npmmirror.com/']` | 前一个注册表不可达或没有该包副本时依次询问的注册表,http(s) URL;pnpm 自身的注册表只在它指向 npm 官方源或这里的某一个时才进入顺序。 |
| `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 |
| `lockWaitMs` | `120000` | 获取 profile 写锁的最长等待毫秒数。 |
| `versionListLimit` | `20` | 版本列表读取并返回的、比已安装版本新的最新版本个数。 |
| `versionLookupConcurrency` | `4` | 版本列表同时运行的单版本注册表查询个数。 |
| `idleTimeoutMs` | `600000` | service 包操作允许持续无捕获输出的最长毫秒数,达到即被管理器终止;继承描述符运行的 `dsh plugin` 不受此上界约束。 |
-----
@@ -138,6 +144,9 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,
- Web 一次批准显示出来的整组待决定包,没有逐包选择。
- 命令改动锁文件时,pnpm 按默认注册表校验锁文件中的每个 tarball URL。某个包从其他注册表安装后,使用默认注册表安装另一个包,或在重启后删除该包,可能以 `ERR_PNPM_TARBALL_URL_MISMATCH` 失败。
- 更新把目标保存为确切版本,失败后的重新安装也把原版本保存为确切版本,即使 profile 原先保存的是范围。有 HMR 时,重新安装的组合包排到选中列表末尾,这可能改变哪个组合包的配置优先。
- 更新把移除与安装作为两次 profile 操作运行,其他操作可能在两者之间运行。
- 依赖需要新的构建脚本批准的新版本会更新失败,并重新安装为原版本;移除该包后通过普通安装批准其脚本。
- 没有 HMR 时,替换包需要重启进程。在 Plugin Manager 之外重新链接仍处于安装状态的包,需要等待重启。
- 仅启动时加载的 profile 不能删除当前进程启动时使用的包;停止进程后使用 `dsh plugin`。
- 管理器不能关闭自身所需的管理组件、修改其他 profile 或编辑 agent 预设组合。
@@ -64,6 +64,7 @@
"@deepseek-ai/dsh-atomic-write": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"execa": "^10.0.1",
"semver": "^7.8.5",
"yaml": "^2.9.0",
"zod": "4.4.3",
"@deepseek-ai/dsh-util-values": "workspace:*"
@@ -91,6 +92,7 @@
},
"devDependencies": {
"chokidar": "^4.0.3",
"@types/semver": "^7.8.0",
"@deepseek-ai/dsh-package-manifest": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
+271 -43
View File
@@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import z from '@deepseek-ai/schemastery'
import semver from 'semver'
import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory'
import {
@@ -29,10 +30,12 @@ import { flatten, writePluginEnabled } from './patch.ts'
import { incompatiblePlugin, ManagementFailure } from './failure.ts'
import { approveBuilds, recordPendingBuilds } from './build-approval.ts'
import { checkGithubConnection } from './github-connection.ts'
import { newerVersions, VERSION_FIELDS, VERSION_LIST_FIELDS, versionFacts } from './versions.ts'
import type {
BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ManagementError, PackageResult, PluginChange,
PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId,
PluginRegistries, PluginSpecInspection, Registry,
BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ListVersionsOptions, ManagementError, PackageResult,
PluginChange, PluginEntryId, PluginInfo, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId,
PluginRegistries, PluginSpecInspection, PluginUpdateResult, PluginUpdateStep, PluginVersion, PluginVersionList, Registry,
RegistryAttempt, UpdateBundleOptions,
} from './types.ts'
export type * from './types.ts'
export { classifyInstallFailure, type InstallFailureFacts } from './install-failure.ts'
@@ -60,6 +63,10 @@ export interface Config {
* unless that is npm's own registry or one of these.
*/
fallbackRegistries?: string[]
/** How many of the newest versions above the installed one a version listing reads and returns. */
versionListLimit?: number
/** How many single-version registry lookups a version listing runs at once. */
versionLookupConcurrency?: number
}
/** An http(s) URL, as pnpm's `--registry` takes it. */
@@ -80,6 +87,9 @@ const protectedModules = new Set([
/** The profile files an installation writes and a failed or cancelled one restores. */
const RESTORED_FILES = ['package.json', 'pnpm-lock.yaml'] as const
/** The manifest fields an inspection reads for a registry spec. */
const INSPECT_FIELDS = ['name', 'version', 'description', 'dsh'] as const
/** pnpm's colour escapes, which a JSON answer may be wrapped in. */
const ANSI_SEQUENCE = /\x1b\[[0-9;]*m/g
@@ -136,10 +146,71 @@ function inspectionOf(kind: 'registry' | 'path', manifest: object, registry: Reg
}
}
function refused(problem: PluginInspectProblem, reason: string): PluginSpecInspection {
function refused(problem: Extract<PluginSpecInspection, { status: 'refused' }>['problem'], reason: string): PluginSpecInspection {
return { status: 'refused', problem, reason }
}
/** An installed registry bundle an update can replace, its installed version, and its parsed registry spec. */
interface UpdateTarget<B> {
bundle: B
installed: string
parsed: Extract<ParsedInstallSpec, { kind: 'registry' }>
}
/**
* Why an update cannot replace a listed bundle, or what it replaces.
* @param bundle The listed bundle, or undefined for a name the inventory does not list.
* @param name The package name, for the refusal.
* @returns The bundle, its installed version, and its registry spec; or the refusal's diagnostic.
*/
function updateRefusal<B extends Pick<BundleInfo, 'name' | 'installed' | 'availability' | 'official' | 'version' | 'source' | 'readOnlyReason'>>(
bundle: B | undefined, name: string,
): UpdateTarget<B> | string {
if (bundle === undefined || !bundle.installed || bundle.availability !== 'profile' || bundle.official || bundle.source === undefined) {
return `${name} is not a third-party package installed in this profile`
}
// A management bundle cannot be removed, so it cannot be replaced either.
if (bundle.readOnlyReason !== undefined) return `${bundle.name} carries plugin management and cannot be removed`
const installed = bundle.version
if (installed === undefined || semver.valid(installed) === null) return `${bundle.name} declares no semver version`
const parsed = parsedForRegistry(bundle.source)
// A protocol in the saved range, such as an `npm:` alias, names another package than the dependency.
if (parsed.kind !== 'registry' || parsed.name !== bundle.name || parsed.range?.includes(':') === true) {
return `${bundle.name} is not installed from a registry under its own name`
}
return { bundle, installed, parsed }
}
/** What asking planned registries in turn answered: the parsed output and the registry that gave it, or the last refusal. */
type RegistryAnswer =
| { status: 'answered'; answer: unknown; registry: Registry; attempts: RegistryAttempt[] }
| { status: 'refused'; problem: NonNullable<RegistryAttempt['problem']>; reason: string; attempts: RegistryAttempt[] }
/**
* Map items with at most `limit` calls in flight, stopping new calls once one answers `stop`.
* @param items - the items, in order.
* @param limit - the most calls in flight.
* @param map - the call for one item.
* @param stop - whether an answer ends the remaining calls.
* @returns the answers of the calls that ran, in item order; items after a stop stay absent.
*/
async function mapConcurrent<T, R>(
items: readonly T[], limit: number, map: (item: T) => Promise<R>, stop: (answer: R) => boolean,
): Promise<(R | undefined)[]> {
const answers: (R | undefined)[] = items.map(() => undefined)
let next = 0
let stopped = false
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
while (!stopped && next < items.length) {
const index = next++
const answer = await map(items[index] as T)
answers[index] = answer
if (stop(answer)) stopped = true
}
}))
return answers
}
/** The refusal `pnpm view --json` prints on stdout, `{ error: { code, message } }`, as one log line; empty for anything else. */
function printedError(printed: string): string {
let parsed: unknown
@@ -215,6 +286,8 @@ export class PluginManager extends TypertRemoteService {
idleTimeoutMs: z.number().step(1).min(1000).default(600000),
registry: z.string().pattern(REGISTRY_URL),
fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]),
versionListLimit: z.number().step(1).min(1).default(20),
versionLookupConcurrency: z.number().step(1).min(1).default(4),
})
/** Management bundles remain protected if their files become unreadable. */
private readonly managementBundles = new Set<string>()
@@ -232,6 +305,8 @@ export class PluginManager extends TypertRemoteService {
private readonly githubConnectionTimeoutMs: number
private readonly idleTimeoutMs: number
private readonly pnpmCommand: string
private readonly versionListLimit: number
private readonly versionLookupConcurrency: number
private readonly configuredRegistries: Omit<PluginRegistries, 'resolved'>
private readonly ownerContext: Context
private readonly abort = new AbortController()
@@ -250,6 +325,8 @@ export class PluginManager extends TypertRemoteService {
this.githubConnectionTimeoutMs = (config as Required<Config>).githubConnectionTimeoutMs
this.idleTimeoutMs = (config as Required<Config>).idleTimeoutMs
this.pnpmCommand = (config as Required<Config>).pnpmCommand
this.versionListLimit = (config as Required<Config>).versionListLimit
this.versionLookupConcurrency = (config as Required<Config>).versionLookupConcurrency
this.configuredRegistries = {
registry: config.registry === undefined ? null : normalizeRegistry(config.registry),
fallbackRegistries: (config as Required<Config>).fallbackRegistries.map(normalizeRegistry),
@@ -314,6 +391,11 @@ export class PluginManager extends TypertRemoteService {
*/
@Remote
listBundles(): Promise<BundleInfo[]> {
return Promise.resolve(this.readBundles().map(bundle => ({ ...bundle, updatable: typeof updateRefusal(bundle, bundle.name) !== 'string' })))
}
/** The bundle inventory before update eligibility, which is derived from each listed bundle. */
private readBundles(): Omit<BundleInfo, 'updatable'>[] {
const manifest = readProfileManifest('dsh', this.profile.dir)
const exemptions = readProfileVersionExemptions(this.profile.dir)
const selected = bundleSelection(manifest)
@@ -322,7 +404,7 @@ export class PluginManager extends TypertRemoteService {
const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest
const catalog = new Map(OFFICIAL_ON_DEMAND_CATALOG.map(entry => [entry.packageName, entry]))
const names = [...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {}), ...catalog.keys()])]
const bundles: BundleInfo[] = []
const bundles: Omit<BundleInfo, 'updatable'>[] = []
const runtimeVersion = getDshRuntimeVersion()
for (const name of names) {
const installed = dependencies.includes(name)
@@ -382,7 +464,7 @@ export class PluginManager extends TypertRemoteService {
}
}
}
return Promise.resolve(bundles)
return bundles
}
/** Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names.
@@ -442,47 +524,193 @@ export class PluginManager extends TypertRemoteService {
}
case 'registry': {
if (known.has(parsed.name)) return refused('already-installed', `${parsed.name} is already installed`)
const registries: Registry[] = []
const refusedBy = (problem: PluginInspectProblem, reason: string): PluginSpecInspection =>
({ status: 'refused', problem, reason, registries })
for (const current of plan) {
registries.push(current)
const view = await viewProfilePackage(this.profile.dir, spec.trim(), {
...this.profile.packageManager ?? { command: this.pnpmCommand },
timeoutMs: this.inspectTimeoutMs, ...signal === undefined ? {} : { signal }, registry: current,
})
const printed = view.stdout.replace(ANSI_SEQUENCE, '').trim()
if (view.exitCode !== 0 || view.cause !== undefined || view.timedOut) {
// pnpm prints a refusal as `{ error: { code, message } }` on stdout, with nothing on stderr.
const log = [view.stderr.trim(), printedError(printed), view.cause === undefined ? '' : messageOf(view.cause)].filter(Boolean).join('\n')
const kind = classifyInstallFailure({ log, timedOut: view.timedOut, ...view.cause === undefined ? {} : { cause: view.cause } })
// A lookup the caller dropped is not carried to the next registry.
if (registries.length < plan.length && signal?.aborted !== true && attributeFailure(kind, log, parsed) === 'registry') continue
const reason = view.timedOut ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` : log || printed || `pnpm view exited with ${String(view.exitCode)}`
if (kind === 'not-found' || kind === 'no-matching-version') return refusedBy('not-found', reason)
if (kind === 'network' || kind === 'timeout') return refusedBy('network', reason)
return refusedBy('unknown', reason)
}
let answer: unknown
try {
answer = JSON.parse(printed || 'null')
} catch (error) {
return refusedBy('unknown', `unreadable pnpm view output: ${messageOf(error)}`)
}
// A range answers one object per matching version, oldest first.
const latest: unknown = Array.isArray(answer) ? answer.at(-1) : answer
if (typeof latest !== 'object' || latest === null) return refusedBy('unknown', 'pnpm view answered no package')
const inspection = inspectionOf('registry', latest, current)
const named = inspection.name === undefined ? { ...inspection, name: parsed.name } : inspection
if (!named.bundle) return refusedBy('not-a-bundle', `${named.name} declares no dsh.bundle`)
return named
}
/* v8 ignore next -- the plan is never empty: every attempt returns or continues to the next */
throw new Error('no registry was asked')
const asked = await this.askRegistries(spec.trim(), INSPECT_FIELDS, plan, parsed, signal)
const registries = asked.attempts.map(attempt => attempt.registry)
if (asked.status === 'refused') return { status: 'refused', problem: asked.problem, reason: asked.reason, registries }
// pnpm prints a range's highest match as one object; npm's array of every match is read by its last entry.
const latest: unknown = Array.isArray(asked.answer) ? asked.answer.at(-1) : asked.answer
if (typeof latest !== 'object' || latest === null) return { status: 'refused', problem: 'unknown', reason: 'pnpm view answered no package', registries }
const inspection = inspectionOf('registry', latest, asked.registry)
const named = inspection.name === undefined ? { ...inspection, name: parsed.name } : inspection
if (!named.bundle) return { status: 'refused', problem: 'not-a-bundle', reason: `${named.name} declares no dsh.bundle`, registries }
return named
}
}
}
/** Read the published versions newer than an installed registry package's version, from the first registry that answers.
* Official bundles follow the DSH version and are not listed.
* @param name Installed profile dependency name.
* @param options The registry asked first; absent, the one the package was installed from in this process, else the configured one.
* @param signal Ends the lookups early.
* @returns At most `versionListLimit` newer versions, newest first, with their tags, deprecation, bundle declaration,
* and DSH compatibility, and the registry that answered; or why no list could be read, with every registry asked.
*/
@Remote
async listVersions(name: string, options?: ListVersionsOptions, signal?: AbortSignal): Promise<PluginVersionList> {
const target = updateRefusal((await this.listBundles()).find(bundle => bundle.name === name), name)
if (typeof target === 'string') return { status: 'refused', problem: 'not-updatable', reason: target, attempts: [] }
const plan = registryPlan(this.registryFor(name, options?.registry), await this.registries())
const listing = await this.askRegistries(name, VERSION_LIST_FIELDS, plan, target.parsed, signal)
if (listing.status === 'refused') return listing
let newer
try { newer = newerVersions(listing.answer, target.installed) }
catch (error) { return { status: 'refused', problem: 'unknown', reason: messageOf(error), attempts: listing.attempts } }
const listed = newer.slice(0, this.versionListLimit)
const exemptions = readProfileVersionExemptions(this.profile.dir)
const looked = await mapConcurrent(listed, this.versionLookupConcurrency, async (published) => {
const spec = `${name}@${published.version}`
const answer = await this.askRegistries(spec, VERSION_FIELDS, [listing.registry], { kind: 'registry', spec, name }, signal)
if (answer.status === 'refused') return answer
const facts = versionFacts(answer.answer, name, published.version)
let compatibility
try { compatibility = evaluatePluginCompatibility(facts.manifest, exemptions) }
catch (error) { return { status: 'refused' as const, problem: 'unknown' as const, reason: messageOf(error), attempts: answer.attempts } }
const version: PluginVersion = {
...published, bundle: facts.bundle, ...facts.deprecated === undefined ? {} : { deprecated: facts.deprecated },
...compatibility === undefined || compatibility.exempted ? {} : { incompatible: incompatiblePlugin(compatibility) },
}
return { status: 'read' as const, version }
}, answer => answer.status === 'refused')
const versions: PluginVersion[] = []
for (const answer of looked) {
/* v8 ignore next -- a lookup stops the rest only by refusing, which returns below */
if (answer === undefined) continue
if (answer.status === 'refused') return { ...answer, attempts: [...listing.attempts.slice(0, -1), ...answer.attempts] }
versions.push(answer.version)
}
return {
status: 'listed', installed: target.installed, registry: listing.registry, attempts: listing.attempts,
versions, omitted: newer.length - listed.length,
}
}
/**
* Replace an installed registry bundle with a newer published version. The target is checked on the registry first:
* it must exist, declare a bundle, and accept the running DSH version, or nothing changes. With HMR the installed
* version is removed, so its plugins stop, and the target is installed with the same selection; when that
* installation fails or is cancelled, the removed version is installed again from the registry it came from.
* Without HMR the target is installed over the running version and the result reports the restart.
* @param name Installed profile dependency name.
* @param version Exact published version newer than the installed one.
* @param options The registry asked first, as for `listVersions`, and the request id naming the progress and the
* target installation's logs and cancellation.
* @returns The versions, the last step that ran with its result, and the reinstallation when one ran.
*/
@Remote
async updateBundle(name: string, version: string, options?: UpdateBundleOptions): Promise<PluginUpdateResult> {
const requestId = options?.requestId
const announce = (step: PluginUpdateStep): void => {
if (requestId !== undefined) this.ownerContext.emit('plugin-manager/update-state', { requestId, step })
}
announce('checking')
const target = updateRefusal((await this.listBundles()).find(item => item.name === name), name)
const from = typeof target === 'string' ? '' : target.installed
const refusedBefore = (error: ManagementError): PluginUpdateResult => ({
from, to: version, step: 'check',
result: { changed: false, application: 'failed', stage: 'install', target: name, error },
})
if (typeof target === 'string') return refusedBefore({ code: 'invalid-spec', diagnostic: target })
const { bundle } = target
if (semver.valid(version) === null || !semver.gt(version, from)) {
return refusedBefore({ code: 'invalid-spec', diagnostic: `${version} is not a version newer than the installed ${from}` })
}
const spec = `${name}@${version}`
const plan = registryPlan(this.registryFor(name, options?.registry), await this.registries())
const checked = await this.askRegistries(spec, VERSION_FIELDS, plan, { kind: 'registry', spec, name })
if (checked.status === 'refused') return refusedBefore({ code: 'operation-error', diagnostic: checked.reason })
const facts = versionFacts(checked.answer, name, version)
if (!facts.bundle) return refusedBefore({ code: 'not-bundle' })
let compatibility
try { compatibility = evaluatePluginCompatibility(facts.manifest, readProfileVersionExemptions(this.profile.dir)) }
catch (error) { return refusedBefore({ code: 'operation-error', diagnostic: messageOf(error) }) }
if (compatibility !== undefined && !compatibility.exempted) {
return refusedBefore({ code: 'incompatible-version', incompatible: [incompatiblePlugin(compatibility)] })
}
const { enabled } = bundle
// A lock timeout or disposal rejects instead of reporting; it reads as the failed step it is.
const settled = (stage: ChangeResult['stage'], target: string, operation: Promise<ChangeResult>): Promise<ChangeResult> =>
operation.catch((error: unknown) => ({ changed: false, application: 'failed', stage, target, error: managementError(error) }))
const installTarget = (): Promise<ChangeResult> => settled('install', spec, this.installBundle(spec, {
saveExact: true, registry: checked.registry, enabled, ...requestId === undefined ? {} : { requestId },
}))
if (this.ownerContext.get('hmr') === undefined) {
announce('installing')
return { from, to: version, step: 'install', result: await installTarget() }
}
// The removed version is installed again where it came from, else over the configured plan, as its removal asks.
const previousRegistry = this.installedFrom.get(name)
announce('removing')
const removal = await settled('remove', name, this.removeBundle(name))
if (removal.application === 'failed' || removal.application === 'cancelled') return { from, to: version, step: 'remove', result: removal }
announce('installing')
const result = await installTarget()
if (result.application !== 'failed' && result.application !== 'cancelled') return { from, to: version, step: 'install', result }
announce('restoring')
// A target that installed but failed to start is still a dependency; it goes before the removed version returns.
if (result.stage === 'enable') await settled('remove', name, this.removeBundle(name))
// The reinstallation carries no request id, so cancelling the update cannot leave the package removed.
const previous = `${name}@${from}`
const restore = await settled('install', previous, this.installBundle(previous, {
saveExact: true, enabled, ...previousRegistry === undefined ? {} : { registry: previousRegistry },
}))
return { from, to: version, step: 'install', result, restore }
}
/**
* The registry a version listing or an update asks first.
* @param name The package name.
* @param requested The caller's registry, when it named one.
* @returns The requested registry, else the one this process last installed the package from, else undefined for the configured one.
*/
private registryFor(name: string, requested: Registry | undefined): Registry | undefined {
return requested !== undefined ? requested : this.installedFrom.get(name)
}
/**
* Ask registries in turn through `pnpm view`, moving to the next one while a failure is the registry's: unreachable,
* timed out, or without the package or version, which a mirror not yet synced reports.
* @param spec The registry spec viewed.
* @param fields The manifest fields printed.
* @param plan The registries, in order; never empty.
* @param parsed The spec's form, which decides whether a failure was the registry's.
* @param signal Ends the lookup; a dropped lookup is not carried to the next registry.
* @returns The parsed answer and the registry that gave it, or the last refusal, with every registry asked.
*/
private async askRegistries(
spec: string, fields: readonly string[], plan: readonly Registry[], parsed: ParsedInstallSpec, signal?: AbortSignal,
): Promise<RegistryAnswer> {
const attempts: RegistryAttempt[] = []
for (const [index, registry] of plan.entries()) {
const view = await viewProfilePackage(this.profile.dir, spec, {
...this.profile.packageManager ?? { command: this.pnpmCommand },
fields, timeoutMs: this.inspectTimeoutMs, ...signal === undefined ? {} : { signal }, registry,
})
const printed = view.stdout.replace(ANSI_SEQUENCE, '').trim()
if (view.exitCode !== 0 || view.cause !== undefined || view.timedOut) {
// pnpm prints a refusal as `{ error: { code, message } }` on stdout, with nothing on stderr.
const log = [view.stderr.trim(), printedError(printed), view.cause === undefined ? '' : messageOf(view.cause)].filter(Boolean).join('\n')
const kind = classifyInstallFailure({ log, timedOut: view.timedOut, ...view.cause === undefined ? {} : { cause: view.cause } })
const reason = view.timedOut ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` : log || printed || `pnpm view exited with ${String(view.exitCode)}`
const problem = kind === 'not-found' || kind === 'no-matching-version' ? 'not-found' : kind === 'network' || kind === 'timeout' ? 'network' : 'unknown'
attempts.push({ registry, problem, reason })
if (index < plan.length - 1 && signal?.aborted !== true && attributeFailure(kind, log, parsed) === 'registry') continue
return { status: 'refused', problem, reason, attempts }
}
try {
const answer: unknown = JSON.parse(printed || 'null')
attempts.push({ registry })
return { status: 'answered', answer, registry, attempts }
} catch (error) {
const reason = `unreadable pnpm view output: ${messageOf(error)}`
attempts.push({ registry, problem: 'unknown', reason })
return { status: 'refused', problem: 'unknown', reason, attempts }
}
}
/* v8 ignore next -- the plan is never empty: every attempt returns or continues to the next */
throw new Error('no registry was asked')
}
/** Persist a plugin entry's desired enablement and apply it on live profiles.
* @param id Loader entry identity returned by listPlugins.
* @param enabled Whether the plugin should run.
@@ -583,6 +583,8 @@ export interface PackageViewOptions {
timeoutMs: number
/** The registry asked; null asks the one pnpm's own configuration names. */
registry?: Registry
/** The manifest fields printed, as `pnpm view` names them; registry-only fields such as `versions`, `time`, and `dist-tags` included. */
fields: readonly string[]
}
/**
@@ -620,12 +622,12 @@ export function registryArguments(registry: Registry): string[] {
* configured after it are the retry.
* @param dir Profile directory.
* @param spec One registry spec: a package name with an optional range.
* @param options The registry, cancellation, and the time bound.
* @param options The fields printed, the registry, cancellation, and the time bound.
* @returns pnpm's exit, output, and how the lookup ended.
*/
export async function viewProfilePackage(dir: string, spec: string, options: PackageViewOptions): Promise<PackageViewResult> {
const result = await execa(options.command ?? 'pnpm', [
...options.args ?? [], 'view', spec, 'name', 'version', 'description', 'dsh', '--json',
...options.args ?? [], 'view', spec, ...options.fields, '--json',
...registryArguments(options.registry ?? null), '--config.fetch-retries=0',
], {
cwd: dir, env: { ...scrubbedParentEnv(), ...options.env }, extendEnv: false, reject: false, stdin: 'ignore',
+4 -1
View File
@@ -128,7 +128,10 @@ export function apply(ctx: Context): void {
const rows = args.action === 'list_plugins' ? await manager.listPlugins() : await manager.listBundles()
const entries = rows.slice(offset, offset + limit).map(({ meta: _meta, ...row }) =>
'rows' in row
? { ...row, rows: row.rows.map(({ meta: _rowMeta, ...declared }) => declared) }
// The tool offers no update action, so update eligibility stays out of the model's view.
? (({ updatable: _updatable, ...bundle }) => ({
...bundle, rows: bundle.rows.map(({ meta: _rowMeta, ...declared }) => declared),
}))(row)
: row)
return JSON.stringify({ entries, total: rows.length,
nextOffset: offset + entries.length < rows.length ? offset + entries.length : null })
+97
View File
@@ -81,6 +81,11 @@ export interface BundleInfo {
*/
optional: boolean
removable: boolean
/**
* Whether `listVersions` and `updateBundle` accept the bundle: a third-party profile dependency saved as a registry
* spec under its own name, whose installed manifest declares a semver version, and that carries no plugin management.
*/
updatable: boolean
readOnlyReason?: ReadOnlyReason
error?: ManagementError
/** The rows the bundle's patch inserts, in declaration order; empty when the patch cannot be read. */
@@ -222,6 +227,91 @@ export type PluginSpecInspection =
readonly registries?: Registry[]
}
/** One registry a lookup asked, in order; the registry that answered carries no problem. */
export interface RegistryAttempt {
readonly registry: Registry
/** Why this registry gave no usable answer; absent for the registry that answered. */
readonly problem?: 'not-found' | 'network' | 'unknown'
/** What pnpm or the registry said, verbatim. */
readonly reason?: string
}
/** One published version newer than the installed one. */
export interface PluginVersion {
readonly version: string
/** The registry's publication time, as an ISO 8601 string. */
readonly publishedAt?: string
/** The dist-tags pointing at this version, such as `latest`. */
readonly tags: readonly string[]
/** Whether the version carries a semver prerelease suffix. */
readonly prerelease: boolean
/** The registry's deprecation message, when the version is deprecated. */
readonly deprecated?: string
/** Whether the version declares a `dsh.bundle`; a version that does not cannot replace a bundle. */
readonly bundle: boolean
/** Present when the version's `engines.dsh` or DSH peers reject the running DSH version without an exemption for the exact pair. */
readonly incompatible?: IncompatiblePlugin
}
/** The versions a registry offers for an installed registry package, read without changing the profile. */
export type PluginVersionList =
| {
readonly status: 'listed'
/** The installed version the list is newer than. */
readonly installed: string
/** The registry that answered; an update installs from it. */
readonly registry: Registry
readonly attempts: readonly RegistryAttempt[]
/** Published versions newer than `installed`, newest first, at most `versionListLimit` of them. */
readonly versions: readonly PluginVersion[]
/** Newer versions beyond `versionListLimit` that the list leaves out. */
readonly omitted: number
}
| {
readonly status: 'refused'
/** `not-updatable`: the package is not an installed profile dependency resolved from a registry. */
readonly problem: 'not-updatable' | 'not-found' | 'network' | 'unknown'
readonly reason: string
/** The registries asked, in order; empty for `not-updatable`. */
readonly attempts: readonly RegistryAttempt[]
}
/** Where a version listing asks. */
export interface ListVersionsOptions {
/** The registry asked first; absent, the one this process last installed the package from, else the configured one. */
readonly registry?: Registry
}
/** Where an update asks for its target, and the request id its progress names. */
export interface UpdateBundleOptions {
/** The registry asked first, normally the one that answered `listVersions`; absent, as for `listVersions`. */
readonly registry?: Registry
/** Names the update's progress and the target installation's logs; `cancelInstall` stops only that installation. */
readonly requestId?: PluginInstallRequestId
}
/** The step of an update its Host progress names. */
export type PluginUpdateStep = 'checking' | 'removing' | 'installing' | 'restoring'
/** Progress of one update request. */
export interface PluginUpdateProgress {
readonly requestId: PluginInstallRequestId
readonly step: PluginUpdateStep
}
/**
* How an update ended. `result` belongs to the last step that ran: the target check, the removal of the installed
* version, or the target's installation. `restore` is present when the target's installation failed or was cancelled
* after the installed version was removed, and reports the reinstallation of that version.
*/
export interface PluginUpdateResult {
readonly from: string
readonly to: string
readonly step: 'check' | 'remove' | 'install'
readonly result: ChangeResult
readonly restore?: ChangeResult
}
/** The Host phase of one installation, before its install call settles; `installing` is announced once per registry asked. */
export interface PluginInstallProgress {
readonly requestId: PluginInstallRequestId
@@ -280,5 +370,12 @@ declare module '@deepseek-ai/cordis' {
* @param progress - the installation's request id and phase, with the attempt while installing.
*/
'plugin-manager/install-state'(progress: PluginInstallProgress): void
/**
* An update moved to its next step: checking the target, removing the installed version, installing the target,
* or reinstalling the removed version after the target failed. Announced once per step.
* @mode emit
* @param progress - the update's request id and step.
*/
'plugin-manager/update-state'(progress: PluginUpdateProgress): void
}
}
@@ -0,0 +1,80 @@
/** Read `pnpm view` answers about a package's published versions. */
import semver from 'semver'
/** The fields a version listing prints for the package: every version, its publication times, and its dist-tags. */
export const VERSION_LIST_FIELDS = ['versions', 'time', 'dist-tags'] as const
/** The fields read for one version: what decides whether it can replace an installed bundle. */
export const VERSION_FIELDS = ['name', 'version', 'engines', 'peerDependencies', 'deprecated', 'dsh'] as const
/** One published version newer than the installed one, before its own manifest is read. */
export interface PublishedVersion {
readonly version: string
readonly publishedAt?: string
readonly tags: readonly string[]
readonly prerelease: boolean
}
/** What one version's manifest says about replacing an installed bundle. */
export interface VersionFacts {
/** The fields `evaluatePluginCompatibility` reads. */
readonly manifest: { name: string; version: string; engines?: unknown; peerDependencies?: unknown }
readonly bundle: boolean
readonly deprecated?: string
}
/** A JSON object, or undefined for anything else. */
function recordOf(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined
}
/**
* The published versions newer than the installed one, newest first.
* @param answer - parsed `pnpm view <name> versions time dist-tags --json` output.
* @param installed - the installed version.
* @returns the newer versions with their publication times and dist-tags.
* @throws when the answer names no versions.
*/
export function newerVersions(answer: unknown, installed: string): PublishedVersion[] {
const fields = recordOf(answer)
// pnpm prints a package with one version as a string rather than a one-element array.
const listed: unknown = typeof fields?.versions === 'string' ? [fields.versions] : fields?.versions
if (!Array.isArray(listed)) throw new Error('pnpm view answered no versions')
const time = recordOf(fields?.time) ?? {}
const tags = Object.entries(recordOf(fields?.['dist-tags']) ?? {})
return listed
.filter((version): version is string => typeof version === 'string' && semver.valid(version) !== null && semver.gt(version, installed))
.sort(semver.rcompare)
.map((version) => {
const published = time[version]
return {
version,
...typeof published === 'string' ? { publishedAt: published } : {},
tags: tags.filter(([, tagged]) => tagged === version).map(([tag]) => tag),
prerelease: semver.prerelease(version) !== null,
}
})
}
/**
* What one exact version's manifest says, from `pnpm view <name>@<version> name version engines peerDependencies deprecated dsh --json`.
* @param answer - the parsed output.
* @param name - the package name, used when the answer omits it.
* @param version - the version asked for, used when the answer omits it.
* @returns the manifest fields compatibility reads, whether the version declares a bundle, and its deprecation message.
*/
export function versionFacts(answer: unknown, name: string, version: string): VersionFacts {
const fields = recordOf(answer) ?? {}
const dsh = recordOf(fields.dsh)
const deprecated = fields.deprecated
return {
manifest: {
name: typeof fields.name === 'string' ? fields.name : name,
version: typeof fields.version === 'string' ? fields.version : version,
...Object.hasOwn(fields, 'engines') ? { engines: fields.engines } : {},
...Object.hasOwn(fields, 'peerDependencies') ? { peerDependencies: fields.peerDependencies } : {},
},
bundle: recordOf(dsh?.bundle) !== undefined,
...typeof deprecated === 'string' && deprecated !== '' ? { deprecated } : {},
}
}
@@ -302,12 +302,12 @@ it('lists bundle versions and current-profile plugin targets', async () => {
expect(plugins.find(row => row.entryId === 'include:manager')?.readOnlyReason).toBe('management-required')
expect((await manager.listBundles()).filter(bundle => bundle.installTarget === undefined)).toEqual([
{
name: 'core', version: '1.0.0', enabled: true, installed: false, official: false, availability: 'profile', optional: false, removable: false, readOnlyReason: 'management-required',
name: 'core', version: '1.0.0', enabled: true, installed: false, official: false, availability: 'profile', optional: false, removable: false, updatable: false, readOnlyReason: 'management-required',
meta: { title: 'core' },
rows: [{ rowId: 'manager', moduleName: 'cordis:manager', entryId: 'include:manager' }], overrides: [],
},
{
name: 'extra', version: '1.0.0', source: 'extra@1.0.0', enabled: true, installed: true, official: false, availability: 'profile', optional: false, removable: true,
name: 'extra', version: '1.0.0', source: 'extra@1.0.0', enabled: true, installed: true, official: false, availability: 'profile', optional: false, removable: true, updatable: true,
meta: { title: 'extra' },
rows: [{ rowId: 'managed', moduleName: pathToFileURL(join(dir, 'node_modules', 'extra', 'plugin.mjs')).href, entryId: 'include:managed' }], overrides: [],
},
@@ -329,7 +329,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.', source: 'described@2.0.0', enabled: false, installed: true, official: false, availability: 'profile', optional: false, removable: true,
name: 'described', version: '2.0.0', description: 'Describes itself.', source: 'described@2.0.0', enabled: false, installed: true, official: false, availability: 'profile', optional: false, removable: true, updatable: true,
meta: { title: 'described', description: 'Describes itself.' },
rows: [{ rowId: 'described-row', moduleName }], overrides: ['managed'],
})
@@ -453,7 +453,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', source: 'unnamed@1.0.0', enabled: false, installed: true, official: false, availability: 'profile', optional: false, removable: true,
name: 'unnamed', version: '1.0.0', source: 'unnamed@1.0.0', enabled: false, installed: true, official: false, availability: 'profile', optional: false, removable: true, updatable: true,
rows: [], overrides: [], ...expected,
})
})
@@ -1025,11 +1025,11 @@ it('reads what a spec names before installing it', async () => {
expect(await manager.inspect('dsh-x')).toEqual({
status: 'accepted', kind: 'registry', name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', bundle: true, registry: null,
})
expect(view).toHaveBeenCalledWith(dir, 'dsh-x', { command: 'pnpm-test', timeoutMs: 1000, registry: null })
expect(view).toHaveBeenCalledWith(dir, 'dsh-x', { command: 'pnpm-test', fields: ['name', 'version', 'description', 'dsh'], timeoutMs: 1000, registry: null })
const signal = AbortSignal.abort()
answers(JSON.stringify([{ name: 'dsh-lib', version: '1.0.0', dsh: { bundle: {} } }, { name: 'dsh-lib', version: '1.1.0', dsh: null }]))
expect(await manager.inspect('dsh-lib@^1', undefined, signal)).toEqual({ status: 'refused', problem: 'not-a-bundle', reason: 'dsh-lib declares no dsh.bundle', registries: [null] })
expect(view).toHaveBeenLastCalledWith(dir, 'dsh-lib@^1', { command: 'pnpm-test', timeoutMs: 1000, signal, registry: null })
expect(view).toHaveBeenLastCalledWith(dir, 'dsh-lib@^1', { command: 'pnpm-test', fields: ['name', 'version', 'description', 'dsh'], timeoutMs: 1000, signal, registry: null })
// An answer that names no package keeps the name the spec gave; colour escapes around the JSON are dropped.
answers('\x1b[36m' + JSON.stringify({ version: '0.0.1', description: '', dsh: { bundle: { patch: './p.yml' } } }) + '\x1b[39m\n')
expect(await manager.inspect('dsh-bare')).toEqual({ status: 'accepted', kind: 'registry', name: 'dsh-bare', version: '0.0.1', bundle: true, registry: null })
@@ -1147,7 +1147,7 @@ it.each(OPTIONAL_BUNDLES)('offers %s switched off and never removable', async (o
expect((await manager.listBundles()).find(row => row.name === offered)).toEqual({
name: offered, version: '3.0.0', description: 'Package one-liner.',
meta: { title: offered, description: 'Package one-liner.' },
enabled: false, installed: false, official: true, availability: 'installation', optional: true, removable: false,
enabled: false, installed: false, official: true, availability: 'installation', optional: true, removable: false, updatable: false,
rows: [{ rowId: 'offered-row', moduleName: pathToFileURL(join(supplied, 'plugin.mjs')).href }], overrides: [],
})
expect(await manager.setBundleEnabled(offered, true)).toMatchObject({ application: 'applied' })
@@ -1265,7 +1265,7 @@ it('asks the registries in turn while one is unreachable or stale, and names the
expect(await manager.inspect('dsh-x')).toEqual({ status: 'accepted', kind: 'registry', name: 'dsh-x', version: '1.0.0', bundle: true, registry: MIRROR })
expect(asked(0)).toEqual([null, MIRROR])
expect(read).toHaveBeenCalledWith(dir, { command: 'pnpm', timeoutMs: 1000 })
expect(view).toHaveBeenLastCalledWith(expect.any(String), 'dsh-x', { command: 'pnpm', timeoutMs: 1000, registry: MIRROR })
expect(view).toHaveBeenLastCalledWith(expect.any(String), 'dsh-x', { command: 'pnpm', fields: ['name', 'version', 'description', 'dsh'], timeoutMs: 1000, registry: MIRROR })
// pnpm prints a refusal as JSON on stdout with nothing on stderr: it is read the same way, and the reason is its message.
view.mockResolvedValueOnce({ exitCode: 1, stdout: '{\n "error": {\n "code": "ERR_PNPM_META_FETCH_FAIL",\n "message": "GET https://registry.npmjs.org/dsh-x: fetch failed"\n }\n}\n', stderr: '', timedOut: false })
answers(manifest)
@@ -1525,7 +1525,7 @@ it('lists absent Official entries offline without resolving their packages or co
for (const entry of OFFICIAL_ON_DEMAND_CATALOG) {
expect(bundles.filter(bundle => bundle.name === entry.packageName)).toEqual([{
name: entry.packageName, official: true, availability: 'missing', enabled: false, installed: false, optional: false,
removable: false, rows: [], overrides: [], meta: entry.meta,
removable: false, updatable: false, rows: [], overrides: [], meta: entry.meta,
installTarget: { spec: `${entry.packageName}@${getDshRuntimeVersion()}`, version: getDshRuntimeVersion() },
}])
expect(resolve.mock.calls.some(([name]) => name === entry.packageName)).toBe(false)
@@ -1685,3 +1685,276 @@ it.each(['absent', 'bundle', 'not-bundle', 'unreadable'])('keeps a missing sourc
expect((await manager.listBundles()).find(item => item.name === name)?.error?.diagnostic).toContain('restore the checkout')
expect((await manager.listBundles()).find(item => item.name === name)?.installTarget).toBeUndefined()
})
/** A `pnpm view` stand-in answering by spec and printed fields. */
function viewAnswers(
answer: (spec: string, fields: readonly string[], registry: string | null | undefined) => operations.PackageViewResult | object | string,
) {
const view = vi.spyOn(operations, 'viewProfilePackage').mockImplementation(async (_dir, spec, options) => {
const value = answer(spec, options.fields, options.registry)
if (typeof value === 'object' && 'exitCode' in value) return value
return { exitCode: 0, stdout: typeof value === 'string' ? value : JSON.stringify(value), stderr: '', timedOut: false }
})
onTestFinished(() => { view.mockRestore() })
return view
}
const BUNDLE = { bundle: { patch: './cordis.patch.yml' } }
/** The published `extra` versions the version tests offer; the installed copy is 1.0.0. */
function extraRegistry(spec: string, fields: readonly string[]): object {
if (fields.includes('versions')) {
return {
versions: ['0.9.0', '1.0.0', '1.1.0', '1.2.0-beta.1', '1.2.0', '2.0.0', 'not-semver'],
time: { '1.1.0': '2026-09-01T00:00:00.000Z', '2.0.0': '2026-10-10T00:00:00.000Z' },
'dist-tags': { latest: '2.0.0', next: '1.2.0-beta.1' },
}
}
const version = spec.slice('extra@'.length)
if (version === '2.0.0') return { name: 'extra', version, peerDependencies: { '@deepseek-ai/dsh': '>=999.0.0' }, dsh: BUNDLE }
if (version === '1.1.0') return { name: 'extra', version, deprecated: 'use 1.2.0', dsh: BUNDLE }
if (version === '1.2.0-beta.1') return { name: 'extra', version }
// An own `engines.dsh` decides compatibility alone, so its unmet peer range does not make this version incompatible.
if (version === '1.2.0') {
return { name: 'extra', version, engines: { dsh: '>=0.0.0-0' }, peerDependencies: { '@deepseek-ai/dsh': '>=999.0.0' }, dsh: BUNDLE }
}
return { name: 'extra', version, dsh: BUNDLE }
}
it('lists newer registry versions with tags, times, deprecation, bundle declaration, and DSH compatibility', async () => {
const config = { inspectTimeoutMs: 1000, versionListLimit: 3, versionLookupConcurrency: 2 }
const { manager, dir } = await fixture(undefined, false, undefined, config)
const view = viewAnswers(extraRegistry)
const listed = await manager.listVersions('extra')
expect(listed).toEqual({
status: 'listed', installed: '1.0.0', registry: null, attempts: [{ registry: null }], omitted: 1,
versions: [
{
version: '2.0.0', publishedAt: '2026-10-10T00:00:00.000Z', tags: ['latest'], prerelease: false, bundle: true,
incompatible: { name: 'extra', version: '2.0.0', runtimeVersion: getDshRuntimeVersion(), requirements: { '@deepseek-ai/dsh': '>=999.0.0' } },
},
{ version: '1.2.0', tags: [], prerelease: false, bundle: true },
{ version: '1.2.0-beta.1', tags: ['next'], prerelease: true, bundle: false },
],
})
// The list itself, then one lookup per listed version on the registry that answered.
expect(view.mock.calls.map(call => call[1])).toEqual(expect.arrayContaining(['extra', 'extra@2.0.0', 'extra@1.2.0', 'extra@1.2.0-beta.1']))
expect(view).toHaveBeenCalledTimes(4)
expect(view).toHaveBeenCalledWith(dir, 'extra', expect.objectContaining({ fields: ['versions', 'time', 'dist-tags'], timeoutMs: 1000 }))
expect(view).toHaveBeenCalledWith(dir, 'extra@1.2.0', expect.objectContaining({ fields: ['name', 'version', 'engines', 'peerDependencies', 'deprecated', 'dsh'], registry: null }))
})
it('lists versions from the next registry while one is unreachable, and refuses what no registry answered', async () => {
const { manager } = await fixture(undefined, false, undefined, { inspectTimeoutMs: 1000 })
const unreachable = { exitCode: 1, stdout: '', stderr: 'ERR_PNPM_META_FETCH_FAIL GET https://registry.npmjs.org/extra: ETIMEDOUT\n', timedOut: false }
viewAnswers((spec, fields, registry) => registry === null ? unreachable
: fields.includes('versions') ? { versions: ['1.0.0', '1.1.0'], 'dist-tags': { latest: '1.1.0' } } : extraRegistry(spec, fields))
// A requested registry is asked first.
expect(await manager.listVersions('extra', { registry: MIRROR })).toMatchObject({ status: 'listed', registry: MIRROR, attempts: [{ registry: MIRROR }] })
expect(await manager.listVersions('extra')).toMatchObject({
status: 'listed', registry: MIRROR,
attempts: [{ registry: null, problem: 'network', reason: unreachable.stderr.trim() }, { registry: MIRROR }],
versions: [{ version: '1.1.0', deprecated: 'use 1.2.0', tags: ['latest'] }],
})
viewAnswers(() => unreachable)
expect(await manager.listVersions('extra')).toEqual({
status: 'refused', problem: 'network', reason: unreachable.stderr.trim(),
attempts: [{ registry: null, problem: 'network', reason: unreachable.stderr.trim() }, { registry: MIRROR, problem: 'network', reason: unreachable.stderr.trim() }],
})
// One version that cannot be read refuses the list, naming the registry that failed it.
viewAnswers((spec, fields) => spec === 'extra@1.1.0' ? { exitCode: 1, stdout: '', stderr: 'boom', timedOut: false } : extraRegistry(spec, fields))
expect(await manager.listVersions('extra')).toMatchObject({ status: 'refused', problem: 'unknown', reason: 'boom', attempts: [{ registry: null, problem: 'unknown' }] })
viewAnswers(() => ({ 'dist-tags': {} }))
expect(await manager.listVersions('extra')).toMatchObject({ status: 'refused', problem: 'unknown', reason: 'pnpm view answered no versions' })
viewAnswers((spec, fields) => fields.includes('versions') ? { versions: '1.0.0' } : extraRegistry(spec, fields))
expect(await manager.listVersions('extra')).toMatchObject({ status: 'listed', versions: [], omitted: 0 })
viewAnswers((_spec, fields) => fields.includes('versions') ? { versions: ['1.1.0'] } : { name: 'extra', version: '1.1.0', peerDependencies: { '@deepseek-ai/dsh': 3 } })
expect(await manager.listVersions('extra')).toMatchObject({ status: 'refused', problem: 'unknown', reason: expect.stringContaining('must be a string') as string })
})
it('refuses to list versions of a package not installed from a registry', async () => {
const { manager, dir, bundle } = await fixture()
const view = viewAnswers(extraRegistry)
const refusal = (reason: string) => ({ status: 'refused', problem: 'not-updatable', reason, attempts: [] })
expect(await manager.listVersions('core')).toEqual(refusal('core is not a third-party package installed in this profile'))
expect(await manager.listVersions('absent')).toEqual(refusal('absent is not a third-party package installed in this profile'))
bundle('linked', [])
const manifest = readProfileManifest('test', dir)
manifest.dependencies = { ...manifest.dependencies, linked: `link:${join(dir, 'node_modules', 'linked')}` }
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
expect(await manager.listVersions('linked')).toEqual(refusal('linked is not installed from a registry under its own name'))
// An alias saves another package under the dependency's name; its versions are not the alias's.
const aliased = readProfileManifest('test', dir)
aliased.dependencies = { ...aliased.dependencies, linked: 'npm:other-plugin@1.0.0' }
writeFileSync(join(dir, 'package.json'), JSON.stringify(aliased))
expect(await manager.listVersions('linked')).toEqual(refusal('linked is not installed from a registry under its own name'))
writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), JSON.stringify({ name: 'extra', version: 'nightly', dsh: BUNDLE }))
expect(await manager.listVersions('extra')).toEqual(refusal('extra declares no semver version'))
// A bundle carrying plugin management cannot be removed, so it is not offered for an update.
writeFileSync(join(dir, 'node_modules', 'extra', 'cordis.patch.yml'), JSON.stringify([{ insert: [{ id: 'pm', name: '@deepseek-ai/dsh-plugin-manager' }] }]))
expect((await manager.listBundles()).find(item => item.name === 'extra')).toMatchObject({ readOnlyReason: 'management-required', updatable: false })
expect(await manager.listVersions('extra')).toEqual(refusal('extra carries plugin management and cannot be removed'))
expect(view).not.toHaveBeenCalled()
})
/** A pnpm stand-in that removes `extra` and installs the version an `add` names, unless `fails` names it. */
function extraPnpm(dir: string, bundle: (name: string, rows: unknown[]) => void, fails: readonly string[] = []) {
const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => {
const manifest = readProfileManifest('test', dir)
const spec = String(args[1])
if (args[0] === 'remove') {
delete manifest.dependencies?.extra
rmSync(join(dir, 'node_modules', 'extra'), { recursive: true, force: true })
} else if (fails.includes(spec)) {
return { exitCode: 1, output: `ERR_PNPM_FETCH_404 GET ${spec}: Not Found - 404`, truncated: false, logPath: '/install.log', kind: 'not-found' as const }
} else {
const version = spec.slice('extra@'.length)
bundle('extra', [{ id: 'managed', name: './plugin.mjs' }])
writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), JSON.stringify({ name: 'extra', version, dsh: BUNDLE }))
manifest.dependencies = { ...manifest.dependencies, extra: version }
}
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
return { exitCode: 0, output: '', truncated: false, logPath: '/install.log' }
})
onTestFinished(() => { pnpm.mockRestore() })
return pnpm
}
it('updates a registry bundle by removing it and installing the exact newer version with its selection', async () => {
const { ctx, manager, dir, bundle } = await fixture()
viewAnswers(extraRegistry)
const pnpm = extraPnpm(dir, bundle)
const steps: string[] = []
ctx.on('plugin-manager/update-state', ({ step }) => { steps.push(step) })
const requestId = 'c2b8f1a4-6f0e-4b52-9a51-0a8e5f0b7d11' as PluginInstallRequestId
const updated = await manager.updateBundle('extra', '1.2.0', { registry: MIRROR, requestId })
expect(updated).toMatchObject({ from: '1.0.0', to: '1.2.0', step: 'install', result: { application: 'applied', version: '1.2.0', enabled: true } })
expect(updated.restore).toBeUndefined()
expect(steps).toEqual(['checking', 'removing', 'installing'])
expect(pnpm.mock.calls.map(call => call[1])).toEqual([['remove', 'extra'], ['add', 'extra@1.2.0', '--save-exact', `--registry=${MIRROR}`]])
expect(readProfileManifest('test', dir).dependencies?.extra).toBe('1.2.0')
expect(ctx.get('managedProbe')).toBe(true)
})
it('installs the removed version again when the target fails to install', async () => {
const { manager, dir, bundle } = await fixture()
viewAnswers(extraRegistry)
const pnpm = extraPnpm(dir, bundle, ['extra@1.2.0'])
// The removed copy came from the mirror, so it is installed again from there.
await manager.updateBundle('extra', '1.1.0', { registry: MIRROR })
pnpm.mockClear()
const updated = await manager.updateBundle('extra', '1.2.0')
expect(updated).toMatchObject({
from: '1.1.0', to: '1.2.0', step: 'install', result: { application: 'failed', packageResult: { kind: 'not-found' } },
restore: { application: 'applied', version: '1.1.0' },
})
expect(pnpm.mock.calls.map(call => call[1])).toEqual([
// The update starts at the registry the package came from; a 404 there may be a mirror not yet synced, so pnpm's
// own registry is asked too before the installation gives up.
['remove', 'extra', `--config.registry=${MIRROR}`], ['add', 'extra@1.2.0', '--save-exact', `--registry=${MIRROR}`], ['add', 'extra@1.2.0', '--save-exact'],
['add', 'extra@1.1.0', '--save-exact', `--registry=${MIRROR}`],
])
expect(readProfileManifest('test', dir).dependencies?.extra).toBe('1.1.0')
})
it.each([
['cancelled', 'cancelled'],
['rejected', 'rejected'],
] as const)('installs the removed version again when the target installation is %s', async (_name, outcome) => {
const { manager, dir, bundle } = await fixture()
viewAnswers(extraRegistry)
extraPnpm(dir, bundle)
const install = manager.installBundle.bind(manager)
const spy = vi.spyOn(manager, 'installBundle').mockImplementation(async (spec, options) => {
if (spec !== 'extra@1.2.0') return install(spec, options)
if (outcome === 'rejected') throw new Error('lock wait timed out')
return { changed: false, application: 'cancelled', stage: 'install', target: spec }
})
onTestFinished(() => { spy.mockRestore() })
const updated = await manager.updateBundle('extra', '1.2.0')
expect(updated.result.application).toBe(outcome === 'rejected' ? 'failed' : 'cancelled')
if (outcome === 'rejected') expect(updated.result.error).toEqual({ code: 'operation-error', diagnostic: 'lock wait timed out' })
expect(updated.restore).toMatchObject({ application: 'applied', version: '1.0.0' })
expect(readProfileManifest('test', dir).dependencies?.extra).toBe('1.0.0')
})
it('removes a target that installed but could not start before installing the removed version again', async () => {
const { manager, dir, bundle } = await fixture()
viewAnswers(extraRegistry)
const pnpm = extraPnpm(dir, bundle)
const install = manager.installBundle.bind(manager)
const spy = vi.spyOn(manager, 'installBundle').mockImplementation(async (spec, options) => {
const result = await install(spec, options)
// The target's files land, then its activation fails, as a plugin that throws on start does.
return spec === 'extra@1.2.0' ? { ...result, application: 'failed', stage: 'enable', error: { code: 'operation-error', diagnostic: 'cannot start' } } : result
})
onTestFinished(() => { spy.mockRestore() })
const updated = await manager.updateBundle('extra', '1.2.0')
expect(updated).toMatchObject({ result: { application: 'failed', stage: 'enable' }, restore: { application: 'applied', version: '1.0.0' } })
expect(pnpm.mock.calls.map(call => call[1].slice(0, 2))).toEqual([['remove', 'extra'], ['add', 'extra@1.2.0'], ['remove', 'extra'], ['add', 'extra@1.0.0']])
expect(readProfileManifest('test', dir).dependencies?.extra).toBe('1.0.0')
})
it('reports a removal that rejects as the update\'s last step', async () => {
const { manager, dir, bundle } = await fixture()
viewAnswers(extraRegistry)
const pnpm = extraPnpm(dir, bundle)
const spy = vi.spyOn(manager, 'removeBundle').mockRejectedValue(new Error('lock wait timed out'))
onTestFinished(() => { spy.mockRestore() })
expect(await manager.updateBundle('extra', '1.2.0')).toMatchObject({
step: 'remove', result: { application: 'failed', stage: 'remove', error: { code: 'operation-error', diagnostic: 'lock wait timed out' } },
})
expect(pnpm).not.toHaveBeenCalled()
})
it('reports a reinstallation that fails too, leaving the package uninstalled', async () => {
const { manager, dir, bundle } = await fixture()
viewAnswers(extraRegistry)
extraPnpm(dir, bundle, ['extra@1.2.0', 'extra@1.0.0'])
const updated = await manager.updateBundle('extra', '1.2.0')
expect(updated).toMatchObject({ step: 'install', result: { application: 'failed' }, restore: { application: 'failed' } })
expect(readProfileManifest('test', dir).dependencies?.extra).toBeUndefined()
})
it('refuses an update before changing anything when the target is not a newer compatible bundle', async () => {
const { manager, dir, bundle } = await fixture()
viewAnswers((spec, fields) => spec === 'extra@3.0.0' ? { exitCode: 1, stdout: '', stderr: 'ERR_PNPM_NO_MATCHING_VERSION No matching version found for extra@3.0.0', timedOut: false }
: spec === 'extra@1.5.0' ? { name: 'extra', version: '1.5.0', peerDependencies: { '@deepseek-ai/dsh': 7 }, dsh: BUNDLE }
: extraRegistry(spec, fields))
const pnpm = extraPnpm(dir, bundle)
const before = readFileSync(join(dir, 'package.json'), 'utf8')
const refused = async (name: string, version: string) => (await manager.updateBundle(name, version)).result.error
expect(await refused('extra', '0.9.0')).toEqual({ code: 'invalid-spec', diagnostic: '0.9.0 is not a version newer than the installed 1.0.0' })
expect(await refused('extra', 'latest')).toMatchObject({ code: 'invalid-spec' })
expect(await refused('core', '2.0.0')).toEqual({ code: 'invalid-spec', diagnostic: 'core is not a third-party package installed in this profile' })
expect(await refused('extra', '2.0.0')).toMatchObject({ code: 'incompatible-version', incompatible: [{ name: 'extra', version: '2.0.0' }] })
expect(await refused('extra', '1.2.0-beta.1')).toEqual({ code: 'not-bundle' })
expect(await refused('extra', '3.0.0')).toMatchObject({ code: 'operation-error', diagnostic: expect.stringContaining('No matching version') as string })
expect(await refused('extra', '1.5.0')).toMatchObject({ code: 'operation-error', diagnostic: expect.stringContaining('must be a string') as string })
expect(await manager.updateBundle('extra', '0.9.0')).toMatchObject({ from: '1.0.0', to: '0.9.0', step: 'check', result: { application: 'failed', changed: false, stage: 'install', target: 'extra' } })
expect(pnpm).not.toHaveBeenCalled()
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
})
it('reports a removal the profile refuses as the update\'s last step', async () => {
const { manager, dir, bundle } = await fixture()
viewAnswers(extraRegistry)
const pnpm = extraPnpm(dir, bundle)
pnpm.mockResolvedValueOnce({ exitCode: 1, output: 'EBUSY', truncated: false, logPath: '/remove.log', kind: 'unknown' })
expect(await manager.updateBundle('extra', '1.2.0')).toMatchObject({ step: 'remove', result: { application: 'failed', stage: 'remove' } })
expect(pnpm).toHaveBeenCalledOnce()
})
it('installs over the running version without HMR and reports the restart', async () => {
const { ctx, manager, dir, bundle } = await fixture('startup')
viewAnswers(extraRegistry)
const pnpm = extraPnpm(dir, bundle)
const steps: string[] = []
ctx.on('plugin-manager/update-state', ({ step }) => { steps.push(step) })
const requestId = '5f7d1e9a-2b3c-4d5e-8f90-a1b2c3d4e5f6' as PluginInstallRequestId
expect(await manager.updateBundle('extra', '1.2.0', { requestId })).toMatchObject({
step: 'install', result: { application: 'restart-required', restartReason: 'no-hmr', version: '1.2.0' },
})
expect(steps).toEqual(['checking', 'installing'])
expect(pnpm.mock.calls.map(call => call[1])).toEqual([['add', 'extra@1.2.0', '--save-exact']])
})
@@ -8,6 +8,9 @@ import { expect, it, onTestFinished, vi } from 'vitest'
import { getDshRuntimeVersion, initProfile, readProfileManifest } from '@deepseek-ai/dsh-app-boot'
import { anchorPathSpec, readProfileRegistry, runPluginCommand, runProfilePnpm, viewProfilePackage } from '../src/operations.ts'
/** The manifest fields an inspection prints. */
const VIEW_FIELDS = ['name', 'version', 'description', 'dsh'] as const
/** What execa resolves for a run that settled, including the buffered output the pre-install lookup is read for. */
interface FakeOutcome {
exitCode: number | undefined
@@ -936,26 +939,26 @@ it('asks the registry through pnpm view in the profile directory, without pnpm\'
const { dir } = fixture()
const answer = (value: object) => command.run.mockResolvedValueOnce(value as never)
answer({ exitCode: 0, stdout: '{"name":"x"}', stderr: '', timedOut: false, isCanceled: false })
expect(await viewProfilePackage(dir, 'x@^1', { timeoutMs: 5 })).toEqual({ exitCode: 0, stdout: '{"name":"x"}', stderr: '', timedOut: false })
expect(await viewProfilePackage(dir, 'x@^1', { fields: VIEW_FIELDS, timeoutMs: 5 })).toEqual({ exitCode: 0, stdout: '{"name":"x"}', stderr: '', timedOut: false })
expect(command.run).toHaveBeenLastCalledWith('pnpm', ['view', 'x@^1', 'name', 'version', 'description', 'dsh', '--json', '--config.fetch-retries=0'],
expect.objectContaining({ cwd: dir, timeout: 5, reject: false, stdin: 'ignore' }))
expect((command.run.mock.lastCall as unknown[])[2]).not.toHaveProperty('cancelSignal')
// A registry asked by URL goes on the command line; null leaves the choice to pnpm's own configuration.
answer({ exitCode: 0, stdout: '{"name":"x"}', stderr: '', timedOut: false, isCanceled: false })
await viewProfilePackage(dir, 'x', { timeoutMs: 5, registry: 'https://registry.npmmirror.com/' })
await viewProfilePackage(dir, 'x', { fields: VIEW_FIELDS, timeoutMs: 5, registry: 'https://registry.npmmirror.com/' })
expect((command.run.mock.lastCall as unknown[])[1]).toEqual(['view', 'x', 'name', 'version', 'description', 'dsh', '--json', '--registry=https://registry.npmmirror.com/', '--config.fetch-retries=0'])
answer({ exitCode: 0, stdout: '{"name":"x"}', stderr: '', timedOut: false, isCanceled: false })
await viewProfilePackage(dir, 'x', { timeoutMs: 5, registry: null })
await viewProfilePackage(dir, 'x', { fields: VIEW_FIELDS, timeoutMs: 5, registry: null })
expect((command.run.mock.lastCall as unknown[])[1]).toEqual(['view', 'x', 'name', 'version', 'description', 'dsh', '--json', '--config.fetch-retries=0'])
const signal = AbortSignal.abort()
answer({ exitCode: undefined, stdout: '', stderr: '', timedOut: true, isCanceled: false })
expect(await viewProfilePackage(dir, 'x', { timeoutMs: 5, signal })).toEqual({ exitCode: null, stdout: '', stderr: '', timedOut: true })
expect(await viewProfilePackage(dir, 'x', { fields: VIEW_FIELDS, timeoutMs: 5, signal })).toEqual({ exitCode: null, stdout: '', stderr: '', timedOut: true })
expect((command.run.mock.lastCall as unknown[])[2]).toMatchObject({ cancelSignal: signal })
answer({ exitCode: undefined, stdout: '', stderr: '', timedOut: false, isCanceled: true })
expect(await viewProfilePackage(dir, 'x', { command: 'node', timeoutMs: 5 })).toEqual({ exitCode: null, stdout: '', stderr: '', timedOut: false })
expect(await viewProfilePackage(dir, 'x', { command: 'node', fields: VIEW_FIELDS, timeoutMs: 5 })).toEqual({ exitCode: null, stdout: '', stderr: '', timedOut: false })
expect((command.run.mock.lastCall as unknown[])[0]).toBe('node')
answer({ exitCode: undefined, stdout: '', stderr: '', timedOut: false, isCanceled: false, code: 'ENOENT', shortMessage: 'spawn pnpm ENOENT' })
const missing = await viewProfilePackage(dir, 'x', { timeoutMs: 5 })
const missing = await viewProfilePackage(dir, 'x', { fields: VIEW_FIELDS, timeoutMs: 5 })
expect(missing).toMatchObject({ exitCode: null, timedOut: false })
expect(missing.cause).toMatchObject({ message: 'spawn pnpm ENOENT', code: 'ENOENT' })
})
@@ -967,7 +970,7 @@ it('uses application-owned executable arguments and environment for package oper
expect(command.run.mock.calls[0]).toEqual([runtime.command, [...runtime.args, 'add', resolve(context.cwd, 'extra')],
expect.objectContaining({ env: expect.objectContaining(runtime.env) as unknown })])
command.run.mockResolvedValueOnce(Object.assign({ exitCode: 0, failed: false }, { stdout: '{}', stderr: '', timedOut: false }))
await viewProfilePackage(dir, 'example', { ...runtime, timeoutMs: 1000 })
await viewProfilePackage(dir, 'example', { ...runtime, fields: VIEW_FIELDS, timeoutMs: 1000 })
expect(command.run).toHaveBeenLastCalledWith(runtime.command,
[...runtime.args, 'view', 'example', 'name', 'version', 'description', 'dsh', '--json', '--config.fetch-retries=0'],
expect.objectContaining({ env: expect.objectContaining(runtime.env) as unknown }))
@@ -86,7 +86,7 @@ it('exposes an absent Official bundle installation target without display metada
const { manager, call } = await fixture()
const offered: BundleInfo = {
name: '@deepseek-ai/dsh-subagent-codex', official: true, availability: 'missing',
installed: false, enabled: false, optional: false, removable: false, rows: [], overrides: [],
installed: false, enabled: false, optional: false, removable: false, updatable: false, rows: [], overrides: [],
installTarget: { spec: '@deepseek-ai/dsh-subagent-codex@1.2.3', version: '1.2.3' },
meta: { title: 'Codex subagent' },
}
@@ -0,0 +1,20 @@
/** Reading `pnpm view` answers about published versions. */
import { expect, it } from 'vitest'
import { newerVersions, versionFacts } from '../src/versions.ts'
it('reads one version\'s manifest, keeping the asked identity when the answer omits it', () => {
const bundle = { bundle: { patch: './p.yml' } }
expect(versionFacts({ name: 'x', version: '1.0.0', engines: { dsh: '>=1' }, peerDependencies: { a: '1' }, deprecated: 'old', dsh: bundle }, 'y', '2.0.0')).toEqual({
manifest: { name: 'x', version: '1.0.0', engines: { dsh: '>=1' }, peerDependencies: { a: '1' } }, bundle: true, deprecated: 'old',
})
expect(versionFacts({ dsh: { bundle: 'no' }, deprecated: '' }, 'y', '2.0.0')).toEqual({ manifest: { name: 'y', version: '2.0.0' }, bundle: false })
expect(versionFacts('scalar', 'y', '2.0.0')).toEqual({ manifest: { name: 'y', version: '2.0.0' }, bundle: false })
})
it('orders newer semver versions newest first and ignores time and tags that are not objects', () => {
expect(newerVersions({ versions: ['1.0.0', '1.10.0', '1.2.0', 7], time: 'x', 'dist-tags': [] }, '1.0.0')).toEqual([
{ version: '1.10.0', tags: [], prerelease: false },
{ version: '1.2.0', tags: [], prerelease: false },
])
expect(() => newerVersions(null, '1.0.0')).toThrow('pnpm view answered no versions')
})
@@ -21,7 +21,7 @@ const URL_B = 'https://other.example/index.json'
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers() })
function bundle(name: string, fields: Partial<BundleInfo>): BundleInfo {
return { name, official: false, availability: 'profile', enabled: true, installed: true, optional: false, removable: true, rows: [], overrides: [], ...fields }
return { name, official: false, availability: 'profile', enabled: true, installed: true, optional: false, removable: true, updatable: false, rows: [], overrides: [], ...fields }
}
function index(plugins: unknown[] = []): unknown {
@@ -15,11 +15,14 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package:
en: 8d8c0fcd9320761a
zh: 087473d318680403
en: ebda556fc4602b34
zh: 3097ad4a0eda6828
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
en: fcb2e69c8b028c0a
zh: d505a075679974eb
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/updating-a-registry-bundle:
en: 9312d8b1b381378f
zh: 8d0ae7ad26ada2ac
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle:
en: 59e12480cf0c3e70
zh: f230ea8c752475d5
+9 -1
View File
@@ -37,7 +37,7 @@ On-demand Official cards are visible offline before installation. Turning one On
The first list read shows a short group-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states.
The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard focus. During a manual refresh, the button shows a spinner and stays disabled until all pending reads settle and at least 400 ms have elapsed, preventing a brief spinner flash; repeated refresh requests are ignored and the existing list remains visible. Success shows no toast. With a loaded list, a failed manual refresh keeps the cached content and shows a failure toast, including after leaving Plugins; select **Refresh** again to retry. Before any successful list read, failures show an in-place message with **Retry**. Background reads do not show the refresh spinner.
The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard focus. It reads the list again, then checks every updatable bundle for newer versions one after another; a failed read checks nothing. During a manual refresh, the button shows a spinner and stays disabled until all pending reads and those checks settle and at least 400 ms have elapsed, preventing a brief spinner flash; repeated refresh requests are ignored and the existing list remains visible. Success shows no toast. With a loaded list, a failed manual refresh keeps the cached content and shows a failure toast, including after leaving Plugins; select the button again to retry. Before any successful list read, failures show an in-place message with **Retry**. Background reads do not show the refresh spinner.
### Installing a bundle
@@ -60,6 +60,14 @@ During preparation and download, **Cancel install** asks the Host to stop the ru
When the Host attributes a network failure or timeout to a GitHub address and offers npmmirror, the dialog says **Cannot access GitHub**, or **GitHub connection timed out** for a timeout, and offers **Use mainland China mirror** or **Cancel**. Choosing the mirror returns to an empty package-name field and remembers the selected registry without starting another installation. When the install already asks npmmirror, whether picked, typed, or named by pnpm's own configuration, the button reads **Try another way** instead and returns to the same empty field with **Install guide and examples** unfolded and the registry unchanged. Other failures retain their existing diagnostics and actions. The mirror supplies registry packages and dependencies, not the GitHub repository itself.
### Updating a registry bundle
A third-party bundle installed from a registry, which the Host marks `updatable`, has a **Versions** section on its page below its rows. Opening the page asks the Host for [its newer versions](../../boot/plugin-manager/README.md#use-this-package) once; **Check again** asks again. The section shows one summary row: checking, up to date with the time of the last check, an update to the newest version that declares a bundle, accepts the running DSH, and is neither deprecated nor a prerelease, newer versions that need a DSH update with the DSH peers they need, or a failed check with the registry that failed last and **Retry**. A summary with newer versions unfolds into one row per version, newest first, with its dist-tags, prerelease and deprecated tags, why it cannot be installed, its publication date, and **Update** for an installable version; the installed version closes the list. Below it, the registry that answered is marked **Fallback** when an earlier registry failed, beside the time of the check and **Show prereleases**. A failed check unfolds through **Details** into every registry asked, its role, what it failed with, and pnpm's words. Bundles installed from a link, a path, git, or a tarball, and Official bundles, show no Versions section. Older versions are never offered.
After the checks, the list toolbar says how many plugins can be updated, that all are up to date, or that some checks failed, and cards with an update show **Update available**. The detail head adds **Update to** with the offered version.
**Update** opens a confirmation with the installed and target versions, the registry it installs from, and that a failed installation keeps or reinstalls the installed version. While the Host runs the update the dialog lists the steps it announced and stays open. The outcome says that the new version runs, that it waits for a restart with the Host's reason, that the target was refused before anything changed, that removing the installed version failed, that the target could not be installed while the installed version stayed in place, that the update failed and the previous version was reinstalled, or that the reinstallation failed too and the plugin is not installed; a failed run unfolds pnpm's output. A lost answer says that the result is unknown and the list is read again.
### Switching a bundle
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 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.
@@ -37,7 +37,7 @@ kind: "package-reference"
首次读取列表时,页面显示一条较短的分组标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。
**刷新**按钮在悬停或键盘聚焦 500 毫秒后显示提示。手动刷新期间,按钮显示旋转加载指示并保持禁用,直到所有待处理读取结束且至少经过 400 毫秒,避免加载指示一闪而过;重复刷新请求会被忽略,已有列表保持可见。成功不显示 toast。已有列表时,手动刷新失败保留缓存内容并显示失败 toast,即使已离开插件页也能看到;再次选择**刷新**即可重试。在首次成功读取列表之前,失败仍原位显示提示与**重试**。后台读取不显示刷新按钮的加载指示。
**刷新**按钮在悬停或键盘聚焦 500 毫秒后显示提示。它重新读取列表,再依次检查每个可更新组合包的新版本;读取失败时不做检查。手动刷新期间,按钮显示旋转加载指示并保持禁用,直到所有待处理读取和这些检查结束且至少经过 400 毫秒,避免加载指示一闪而过;重复刷新请求会被忽略,已有列表保持可见。成功不显示 toast。已有列表时,手动刷新失败保留缓存内容并显示失败 toast,即使已离开插件页也能看到;再次选择该按钮即可重试。在首次成功读取列表之前,失败仍原位显示提示与**重试**。后台读取不显示刷新按钮的加载指示。
### 安装一个组合包
@@ -60,6 +60,14 @@ Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https
Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时,对话框显示**无法访问 GitHub**,超时时显示**连接 GitHub 超时**,提供**改用国内镜像**和**取消**。选择镜像后回到空的包名输入框,记住所选安装源,不自动开始下一次安装。安装已在使用 npmmirror 时(无论是选中、手动输入,还是 pnpm 自身配置指向它),按钮改为**试试其他方式**,同样回到空的包名输入框并展开**插件安装引导和示例**,安装源保持不变。其他失败保留原有诊断和操作。镜像提供注册表中的包及依赖,不代替 GitHub 仓库下载。
### 更新注册表组合包
从注册表安装、Host 标为 `updatable` 的第三方组合包,在其页面的行列表下方有 **版本** 区块。打开页面时向 Host 询问一次[它的新版本](../../boot/plugin-manager/README.zh.md#use-this-package);**重新检查** 再问一次。区块显示一行摘要:正在检查;已是最新及上次检查时间;可更新到声明组合包、接受当前 DSH、未弃用且非预发布的最新版本;新版本需要更新 DSH 及其所需的 DSH peer;或检查失败、最后失败的注册表及 **重试**。有新版本的摘要可展开为每个版本一行,从新到旧,带 dist-tag、预发布与已弃用标签、无法安装的原因、发布日期,以及可安装版本的 **更新**;列表以已安装版本结尾。其下显示作答的注册表,前面的注册表失败时标为 **备选源**,旁边是检查时间和 **显示预发布版本**。检查失败时可通过 **详情** 展开问过的每个注册表、其角色、失败类型和 pnpm 的原文。从链接、路径、git 或压缩包安装的组合包以及官方组合包不显示版本区块。不会提供更旧的版本。
检查结束后,列表工具栏说明有几个插件可更新、全部已是最新,或部分检查失败,有更新的卡片显示 **可更新**。详情页头部增加带目标版本的 **更新到**。
**更新** 打开确认框,显示已安装版本与目标版本、安装来源,以及安装失败时会保留或重新安装已安装版本。Host 运行更新期间,对话框列出它通告的步骤并保持打开。结果说明新版本已开始运行、需要重启才生效及 Host 给出的原因、目标版本在任何改动前被拒绝、移除已安装版本失败、目标版本未能安装而已安装版本保持不变、更新失败且已重新安装原版本,或重新安装也失败、插件未安装;失败的运行可展开 pnpm 输出。答复丢失时说明结果未知,并重新读取列表。
### 切换一个组合包
组合包页面在标题下方显示完整包名。组件列表之后的「来源信息」给出在别处安装它所需的 spec(`pnpm add` 接受的形式),安装随附的官方组合包显示「内置」,随后是当前版本。组合包开关改变其层选择。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。
@@ -72,10 +72,12 @@
.toolbar {
display: flex;
flex: none;
flex: 0 1 auto;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 16px;
gap: 8px 16px;
min-width: 0;
white-space: nowrap;
}
@@ -1542,3 +1544,412 @@ label.registryOption {
flex-direction: column;
gap: 24px;
}
/* The list toolbar's sentence about the last update check. */
.updatesNote {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
.updatesNote[data-tone='info'] {
color: var(--dsw-alias-state-business-primary);
}
.updatesNote[data-tone='error'] {
color: var(--dsw-alias-state-error-primary);
}
/* A bundle's Versions section: one summary row that unfolds into the newer versions or the registries asked. */
.versionPanel {
container-type: inline-size;
overflow: hidden;
border: 0.5px solid var(--dsw-alias-settings-card-stroke);
border-radius: var(--dsw-radius-xl);
background: var(--dsw-alias-settings-card-fill);
}
.versionSummary {
display: flex;
align-items: center;
gap: 12px;
box-sizing: border-box;
width: 100%;
min-height: 64px;
padding: 12px 16px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
}
button.versionSummary {
cursor: pointer;
}
button.versionSummary:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.versionSummary[aria-expanded='true'] {
background: var(--dsw-alias-bg-module-platform);
}
button.versionSummary:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
outline-offset: -2px;
}
.versionSummary[aria-expanded='true'] .detailsChevron,
.versionSummaryEnd [aria-expanded='true'] .detailsChevron {
transform: rotate(180deg);
}
.versionGlyph {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-secondary);
corner-shape: round;
}
.versionGlyph[data-tone='info'] {
background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent);
color: var(--dsw-alias-state-business-primary);
}
.versionGlyph[data-tone='success'] {
background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);
color: var(--dsw-alias-state-success-primary);
}
.versionGlyph[data-tone='warn'] {
background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent);
color: var(--dsw-alias-state-warn-primary);
}
.versionGlyph[data-tone='error'] {
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);
color: var(--dsw-alias-state-error-primary);
}
.versionSummaryText {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.versionSummaryTitle {
font-size: 14px;
line-height: 20px;
font-weight: 500;
}
.versionSummarySub {
font-size: 12.5px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
overflow-wrap: anywhere;
}
.versionSummaryEnd {
display: inline-flex;
flex: none;
align-items: center;
gap: 8px;
font-size: 12.5px;
color: var(--dsw-alias-label-secondary);
white-space: nowrap;
}
.versionBody {
padding: 0 16px;
border-top: 0.5px solid var(--dsw-alias-border-l2);
}
.versionRows {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
list-style: none;
}
.versionRow {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content 56px;
align-items: center;
gap: 16px;
min-height: 28px;
padding: 12px 0;
border-bottom: 0.5px solid var(--dsw-alias-border-l2);
}
.versionRow:last-child {
border-bottom: 0;
}
.versionMain {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.versionTitle {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.versionNumber {
font-family: var(--ds-font-family-code);
font-size: 13px;
line-height: 20px;
font-weight: 500;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.versionRow[data-blocked] .versionNumber,
.versionNumber[data-old] {
color: var(--dsw-alias-label-tertiary);
}
.versionNote {
margin: 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
}
.versionNote[data-tone='warn'] {
color: var(--dsw-alias-state-warn-label, var(--dsw-alias-state-warn-primary));
}
.versionDate {
flex: none;
font-size: 12.5px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
font-variant-numeric: tabular-nums;
}
.versionAction {
display: flex;
flex: none;
justify-content: flex-end;
min-width: 56px;
}
.versionLog {
display: block;
margin-top: 4px;
font-family: var(--ds-font-family-code);
font-size: 11.5px;
line-height: 16px;
color: var(--dsw-alias-label-tertiary);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.versionMeta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 24px;
padding: 12px 0;
border-top: 0.5px solid var(--dsw-alias-border-l2);
}
.versionFact {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 12px;
line-height: 18px;
}
.versionFact dt {
color: var(--dsw-alias-label-tertiary);
}
.versionFact dd {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
color: var(--dsw-alias-label-secondary);
font-variant-numeric: tabular-nums;
}
.versionHint {
display: inline-flex;
border-radius: 999px;
corner-shape: round;
}
.versionHint:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
outline-offset: 1px;
}
.versionSpacer {
flex: 1;
}
/* The update dialog: the versions it moves between, then the Host's steps and the outcome. */
.updateDialog {
width: min(460px, 100%);
}
.updateBody {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.updateVersions {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 20px 16px;
border-radius: var(--dsw-radius-md);
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-tertiary);
}
.updateVersions .versionNumber {
font-size: 16px;
line-height: 24px;
color: var(--dsw-alias-label-primary);
}
.updateVersions .versionNumber[data-old] {
color: var(--dsw-alias-label-tertiary);
}
.updateText {
margin: 0;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
}
.updateOutcome {
display: flex;
align-items: flex-start;
gap: 12px;
}
.updateOutput {
margin: 0;
max-height: 200px;
overflow: auto;
padding: 10px 12px;
border-radius: var(--dsw-radius-md);
background: var(--dsw-alias-bg-module-platform);
font-family: var(--ds-font-family-code);
font-size: 11.5px;
line-height: 16px;
color: var(--dsw-alias-label-secondary);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.updateSteps {
display: flex;
flex-direction: column;
gap: 12px;
margin: 0;
padding: 12px 16px;
border-radius: var(--dsw-radius-md);
background: var(--dsw-alias-bg-module-platform);
list-style: none;
}
.updateStep {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
line-height: 20px;
}
.updateStep > :last-child {
min-width: 0;
overflow-wrap: anywhere;
}
.updateStepDot {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
color: var(--dsw-alias-label-secondary);
corner-shape: round;
}
.updateStep[data-state='done'] .updateStepDot {
background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent);
color: var(--dsw-alias-state-success-primary);
}
.updateStep[data-state='failed'] .updateStepDot {
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);
color: var(--dsw-alias-state-error-primary);
}
.updateStep[data-state='unknown'] .updateStepDot {
border: 0.5px solid var(--dsw-alias-border-l3);
}
@container (max-width: 460px) {
.versionRow {
grid-template-columns: minmax(0, 1fr) 56px;
gap: 4px 12px;
}
.versionMain {
grid-column: 1;
grid-row: 1;
}
.versionDate {
grid-column: 1;
grid-row: 2;
}
.versionAction {
grid-column: 2;
grid-row: 1 / span 2;
}
.versionMeta {
gap: 8px 16px;
}
.versionMeta > label {
flex-basis: 100%;
}
.versionSpacer {
display: none;
}
}
@@ -25,13 +25,14 @@ import type { createNavigationStore } from './navigation-store.ts'
import { rowConfigKey, type OfficialItem } from './config-ledger.ts'
import { INSTALL_GIT_EXAMPLE, INSTALL_PATH_EXAMPLE, type PluginManagerLocaleKey } from './locales.ts'
import {
asksMirror, githubRecoveryRegistry, isInstallPending, offeredRegistries, rowKey,
asksMirror, githubRecoveryRegistry, isInstallPending, offeredRegistries, rowKey, updateCandidate,
type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView,
type PluginManagerFace, type RegistryChoice,
type PluginManagerFace, type PluginManagerState, type RegistryChoice, type VersionCheck,
} from './manager-store.ts'
import { managementText, noticeText, packageText, registryText, restartText, rowText, type Translate } from './presentation.ts'
import type { PluginPackageRef, PluginRowRef, PluginsSubject } from './slot-contract.ts'
import type { ConfigPageForm } from './slot-contract.ts'
import { UpdateDialog, VersionSection } from './versions.tsx'
import css from './PluginManagerPage.module.css'
import badgeSkillPreview from './assets/badge-skill-preview.svg'
import badgeSkillPreviewZh from './assets/badge-skill-preview.zh.svg'
@@ -523,6 +524,21 @@ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: {
)
}
/** The registry version a finished version check offers for a package, when it offers one. */
function versionUpdate(check: VersionCheck | undefined): string | undefined {
return check?.phase === 'done' ? updateCandidate(check.list)?.version : undefined
}
/** The list toolbar's sentence about the last update check of every updatable package. */
function updatesNote(packages: readonly PackageView[], versions: PluginManagerState['versions'], t: Translate): { text: string; tone?: 'info' | 'error' } | undefined {
const checks = packages.filter(pkg => pkg.updatable).map(pkg => versions[pkg.name])
if (checks.length === 0 || checks.some(check => check === undefined || check.phase === 'checking')) return undefined
const available = checks.filter(check => versionUpdate(check) !== undefined).length
if (available > 0) return { text: t('updatesAvailable', { count: String(available) }), tone: 'info' }
if (checks.some(check => check?.phase === 'done' && check.list.status === 'refused')) return { text: t('updatesFailed'), tone: 'error' }
return { text: t('updatesNone') }
}
function updateAvailable(pkg: PackageView): boolean {
const target = pkg.installTarget
// An update removes the installed package first, so a package that cannot be removed offers none.
@@ -532,8 +548,10 @@ function updateAvailable(pkg: PackageView): boolean {
}
/** One package as a card that opens its page: its name, its one-liner, its tags, and its bundle switch. */
function PackageCard({ pkg, t, resolveText, busy, highlighted, onOpen, onSetEnabled }: {
function PackageCard({ pkg, t, resolveText, busy, highlighted, newer, onOpen, onSetEnabled }: {
readonly pkg: PackageView
/** The newer registry version a version check offers. */
readonly newer: string | undefined
readonly t: Translate
readonly resolveText: ResolveText
readonly busy: boolean
@@ -560,7 +578,7 @@ function PackageCard({ pkg, t, resolveText, busy, highlighted, onOpen, onSetEnab
{beta ? <Tag className={css.statusTag} tone="info">{t('statusBeta')}</Tag> : null}
{status === 'problem' ? <Tag className={css.statusTag} tone="danger">{t('statusProblem')}</Tag> : null}
{pkg.availability === 'missing' ? <Tag className={css.statusTag}>{t(pkg.installed ? 'statusMissingFiles' : 'statusNotInstalled')}</Tag> : null}
{updateAvailable(pkg) ? <Tag className={css.statusTag} tone="info">{t('statusUpdateAvailable')}</Tag> : null}
{updateAvailable(pkg) || newer !== undefined ? <Tag className={css.statusTag} tone="info">{t('statusUpdateAvailable')}</Tag> : null}
</>
)}
description={description}
@@ -698,10 +716,12 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot, form }: {
* and configure controls; and where it comes from.
*/
function PackageDetail({
pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot, inMore,
onBack, onSetEnabled, onUninstall, onUpdate, onSetRowEnabled,
pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot, inMore, versions,
onBack, onSetEnabled, onUninstall, onUpdate, onSetRowEnabled, onEnsureVersions, onCheckVersions, onUpdateVersion,
}: {
readonly pkg: PackageView
/** The package's version check, when one started. */
readonly versions: VersionCheck | undefined
readonly t: Translate
readonly resolveText: ResolveText
readonly busy: boolean
@@ -717,8 +737,14 @@ function PackageDetail({
readonly onUninstall: () => void
readonly onUpdate: () => void
readonly onSetRowEnabled: (row: PackageRow, enabled: boolean) => void
/** Check a package's versions unless a check already started. */
readonly onEnsureVersions: (packageName: string) => void
readonly onCheckVersions: () => void
/** Ask before installing one listed newer version. */
readonly onUpdateVersion: (version: string) => void
}): ReactNode {
const { title, description, beta } = packageText(pkg, resolveText)
const newer = versionUpdate(versions)
const status = packageStatus(pkg)
const previews = BUNDLE_PREVIEWS.get(pkg.name)
const preview = previews === undefined ? undefined : resolveText(previews)
@@ -739,6 +765,12 @@ function PackageDetail({
{t('update')}
</Button>
) : null}
{newer === undefined ? null : (
<Button variant="primary" size="sm" icon={<IconRefreshOutlineRegular size={13} />}
disabled={busy} aria-label={t('versionsUpdateLabel', { version: newer })} onClick={() => { onUpdateVersion(newer) }}>
{t('versionsUpdateLabel', { version: newer })}
</Button>
)}
{pkg.installed || pkg.removable
? (
<Button
@@ -765,7 +797,7 @@ function PackageDetail({
{beta ? <Tag className={css.statusTag} tone="info">{t('statusBeta')}</Tag> : null}
{status === 'problem' ? <Tag className={css.statusTag} tone="danger">{t('statusProblem')}</Tag> : null}
{pkg.availability === 'missing' ? <Tag className={css.statusTag}>{t(pkg.installed ? 'statusMissingFiles' : 'statusNotInstalled')}</Tag> : null}
{updateAvailable(pkg) ? <Tag className={css.statusTag} tone="info">{t('statusUpdateAvailable')}</Tag> : null}
{updateAvailable(pkg) || newer !== undefined ? <Tag className={css.statusTag} tone="info">{t('statusUpdateAvailable')}</Tag> : null}
{renderSlot('plugins.detail.badge', { subject })}
</div>
<p className={css.detailName}><code data-plugin-name>{pkg.name}</code></p>
@@ -790,6 +822,9 @@ function PackageDetail({
toggle={pkg.enabled ? { busy: row => busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined}
configure={configure}
/>)}
<VersionSection
pkg={pkg} check={versions} t={t} busy={busy} onEnsure={onEnsureVersions} onCheck={onCheckVersions} onUpdate={onUpdateVersion}
/>
<SourceSection pkg={pkg} t={t} />
{renderSlot('plugins.detail.section', { subject })}
</div>
@@ -1516,8 +1551,6 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
// A contributed tab that left the ledger falls back to Installed, and stays there when it returns.
const tab = tabs.some(item => item.id === selectedTab) ? selectedTab : INSTALLED_TAB
useEffect(() => { if (tab !== selectedTab) setTab(tab) }, [tab, selectedTab, setTab])
// A bundle an install just enabled is shown where its card is.
useEffect(() => { if (state.highlight !== null) setTab(INSTALLED_TAB) }, [state.highlight, setTab])
const tabsId = useId()
const [activation, setActivation] = useState<string | null>(null)
useEffect(() => { ensure() }, [ensure])
@@ -1572,7 +1605,9 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
has: row => ledger.rows.has(rowConfigKey(pkg.name, row.rowId)),
open: (row) => { setView({ kind: 'row', name: pkg.name, rowId: row.rowId, ...openPkgInMore ? { from: 'more' } : {} }) },
})
const packageBusy = (pkg: PackageView): boolean => state.busy.includes(pkg.name)
const updates = updatesNote(mine, state.versions, t)
const updatePkg = state.update === null ? undefined : state.packages.find(pkg => pkg.name === state.update?.packageName)
const packageBusy = (pkg: PackageView): boolean => state.busy.includes(pkg.name) || state.update?.packageName === pkg.name
|| (state.install.subject?.name === pkg.name && (isInstallPending(state.install.phase) || state.install.phase === 'checking'))
const packageCard = (pkg: PackageView): ReactNode => (
<PackageCard
@@ -1582,6 +1617,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
resolveText={resolveText}
busy={packageBusy(pkg)}
highlighted={state.highlight === pkg.name}
newer={versionUpdate(state.versions[pkg.name])}
onOpen={() => { setActivation(null); setView({ kind: 'package', name: pkg.name, ...showsMoreList ? { from: 'more' } : {} }) }}
onSetEnabled={(enabled) => { setActivation(enabled ? pkg.name : null); props.setEnabled(pkg.name, enabled) }}
/>
@@ -1624,12 +1660,13 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
</div>
</div>
<div className={css.toolbar} hidden={!onInstalled}>
{updates === undefined ? null : <span className={css.updatesNote} data-tone={updates.tone}>{updates.text}</span>}
<Tooltip label={t('refresh')} delayMs={500} focusDelayMs={500} side="bottom" portal disabled={!loaded || refreshing}>
<button type="button" className={css.iconButton} aria-label={t('refresh')} aria-busy={refreshing} disabled={!loaded || refreshing} onClick={props.refresh}>
<span className={css.iconWrap} aria-hidden="true">
{refreshing ? <StateDot state="ongoing" size={18} /> : <IconRefreshOutlineRegular />}
</span>
</button>
<Button variant="outline" size="sm" className={css.addButton} aria-label={t('refresh')} aria-busy={refreshing}
disabled={!loaded || refreshing} onClick={props.refresh}
icon={refreshing ? <StateDot state="ongoing" size={13} /> : <IconRefreshOutlineRegular size={13} />}>
{t('refresh')}
</Button>
</Tooltip>
{state.install.requestId === undefined
? <AddPluginMenu t={t} disabled={!loaded} openInstall={props.openInstall} renderSlot={renderSlot} />
@@ -1735,6 +1772,10 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
onUninstall={() => { props.uninstall(openPkg.name) }}
onUpdate={() => { props.update(openPkg.name) }}
onSetRowEnabled={setRowEnabled}
versions={state.versions[openPkg.name]}
onEnsureVersions={props.ensureVersions}
onCheckVersions={() => { props.checkVersions(openPkg.name) }}
onUpdateVersion={(version) => { props.openUpdate(openPkg.name, version) }}
/>
)
: null}
@@ -1772,7 +1813,8 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
</div>
)
: null}
{showsCards && activated !== undefined && !state.install.open
{/* Activation guidance belongs beside the Installed list that holds the enabled bundle's card. */}
{installedCards && activated !== undefined && !state.install.open
? renderSlot('plugins.bundle.activation', {
packageName: activated.name,
onDismiss: () => { setActivation(null) },
@@ -1781,6 +1823,16 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
setView({ kind: 'package', name: activated.name, ...showsMoreList ? { from: 'more' } : {} })
},
}, { entryKey: activated.name }) : null}
{state.update === null ? null : (
<UpdateDialog
update={state.update}
title={updatePkg === undefined ? state.update.packageName : packageText(updatePkg, resolveText).title}
t={t}
onRun={props.runUpdate}
onClose={props.closeUpdate}
onToggleOutput={props.toggleUpdateOutput}
/>
)}
<InstallDialog
install={state.install}
t={t}
@@ -90,6 +90,7 @@ export function apply(ctx: ClientContext): void {
ctx.remote.$on('plugin-manager/changed', refresh),
ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }),
ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }),
ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }),
ctx.on('connection/reset', refresh),
]
return () => { for (const dispose of disposers) dispose() }
@@ -122,6 +122,64 @@ export const zh = {
sourceTargetVersion: '当前 DSH 对应版本',
update: '更新',
updateLabel: '更新 {name}',
versionsTitle: '版本',
versionsRecheck: '重新检查',
versionsChecking: '正在检查更新',
versionsAvailable: '可更新到 {version}',
versionsPublished: '发布于 {date}',
versionsLatest: '已是最新版本',
versionsCheckedAt: '上次检查于 {time}',
versionsBlocked: '新版本需要更新 DSH',
versionsBlockedDetail: '{version} 需要 {peers},当前 DSH 为 {runtime}',
versionsNoneInstallable: '没有可直接安装的新版本',
versionsNoneInstallableDetail: '新版本已弃用、为预发布版本或没有声明组合包,展开查看',
versionsFailedNetwork: '无法连接安装源',
versionsFailedOther: '无法检查更新',
versionsDetails: '详情',
versionsNewerCount: '{count} 个新版本',
versionsCollapse: '收起',
versionsInstalledTag: '已安装',
versionsPrereleaseTag: '预发布',
versionsDeprecatedTag: '已弃用',
versionsNeedsDsh: '需要 {peers}',
versionsNotBundle: '这个版本没有声明组合包,无法安装',
versionsUpdateLabel: '更新到 {version}',
versionsRegistry: '安装源',
versionsCheckedAtLabel: '检查于',
versionsFallbackTag: '备选源',
versionsFallbackHint: '{registry} 无法连接,版本列表来自备选源',
versionsPrimaryTag: '首选源',
versionsShowPrerelease: '显示预发布版本',
versionsOmitted: '另有 {count} 个比已安装版本新的版本未列出',
versionsProblemNetwork: '无法连接',
versionsProblemNotFound: '未找到',
versionsProblemUnknown: '失败',
updatesAvailable: '{count} 个插件可更新',
updatesNone: '所有插件均为最新版本',
updatesFailed: '部分插件检查更新失败',
updateTitle: '更新 {name}',
updateSource: '安装来源',
updateDescription: '将从 {from} 更新到 {to},期间插件可能短暂停止。安装失败时会保留或重新安装 {from}。',
updateRunning: '正在更新',
updateStepChecking: '检查 {version}',
updateStepRemoving: '停止并移除 {version}',
updateStepInstalling: '从 {registry} 安装 {version}',
updateStepRestoring: '重新安装 {version}',
updateDone: '已更新到 {version}',
updateDoneDetail: '新版本已经开始运行。',
updateRestart: '已安装 {version},重启后生效',
updateRefused: '无法更新到 {version}',
updateRemoveFailed: '无法移除 {version}',
updateRestored: '更新失败,已恢复到 {version}',
updateRestoredDetail: '{to} 安装失败,插件已重新安装 {from}。',
updateKept: '无法更新到 {version}',
updateKeptDetail: '{from} 未被移除,仍在使用。{reason}',
updateBroken: '更新失败,插件未安装',
updateBrokenDetail: '{to} 安装失败,重新安装 {from} 也未成功。请从“添加插件”重新安装 {name}。',
updateLost: '无法确认更新结果',
updateLostDetail: '与 DSH 的连接中断:{reason}。刷新插件列表可查看当前版本。',
updateOutput: '查看安装输出',
updateFinish: '完成',
installFailedTitle: '插件安装失败',
installGithubFailedTitle: '无法访问 GitHub',
installGithubTimeoutTitle: '连接 GitHub 超时',
@@ -350,6 +408,64 @@ export const en = {
sourceTargetVersion: 'Version for this DSH',
update: 'Update',
updateLabel: 'Update {name}',
versionsTitle: 'Versions',
versionsRecheck: 'Check again',
versionsChecking: 'Checking for updates',
versionsAvailable: 'Update to {version} available',
versionsPublished: 'Published {date}',
versionsLatest: 'Up to date',
versionsCheckedAt: 'Last checked at {time}',
versionsBlocked: 'Newer versions need a DSH update',
versionsBlockedDetail: '{version} needs {peers}; this DSH is {runtime}',
versionsNoneInstallable: 'No newer version to install directly',
versionsNoneInstallableDetail: 'The newer versions are deprecated, prereleases, or declare no bundle. Expand to see them',
versionsFailedNetwork: 'Could not reach the registry',
versionsFailedOther: 'Could not check for updates',
versionsDetails: 'Details',
versionsNewerCount: '{count} newer',
versionsCollapse: 'Collapse',
versionsInstalledTag: 'Installed',
versionsPrereleaseTag: 'Prerelease',
versionsDeprecatedTag: 'Deprecated',
versionsNeedsDsh: 'Needs {peers}',
versionsNotBundle: 'This version declares no bundle and cannot be installed',
versionsUpdateLabel: 'Update to {version}',
versionsRegistry: 'Registry',
versionsCheckedAtLabel: 'Checked at',
versionsFallbackTag: 'Fallback',
versionsFallbackHint: '{registry} could not be reached; the list comes from a fallback registry',
versionsPrimaryTag: 'First',
versionsShowPrerelease: 'Show prereleases',
versionsOmitted: '{count} more versions newer than the installed one are not listed',
versionsProblemNetwork: 'Unreachable',
versionsProblemNotFound: 'Not found',
versionsProblemUnknown: 'Failed',
updatesAvailable: 'Updates available: {count}',
updatesNone: 'All plugins are up to date',
updatesFailed: 'Some plugins could not be checked',
updateTitle: 'Update {name}',
updateSource: 'Install from',
updateDescription: 'The update replaces {from} with {to}; the plugin may stop briefly. If the installation fails, {from} is kept or installed again.',
updateRunning: 'Updating',
updateStepChecking: 'Check {version}',
updateStepRemoving: 'Stop and remove {version}',
updateStepInstalling: 'Install {version} from {registry}',
updateStepRestoring: 'Install {version} again',
updateDone: 'Updated to {version}',
updateDoneDetail: 'The new version is running.',
updateRestart: '{version} installed; it takes effect after a restart',
updateRefused: 'Cannot update to {version}',
updateRemoveFailed: 'Could not remove {version}',
updateRestored: 'Update failed; restored {version}',
updateRestoredDetail: '{to} failed to install; {from} was installed again.',
updateKept: 'Could not update to {version}',
updateKeptDetail: '{from} was not removed and stays in use. {reason}',
updateBroken: 'Update failed; the plugin is not installed',
updateBrokenDetail: '{to} failed to install, and installing {from} again failed too. Install {name} again from Add plugin.',
updateLost: 'The update result is unknown',
updateLostDetail: 'The connection to DSH was interrupted: {reason}. Refresh the plugin list to see the installed version.',
updateOutput: 'Show installation output',
updateFinish: 'Done',
installFailedTitle: 'The plugin could not be installed',
installGithubFailedTitle: 'Cannot access GitHub',
installGithubTimeoutTitle: 'GitHub connection timed out',
@@ -23,6 +23,11 @@ import type {
PluginInstallRequestId,
PluginRegistries,
PluginSpecInspection,
PluginUpdateProgress,
PluginUpdateResult,
PluginUpdateStep,
PluginVersion,
PluginVersionList,
ReadOnlyReason,
Registry,
RestartReason,
@@ -100,6 +105,8 @@ export interface PackageView {
readonly optional: boolean
/** Whether the Host removes the bundle: a profile dependency, or a selection that neither the profile nor the installation holds. */
readonly removable: boolean
/** Whether the Host lists and installs newer registry versions of the bundle. */
readonly updatable: boolean
/** Whether the bundle is in the profile's layer list. */
readonly enabled: boolean
/** Why the Host refuses to switch the bundle off or remove it, when it does. */
@@ -300,6 +307,50 @@ export interface ConfirmState {
readonly packageName: string
}
/** One package's version check: in flight, or the Host's answer and when it arrived. */
export type VersionCheck =
| { readonly phase: 'checking' }
| { readonly phase: 'done'; readonly list: PluginVersionList; readonly checkedAt: number }
/**
* The version an update offers first: the newest one that declares a bundle, accepts the running DSH version, and is
* neither deprecated nor a prerelease.
* @param list - the Host's version list.
* @returns the version, or undefined when none qualifies or the list was refused.
*/
export function updateCandidate(list: PluginVersionList): PluginVersion | undefined {
if (list.status !== 'listed') return undefined
return list.versions.find(version => installable(version) && version.deprecated === undefined && !version.prerelease)
}
/**
* Whether the Host can install a listed version in place of the installed one.
* @param version - one listed version.
* @returns true when it declares a bundle and accepts the running DSH version.
*/
export function installable(version: PluginVersion): boolean {
return version.bundle && version.incompatible === undefined
}
/** One update from its confirmation to its outcome. */
export interface UpdateState {
readonly packageName: string
readonly from: string
readonly to: string
/** The registry that listed the target; the Host checks and installs it there. */
readonly registry: Registry
readonly phase: 'confirm' | 'running' | 'done'
/** The steps the Host announced, in order; the last one runs while the update does. */
readonly steps: readonly PluginUpdateStep[]
readonly requestId?: PluginInstallRequestId
/** The Host's answer, once it settled. */
readonly result: PluginUpdateResult | null
/** The transport's words when the answer was lost; the Host may still have finished the update. */
readonly lost?: string
/** Whether the failed installation's pnpm output is unfolded. */
readonly outputOpen: boolean
}
/** What the tab renders. */
export interface PluginManagerState {
/** `unavailable` when the Host runs without a managed profile; `error` keeps the last packages. */
@@ -318,6 +369,9 @@ export interface PluginManagerState {
readonly confirm: ConfirmState | null
/** The package the list scrolls to and marks, once an install enabled it. */
readonly highlight: string | null
/** Version checks by package name; a package never checked has no entry. */
readonly versions: Readonly<Record<string, VersionCheck>>
readonly update: UpdateState | null
}
/** The registration-side face the tab's slot entry injects. */
@@ -338,7 +392,7 @@ export interface PluginManagerFace {
}
/** Read the Host once the tab first renders. */
ensure: () => void
/** Read the Host again. */
/** Read the Host again, then check every updatable package for newer versions. */
refresh: () => void
openInstall: () => void
/**
@@ -383,6 +437,18 @@ export interface PluginManagerFace {
/** Switch one of a bundle's rows on or off in the profile's user layer. */
setRowEnabled: (entryId: PluginEntryId, enabled: boolean) => void
dismissNotice: () => void
/** Ask the Host for an updatable package's newer versions, again when asked again. */
checkVersions: (packageName: string) => void
/** Check a package's versions once: a package already checked or being checked is left as it is. */
ensureVersions: (packageName: string) => void
/** Ask before replacing a package's installed version with a listed newer one. */
openUpdate: (packageName: string, version: string) => void
/** Start the confirmed update. */
runUpdate: () => void
/** Leave the confirmation or the outcome; a running update keeps its dialog. */
closeUpdate: () => void
/** Fold or unfold a failed installation's pnpm output. */
toggleUpdateOutput: () => void
}
/** A Remote answer as the generated client returns it. */
@@ -466,6 +532,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]):
installed: bundle.installed,
optional: bundle.optional,
removable: bundle.removable,
updatable: bundle.updatable,
enabled: bundle.enabled,
rows,
...bundle.version === undefined ? {} : { version: bundle.version },
@@ -577,7 +644,7 @@ export class PluginManagerController {
) {
this.store = createSnapshotStore<PluginManagerState>({
status: 'idle', refreshStatus: 'idle', packages: [], busy: [], notice: null,
install: IDLE_INSTALL, confirm: null, highlight: null,
install: IDLE_INSTALL, confirm: null, highlight: null, versions: {}, update: null,
})
}
@@ -723,6 +790,23 @@ export class PluginManagerController {
})
},
dismissNotice: () => { this.patch({ notice: null }) },
checkVersions: (packageName) => { void this.checkVersions(packageName) },
ensureVersions: (packageName) => {
if (this.getSnapshot().versions[packageName] === undefined) void this.checkVersions(packageName)
},
openUpdate: (packageName, version) => {
const check = this.getSnapshot().versions[packageName]
if (this.getSnapshot().update?.phase === 'running' || check?.phase !== 'done' || check.list.status !== 'listed') return
this.patch({ update: {
packageName, from: check.list.installed, to: version, registry: check.list.registry, phase: 'confirm', steps: [], result: null, outputOpen: false,
} })
},
runUpdate: () => { void this.runUpdate() },
closeUpdate: () => { if (this.getSnapshot().update?.phase !== 'running') this.patch({ update: null }) },
toggleUpdateOutput: () => {
const update = this.getSnapshot().update
if (update !== null) this.patch({ update: { ...update, outputOpen: !update.outputOpen } })
},
}
}
@@ -746,6 +830,65 @@ export class PluginManagerController {
return started
}
/**
* Follow the Host's steps of this tab's update.
* @param progress - a request id and step received from the Host.
*/
updateProgress(progress: PluginUpdateProgress): void {
const update = this.getSnapshot().update
if (update?.phase !== 'running' || update.requestId !== progress.requestId || update.steps.includes(progress.step)) return
this.patch({ update: { ...update, steps: [...update.steps, progress.step] } })
}
/** Read one package's newer versions; a transport failure reads as an unknown refusal. */
private async checkVersions(packageName: string): Promise<void> {
const { versions, update } = this.getSnapshot()
// A check during the package's update would answer for the version being replaced.
if (this.disposed || versions[packageName]?.phase === 'checking' || (update?.phase === 'running' && update.packageName === packageName)) return
this.patchVersions(packageName, { phase: 'checking' })
// The generated Remote takes every business argument before its optional signal; `{}` asks the Host's own plan.
const answer = await this.ctx.remote.pluginManager.listVersions(packageName, {})
const list: PluginVersionList = answer.ok ? answer.value : { status: 'refused', problem: 'unknown', reason: answer.error.message, attempts: [] }
this.patchVersions(packageName, { phase: 'done', list, checkedAt: Date.now() })
}
/** Check the updatable packages one after another, so one click starts at most one package's registry lookups. */
private async checkAllVersions(): Promise<void> {
for (const pkg of this.getSnapshot().packages.filter(item => item.updatable)) await this.checkVersions(pkg.name)
}
/** Hand the confirmed update to the Host, keep its outcome, then read the package and its versions again. */
private async runUpdate(): Promise<void> {
const update = this.getSnapshot().update
if (this.disposed || update === null || update.phase !== 'confirm') return
const requestId = randomUUID() as PluginInstallRequestId
const running: UpdateState = { ...update, phase: 'running', steps: [], requestId, result: null }
this.patch({ update: running })
const answer = await this.ctx.remote.pluginManager.updateBundle(update.packageName, update.to, { registry: update.registry, requestId })
// Nothing closes or replaces a running update before its answer; after disposal `patch` publishes nothing.
const current = this.getSnapshot().update as UpdateState
this.patch({ update: { ...current, phase: 'done', ...answer.ok ? { result: answer.value } : { lost: answer.error.message } } })
await this.load()
await this.checkVersions(update.packageName)
}
/**
* Forget the checks that no longer describe a listed package: one that left the list or stopped being updatable, and
* one answered for another installed version, after a reinstallation or a change from another surface.
*/
private dropStaleVersions(): void {
const { packages, versions } = this.getSnapshot()
const current = Object.fromEntries(Object.entries(versions).filter(([name, check]) => {
const pkg = packages.find(item => item.name === name)
return pkg?.updatable === true && (check.phase === 'checking' || check.list.status !== 'listed' || check.list.installed === pkg.version)
}))
if (Object.keys(current).length !== Object.keys(versions).length) this.patch({ versions: current })
}
private patchVersions(packageName: string, check: VersionCheck): void {
this.patch({ versions: { ...this.getSnapshot().versions, [packageName]: check } })
}
/**
* Follow the Host's cancellation window for this dialog's installation.
* @param progress - a request id and phase received from the Host.
@@ -832,7 +975,10 @@ export class PluginManagerController {
return run
}
/** Keep manual refresh feedback until its coalesced reads settle, without clearing cached cards. */
/**
* Keep manual refresh feedback until its coalesced reads and the version checks of the updatable packages settle,
* without clearing cached cards; a failed read checks nothing.
*/
private async refresh(): Promise<void> {
if (this.disposed || this.getSnapshot().refreshStatus === 'refreshing') return
const startedAt = Date.now()
@@ -842,6 +988,7 @@ export class PluginManagerController {
})
try {
await this.load()
if (this.getSnapshot().status !== 'error') await this.checkAllVersions()
} catch (_error) {
// A rejected transport request leaves the cached cards available for retry.
this.patch({ status: 'error' })
@@ -896,6 +1043,7 @@ export class PluginManagerController {
refreshStatus: this.getSnapshot().refreshStatus === 'refreshing' ? 'refreshing' : 'idle',
packages: sortPackages(bundles.value.map(bundle => packageView(bundle, plugins.value))),
})
this.dropStaleVersions()
} while (this.shouldRerun())
} finally {
this.inFlight = undefined
@@ -0,0 +1,456 @@
/**
* A registry bundle's Versions section and its update dialog. The section summarizes the Host's version check in one
* row and unfolds into the newer versions; the dialog confirms one update, follows the Host's steps, and reports how
* the update ended, including the reinstallation of the removed version after a failed installation.
*/
import { useEffect, useState, type ReactNode } from 'react'
import type {
IncompatiblePlugin, PluginUpdateResult, PluginUpdateStep, PluginVersion, PluginVersionList, Registry, RegistryAttempt,
} from '@deepseek-ai/dsh-api-remotes/client'
import {
Button, Checkbox, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconCloseOutlineRegular,
IconDownloadOutlineRegular, IconRefreshOutlineRegular, IconWarningOutlineRegular, Modal, StateDot, Tag, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { installable, updateCandidate, type PackageView, type UpdateState, type VersionCheck } from './manager-store.ts'
import { managementText, registryText, restartText, type Translate } from './presentation.ts'
import css from './PluginManagerPage.module.css'
/** The tone of a summary or outcome glyph. */
type Tone = 'info' | 'success' | 'warn' | 'error' | 'neutral'
/** Two digits of a date or time field. */
function pad(value: number): string {
return String(value).padStart(2, '0')
}
/** A version's local publication day as `YYYY-MM-DD`, which reads the same in every UI language. */
function publishedDate(version: PluginVersion): string | undefined {
if (version.publishedAt === undefined) return undefined
const day = new Date(version.publishedAt)
return `${String(day.getFullYear())}-${pad(day.getMonth() + 1)}-${pad(day.getDate())}`
}
/** The local time of day a check arrived, as 24-hour `HH:MM`. */
function checkedTime(checkedAt: number): string {
const time = new Date(checkedAt)
return `${pad(time.getHours())}:${pad(time.getMinutes())}`
}
/** The registry by name: npm's own and the mainland mirror by theirs, pnpm's own configuration as the default, any other by host. */
function registryName(registry: Registry, t: Translate): string {
return registryText(registry, t, null).name
}
/** The DSH requirements an incompatible version states, as one phrase: `engines.dsh` or DSH peer ranges. */
function peersText(incompatible: IncompatiblePlugin): string {
return Object.entries(incompatible.requirements).map(([name, range]) => `${name} ${range}`).join(', ')
}
const PROBLEM_KEYS = {
'network': 'versionsProblemNetwork',
'not-found': 'versionsProblemNotFound',
'unknown': 'versionsProblemUnknown',
} as const
function Glyph({ tone, children }: { readonly tone: Tone; readonly children: ReactNode }): ReactNode {
return <span className={css.versionGlyph} data-tone={tone} aria-hidden="true">{children}</span>
}
/** The summary row's glyph, title, the line under it, and how many newer versions it unfolds into. */
interface Summary {
tone: Tone
glyph: ReactNode
title: string
sub?: string
newer: number
}
function summaryOf(check: VersionCheck | undefined, t: Translate): Summary {
if (check === undefined || check.phase === 'checking') {
return { tone: 'neutral', glyph: <StateDot state="ongoing" size={14} />, title: t('versionsChecking'), newer: 0 }
}
const { list } = check
if (list.status === 'refused') {
const failed = list.attempts.at(-1)
return {
tone: 'error', glyph: <IconCloseOutlineRegular size={14} />,
title: t(list.problem === 'network' ? 'versionsFailedNetwork' : 'versionsFailedOther'),
// A check the transport lost asked no registry the page knows of; its words stand alone.
sub: failed?.problem === undefined ? list.reason : `${registryName(failed.registry, t)} ${t(PROBLEM_KEYS[failed.problem])}`,
newer: 0,
}
}
const candidate = updateCandidate(list)
if (candidate !== undefined) {
const date = publishedDate(candidate)
return {
tone: 'info', glyph: <IconDownloadOutlineRegular size={14} />, title: t('versionsAvailable', { version: candidate.version }),
...date === undefined ? {} : { sub: t('versionsPublished', { date }) }, newer: list.versions.length,
}
}
if (list.versions.length === 0) {
return { tone: 'success', glyph: <IconCheckOutlineRegular size={14} />, title: t('versionsLatest'), sub: t('versionsCheckedAt', { time: checkedTime(check.checkedAt) }), newer: 0 }
}
const blocked = list.versions.find(version => !version.prerelease && version.incompatible !== undefined)?.incompatible
return blocked === undefined
? { tone: 'warn', glyph: <IconWarningOutlineRegular size={14} />, title: t('versionsNoneInstallable'), sub: t('versionsNoneInstallableDetail'), newer: list.versions.length }
: {
tone: 'warn', glyph: <IconWarningOutlineRegular size={14} />, title: t('versionsBlocked'), newer: list.versions.length,
sub: t('versionsBlockedDetail', { version: blocked.version, peers: peersText(blocked), runtime: blocked.runtimeVersion }),
}
}
/** One version row: its number and tags, why it cannot or should not be installed, its date, and its update action. */
function VersionRow({ version, installed, t, busy, onUpdate }: {
readonly version: PluginVersion | { readonly version: string; readonly installedRow: true }
readonly installed: string
readonly t: Translate
readonly busy: boolean
readonly onUpdate: (version: string) => void
}): ReactNode {
if ('installedRow' in version) {
return (
<li className={css.versionRow} data-plugin-version={version.version}>
<div className={css.versionMain}>
<div className={css.versionTitle}>
<span className={css.versionNumber}>{version.version}</span>
<Tag className={css.statusTag} tone="neutral">{t('versionsInstalledTag')}</Tag>
</div>
</div>
</li>
)
}
const usable = installable(version)
const note = version.incompatible !== undefined ? t('versionsNeedsDsh', { peers: peersText(version.incompatible) })
: !version.bundle ? t('versionsNotBundle') : version.deprecated
const date = publishedDate(version)
return (
<li className={css.versionRow} data-plugin-version={version.version} {...usable ? {} : { 'data-blocked': '' }}>
<div className={css.versionMain}>
<div className={css.versionTitle}>
<span className={css.versionNumber}>{version.version}</span>
{version.tags.map(tag => <Tag key={tag} className={css.statusTag} tone="outline">{tag}</Tag>)}
{version.prerelease ? <Tag className={css.statusTag} tone="outline">{t('versionsPrereleaseTag')}</Tag> : null}
{version.deprecated === undefined ? null : <Tag className={css.statusTag} tone="warning">{t('versionsDeprecatedTag')}</Tag>}
</div>
{note === undefined ? null : <span className={css.versionNote} data-tone={usable && version.deprecated !== undefined ? 'warn' : undefined}>{note}</span>}
</div>
{date === undefined ? null : <span className={css.versionDate}>{date}</span>}
<span className={css.versionAction}>
{usable && version.version !== installed ? (
<Button
variant={version.deprecated === undefined && !version.prerelease ? 'outline' : 'ghost'}
size="sm"
disabled={busy}
aria-label={t('versionsUpdateLabel', { version: version.version })}
onClick={() => { onUpdate(version.version) }}
>
{t('update')}
</Button>
) : null}
</span>
</li>
)
}
/** The registries a refused check asked, each with what it answered. */
function AttemptRows({ attempts, t }: { readonly attempts: readonly RegistryAttempt[]; readonly t: Translate }): ReactNode {
return (
<ul className={css.versionRows}>
{attempts.map((attempt, index) => (
<li key={`${String(attempt.registry)}:${String(index)}`} className={css.versionRow}>
<div className={css.versionMain}>
<div className={css.versionTitle}>
<span className={css.versionNumber}>{registryName(attempt.registry, t)}</span>
<Tag className={css.statusTag} tone="outline">{t(index === 0 ? 'versionsPrimaryTag' : 'versionsFallbackTag')}</Tag>
{attempt.problem === undefined ? null : <Tag className={css.statusTag} tone="danger">{t(PROBLEM_KEYS[attempt.problem])}</Tag>}
</div>
{attempt.reason === undefined ? null : <code className={css.versionLog}>{attempt.reason}</code>}
</div>
</li>
))}
</ul>
)
}
/** The unfolded list: newer versions, the installed one, and where and when the list was read. */
function VersionList({ list, checkedAt, t, busy, onUpdate }: {
readonly list: Extract<PluginVersionList, { status: 'listed' }>
readonly checkedAt: number
readonly t: Translate
readonly busy: boolean
readonly onUpdate: (version: string) => void
}): ReactNode {
const [prereleases, setPrereleases] = useState(false)
const shown = list.versions.filter(version => prereleases || !version.prerelease)
const fallback = list.attempts.length > 1 ? list.attempts[0] : undefined
return (
<div className={css.versionBody}>
<ul className={css.versionRows}>
{shown.map(version => (
<VersionRow key={version.version} version={version} installed={list.installed} t={t} busy={busy} onUpdate={onUpdate} />
))}
<VersionRow version={{ version: list.installed, installedRow: true }} installed={list.installed} t={t} busy={busy}
onUpdate={onUpdate} />
</ul>
{list.omitted > 0 ? <p className={css.versionNote}>{t('versionsOmitted', { count: String(list.omitted) })}</p> : null}
<div className={css.versionMeta}>
<dl className={css.versionFact}>
<dt>{t('versionsRegistry')}</dt>
<dd>
{registryName(list.registry, t)}
{fallback === undefined ? null : (
<Tooltip label={t('versionsFallbackHint', { registry: registryName(fallback.registry, t) })} side="top" portal>
<span className={css.versionHint} tabIndex={0}><Tag className={css.statusTag} tone="outline">{t('versionsFallbackTag')}</Tag></span>
</Tooltip>
)}
</dd>
</dl>
<dl className={css.versionFact}>
<dt>{t('versionsCheckedAtLabel')}</dt>
<dd>{checkedTime(checkedAt)}</dd>
</dl>
<span className={css.versionSpacer} />
{list.versions.some(version => version.prerelease)
? <Checkbox checked={prereleases} onChange={setPrereleases} label={t('versionsShowPrerelease')} />
: null}
</div>
</div>
)
}
/**
* The Versions section of an updatable bundle's page. It checks once when the page opens and folds the newer versions
* behind a summary row.
*/
export function VersionSection({ pkg, check, t, busy, onEnsure, onCheck, onUpdate }: {
readonly pkg: PackageView
readonly check: VersionCheck | undefined
readonly t: Translate
readonly busy: boolean
/** Check a package's versions unless a check already started. */
readonly onEnsure: (packageName: string) => void
readonly onCheck: () => void
readonly onUpdate: (version: string) => void
}): ReactNode {
const [open, setOpen] = useState(false)
const { name, updatable } = pkg
useEffect(() => { if (updatable) onEnsure(name) }, [name, updatable, onEnsure])
if (!updatable) return null
const done = check?.phase === 'done' ? check : undefined
const summary = summaryOf(check, t)
const refused = done?.list.status === 'refused' ? done.list : undefined
const listed = done?.list.status === 'listed' ? done.list : undefined
const unfolds = summary.newer > 0 || (refused !== undefined && refused.attempts.length > 0)
const expanded = open && unfolds
const text = (
<span className={css.versionSummaryText}>
<span className={css.versionSummaryTitle}>{summary.title}</span>
{summary.sub === undefined ? null : <span className={css.versionSummarySub}>{summary.sub}</span>}
</span>
)
return (
<section className={css.detailSection} data-plugin-versions>
<div className={css.sectionHead}>
<h4 className={css.sectionTitle}>{t('versionsTitle')}</h4>
<span className={css.versionSpacer} />
{refused === undefined ? (
<Button variant="ghost" size="sm" icon={<IconRefreshOutlineRegular size={13} />} disabled={check?.phase !== 'done'} onClick={onCheck}>
{t('versionsRecheck')}
</Button>
) : null}
</div>
<div className={css.versionPanel}>
{listed !== undefined && summary.newer > 0 ? (
<button type="button" className={css.versionSummary} aria-expanded={expanded} onClick={() => { setOpen(!open) }}>
<Glyph tone={summary.tone}>{summary.glyph}</Glyph>
{text}
<span className={css.versionSummaryEnd}>
{expanded ? t('versionsCollapse') : t('versionsNewerCount', { count: String(summary.newer) })}
<IconChevronDownOutlineRegular className={css.detailsChevron} aria-hidden="true" />
</span>
</button>
) : (
<div className={css.versionSummary} role="status">
<Glyph tone={summary.tone}>{summary.glyph}</Glyph>
{text}
{refused === undefined ? null : (
<span className={css.versionSummaryEnd}>
{unfolds ? (
<button type="button" className={css.detailsToggle} aria-expanded={expanded} onClick={() => { setOpen(!open) }}>
{t('versionsDetails')}
<IconChevronDownOutlineRegular className={css.detailsChevron} aria-hidden="true" />
</button>
) : null}
<Button variant="outline" size="sm" onClick={onCheck}>{t('retry')}</Button>
</span>
)}
</div>
)}
{expanded && listed !== undefined && done !== undefined
? <VersionList list={listed} checkedAt={done.checkedAt} t={t} busy={busy} onUpdate={onUpdate} />
: null}
{expanded && refused !== undefined
? <div className={css.versionBody}><AttemptRows attempts={refused.attempts} t={t} /></div>
: null}
</div>
</section>
)
}
/** The step labels of an update; the Host announces each step as it starts. */
function stepLabel(step: PluginUpdateStep, update: UpdateState, t: Translate): string {
switch (step) {
case 'checking': return t('updateStepChecking', { version: update.to })
case 'removing': return t('updateStepRemoving', { version: update.from })
case 'installing': return t('updateStepInstalling', { version: update.to, registry: registryName(update.registry, t) })
case 'restoring': return t('updateStepRestoring', { version: update.from })
}
}
/** Whether a change result left its operation undone. */
function failed(application: PluginUpdateResult['result']['application']): boolean {
return application === 'failed' || application === 'cancelled'
}
/** The steps in the order the Host runs them. */
const STEP_ORDER: readonly PluginUpdateStep[] = ['checking', 'removing', 'installing', 'restoring']
/** The steps an answer implies, joined with the announced ones, so a lost announcement does not drop a step that ran. */
function settledSteps(announced: readonly PluginUpdateStep[], result: PluginUpdateResult): PluginUpdateStep[] {
const implied = new Set<PluginUpdateStep>(['checking', ...announced])
if (result.step === 'remove') implied.add('removing')
if (result.step === 'install') implied.add('installing')
if (result.restore !== undefined) implied.add('restoring')
return STEP_ORDER.filter(step => implied.has(step))
}
/** How one step ended, or that it still runs. */
function stepState(step: PluginUpdateStep, update: UpdateState, last: boolean): 'running' | 'done' | 'failed' | 'unknown' {
const result = update.result
if (update.phase === 'running') return last ? 'running' : 'done'
if (result === null) return last ? 'unknown' : 'done'
switch (step) {
case 'checking': return result.step === 'check' ? 'failed' : 'done'
case 'removing': return result.step === 'remove' ? 'failed' : 'done'
case 'installing': return failed(result.result.application) ? 'failed' : 'done'
case 'restoring': return result.restore !== undefined && failed(result.restore.application) ? 'failed' : 'done'
}
}
const STEP_GLYPHS = {
running: <StateDot state="ongoing" size={12} />,
done: <IconCheckOutlineRegular size={12} />,
failed: <IconCloseOutlineRegular size={12} />,
unknown: null,
} as const
/** What the update's outcome reads as: its glyph, title, and the sentence under it. */
function outcomeOf(update: UpdateState, title: string, t: Translate): Omit<Summary, 'sub' | 'newer'> & { detail: string } {
const error = { tone: 'error' as const, glyph: <IconCloseOutlineRegular size={14} /> }
const result = update.result
// A finished update without the Host's answer carries the transport's words.
if (result === null) return { ...error, title: t('updateLost'), detail: t('updateLostDetail', { reason: String(update.lost) }) }
// A failed step always carries the Host's refusal; an absent one reads as the generic operation error.
const reason = (change: PluginUpdateResult['result']): string => managementText({ code: 'operation-error', ...change.error, installing: true }, t)
if (result.step === 'check') return { ...error, title: t('updateRefused', { version: update.to }), detail: reason(result.result) }
if (result.step === 'remove') return { ...error, title: t('updateRemoveFailed', { version: update.from }), detail: reason(result.result) }
if (!failed(result.result.application)) {
return result.result.application === 'restart-required'
? { tone: 'warn', glyph: <IconWarningOutlineRegular size={14} />, title: t('updateRestart', { version: update.to }), detail: restartText('restartNotice', result.result.restartReason, t) }
: { tone: 'success', glyph: <IconCheckOutlineRegular size={14} />, title: t('updateDone', { version: update.to }), detail: t('updateDoneDetail') }
}
// Without a reinstallation nothing was removed: the installation went over the running version and was rolled back.
if (result.restore === undefined) {
return { ...error, title: t('updateKept', { version: update.to }), detail: t('updateKeptDetail', { from: update.from, reason: reason(result.result) }) }
}
return failed(result.restore.application)
? { ...error, title: t('updateBroken'), detail: t('updateBrokenDetail', { from: update.from, to: update.to, name: title }) }
: { ...error, title: t('updateRestored', { version: update.from }), detail: t('updateRestoredDetail', { from: update.from, to: update.to }) }
}
/** The update dialog: confirmation, then the Host's steps, then the outcome. */
export function UpdateDialog({ update, title, t, onRun, onClose, onToggleOutput }: {
readonly update: UpdateState
/** The package's display title. */
readonly title: string
readonly t: Translate
readonly onRun: () => void
readonly onClose: () => void
readonly onToggleOutput: () => void
}): ReactNode {
const running = update.phase === 'running'
const steps = update.result === null ? update.steps : settledSteps(update.steps, update.result)
const outcome = update.phase === 'done' ? outcomeOf(update, title, t) : undefined
// The failed run's own output: the target's installation, else the removal or the reinstallation.
const output = [update.result?.result, update.result?.restore]
.map(change => change !== undefined && failed(change.application) ? change.packageResult?.output : undefined)
.find(text => text !== undefined && text !== '')
return (
<Modal
open
onClose={() => { if (!running) onClose() }}
title={t('updateTitle', { name: title })}
closeLabel={t('close')}
className={css.updateDialog as string}
footer={update.phase === 'confirm' ? (
<>
<Button variant="outline" onClick={onClose}>{t('cancel')}</Button>
<Button variant="primary" onClick={onRun}>{t('update')}</Button>
</>
) : running
? <Button variant="outline" disabled>{t('updateRunning')}</Button>
: <Button variant="primary" onClick={onClose}>{t('updateFinish')}</Button>}
>
<div className={css.updateBody} data-plugin-update={update.phase}>
{update.phase === 'confirm' ? (
<>
<div className={css.updateVersions}>
<span className={css.versionNumber} data-old="">{update.from}</span>
<IconChevronRightOutlineRegular size={14} aria-hidden="true" />
<span className={css.versionNumber}>{update.to}</span>
</div>
<dl className={css.versionFact}>
<dt>{t('updateSource')}</dt>
<dd>{registryName(update.registry, t)}</dd>
</dl>
<p className={css.updateText}>{t('updateDescription', { from: update.from, to: update.to })}</p>
</>
) : (
<>
{outcome === undefined ? null : (
<div className={css.updateOutcome} role="status">
<Glyph tone={outcome.tone}>{outcome.glyph}</Glyph>
<span className={css.versionSummaryText}>
<span className={css.versionSummaryTitle}>{outcome.title}</span>
<span className={css.versionSummarySub}>{outcome.detail}</span>
</span>
</div>
)}
{output === undefined ? null : (
<>
<button type="button" className={css.detailsToggle} aria-expanded={update.outputOpen} onClick={onToggleOutput}>
{t('updateOutput')}
<IconChevronDownOutlineRegular className={css.detailsChevron} aria-hidden="true" />
</button>
{update.outputOpen ? <pre className={css.updateOutput}>{output}</pre> : null}
</>
)}
<ol className={css.updateSteps}>
{steps.map((step, index) => {
const state = stepState(step, update, index === steps.length - 1)
return (
<li key={step} className={css.updateStep} data-state={state}>
<span className={css.updateStepDot} aria-hidden="true">{STEP_GLYPHS[state]}</span>
<span>{stepLabel(step, update, t)}</span>
</li>
)
})}
</ol>
</>
)}
</div>
</Modal>
)
}
@@ -197,6 +197,9 @@ describe('ui-plugin-manager browser plugin', () => {
b.remote.emit('plugin-manager/install-log', [{ jobId: 'j', argv: ['pnpm', 'add', 'pkg'], cwd: '/p', stream: 'stdout', text: 'early' }])
b.remote.emit('plugin-manager/install-state', [{ requestId: 'foreign', phase: 'installing' }])
expect(face.hooks.pluginManager.getSnapshot().install.runs).toEqual([])
// Update progress reaches only the update this page started.
b.remote.emit('plugin-manager/update-state', [{ requestId: 'foreign', step: 'checking' }])
expect(face.hooks.pluginManager.getSnapshot().update).toBeNull()
await fiber.dispose()
expect(b.ctx.get('pluginNavigation')).toBeUndefined()
@@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen, within } from '@testing-librar
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { PluginEntryId, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client'
import type { ChangeResult, PluginEntryId, PluginInstallRequestId, PluginUpdateResult, PluginVersion, RegistryAttempt } from '@deepseek-ai/dsh-api-remotes/client'
import { allowAllAccess, bindSnapshotSelector, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime'
import type { ConfigForm, ConfigFormSnapshot, SettingsMirrorSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -16,7 +16,7 @@ import { PluginRefreshToast } from '../src/client/PluginRefreshToast.tsx'
import type { PluginManagerPageProps } from '../src/client/index.ts'
import type { PluginTab } from '../src/client/tab-ledger.ts'
import type { ConfigLedger } from '../src/client/config-ledger.ts'
import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts'
import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState, type UpdateState, type VersionCheck } from '../src/client/manager-store.ts'
import { INSTALL_GIT_EXAMPLE, INSTALL_PATH_EXAMPLE, en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts'
import type { PluginActivationOwnerProps, PluginAddActionsProps, PluginDetailProps, PluginTabProps, PluginsSubject } from '../src/client/slot-contract.ts'
@@ -40,6 +40,7 @@ function pkg(overrides: Partial<PackageView> = {}): PackageView {
installed: true,
optional: false,
removable: true,
updatable: false,
enabled: true,
rows: [],
...overrides,
@@ -78,6 +79,8 @@ const READY: PluginManagerState = {
busy: [],
notice: null,
install: IDLE_INSTALL,
versions: {},
update: null,
confirm: null,
highlight: null,
}
@@ -133,6 +136,12 @@ function renderTab(
cancelConfirm: vi.fn(),
setRowEnabled: vi.fn(),
dismissNotice: vi.fn(),
checkVersions: vi.fn(),
ensureVersions: vi.fn(),
openUpdate: vi.fn(),
runUpdate: vi.fn(),
closeUpdate: vi.fn(),
toggleUpdateOutput: vi.fn(),
}
const unusedStandardHook = (): never => { throw new Error('Plugin manager fixture does not provide global state') }
const standard = {
@@ -207,7 +216,7 @@ function renderTab(
describe('PluginManagerPage', () => {
it('shows no tabs without contributions, and switches between Installed and a contributed tab', () => {
const { actions, navigation, setTabs, set } = renderTab({ packages: [pkg()] }, {}, {
const { actions, navigation, setTabs } = renderTab({ packages: [pkg()] }, {}, {
'plugins.tab:discover': (_view, owner) => {
const tab = owner as PluginTabProps
return (
@@ -238,13 +247,20 @@ describe('PluginManagerPage', () => {
setTabs([])
expect(document.querySelector('[data-plugin-group="bundles"]')).not.toBeNull()
expect(navigation.getSnapshot().tab).toBe('installed')
// Enabling a bundle an install added highlights its card, so the page shows Installed.
// A contributed tab that returns can be selected again.
setTabs([{ id: 'discover', label: 'Discover' }])
fireEvent.click(screen.getByRole('tab', { name: 'Discover' }))
expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy()
})
it('marks the bundle an install enabled without leaving a contributed tab', () => {
const { navigation, set } = renderTab({ packages: [pkg()] }, {}, {}, {}, [{ id: 'discover', label: 'Discover' }])
fireEvent.click(screen.getByRole('tab', { name: 'Discover' }))
expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy()
// Enable now publishes the new bundle's mark; the mark scrolls to a visible card and selects no page.
set({ highlight: 'dsh-better-sidebar' })
expect(navigation.getSnapshot().tab).toBe('installed')
expect(screen.getByRole('tabpanel', { name: en.installedTab })).toBeTruthy()
expect(navigation.getSnapshot().tab).toBe('discover')
expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy()
})
it('keeps the Installed tab panel through loading and the More list out of the tabs', () => {
@@ -421,9 +437,9 @@ describe('PluginManagerPage', () => {
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, official: true, availability: 'installation', optional: true, removable: false, enabled: false, rows: [] },
{ name: 'dsh-shadowed', installed: true, official: false, availability: 'profile', optional: false, removable: false, enabled: true, rows: [] },
{ name: 'dsh-missing', installed: false, official: false, availability: 'missing', optional: false, removable: true, enabled: true, error: { code: 'unknown-plugin' }, rows: [] },
{ name: 'dsh-official', installed: false, official: true, availability: 'installation', optional: true, removable: false, updatable: false, enabled: false, rows: [] },
{ name: 'dsh-shadowed', installed: true, official: false, availability: 'profile', optional: false, removable: false, updatable: false, enabled: true, rows: [] },
{ name: 'dsh-missing', installed: false, official: false, availability: 'missing', optional: false, removable: true, updatable: 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' }) })
@@ -1250,7 +1266,7 @@ describe('PluginManagerPage', () => {
})
it('leaves the version out of a bundle the Host reports none for', () => {
const unversioned: PackageView = { name: 'dsh-better-sidebar', installed: true, official: false, availability: 'profile', optional: false, removable: true, enabled: true, rows: [] }
const unversioned: PackageView = { name: 'dsh-better-sidebar', installed: true, official: false, availability: 'profile', optional: false, removable: true, updatable: false, enabled: true, rows: [] }
renderTab({ packages: [unversioned] }, {}, bodies)
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
expect(subjects.at(-1)).toEqual({ kind: 'bundle', pkg: { name: 'dsh-better-sidebar', installed: true, official: false, availability: 'profile', enabled: true, rows: [] } })
@@ -2309,6 +2325,24 @@ it('dismisses activation guidance until the user enables the bundle again', () =
expect(screen.getByText('Later')).toBeTruthy()
})
it('keeps activation guidance with the Installed list when a contributed tab enables the bundle', () => {
const name = 'dsh-better-sidebar'
const subject = { spec: `${name}@0.16.0`, status: 'accepted', kind: 'registry', name, bundle: true, registry: null } as const
const { set } = renderTab({
install: { ...IDLE_INSTALL, open: true, phase: 'done', subject, installed: name },
}, { bundles: new Set([name]) }, {
[`plugins.bundle.activation:${name}`]: () => <div>Enable guidance</div>,
}, {}, [{ id: 'discover', label: 'Discover' }])
fireEvent.click(screen.getByRole('button', { name: en.installEnableNow }))
set({ install: IDLE_INSTALL, packages: [pkg()] })
expect(screen.getByText('Enable guidance')).toBeTruthy()
// A contributed tab shows its own content, not guidance for the Installed list.
fireEvent.click(screen.getByRole('tab', { name: 'Discover' }))
expect(screen.queryByText('Enable guidance')).toBeNull()
fireEvent.click(screen.getByRole('tab', { name: en.installedTab }))
expect(screen.getByText('Enable guidance')).toBeTruthy()
})
it('supplies the accepted entry values and atomic mutation action to a custom plugin page', () => {
const mutate = vi.fn(async () => true)
const form: ConfigPageForm = {
@@ -2328,7 +2362,7 @@ describe('on-demand Official bundles', () => {
const name = '@deepseek-ai/dsh-subagent-codex'
const title = 'Codex subagent'
const catalog = (): PackageView => ({ name, meta: { title }, official: true, availability: 'missing', installed: false,
optional: false, removable: false, enabled: false, rows: [],
optional: false, removable: false, updatable: false, enabled: false, rows: [],
installTarget: { spec: `${name}@2.0.0`, version: '2.0.0' },
})
@@ -2430,3 +2464,221 @@ describe('on-demand Official bundles', () => {
expect(detail.querySelector('[data-plugin-rows]')).toBeNull()
})
})
describe('registry versions and updates', () => {
const MIRROR = 'https://registry.npmmirror.com/'
const updatable = (): PackageView => pkg({ updatable: true, version: '0.16.0', source: 'dsh-better-sidebar@0.16.0' })
const runtime = '0.8.3'
const listed = (versions: PluginVersion[], attempts: RegistryAttempt[] = [{ registry: null }]): VersionCheck => ({
phase: 'done', checkedAt: 0,
list: { status: 'listed', installed: '0.16.0', registry: attempts.at(-1)?.registry ?? null, attempts, versions, omitted: 0 },
})
const v = (version: string, more: Partial<PluginVersion> = {}): PluginVersion => ({
version, tags: [], prerelease: false, bundle: true, ...more,
})
const incompatible = (version: string) => ({ name: 'dsh-better-sidebar', version, runtimeVersion: runtime, requirements: { '@deepseek-ai/dsh': '>=0.9.0' } })
const detail = (): HTMLElement => document.querySelector('[data-plugin-detail]') as HTMLElement
const section = (): HTMLElement => document.querySelector('[data-plugin-versions]') as HTMLElement
const open = (navigation: ReturnType<typeof renderTab>['navigation']) => {
act(() => { navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) })
}
it('checks once on opening, offers the newest installable version, and lists newer versions when unfolded', () => {
const versions = [
v('2.1.0-beta.1', { prerelease: true, tags: ['next'] }),
v('2.0.0', { tags: ['latest'], incompatible: incompatible('2.0.0'), publishedAt: '2026-10-10T00:00:00.000Z' }),
v('1.4.0', { publishedAt: '2026-09-28T08:00:00.000Z' }),
v('1.3.2', { deprecated: 'use 1.4.0' }),
v('1.3.1', { bundle: false }),
]
const check = listed(versions, [{ registry: null, problem: 'network', reason: 'ETIMEDOUT' }, { registry: MIRROR }])
const { actions, navigation } = renderTab({ packages: [updatable()], versions: { 'dsh-better-sidebar': check } })
expect(screen.getByText(en.statusUpdateAvailable)).toBeTruthy()
open(navigation)
expect(actions.ensureVersions).toHaveBeenCalledWith('dsh-better-sidebar')
expect(actions.ensureVersions).toHaveBeenCalledOnce()
expect(within(section()).getByText('Update to 1.4.0 available')).toBeTruthy()
expect(within(section()).getByText('Published 2026-09-28')).toBeTruthy()
fireEvent.click(within(detail()).getAllByRole('button', { name: 'Update to 1.4.0' })[0]!)
expect(actions.openUpdate).toHaveBeenCalledWith('dsh-better-sidebar', '1.4.0')
const summary = within(section()).getByRole('button', { name: /Update to 1\.4\.0 available/ })
expect(summary.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(summary)
expect(summary.getAttribute('aria-expanded')).toBe('true')
const rows = [...section().querySelectorAll('[data-plugin-version]')].map(row => row.getAttribute('data-plugin-version'))
expect(rows).toEqual(['2.0.0', '1.4.0', '1.3.2', '1.3.1', '0.16.0'])
const row = (version: string) => section().querySelector(`[data-plugin-version="${version}"]`) as HTMLElement
expect(within(row('2.0.0')).getByText('Needs @deepseek-ai/dsh >=0.9.0')).toBeTruthy()
expect(within(row('2.0.0')).queryByRole('button')).toBeNull()
expect(within(row('1.3.1')).getByText(en.versionsNotBundle)).toBeTruthy()
expect(within(row('1.3.2')).getByText('use 1.4.0')).toBeTruthy()
expect(within(row('0.16.0')).getByText(en.versionsInstalledTag)).toBeTruthy()
fireEvent.click(within(row('1.3.2')).getByRole('button', { name: 'Update to 1.3.2' }))
expect(actions.openUpdate).toHaveBeenLastCalledWith('dsh-better-sidebar', '1.3.2')
// The registry that answered, marked as a fallback after the first one failed.
expect(within(section()).getByText(en.registryNpmmirror)).toBeTruthy()
expect(within(section()).getByText(en.versionsFallbackTag)).toBeTruthy()
fireEvent.click(within(section()).getByRole('checkbox', { name: en.versionsShowPrerelease }))
expect(row('2.1.0-beta.1')).toBeTruthy()
fireEvent.click(within(section()).getByRole('button', { name: en.versionsRecheck }))
expect(actions.checkVersions).toHaveBeenCalledWith('dsh-better-sidebar')
fireEvent.click(summary)
expect(row('1.4.0')).toBeNull()
})
it.each([
['checking', { phase: 'checking' }, en.versionsChecking],
['up to date', listed([]), en.versionsLatest],
['blocked', listed([v('2.0.0', { incompatible: incompatible('2.0.0') })]), en.versionsBlocked],
['not installable', listed([v('1.0.0', { deprecated: 'old' })]), en.versionsNoneInstallable],
] as const)('summarizes a %s check', (_name, check, title) => {
const { navigation } = renderTab({ packages: [updatable()], versions: { 'dsh-better-sidebar': check } })
open(navigation)
expect(within(section()).getByText(title)).toBeTruthy()
expect(within(detail()).queryByRole('button', { name: /^Update to / })).toBeNull()
if (title === en.versionsBlocked) expect(within(section()).getByText(`2.0.0 needs @deepseek-ai/dsh >=0.9.0; this DSH is ${runtime}`)).toBeTruthy()
})
it('reports a refused check with the registries asked and a retry', () => {
const attempts: RegistryAttempt[] = [
{ registry: null, problem: 'network', reason: 'pnpm view timed out after 20000ms' },
{ registry: MIRROR, problem: 'network', reason: 'getaddrinfo ENOTFOUND registry.npmmirror.com' },
]
const { actions, navigation } = renderTab({ packages: [updatable()], versions: {
'dsh-better-sidebar': { phase: 'done', checkedAt: 0, list: { status: 'refused', problem: 'network', reason: 'down', attempts } },
} })
open(navigation)
expect(within(section()).getByText(en.versionsFailedNetwork)).toBeTruthy()
expect(within(section()).getByText(`${en.registryNpmmirror} ${en.versionsProblemNetwork}`)).toBeTruthy()
expect(within(section()).queryByRole('button', { name: en.versionsRecheck })).toBeNull()
fireEvent.click(within(section()).getByRole('button', { name: en.versionsDetails }))
expect(within(section()).getByText('getaddrinfo ENOTFOUND registry.npmmirror.com')).toBeTruthy()
expect(within(section()).getByText(en.versionsPrimaryTag)).toBeTruthy()
fireEvent.click(within(section()).getByRole('button', { name: en.retry }))
expect(actions.checkVersions).toHaveBeenCalledWith('dsh-better-sidebar')
})
it('reports a check the transport lost by its words, and lists a single registry\'s answer without a fallback mark', () => {
const { navigation, set } = renderTab({ packages: [updatable()], versions: {
'dsh-better-sidebar': { phase: 'done', checkedAt: 0, list: { status: 'refused', problem: 'unknown', reason: 'socket closed', attempts: [] } },
} })
open(navigation)
expect(within(section()).getByText(en.versionsFailedOther)).toBeTruthy()
expect(within(section()).getByText('socket closed')).toBeTruthy()
expect(within(section()).queryByRole('button', { name: en.versionsDetails })).toBeNull()
// A refused list can name a registry that answered before a later lookup failed.
set({ versions: { 'dsh-better-sidebar': { phase: 'done', checkedAt: 0, list: { status: 'refused', problem: 'unknown', reason: 'boom', attempts: [{ registry: null }] } } } })
fireEvent.click(within(section()).getByRole('button', { name: en.versionsDetails }))
expect(within(section()).getByText(en.registryDefault)).toBeTruthy()
expect(within(section()).queryByText(en.versionsProblemUnknown)).toBeNull()
set({ versions: { 'dsh-better-sidebar': { ...listed([v('0.17.0')]), list: { ...(listed([v('0.17.0')]) as Extract<VersionCheck, { phase: 'done' }>).list, omitted: 3 } } as VersionCheck } })
expect(within(section()).queryByText(/^Published/)).toBeNull()
// The section stays unfolded across the new answer.
expect(within(section()).getByRole('button', { name: /Update to 0\.17\.0 available/ }).getAttribute('aria-expanded')).toBe('true')
expect(within(section()).getByText('3 more versions newer than the installed one are not listed')).toBeTruthy()
expect(within(section()).queryByText(en.versionsFallbackTag)).toBeNull()
expect(within(section()).queryByRole('checkbox')).toBeNull()
})
it('shows no Versions section for a package that is not updatable', () => {
const { actions, navigation } = renderTab({ packages: [pkg({ source: 'link:/dev/dsh-better-sidebar' })] })
open(navigation)
expect(section()).toBeNull()
expect(actions.ensureVersions).not.toHaveBeenCalled()
})
it('says in the toolbar what the version checks found', () => {
const { set } = renderTab({ packages: [updatable(), pkg({ name: 'dsh-linked' })] })
set({ versions: { 'dsh-better-sidebar': { phase: 'checking' } } })
expect(screen.queryByText(en.updatesNone)).toBeNull()
set({ versions: { 'dsh-better-sidebar': listed([v('1.0.0')]) } })
expect(screen.getByText('Updates available: 1')).toBeTruthy()
set({ versions: { 'dsh-better-sidebar': listed([]) } })
expect(screen.getByText(en.updatesNone)).toBeTruthy()
set({ versions: { 'dsh-better-sidebar': { phase: 'done', checkedAt: 0, list: { status: 'refused', problem: 'unknown', reason: 'x', attempts: [] } } } })
expect(screen.getByText(en.updatesFailed)).toBeTruthy()
})
const base: UpdateState = { packageName: 'dsh-better-sidebar', from: '1.3.0', to: '1.4.0', registry: MIRROR, phase: 'confirm', steps: [], result: null, outputOpen: false }
const change = (application: ChangeResult['application'], more: Partial<ChangeResult> = {}): ChangeResult => ({
changed: true, application, stage: 'install', target: 'dsh-better-sidebar', ...more,
})
const dialog = (): HTMLElement => screen.getByRole('dialog')
it('confirms an update with its versions and registry, and runs it', () => {
const { actions } = renderTab({ packages: [updatable()], update: base })
expect(within(dialog()).getByText('1.3.0')).toBeTruthy()
expect(within(dialog()).getByText(en.registryNpmmirror)).toBeTruthy()
expect(within(dialog()).getByText(en.updateDescription.replaceAll('{from}', '1.3.0').replace('{to}', '1.4.0'))).toBeTruthy()
fireEvent.click(within(dialog()).getByRole('button', { name: en.update }))
expect(actions.runUpdate).toHaveBeenCalledOnce()
fireEvent.click(within(dialog()).getByRole('button', { name: en.cancel }))
expect(actions.closeUpdate).toHaveBeenCalledOnce()
})
it('names a package that left the list by its package name', () => {
renderTab({ packages: [], update: base })
expect(within(dialog()).getByText(en.updateTitle.replace('{name}', 'dsh-better-sidebar'))).toBeTruthy()
})
it('follows the running steps and keeps the dialog open', () => {
const { actions } = renderTab({ packages: [updatable()], update: { ...base, phase: 'running', steps: ['checking', 'removing'] } })
const steps = [...dialog().querySelectorAll('li')].map(item => [item.getAttribute('data-state'), item.textContent])
expect(steps).toEqual([['done', 'Check 1.4.0'], ['running', 'Stop and remove 1.3.0']])
expect(within(dialog()).getByRole<HTMLButtonElement>('button', { name: en.updateRunning }).disabled).toBe(true)
fireEvent.keyDown(document, { key: 'Escape' })
expect(actions.closeUpdate).not.toHaveBeenCalled()
})
it.each([
['applied', { step: 'install', result: change('applied') }, 'Updated to 1.4.0', [['done', 'Check 1.4.0'], ['done', `Install 1.4.0 from ${en.registryNpmmirror}`]]],
['restart', { step: 'install', result: change('restart-required', { restartReason: 'host-module' }) }, '1.4.0 installed; it takes effect after a restart', [['done', 'Check 1.4.0'], ['done', `Install 1.4.0 from ${en.registryNpmmirror}`]]],
['refused', { step: 'check', result: change('failed', { error: { code: 'incompatible-version' } }) }, 'Cannot update to 1.4.0', [['failed', 'Check 1.4.0']]],
['removal failure', { step: 'remove', result: change('failed', { stage: 'remove', error: { code: 'operation-error', diagnostic: 'EBUSY' } }) }, 'Could not remove 1.3.0', [['done', 'Check 1.4.0'], ['failed', 'Stop and remove 1.3.0']]],
['restored', {
step: 'install', result: change('failed', { packageResult: { exitCode: 1, output: '\nERR_PNPM_FETCH_404 Not Found', truncated: false, logPath: '/l', kind: 'not-found' } }),
restore: change('applied'),
}, 'Update failed; restored 1.3.0', [['done', 'Check 1.4.0'], ['failed', `Install 1.4.0 from ${en.registryNpmmirror}`], ['done', 'Install 1.3.0 again']]],
['kept', { step: 'install', result: change('failed', {
error: { code: 'operation-error', diagnostic: 'ETARGET' }, packageResult: { exitCode: 1, output: 'ETARGET', truncated: false, logPath: '/l' },
}) }, 'Could not update to 1.4.0', [['done', 'Check 1.4.0'], ['failed', `Install 1.4.0 from ${en.registryNpmmirror}`]]],
['broken', { step: 'install', result: change('cancelled'), restore: change('failed', { packageResult: { exitCode: 1, output: 'gone', truncated: false, logPath: '/l' } }) },
en.updateBroken, [['done', 'Check 1.4.0'], ['failed', `Install 1.4.0 from ${en.registryNpmmirror}`], ['failed', 'Install 1.3.0 again']]],
] as const)('reports a %s outcome with the steps its answer implies', (_name, answer, title, steps) => {
const result = { from: '1.3.0', to: '1.4.0', ...answer } as PluginUpdateResult
const { actions } = renderTab({ packages: [updatable()], update: { ...base, phase: 'done', result } })
expect(within(dialog()).getByText(title)).toBeTruthy()
expect([...dialog().querySelectorAll('li')].map(item => [item.getAttribute('data-state'), item.textContent])).toEqual(steps)
const output = within(dialog()).queryByRole('button', { name: en.updateOutput })
expect(output !== null).toBe(_name === 'restored' || _name === 'broken' || _name === 'kept')
if (_name === 'kept') expect(within(dialog()).getByText('1.3.0 was not removed and stays in use. ETARGET')).toBeTruthy()
if (output !== null) {
fireEvent.click(output)
expect(actions.toggleUpdateOutput).toHaveBeenCalledOnce()
}
fireEvent.click(within(dialog()).getByRole('button', { name: en.updateFinish }))
expect(actions.closeUpdate).toHaveBeenCalledOnce()
fireEvent.keyDown(document, { key: 'Escape' })
expect(actions.closeUpdate).toHaveBeenCalledTimes(2)
})
it('lists the steps the answer implies beside the announced ones', () => {
renderTab({ packages: [updatable()], update: {
...base, phase: 'done', steps: ['checking', 'removing'],
result: { from: '1.3.0', to: '1.4.0', step: 'install', result: change('failed'), restore: change('applied') },
} })
expect([...dialog().querySelectorAll('li')].map(item => item.getAttribute('data-state'))).toEqual(['done', 'done', 'failed', 'done'])
})
it('shows a failed run\'s output once unfolded and an unknown outcome after a lost answer', () => {
const failedRun = change('failed', { packageResult: { exitCode: 1, output: 'ERR_PNPM_FETCH_404', truncated: false, logPath: '/l' } })
const { set } = renderTab({ packages: [updatable()], update: {
...base, phase: 'done', outputOpen: true, steps: ['checking', 'removing', 'installing', 'restoring'],
result: { from: '1.3.0', to: '1.4.0', step: 'install', result: failedRun, restore: change('restart-required') },
} })
expect(within(dialog()).getByText('ERR_PNPM_FETCH_404').tagName).toBe('PRE')
set({ update: { ...base, phase: 'done', steps: ['checking', 'removing'], lost: 'socket closed' } })
expect(within(dialog()).getByText(en.updateLost)).toBeTruthy()
expect([...dialog().querySelectorAll('li')].map(item => item.getAttribute('data-state'))).toEqual(['done', 'unknown'])
})
})
@@ -9,7 +9,7 @@ import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { PluginTab } from '../src/client/tab-ledger.ts'
import type { ConfigLedger } from '../src/client/config-ledger.ts'
import { offeredRegistries, packageView, PluginManagerController, rowKey, sortPackages } from '../src/client/manager-store.ts'
import { installable, offeredRegistries, packageView, PluginManagerController, rowKey, sortPackages, updateCandidate } from '../src/client/manager-store.ts'
const INCOMPATIBLE = { name: 'dsh-late', version: '2.0.0', runtimeVersion: '0.1.0', requirements: { '@deepseek-ai/dsh': '^0.2.0' } }
const ROW_ENTRY = 'include:sidebar' as PluginEntryId
@@ -25,6 +25,7 @@ const BUNDLE: BundleInfo = {
source: 'dsh-better-sidebar@^0.16.0',
optional: false,
removable: true,
updatable: false,
rows: [{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY }, { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme' }],
overrides: [],
}
@@ -122,7 +123,7 @@ 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.', source: 'dsh-better-sidebar@^0.16.0',
installed: true, official: false, availability: 'profile', optional: false, removable: true, enabled: false,
installed: true, official: false, availability: 'profile', optional: false, removable: true, updatable: false, enabled: false,
rows: [
{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY, enabled: true, phase: 'active' },
{ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', enabled: false, phase: null },
@@ -130,13 +131,13 @@ describe('packageView', () => {
})
// A row the inventory no longer lists, a protected row, and a bundle the Host cannot read.
const protectedBundle: BundleInfo = {
name: '@deepseek-ai/dsh-base', enabled: true, installed: false, official: false, availability: 'installation', optional: false, removable: false, readOnlyReason: 'management-required',
name: '@deepseek-ai/dsh-base', enabled: true, installed: false, official: false, availability: 'installation', optional: false, removable: false, updatable: false, readOnlyReason: 'management-required',
error: { code: 'operation-error', diagnostic: 'broken' },
rows: [{ rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core' as PluginEntryId }, { rowId: 'gone', moduleName: 'x', entryId: 'include:gone' as PluginEntryId }],
overrides: [],
}
expect(packageView(protectedBundle, PLUGINS)).toEqual({
name: '@deepseek-ai/dsh-base', installed: false, official: false, availability: 'installation', optional: false, removable: false, enabled: true, readOnlyReason: 'management-required',
name: '@deepseek-ai/dsh-base', installed: false, official: false, availability: 'installation', optional: false, removable: false, updatable: false, enabled: true, readOnlyReason: 'management-required',
error: { code: 'operation-error', diagnostic: 'broken' },
rows: [
{ rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core', enabled: true, phase: 'active', readOnlyReason: 'management-required' },
@@ -148,7 +149,7 @@ describe('packageView', () => {
describe('sortPackages', () => {
it('orders packages by the short name a person reads, not by the Host order or enablement', async () => {
const plain = { enabled: true, installed: true, official: false, availability: 'profile' as const, optional: false, removable: true, rows: [], overrides: [] }
const plain = { enabled: true, installed: true, official: false, availability: 'profile' as const, optional: false, removable: true, updatable: false, rows: [], overrides: [] }
const zeta: BundleInfo = { ...plain, name: 'dsh-zeta' }
const alpha: BundleInfo = { ...plain, name: '@acme/dsh-alpha', enabled: false }
const views = [zeta, BUNDLE, alpha].map(bundle => packageView(bundle, PLUGINS))
@@ -1832,7 +1833,7 @@ it('reports a successful install with only documented result fields', async () =
const CATALOG: BundleInfo = {
name: '@deepseek-ai/dsh-subagent-codex', official: true, availability: 'missing', installed: false, optional: false,
enabled: false, removable: false, rows: [], overrides: [],
enabled: false, removable: false, updatable: false, rows: [], overrides: [],
installTarget: { spec: '@deepseek-ai/dsh-subagent-codex@2.0.0', version: '2.0.0' },
}
@@ -2048,3 +2049,158 @@ it('deselects a source bundle whose target is unavailable without starting insta
await vi.waitFor(() => { expect(plugins.setBundleEnabled).toHaveBeenCalledWith(entry.name, false) })
expect(plugins.installBundle).not.toHaveBeenCalled()
})
describe('version checks and updates', () => {
const UPDATABLE: BundleInfo = { ...BUNDLE, updatable: true }
const LISTED = {
status: 'listed' as const, installed: '0.16.0', registry: MIRROR, omitted: 0,
attempts: [{ registry: null, problem: 'network' as const, reason: 'ETIMEDOUT' }, { registry: MIRROR }],
versions: [
{ version: '1.0.0', tags: ['latest'], prerelease: false, bundle: true, incompatible: INCOMPATIBLE },
{ version: '0.18.0-beta.1', tags: [], prerelease: true, bundle: true },
{ version: '0.17.1', tags: [], prerelease: false, bundle: true, deprecated: 'broken' },
{ version: '0.17.0', tags: [], prerelease: false, bundle: true },
],
}
const UPDATED = { from: '0.16.0', to: '0.17.0', step: 'install' as const, result: { ...APPLIED, version: '0.17.0' } }
it('offers the newest version that declares a bundle, accepts this DSH, and is neither deprecated nor a prerelease', () => {
expect(updateCandidate(LISTED)?.version).toBe('0.17.0')
expect(updateCandidate({ ...LISTED, versions: LISTED.versions.slice(0, 3) })).toBeUndefined()
expect(updateCandidate({ status: 'refused', problem: 'network', reason: 'down', attempts: [] })).toBeUndefined()
expect(installable({ version: '2.0.0', tags: [], prerelease: false, bundle: false })).toBe(false)
})
it('checks a package once on ensure, again on request, and keeps a transport failure as an unknown refusal', async () => {
const listVersions = vi.fn().mockResolvedValueOnce(ok(LISTED)).mockResolvedValueOnce(refused('gateway/internal', 'offline'))
const { face, state } = bench({ listVersions })
face.ensureVersions('dsh-better-sidebar')
expect(state().versions['dsh-better-sidebar']).toEqual({ phase: 'checking' })
// A check in flight is not started again.
face.checkVersions('dsh-better-sidebar')
await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']).toMatchObject({ phase: 'done', list: LISTED }) })
face.ensureVersions('dsh-better-sidebar')
expect(listVersions).toHaveBeenCalledOnce()
expect(listVersions).toHaveBeenCalledWith('dsh-better-sidebar', {})
face.checkVersions('dsh-better-sidebar')
await vi.waitFor(() => {
expect(state().versions['dsh-better-sidebar']).toMatchObject({ phase: 'done', list: { status: 'refused', problem: 'unknown', reason: 'offline', attempts: [] } })
})
expect(listVersions).toHaveBeenCalledTimes(2)
})
it('forgets a check answered for another installed version, or for a package that is no longer updatable', async () => {
const listBundles = vi.fn(() => Promise.resolve(ok([UPDATABLE, { ...UPDATABLE, name: 'dsh-a' }])))
const { controller, face, state } = bench({ listBundles, listVersions: vi.fn(() => Promise.resolve(ok(LISTED))) })
await controller.load()
face.checkVersions('dsh-better-sidebar')
face.checkVersions('dsh-a')
await vi.waitFor(() => { expect(state().versions['dsh-a']?.phase).toBe('done') })
await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') })
// Another surface reinstalled one package at another version and replaced the other by a linked copy.
listBundles.mockResolvedValue(ok([{ ...UPDATABLE, version: '0.17.0' }, { ...UPDATABLE, name: 'dsh-a', updatable: false }]))
await controller.load()
expect(state().versions).toEqual({})
})
it('checks every updatable package in turn on a manual refresh and keeps the spinner until the checks settle', async () => {
const order: string[] = []
const last = deferred<ReturnType<typeof ok<typeof LISTED>>>()
const listVersions = vi.fn(async (name: string) => { order.push(name); return name === 'dsh-better-sidebar' ? last.promise : ok(LISTED) })
const listBundles = vi.fn(() => Promise.resolve(ok([UPDATABLE, { ...UPDATABLE, name: 'dsh-a' }, { ...BUNDLE, name: 'dsh-linked' }])))
const { face, state } = bench({ listVersions, listBundles })
face.refresh()
await vi.waitFor(() => { expect(order).toEqual(['dsh-a', 'dsh-better-sidebar']) })
expect(state().refreshStatus).toBe('refreshing')
last.resolve(ok(LISTED))
await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') })
expect(state().versions['dsh-better-sidebar']?.phase).toBe('done')
})
it('checks no versions after a failed refresh', async () => {
const listVersions = vi.fn(() => Promise.resolve(ok(LISTED)))
const listBundles = vi.fn(() => Promise.resolve(refused('gateway/internal', 'offline')))
const { face, state } = bench({ listVersions, listBundles })
face.refresh()
await vi.waitFor(() => { expect(state().refreshStatus).toBe('failed') })
expect(listVersions).not.toHaveBeenCalled()
})
it('confirms, runs, and keeps an update\'s steps and outcome, then checks the package again', async () => {
const answer = deferred<ReturnType<typeof ok<typeof UPDATED>>>()
const updateBundle = vi.fn(() => answer.promise)
const listVersions = vi.fn(() => Promise.resolve(ok(LISTED)))
const listBundles = vi.fn(() => Promise.resolve(ok([UPDATABLE])))
const { controller, face, state, plugins } = bench({ updateBundle, listVersions, listBundles })
await controller.load()
// Nothing to confirm before the package's versions are read.
face.openUpdate('dsh-better-sidebar', '0.17.0')
expect(state().update).toBeNull()
face.checkVersions('dsh-better-sidebar')
await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') })
face.openUpdate('dsh-better-sidebar', '0.17.0')
expect(state().update).toEqual({
packageName: 'dsh-better-sidebar', from: '0.16.0', to: '0.17.0', registry: MIRROR, phase: 'confirm', steps: [], result: null, outputOpen: false,
})
face.closeUpdate()
expect(state().update).toBeNull()
face.openUpdate('dsh-better-sidebar', '0.17.0')
face.runUpdate()
expect(state().update).toMatchObject({ phase: 'running' })
const requestId = state().update?.requestId as PluginInstallRequestId
expect(updateBundle).toHaveBeenCalledWith('dsh-better-sidebar', '0.17.0', { registry: MIRROR, requestId })
face.runUpdate()
face.closeUpdate()
expect(updateBundle).toHaveBeenCalledOnce()
expect(state().update?.phase).toBe('running')
// Progress of another request, and a repeated step, change nothing.
controller.updateProgress({ requestId: 'other' as PluginInstallRequestId, step: 'checking' })
controller.updateProgress({ requestId, step: 'checking' })
controller.updateProgress({ requestId, step: 'checking' })
controller.updateProgress({ requestId, step: 'removing' })
expect(state().update?.steps).toEqual(['checking', 'removing'])
face.openUpdate('dsh-better-sidebar', '1.0.0')
expect(state().update?.to).toBe('0.17.0')
// A check of the package being replaced waits for the update.
face.checkVersions('dsh-better-sidebar')
expect(listVersions).toHaveBeenCalledOnce()
const reads = plugins.listBundles.mock.calls.length
answer.resolve(ok(UPDATED))
await vi.waitFor(() => { expect(state().update).toMatchObject({ phase: 'done', result: UPDATED, steps: ['checking', 'removing'] }) })
expect(plugins.listBundles.mock.calls.length).toBeGreaterThan(reads)
// The package's versions are read again for the version now installed.
await vi.waitFor(() => { expect(listVersions).toHaveBeenCalledTimes(2) })
await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') })
controller.updateProgress({ requestId, step: 'installing' })
expect(state().update?.steps).toEqual(['checking', 'removing'])
face.toggleUpdateOutput()
expect(state().update?.outputOpen).toBe(true)
face.closeUpdate()
expect(state().update).toBeNull()
face.toggleUpdateOutput()
expect(state().update).toBeNull()
})
it('keeps a lost update answer as unknown and ignores an answer after disposal', async () => {
const updateBundle = vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'socket closed'))
const listVersions = vi.fn(() => Promise.resolve(ok(LISTED)))
const { controller, face, state } = bench({ updateBundle, listVersions, listBundles: vi.fn(() => Promise.resolve(ok([UPDATABLE]))) })
await controller.load()
face.checkVersions('dsh-better-sidebar')
await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') })
face.openUpdate('dsh-better-sidebar', '0.17.0')
face.runUpdate()
await vi.waitFor(() => { expect(state().update).toMatchObject({ phase: 'done', result: null, lost: 'socket closed' }) })
const late = deferred<ReturnType<typeof ok<typeof UPDATED>>>()
updateBundle.mockReturnValueOnce(late.promise)
await vi.waitFor(() => { expect(listVersions).toHaveBeenCalledTimes(2) })
await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') })
face.closeUpdate()
face.openUpdate('dsh-better-sidebar', '0.17.0')
face.runUpdate()
controller.dispose()
late.resolve(ok(UPDATED))
await late.promise
expect(state().update?.phase).toBe('running')
})
})
@@ -1787,6 +1787,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'spec', description: 'One package spec: a registry name, an absolute path, a git address, or a tarball.' }, { name: 'options', description: 'The registry asked first.' }, { name: 'signal', description: 'Ends a registry lookup early.' }],
returns: 'The package the spec names, or why it is refused.',
},
{
signature: '@Remote async listVersions(name: string, options?: ListVersionsOptions, signal?: AbortSignal): Promise<PluginVersionList>',
description: 'Read the published versions newer than an installed registry package\'s version, from the first registry that answers. Official bundles follow the DSH version and are not listed.',
parameters: [{ name: 'name', description: 'Installed profile dependency name.' }, { name: 'options', description: 'The registry asked first; absent, the one the package was installed from in this process, else the configured one.' }, { name: 'signal', description: 'Ends the lookups early.' }],
returns: 'At most `versionListLimit` newer versions, newest first, with their tags, deprecation, bundle declaration, and DSH compatibility, and the registry that answered; or why no list could be read, with every registry asked.',
},
{
signature: '@Remote async updateBundle(name: string, version: string, options?: UpdateBundleOptions): Promise<PluginUpdateResult>',
description: 'Replace an installed registry bundle with a newer published version. The target is checked on the registry first: it must exist, declare a bundle, and accept the running DSH version, or nothing changes. With HMR the installed version is removed, so its plugins stop, and the target is installed with the same selection; when that installation fails or is cancelled, the removed version is installed again from the registry it came from. Without HMR the target is installed over the running version and the result reports the restart.',
parameters: [{ name: 'name', description: 'Installed profile dependency name.' }, { name: 'version', description: 'Exact published version newer than the installed one.' }, { name: 'options', description: 'The registry asked first, as for `listVersions`, and the request id naming the progress and the target installation\'s logs and cancellation.' }],
returns: 'The versions, the last step that ran with its result, and the reinstallation when one ran.',
},
{
signature: '@Remote setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise<ChangeResult>',
description: 'Persist a plugin entry\'s desired enablement and apply it on live profiles.',
@@ -4458,6 +4470,14 @@ export const EVENT_API: readonly EventApiEntry[] = [
description: 'An installation moved between its Host phases. `installing` is announced once per registry the installation asks, with the attempt\'s registry and position; `cancelling` and `applying` once.',
parameters: [{ name: 'progress', description: 'the installation\'s request id and phase, with the attempt while installing.' }],
},
{
name: 'plugin-manager/update-state',
mode: 'emit',
signature: '\'plugin-manager/update-state\'(progress: PluginUpdateProgress): void',
summary: 'An update moved to its next step: checking the target, removing the installed version, installing the target, or reinstalling the removed version after the target failed.',
description: 'An update moved to its next step: checking the target, removing the installed version, installing the target, or reinstalling the removed version after the target failed. Announced once per step.',
parameters: [{ name: 'progress', description: 'the update\'s request id and step.' }],
},
{
name: 'plugin-market/changed',
mode: 'emit',
@@ -5032,7 +5052,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'BundleInfo',
declaration: 'export interface BundleInfo {\n name: string;\n official: boolean;\n availability: \'installation\' | \'profile\' | \'missing\';\n installTarget?: {\n spec: string;\n version: string;\n };\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}',
declaration: 'export interface BundleInfo {\n name: string;\n official: boolean;\n availability: \'installation\' | \'profile\' | \'missing\';\n installTarget?: {\n spec: string;\n version: string;\n };\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 updatable: boolean;\n readOnlyReason?: ReadOnlyReason;\n error?: ManagementError;\n rows: BundleRowInfo[];\n overrides: string[];\n}',
},
{
name: 'BundleRowInfo',
@@ -5954,6 +5974,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'KvUnitDescriptor',
declaration: 'export interface KvUnitDescriptor {\n readonly name: string;\n readonly version: number;\n readonly tables: readonly string[];\n readonly hasGlobal: boolean;\n readonly layout?: \'single\' | \'per-record\';\n readonly compatibleVersions?: readonly number[];\n}',
},
{
name: 'ListVersionsOptions',
declaration: 'export interface ListVersionsOptions {\n readonly registry?: Registry;\n}',
},
{
name: 'LlmAdapter',
declaration: 'export abstract class LlmAdapter {\n providerInfo(provider: string): LlmProviderInfo;\n providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined;\n imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined;\n imageRequestTarget(_provider: string, _model: string, image: ImageAttachmentRef): ImageRequestTarget;\n listModels(_provider: string): Promise<readonly LlmModelInfo[]>;\n resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise<LlmResolvedModelInfo>;\n async prepareCall(provider: string, model: string, signal?: AbortSignal): Promise<PreparedAdapterCall>;\n abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
@@ -6502,6 +6526,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PluginSpecInspection',
declaration: 'export type PluginSpecInspection = {\n readonly status: \'accepted\';\n readonly kind: InstallSpecKind;\n readonly name?: string;\n readonly version?: string;\n readonly description?: string;\n readonly bundle: boolean | null;\n readonly registry: Registry;\n readonly host?: string;\n} | {\n readonly status: \'refused\';\n readonly problem: PluginInspectProblem;\n readonly reason: string;\n readonly registries?: Registry[];\n};',
},
{
name: 'PluginUpdateProgress',
declaration: 'export interface PluginUpdateProgress {\n readonly requestId: PluginInstallRequestId;\n readonly step: PluginUpdateStep;\n}',
},
{
name: 'PluginUpdateResult',
declaration: 'export interface PluginUpdateResult {\n readonly from: string;\n readonly to: string;\n readonly step: \'check\' | \'remove\' | \'install\';\n readonly result: ChangeResult;\n readonly restore?: ChangeResult;\n}',
},
{
name: 'PluginUpdateStep',
declaration: 'export type PluginUpdateStep = \'checking\' | \'removing\' | \'installing\' | \'restoring\';',
},
{
name: 'PluginVersion',
declaration: 'export interface PluginVersion {\n readonly version: string;\n readonly publishedAt?: string;\n readonly tags: readonly string[];\n readonly prerelease: boolean;\n readonly deprecated?: string;\n readonly bundle: boolean;\n readonly incompatible?: IncompatiblePlugin;\n}',
},
{
name: 'PluginVersionList',
declaration: 'export type PluginVersionList = {\n readonly status: \'listed\';\n readonly installed: string;\n readonly registry: Registry;\n readonly attempts: readonly RegistryAttempt[];\n readonly versions: readonly PluginVersion[];\n readonly omitted: number;\n} | {\n readonly status: \'refused\';\n readonly problem: \'not-updatable\' | \'not-found\' | \'network\' | \'unknown\';\n readonly reason: string;\n readonly attempts: readonly RegistryAttempt[];\n};',
},
{
name: 'PostToolDecision',
declaration: 'export type PostToolDecision = {\n kind: \'accept\';\n content?: ContentBlock[];\n value?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'accept\';\n value: JsonValue;\n content?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'block\';\n feedback: ContentBlock[];\n additionalContexts?: UserMessage[];\n};',
@@ -6734,6 +6778,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'Registry',
declaration: 'export type Registry = string | null;',
},
{
name: 'RegistryAttempt',
declaration: 'export interface RegistryAttempt {\n readonly registry: Registry;\n readonly problem?: \'not-found\' | \'network\' | \'unknown\';\n readonly reason?: string;\n}',
},
{
name: 'Reload',
declaration: 'export interface Reload {\n filename: string;\n modules: ReloadModules;\n runtime?: Plugin.Runtime | undefined;\n}',
@@ -8626,6 +8674,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'UiRenderResult',
declaration: 'export type UiRenderResult = UiNode;',
},
{
name: 'UpdateBundleOptions',
declaration: 'export interface UpdateBundleOptions {\n readonly registry?: Registry;\n readonly requestId?: PluginInstallRequestId;\n}',
},
{
name: 'UpdateTeamTaskRequest',
declaration: 'export interface UpdateTeamTaskRequest {\n readonly taskId: TeamTaskId;\n readonly expectedRevision: number;\n readonly action: TeamTaskAction;\n readonly subject?: string;\n readonly description?: string;\n readonly blockedBy?: readonly TeamTaskId[];\n readonly writeScopes?: readonly string[];\n readonly owner?: string;\n}',
+6
View File
@@ -2061,6 +2061,9 @@ importers:
execa:
specifier: ^10.0.1
version: 10.0.1
semver:
specifier: ^7.8.5
version: 7.8.5
yaml:
specifier: ^2.9.0
version: 2.9.0
@@ -2119,6 +2122,9 @@ importers:
'@deepseek-ai/dsh-user-approval':
specifier: workspace:*
version: link:../../interaction/user-approval
'@types/semver':
specifier: ^7.8.0
version: 7.8.0
chokidar:
specifier: ^4.0.3
version: 4.0.3
+9 -4
View File
@@ -671,15 +671,20 @@ describe('CI workflow', () => {
expect(uploadIndex).toBeGreaterThan(benchmarkIndex)
})
it('generates metadata before the Wine Host TypeScript compile and retains its heap budget', () => {
it('splits the Wine Host TypeScript compile and retains its heap budget', () => {
const wineGates = readFileSync(resolve(root, 'scripts/wine-windows-gates.sh'), 'utf8')
expect(wineGates).toContain(
'wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -b tsconfig.host.json --pretty false',
const references = wineGates.indexOf(
'wine_node "$scratch/logs/host-references.log" --import tsx/esm scripts/compile-referenced-projects.ts host',
)
const aggregate = wineGates.indexOf(
'wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -p tsconfig.host.json --pretty false',
)
const metadata = wineGates.indexOf('wine_node "$scratch/logs/host-typert.log" --import tsx/esm scripts/generate-typert.ts')
expect(metadata).toBeGreaterThan(-1)
expect(metadata).toBeLessThan(wineGates.indexOf('wine_node "$scratch/logs/host-tsc.log"'))
expect(references).toBeGreaterThan(metadata)
expect(aggregate).toBeGreaterThan(references)
expect(wineGates).not.toContain('-b tsconfig.host.json')
})
it('cancels superseded master runs without changing the post-merge job inventory', () => {
+5
View File
@@ -319,6 +319,11 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
PluginChange: 'boot.md',
PluginInstallLogChunk: 'boot.md',
PluginInstallProgress: 'boot.md',
PluginUpdateProgress: 'boot.md',
PluginUpdateResult: 'boot.md',
PluginVersionList: 'boot.md',
ListVersionsOptions: 'boot.md',
UpdateBundleOptions: 'boot.md',
PluginRegistries: 'boot.md',
InspectOptions: 'boot.md',
BrowserUseProviderName: 'browser-use.md',
+8 -3
View File
@@ -243,12 +243,17 @@ grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-ga
# ---- the two blocking surfaces, concurrently ------------------------------
# The build mirrors the root build:lib:host and build:lib:client scripts:
# generate metadata, compile and bundle the Host face, bundle apps/desktop from
# the Host lib/ outputs, then compile and bundle the Client face.
# generate metadata, compile the Host package projects, type-check the Host
# aggregate program, bundle the Host face, bundle apps/desktop from the Host
# lib/ outputs, then compile and bundle the Client face. The Host compile is two
# invocations because that is how the supported build splits it; a single
# `tsc -b` holds the reference graph and the aggregate program in one process
# and exceeds the heap the Host face is budgeted.
# Both statuses are captured so one failure cannot hide the other's result.
build_gate() {
wine_node "$scratch/logs/host-typert.log" --import tsx/esm scripts/generate-typert.ts || return $?
wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -b tsconfig.host.json --pretty false || return $?
wine_node "$scratch/logs/host-references.log" --import tsx/esm scripts/compile-referenced-projects.ts host || return $?
wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -p tsconfig.host.json --pretty false || return $?
wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $?
(cd apps/desktop && wine_node "$scratch/logs/desktop-tsdown.log" "../../$tsdown_js") || return $?
wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $?