diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml index f5b5d88586..a643f7935e 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-global-main-panels.md -2026-09-08-global-main-panels.md: 75be68ac1bf6dceb812a5aedbca74ce58df929b3 -2026-09-08-global-main-panels.zh.md: 343f183367a5cfbd13127e32542f689c50306c89 +2026-09-08-global-main-panels.md: 6aa7df2dcdd28b94cc0084a0044764efb2e749e2 +2026-09-08-global-main-panels.zh.md: bbb10b2ef5784748a165ad54c907fac2a63c9b17 diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md index 75be68ac1b..6aa7df2dcd 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md +++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md @@ -12,7 +12,7 @@ Plugins need application-wide views that do not belong to a Session. A Session-s The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to the Conversation plugin, whose `main.conversation` child retains optional-Session binding. Other main entries receive no implicit Session binding. -The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registers no panel entry, so the empty list has no DOM or spacing. Selection validates the live main entry and rejects a missing key without replacing the current panel. +The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registered no panel entry when this landed, so an empty list has no DOM or spacing; the web bundle's plugin manager now registers the first one ([plugin management moves to the Web sidebar](2026-09-09-plugin-management-in-the-web-sidebar.md)). Selection validates the live main entry and rejects a missing key without replacing the current panel. One eagerly created root store is shared by the renderer and layout controller. Its `panelInfo` and `layoutInfo` objects preserve independent references. The framework supplies `usePanelInfo`; individual rows and main content subscribe to their required selection values, while AppFrame reads only layout information. The right Sidebar's root controller decides whether to mount its Session subtree and reports the resulting track requirements to the frame. diff --git a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md index 343f183367..bbb10b2ef5 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md @@ -12,7 +12,7 @@ Status: implemented 布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation 插件,其 `main.conversation` 子 slot 保留可选的会话绑定。其他主面板条目不获得隐式会话绑定。 -侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。默认组合不注册面板条目,因此空列表没有 DOM 或间距。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。 +侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。本决定落地时默认组合不注册面板条目,因此空列表没有 DOM 或间距;现在 web bundle 的插件管理器注册了第一个条目([插件管理移到 Web 侧栏](2026-09-09-plugin-management-in-the-web-sidebar.zh.md))。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。 渲染器与布局控制器共享一个直接创建的 root 存储。其 `panelInfo` 和 `layoutInfo` 对象保持独立的引用。框架提供 `usePanelInfo`;各行和中央内容订阅所需的选中态值,AppFrame 仅读取布局信息。右侧 Sidebar 的 root 控制器决定是否挂载其会话子树,并把最终所需的列宽报告给框架。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml new file mode 100644 index 0000000000..07c6da31be --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md +2026-09-09-plugin-management-in-the-web-sidebar.md: d2a3329e7c878f25ad5e50d98fc2e3ab39722887 +2026-09-09-plugin-management-in-the-web-sidebar.zh.md: aae035b3a93aec72e84e2792334c42c54d2bca62 diff --git a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md new file mode 100644 index 0000000000..d2a3329e7c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md @@ -0,0 +1,31 @@ +# Agent Note: Plugin management moves to the Web sidebar + +Status: implemented + +English | [中文](2026-09-09-plugin-management-in-the-web-sidebar.zh.md) + +## Problem + +Installed packages belong to the running profile, while Settings is a modal over a Session. The management page needs room for package details and installation output. The layout's [global main panels](2026-09-08-global-main-panels.md) provide that lifetime and space. + +## Decision + +**Management is a sidebar entry; configuration stays in Settings.** `ui-plugin-manager` registers a `sidebar.panellist` entry and the `main` panel it opens under `plugins`. The page manages the profile's bundles and their rows through the [plugin manager](2026-09-14-current-profile-plugin-management.md) Remote, displays install output and confirms uninstalls. The page lists installed bundles only. The Settings Plugins section keeps the global configuration cards beside the read-only Plugin list tab, where the installation's own bundles (`dsh-base`, `dsh-web-app`) are inspected; both of that tab's groups start collapsed, and the tab carries no management controls of its own. + +**One store follows Host state.** The manager controller joins `listBundles` with `listPlugins` into one view per bundle, decides availability from the inventory's `managementAvailable`, refreshes after management operations, on `plugin-manager/changed`, and on reconnect, and keeps installation progress under the owning job. Configuration cards use the existing global settings bindings. + +**Installation results belong to a request.** The dialog generates a fresh request id for every install or retry and filters Host progress, logs, and responses by that id. A cancellation acknowledgement can arrive before the original add response, so that response cannot settle a subsequent retry. Cancellation uses the manager's explicit cleanup acknowledgement; local RPC cancellation and connection loss never imply that pnpm has stopped. The application phase closes the cancellation window. + +## Alternatives considered + +**A settings section that opens the management page.** Rejected: the dialog covers the main column, so such an entry would have to close Settings to show the page. + +**Configuration on the plugin's page.** Rejected for now, for the reasons in the decision; it becomes a link from the plugin's page once Settings can be opened on one section. + +## Consequences + +The web bundle's panel list is no longer empty: the **Plugins** entry sits between New Session and the workspaces. The Settings Plugins section keeps two tabs: the configuration page and the read-only Plugin list. `apps/web/tests/plugin-manager.e2e.ts` reaches the manager through the sidebar, and the `plugin-config` and `settings-chrome` scenarios and goldens follow. + +## Testing + +`packages/client/ui-plugin-manager/tests` pin the two registrations under one id and the page's rendering; `packages/client/ui-settings-plugins/tests` the tab-less single contribution; the web e2e scenarios above drive the panel and the section over a scaffold. diff --git a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md new file mode 100644 index 0000000000..aae035b3a9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md @@ -0,0 +1,31 @@ +# Agent Note:插件管理移到 Web 侧栏 + +Status: implemented + +[English](2026-09-09-plugin-management-in-the-web-sidebar.md) | 中文 + +## 问题 + +已安装的包属于运行中的 profile,而设置是覆盖在 Session 上的弹窗。管理页面需要容纳包详情与安装输出。布局的[全局主面板](2026-09-08-global-main-panels.zh.md)提供对应的生命周期与空间。 + +## 决定 + +**管理位于侧栏,配置保留在设置中。** `ui-plugin-manager` 在 `plugins` 下注册 `sidebar.panellist` 入口与它打开的 `main` 面板。页面通过[插件管理器](2026-09-14-current-profile-plugin-management.zh.md)的 Remote 管理 profile 的组合包及其行、展示安装输出,并确认卸载。页面只列出已安装的组合包。设置的插件分区保留全局配置卡片,旁边是只读的「插件列表」标签页,随安装提供的组合包(`dsh-base`、`dsh-web-app`)在那里查看;该标签页的两个分组默认收起,且不带任何管理控件。 + +**一个 store 跟随 Host 状态。** 管理器控制器把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,按清单的 `managementAvailable` 判定可用性,在管理操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并按所属 job 保存安装进度。配置卡片使用原有全局 settings 绑定。 + +**安装结果属于一次请求。** 每次安装或重试都生成新的请求 ID,对话框按该 ID 筛选 Host 进度、日志和返回结果。取消确认可能先于原 add 响应到达,因此旧响应不能结束随后的重试。取消采用管理器的明确清理确认;本地 RPC 取消和断线均不表示 pnpm 已停止。进入配置应用阶段后关闭取消窗口。 + +## 考虑过的替代方案 + +**用一个设置分区打开管理页。** 否决:对话框盖住主列,这样的入口必须先关掉设置才能显示页面。 + +**把配置放在插件页面上。** 暂不采纳,理由见决定;等设置能按分区打开后,它会变成插件页面上的一个链接。 + +## 后果 + +web bundle 的面板列表不再为空:**插件**入口位于新建会话与工作区之间。设置的「插件」分区保留两个标签页:配置页与只读的「插件列表」。`apps/web/tests/plugin-manager.e2e.ts` 经侧栏到达管理器,`plugin-config` 与 `settings-chrome` 的场景与 golden 随之更新。 + +## 测试 + +`packages/client/ui-plugin-manager/tests` 钉住同一 id 下的两处注册与页面的渲染;`packages/client/ui-settings-plugins/tests` 钉住没有标签条的单一贡献;上述 web e2e 场景在脚手架上驱动面板与分区。 diff --git a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml index 5d2d73bd75..794dc339d8 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md -2026-09-14-current-profile-plugin-management.md: eef121bd7968547ebec83bbb52c4776f84082278 -2026-09-14-current-profile-plugin-management.zh.md: 009d1df124bfee31385c25b5c1e80696556448a4 +2026-09-14-current-profile-plugin-management.md: a59a79022bce88dcee7b1629ec35c9759489e34a +2026-09-14-current-profile-plugin-management.zh.md: 7c3e8d470fcf87fb32cda8d9f68dd2ffe4b7fe15 diff --git a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md index eef121bd79..a59a79022b 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md +++ b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md @@ -14,7 +14,7 @@ Web and agent controls need to change a running profile without creating an inde Configuration watches use Chokidar write stabilization by default. Its ordinary change handler discards a second event within 50 ms, so a write immediately after activation can leave the previous bundle running. Stabilized delivery observes the final file instead; file-driven updates pay the stability delay, while direct manager transactions do not. A regression feeds consecutive changes through Chokidar’s real normalization and verifies both applied states. -Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; failure preserves the actual partial state and a diagnostic path. +Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; a failed removal preserves the actual partial state and a diagnostic path, while a failed or cancelled installation restores the profile files it snapshotted. This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service. Management operations return results to callers without adding messages to live Agents. The agent tool is disabled by default in the base bundle and shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery. @@ -26,7 +26,7 @@ Build approvals update pnpm 11's unresolved `allowBuilds` entries under the same **Spawning another dsh process from the service.** This duplicates lifecycle coordination and cannot establish that the current Loader finished unloading before pnpm removes files. Sharing the operation module retains one implementation while letting each caller own its presentation. -**Restoring existing packages after failure.** Package versions, dependency trees and install-script effects cannot be reconstructed reliably from the previous manifest. Failed installation permits one removal attempt for an unambiguously identified new dependency. Existing dependencies and successful installations whose activation fails remain in place. Invalid leftover packages stay visible and removable. +**Restoring existing packages after failure.** Package versions, dependency trees and install-script effects cannot be reconstructed reliably from the previous manifest, so existing dependencies and successful installations whose activation fails remain in place. A failed or cancelled installation restores only the manifest and lockfile text snapshotted before pnpm ran ([guided plugin installation](2026-09-15-guided-plugin-installation.md)); downloaded files stay until the next package operation prunes them. **Source-module hot replacement for package updates.** Configuration changes can reuse the loaded module cache, whereas replacing installed JavaScript needs a new process generation. Replacing an existing dependency reports a required restart. diff --git a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md index 009d1df124..7c3e8d470f 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md @@ -14,7 +14,7 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安 配置监听默认使用 Chokidar 写入稳定检测。普通变化处理器会丢弃 50 ms 内的第二个事件,因此激活后立即再次写入可能让之前的组合包继续运行。稳定后交付事件会观察最终文件;文件驱动的更新承担稳定等待,直接管理器事务则不需要。回归测试通过 Chokidar 的真实规范化路径交付连续变化,验证两个状态均被应用。 -profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败保留实际的部分状态与诊断路径。 +profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败的删除保留实际的部分状态与诊断路径,失败或被取消的安装则恢复它快照的 profile 文件。 这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。base 组合包和内置预设默认禁用该 Agent 工具。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。 @@ -26,7 +26,7 @@ CLI 调用继承终端和认证环境;service 调用保留子进程凭据清 **由 service 启动另一个 dsh 进程。** 这会重复生命周期协调,也无法确认当前 Loader 已完成卸载后才让 pnpm 删除文件。共享操作模块保留单一实现,同时让调用方持有各自的呈现方式。 -**失败后恢复已有包。** 无法仅凭原 manifest 可靠重建包版本、依赖树和安装脚本的副作用。安装失败只允许对可唯一识别的新增依赖尝试一次删除。已有依赖及安装成功但激活失败的包保留原处。无效的残留依赖仍可见、可删除。 +**失败后恢复已有包。** 无法仅凭原 manifest 可靠重建包版本、依赖树和安装脚本的副作用,因此已有依赖及安装成功但激活失败的包保留原处。失败或被取消的安装只恢复 pnpm 运行前快照的 manifest 与 lockfile 文本([引导式插件安装](2026-09-15-guided-plugin-installation.zh.md));已下载文件保留到下一次包操作清理为止。 **包更新时热替换源码模块。** 配置变化可以复用已加载模块缓存,替换已安装 JavaScript 则需要新的进程。替换已有依赖会报告需要重启。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml new file mode 100644 index 0000000000..5c2d10151d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md +2026-09-15-guided-plugin-installation.md: 26ad870856ce948e1ebb736a07004e41af2a17be +2026-09-15-guided-plugin-installation.zh.md: 36e76c8ace0ae990ede14436b65fa00752f86bbe diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md new file mode 100644 index 0000000000..26ad870856 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md @@ -0,0 +1,43 @@ +# Agent Note: Guided plugin installation + +Status: implemented + +English | [中文](2026-09-15-guided-plugin-installation.zh.md) + +## Problem + +The install dialog put a spec straight into `pnpm add` and showed pnpm's terminal as the whole story: a typo, an installed package, a missing path, and a registry outage all ended in the same red exit code, the person read pnpm's output to learn which, and nothing could be stopped once started. A run that failed, or that added a package without a bundle patch, left the dependency in the profile with nothing in the list to show or remove it. Enabling was a checkbox to tick before knowing what would be installed, and a finished install left the new package somewhere in the list. + +## Decision + +**The Host reads a spec before installing it.** `PluginManager.inspect` sorts the spec — registry name, absolute path, git address, tarball — with `parseInstallSpec`, refuses what pnpm or the registry would refuse, and asks the registry through `pnpm view` or the directory through its `package.json` for the name, version, description, and bundle declaration. `pnpm view` runs in the profile directory so the registry and proxy settings match the install. A package without a bundle patch is refused here, before pnpm runs: the manager installs bundles only. The answer carries one of seven problems; the client renders each as a sentence under the field and keeps the spec editable. The dialog refuses a name the list already shows without asking the Host. + +**A failed or cancelled installation restores the profile files.** `installBundle` snapshots `package.json` and `pnpm-lock.yaml` before pnpm runs and puts them back when pnpm fails, when the run is cancelled, or when the package pnpm added declares no bundle patch. This reverses, for installations only, the [manager's decision](2026-09-14-current-profile-plugin-management.md) to retain partial changes: an installation that did not produce a usable bundle must not leave a dependency the page cannot show. Removals keep that decision. Downloaded files can stay under `node_modules` and the pnpm store. + +**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test. + +**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. + +**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. + +**Blocked install scripts are approved from the failed screen.** When pnpm 11 leaves a dependency's scripts undecided, the failed run reports the pending names ([the manager's approval](2026-09-14-current-profile-plugin-management.md)), and the failed screen shows them with **Allow these scripts and retry** in place of plain retry; the store runs the same checked subject again with `approvedBuilds`, and the installed screen names what was allowed. `pnpm-workspace.yaml` is not among the restored files for this reason. Without pending names the failure falls back to the manual instruction. + +## Alternatives considered + +**Validate specs on the client.** Rejected: the rules are pnpm's, the registry's, and the profile's, and the client cannot import the Host package that owns them. + +**Look the package up over HTTP instead of `pnpm view`.** Rejected: the registry, proxy, and auth settings that decide whether the install can succeed live in pnpm's configuration, which `pnpm view` reads and a direct fetch would have to reimplement. + +**Stop the run by aborting the add RPC.** Rejected: a dropped RPC does not say whether pnpm stopped or the manifest is back, so the dialog would offer the spec again over a run still writing to the profile. The manager's `cancelInstall` answers only after cleanup, and the dialog waits for it. + +**Keep listing dependencies that are not bundles.** Rejected: the manager manages bundles, and a package it refuses to install cannot be listed or removed through it; the check refuses such a package before anything is written. + +## Consequences + +`inspect`, `cancelInstall`, and the `plugin-manager/changed`, `plugin-manager/install-log`, and `plugin-manager/install-state` events join the manager's Remote; `listBundles` carries titles, rows, and overrides; `ChangeResult` gains `cancelled`, `bundle`, and `packageResult.kind`; the config gains `pnpmCommand` and `inspectTimeoutMs`. The dialog is four screens over one subject card. + +## Testing + +`packages/boot/plugin-manager/tests/install-spec.spec.ts` pins the spec forms and the failure classifier's inputs; `manager.spec.ts` drives `inspect` against a stubbed registry lookup and a real directory, streams a run, stops one and checks the restored files, and checks the change events; `operations.spec.ts` covers the registry lookup. `packages/client/ui-plugin-manager/tests` cover the store's phases, Host-confirmed cancellation, post-install enabling, toasts, and the page's four screens; `apps/web/tests/plugin-manager.e2e.ts` refuses an installed name, a missing path, and a bad name through the real Host and switches a bundle and one of its rows live, and `plugin-install-cancel.e2e.ts` stops a real child from the dialog, checks the restored files, and installs on the second try, and `plugin-install-approve.e2e.ts` leaves a script undecided through the fake pnpm, allows it from the dialog, and installs on the retry. diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md new file mode 100644 index 0000000000..36e76c8ace --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md @@ -0,0 +1,43 @@ +# Agent Note:引导式插件安装 + +Status: implemented + +[English](2026-09-15-guided-plugin-installation.md) | 中文 + +## 问题 + +安装对话框把 spec 直接交给 `pnpm add`,并把 pnpm 的终端当作全部说明:打错字、已装过的包、不存在的路径、注册表不可用,最后都是同一个红色退出码,人得去读 pnpm 输出才知道是哪一种,而且一旦开始就停不下来。失败的运行,或者装进来一个没有组合包 patch 的包,会把依赖留在 profile 里,列表上却没有任何东西能显示或移除它。启用是一个在知道要装什么之前就得勾的选项,装完的包散在列表某处。 + +## 决定 + +**宿主先读 spec,再安装。** `PluginManager.inspect` 用 `parseInstallSpec` 把 spec 分成注册表名、绝对路径、git 地址、压缩包,拒绝 pnpm 或注册表不会接受的写法,再通过 `pnpm view` 问注册表、或读目录的 `package.json`,得到名字、版本、描述和组合包声明。`pnpm view` 在 profile 目录里运行,让注册表和代理设置与安装一致。没有组合包 patch 的包在这一步、在 pnpm 运行之前就被拒绝:管理器只安装组合包。答复带七种 problem 之一;客户端把每一种渲染成输入框下的一句话,spec 保留可改。列表里已有的名字由对话框直接拒绝,不问宿主。 + +**失败或被取消的安装恢复 profile 文件。** `installBundle` 在 pnpm 运行前快照 `package.json` 与 `pnpm-lock.yaml`,在 pnpm 失败、运行被取消、或 pnpm 装入的包没有声明组合包 patch 时把它们放回去。这只针对安装反转了[管理器的决定](2026-09-14-current-profile-plugin-management.zh.md)中"保留部分改动"的部分:没有产出可用组合包的安装,不能留下一个页面无法显示的依赖。删除仍沿用那个决定。已下载文件可能留在 `node_modules` 与 pnpm 缓存中。 + +**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。 + +**取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。 + +**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭,列表滚动到它。在人看到装了什么之前,不会启用任何东西。 + +**当下的结果是 toast。** 要等下次启动的变更、被更高层覆盖的变更、被取消的运行、被拒绝的操作,各弹一条 toast 后自行消失;页面上不留任何东西。 + +**被拦下的安装脚本在失败界面批准。** pnpm 11 把依赖脚本留作未决时,失败的运行报告待决定的包名([管理器的授权](2026-09-14-current-profile-plugin-management.zh.md)),失败界面列出它们并以**允许这些脚本并重试**取代普通重试;store 带着 `approvedBuilds` 用同一个已检查的 subject 再跑一次,安装完成界面说明允许了哪些脚本。正因如此,`pnpm-workspace.yaml` 不在恢复的文件之列。没有待决定的名字时,失败退回到手动放行的提示。 + +## 考虑过的替代方案 + +**在客户端校验 spec。** 否决:规则属于 pnpm、注册表和 profile,客户端无法导入拥有这些规则的宿主包。 + +**用 HTTP 直接查注册表而不是 `pnpm view`。** 否决:决定安装能否成功的注册表、代理与认证设置都在 pnpm 的配置里,`pnpm view` 读得到,直接 fetch 得重新实现一遍。 + +**通过中止 add RPC 来停止运行。** 否决:断掉的 RPC 说不清 pnpm 是否停了、manifest 是否已恢复,对话框会在一次仍在写 profile 的运行之上把 spec 重新交回。管理器的 `cancelInstall` 只在清理完成后答复,对话框等它。 + +**继续列出不是组合包的依赖。** 否决:管理器管理的是组合包,它拒绝安装的包无法经由它列出或移除;检查在写入任何东西之前就拒绝这样的包。 + +## 后果 + +`inspect`、`cancelInstall` 以及 `plugin-manager/changed`、`plugin-manager/install-log`、`plugin-manager/install-state` 事件加入管理器的 Remote;`listBundles` 携带标题、行与覆盖项;`ChangeResult` 增加 `cancelled`、`bundle` 与 `packageResult.kind`;配置增加 `pnpmCommand` 与 `inspectTimeoutMs`。对话框是围绕同一张主题卡的四个画面。 + +## 测试 + +`packages/boot/plugin-manager/tests/install-spec.spec.ts` 钉住 spec 形式与失败分类器的输入;`manager.spec.ts` 用桩住的注册表查询和真实目录驱动 `inspect`,流式转发一次运行,停下一次运行并检查恢复后的文件,并检查变更事件;`operations.spec.ts` 覆盖注册表查询。`packages/client/ui-plugin-manager/tests` 覆盖 store 的阶段、经宿主确认的取消、装后启用、toast 与页面的四个画面;`apps/web/tests/plugin-manager.e2e.ts` 经真实宿主拒绝已装名字、不存在的路径和坏名字,并实时切换一个组合包及其中一行,`plugin-install-cancel.e2e.ts` 从对话框停下一个真实子进程、检查恢复后的文件,并在第二次尝试时装成,`plugin-install-approve.e2e.ts` 用假 pnpm 把脚本留作未决、从对话框允许后在重试中装上。 diff --git a/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.i18n.yaml b/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.i18n.yaml index fe1e1ecf51..30118b2276 100644 --- a/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md -2026-09-12-default-product-experimental-isolation.md: d2312d6061f372e910bfd625512230ccd48a586a -2026-09-12-default-product-experimental-isolation.zh.md: 6ea86f1f3e859719ffd8287e66ebe63df77d64c2 +2026-09-12-default-product-experimental-isolation.md: 5c2485e4f737b822088a680b0006f395a5479fa9 +2026-09-12-default-product-experimental-isolation.zh.md: 3a412a491ab8356a4e29d706fed55226dc0f6038 diff --git a/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md b/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md index d2312d6061..5c2485e4f7 100644 --- a/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md +++ b/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md @@ -10,7 +10,7 @@ Public npm availability does not make an experimental package part of the defaul ## Decision -[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) runs in static CI and package hygiene. It follows runtime dependencies, optional dependencies, and peers from every app and the Python runtime, resolves workspace and npm aliases, and identifies experimental packages by their npm prefix or repository directory. Publication denylist membership has no effect on this classification. +[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) runs in static CI and package hygiene. It follows runtime dependencies, optional dependencies, and peers from every app and the Python runtime, resolves workspace and npm aliases, and identifies experimental packages by their npm prefix or repository directory. Publication denylist membership has no effect on this classification. The bundles the launcher names in `OPTIONAL_BUNDLES` are the one declared exception ([shipped optional bundles](2026-09-15-shipped-optional-bundles.md)). The source check also reads runtime imports in the selected packages, installation-owned profile bundle lists, bundle patches, shipped agent presets, and declared configuration trees. It loads the default Web layers with the production patch parser and composes them with the same patch engine used at boot. The effective rows and patched Include trees are checked, so an id-only patch cannot hide a replacement group's plugins. Disabled plugin rows remain checked; ordinary plugin configuration data is not interpreted as another Loader entry list. Missing default roots fail the check. diff --git a/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.zh.md b/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.zh.md index 6ea86f1f3e..3a412a491a 100644 --- a/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.zh.md +++ b/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) 在静态 CI 和包 hygiene 中运行。它从所有应用与 Python runtime 出发,遍历运行时依赖、可选依赖和 peer,解析 workspace 与 npm 别名,并按 npm 前缀或仓库目录识别实验包。发布 denylist 的成员关系不影响此分类。 +[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) 在静态 CI 和包 hygiene 中运行。它从所有应用与 Python runtime 出发,遍历运行时依赖、可选依赖和 peer,解析 workspace 与 npm 别名,并按 npm 前缀或仓库目录识别实验包。发布 denylist 的成员关系不影响此分类。启动器在 `OPTIONAL_BUNDLES` 里点名的组合包是唯一声明的例外([随安装提供的可选组合包](2026-09-15-shipped-optional-bundles.zh.md))。 源码检查还读取所选包的运行时导入、安装自带的 profile bundle 列表、bundle patch、随产品提供的 Agent preset,以及声明的配置树。它使用生产 patch 解析器加载默认 Web 各层,并使用启动时的同一个 patch 引擎完成组合。检查对象包括最终 entry 和应用 patch 后的 Include 树,因此仅按 id 覆盖 group 的 patch 也无法隐藏替换后的插件。禁用的插件行仍纳入检查;普通插件配置数据不会被解释为另一个 Loader entry 列表。默认入口缺失会使检查失败。 diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml new file mode 100644 index 0000000000..bd8082bc65 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md +2026-09-15-shipped-optional-bundles.md: 6de3eb7d39b86a2e5dd6db756d2bf6561aeb8ed4 +2026-09-15-shipped-optional-bundles.zh.md: 05c23c7c0cdd6a146b938370fec64377652e31d4 diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md new file mode 100644 index 0000000000..6de3eb7d39 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md @@ -0,0 +1,29 @@ +# Agent Note: Ship optional bundles with the installation + +Status: implemented + +English | [中文](2026-09-15-shipped-optional-bundles.zh.md) + +## Problem + +The Web plugin page manages only the bundles a person installed into the profile. An official experimental layer such as Agent Teams had to be found on npm and installed by name before it could be switched on, and [default-product isolation](2026-09-12-default-product-experimental-isolation.md) kept every experimental package out of the installation's runtime dependencies, so nothing shipped with dsh could offer it. + +## Decision + +The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page lists optional bundles in a built-in group with an official tag beside the profile's own installed bundles. + +Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed. + +Agent Teams and Auto review ship this way first, as `@deepseek-ai/dsh-experimental-agent-team-profile`, `@deepseek-ai/dsh-experimental-agent-team-web-profile`, and `@deepseek-ai/dsh-experimental-auto-review`. + +## Alternatives considered + +**A catalog of installable official bundles.** The page would offer names to install from the registry on demand. That keeps the installation unchanged but needs network access at the moment of switching on and a version pin per release. + +**A flag on the bundle package.** A `dsh.bundle.optional` declaration would let any published bundle claim a place in the installation; the launcher's own list keeps the choice with the product. + +**A list in the installation's manifest.** `dsh.optionalBundles` in `apps/cli/package.json` was the first form; the maintainers keep product decisions in code, where the list is typed, read once, and shared by the manager and the gates. + +## Consequences + +Optional bundles are downloaded with the product and stay inactive until selected; the runtime isolation smokes still observe no experimental module in a default composition. Switching on an optional Web layer loads its client plugin through the live client module graph. A bundle that needs a companion layer, such as the Agent Teams Web layer over its Host layer, says so in its description; the manager does not select companions automatically. diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md new file mode 100644 index 0000000000..05c23c7c0c --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Ship optional bundles with the installation + +Status: implemented + +[English](2026-09-15-shipped-optional-bundles.md) | 中文 + +## 问题 + +Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样的官方实验层,用户得先去 npm 找到包名并按名安装才能开启;而[默认产品隔离](2026-09-12-default-product-experimental-isolation.zh.md)又把所有实验包挡在安装的运行时依赖之外,所以随 dsh 一起交付的东西没有办法把它提供出来。 + +## 决策 + +启动器在 `OPTIONAL_BUNDLES`(`packages/boot/app-boot/src/profile.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页把可选组合包放在带官方标签的内置分组里,与 profile 自己安装的组合包并列。 + +默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。 + +Agent Teams 与 Auto review 首先以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-team-profile`、`@deepseek-ai/dsh-experimental-agent-team-web-profile` 与 `@deepseek-ai/dsh-experimental-auto-review`。 + +## 考虑过的替代方案 + +**可安装官方组合包目录。** 页面按需提供从注册表安装的包名。安装本身不变,但开启那一刻需要网络,并且每次发布都要钉一个版本。 + +**组合包自身的标记。** `dsh.bundle.optional` 声明会让任何已发布的组合包都能自称随安装提供;由启动器自己的列表来决定,选择权留在产品手里。 + +**放在安装 manifest 里的列表。** `apps/cli/package.json` 的 `dsh.optionalBundles` 是最初的形式;维护者把产品决定放在代码里,列表有类型、只读一次,管理器与门禁共用。 + +## 影响 + +可选组合包随产品一起下载,选中前保持不活动;运行时隔离 smoke 在默认组合中仍观察不到任何实验模块。开启一个可选 Web 层会通过实时客户端模块图加载它的客户端插件。需要配套层的组合包,例如 Agent Teams Web 层依赖其 Host 层,会在描述里说明;管理器不会自动选中配套层。 diff --git a/apps/cli/package.json b/apps/cli/package.json index e5c13a5702..82383295bc 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -47,6 +47,9 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", + "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^", + "@deepseek-ai/dsh-experimental-agent-team-web-profile": "workspace:^", + "@deepseek-ai/dsh-experimental-auto-review": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-round-driver": "workspace:^", @@ -116,7 +119,6 @@ "@deepseek-ai/dsh-credentials-local": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", - "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^", "@deepseek-ai/dsh-experimental-ptc-runtime-python": "workspace:^", "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", diff --git a/apps/web/tests/expected/plugin-install-approve/blocked.expected.md b/apps/web/tests/expected/plugin-install-approve/blocked.expected.md new file mode 100644 index 0000000000..b166febaf1 --- /dev/null +++ b/apps/web/tests/expected/plugin-install-approve/blocked.expected.md @@ -0,0 +1,22 @@ +- dialog "插件安装失败": + - button "返回编辑": + - img + - text: 编辑 + - button "关闭": + - img + - alert: 插件安装失败 + - paragraph: 有依赖的安装脚本需要你允许后才能继续 + - paragraph: native-package + - paragraph: 版本 1.0.0 + - group "需要允许安装脚本": + - heading "需要允许安装脚本" [level=3] + - paragraph: 以下包声明了安装脚本,pnpm 默认不运行。 + - list: + - listitem: + - code: native-package + - paragraph: 允许后,脚本会以你的权限在本机运行,授权保存在当前 profile,之后不再询问。 + - paragraph: 只在信任这些包时允许。 + - button "允许这些脚本并重试" + - button "查看安装详情": + - text: 查看安装详情 + - img diff --git a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md new file mode 100644 index 0000000000..be08c280a3 --- /dev/null +++ b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md @@ -0,0 +1,13 @@ +- dialog "添加插件": + - heading "添加插件" [level=2] + - button "关闭": + - img + - paragraph: 输入插件的包名、GitHub 仓库地址或本地目录路径。 + - text: 包名或地址 + - textbox "包名或地址": + - /placeholder: 例如 dsh-better-sidebar + - text: slow-package + - button "不知道该填什么?": + - img + - text: 不知道该填什么? + - button "安装" diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md new file mode 100644 index 0000000000..382a8254de --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -0,0 +1,28 @@ +- heading "插件" [level=1] +- paragraph: 添加和管理插件 +- button "刷新" +- button "添加插件": + - img + - text: 添加插件 +- heading "内置" [level=3] +- text: "3" +- list: + - listitem: + - button "查看 experimental-agent-team-profile": experimental-agent-team-profile + - text: 官方 Experimental profile bundle enabling Agent Teams over dsh-base + - switch "启用 experimental-agent-team-profile" + - listitem: + - button "查看 experimental-agent-team-web-profile": experimental-agent-team-web-profile + - text: 官方 Experimental Web profile layer for Agent Teams Remote and UI plugins + - switch "启用 experimental-agent-team-web-profile" + - listitem: + - button "查看 experimental-auto-review": experimental-auto-review + - text: 官方 Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset + - switch "启用 experimental-auto-review" +- heading "已安装" [level=3] +- text: "1" +- list: + - listitem: + - button "查看 bundle": bundle + - text: "Web e2e fixture: a bundle whose one row is an inert plugin." + - switch "启用 bundle" [checked] diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md new file mode 100644 index 0000000000..fa151f6eb7 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -0,0 +1,28 @@ +- heading "插件" [level=1] +- paragraph: 添加和管理插件 +- button "刷新" +- button "添加插件": + - img + - text: 添加插件 +- heading "内置" [level=3] +- text: "3" +- list: + - listitem: + - button "查看 experimental-agent-team-profile": experimental-agent-team-profile + - text: 官方 Experimental profile bundle enabling Agent Teams over dsh-base + - switch "启用 experimental-agent-team-profile" + - listitem: + - button "查看 experimental-agent-team-web-profile": experimental-agent-team-web-profile + - text: 官方 Experimental Web profile layer for Agent Teams Remote and UI plugins + - switch "启用 experimental-agent-team-web-profile" + - listitem: + - button "查看 experimental-auto-review": experimental-auto-review + - text: 官方 Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset + - switch "启用 experimental-auto-review" +- heading "已安装" [level=3] +- text: "1" +- list: + - listitem: + - button "查看 bundle": bundle + - text: "Web e2e fixture: a bundle whose one row is an inert plugin." + - switch "启用 bundle" diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml b/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml new file mode 100644 index 0000000000..a3e71146b9 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml @@ -0,0 +1,4 @@ +# Web e2e fixture bundle: one inert row whose mount state exposes live layer changes. +- insert: + - id: fixture-row + name: '@fixture/bundle' diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/index.js b/apps/web/tests/fixtures/plugins/fixture-bundle/index.js new file mode 100644 index 0000000000..73273c522a --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/index.js @@ -0,0 +1,5 @@ +/** Web e2e fixture: an inert cordis plugin. */ +export const name = 'fixture-bundle' + +/** Nothing to mount. */ +export function apply() {} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json new file mode 100644 index 0000000000..263634fb08 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json @@ -0,0 +1,13 @@ +{ + "name": "@fixture/bundle", + "version": "0.0.1", + "private": true, + "description": "Web e2e fixture: a bundle whose one row is an inert plugin.", + "type": "module", + "main": "index.js", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + } +} diff --git a/apps/web/tests/fixtures/plugins/fixture-live-client/package.json b/apps/web/tests/fixtures/plugins/fixture-live-client/package.json index 963d0f0cd4..f0dc6ea35f 100644 --- a/apps/web/tests/fixtures/plugins/fixture-live-client/package.json +++ b/apps/web/tests/fixtures/plugins/fixture-live-client/package.json @@ -6,7 +6,6 @@ "main": "index.js", "exports": { ".": "./index.js", "./client": "./client.js" }, "dsh": { - "title": "动态界面示例", "bundle": { "patch": "./cordis.patch.yml" }, "client": { "platform": "web" } } diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 9d7982d7d8..2d1b770466 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -60,9 +60,6 @@ describe('web e2e: plugin configuration section', () => { await expect .poll(() => dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current'), { timeout: 5_000 }) .toBe('true') - await expect - .poll(() => dialog.getByRole('tab', { name: '插件配置', exact: true }).getAttribute('aria-selected'), { timeout: 5_000 }) - .toBe('true') return dialog } diff --git a/apps/web/tests/plugin-install-approve.e2e.ts b/apps/web/tests/plugin-install-approve.e2e.ts new file mode 100644 index 0000000000..de20583291 --- /dev/null +++ b/apps/web/tests/plugin-install-approve.e2e.ts @@ -0,0 +1,78 @@ +// A real Host/Remote/browser composition with a controllable package-manager process: +// pnpm leaves an install script undecided, the dialog offers approval, and the retry runs with it. +import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts' +import { ZH_BROWSER_LOCALE } from './support.ts' + +it('offers approval for blocked install scripts and installs once they are allowed', async () => { + const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-approve-')) + const overlay = join(scratch, 'cordis.patch.yml') + await writeFile(overlay, `- id: plugin-manager\n config: ${JSON.stringify({ pnpmCommand: process.execPath })}\n`) + let scaffold: WebScaffold | undefined + try { + scaffold = await launchWebScaffold({ profile: { packages: [] }, extraOverlayPath: overlay }) + const browser = await chromium.launch() + try { + const profile = join(scaffold.harnessHome, 'profiles', 'scaffold') + const policyPath = join(profile, 'pnpm-workspace.yaml') + // Node stands in for pnpm: `view` answers the check, and `add` first leaves the script undecided in the + // profile policy the way pnpm 11 does and fails; once the policy allows it, the same `add` installs the package. + await writeFile(join(profile, 'view'), 'console.log(JSON.stringify({ name: "native-package", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }))\n') + await writeFile(join(profile, 'add'), ` + import('node:fs').then(fs => { + const policy = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); + if (!/native-package: true/.test(policy)) { + const pending = ' native-package: set this to true or false\\n'; + fs.writeFileSync('pnpm-workspace.yaml', /^allowBuilds:/m.test(policy) + ? policy.replace(/^allowBuilds:\\n/m, 'allowBuilds:\\n' + pending) + : policy + 'allowBuilds:\\n' + pending); + console.error('ERR_PNPM_IGNORED_BUILDS Ignored build scripts: native-package'); + process.exitCode = 1; + return; + } + fs.mkdirSync('node_modules/native-package', { recursive: true }); + fs.writeFileSync('node_modules/native-package/package.json', JSON.stringify({ name: 'native-package', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })); + fs.writeFileSync('node_modules/native-package/cordis.patch.yml', '[]\\n'); + fs.writeFileSync('package.json', JSON.stringify({ ...JSON.parse(fs.readFileSync('package.json', 'utf8')), dependencies: { 'native-package': '1.0.0' } })); + console.log('Built native-package'); + }); + `) + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await page.waitForSelector('[class*="frame"]') + if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape') + await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + const panel = page.locator('[data-plugin-panel]') + await panel.getByRole('button', { name: '添加插件', exact: true }).click() + const dialog = page.getByRole('dialog') + await dialog.getByRole('textbox').fill('native-package') + await dialog.getByRole('button', { name: '安装', exact: true }).click() + // pnpm's refusal becomes the approval block, naming the package whose script waits; plain retry is not offered. + const approval = dialog.getByRole('group', { name: '需要允许安装脚本' }) + await approval.waitFor({ timeout: 20_000 }) + await expect.poll(() => approval.getByText('native-package', { exact: true }).count()).toBe(1) + expect(await dialog.getByRole('button', { name: '重试', exact: true }).count()).toBe(0) + expect(await readFile(policyPath, 'utf8')).toContain('native-package: set this to true or false') + const snapshot = (await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) + .split(process.execPath).join('{{node}}') + .split(scaffold.harnessHome).join('{{harnessHome}}') + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-approve/blocked.expected.md', import.meta.url)), snapshot, webSnapshotMode()) + await approval.getByRole('button', { name: '允许这些脚本并重试', exact: true }).click() + // The Host saved the permission before running pnpm again, and the installed screen says so. + await dialog.getByRole('button', { name: '立即启用', exact: true }).waitFor({ timeout: 20_000 }) + await dialog.getByText('已允许运行安装脚本:native-package', { exact: true }).waitFor() + expect(await readFile(policyPath, 'utf8')).toMatch(/native-package: true/) + expect(JSON.parse(await readFile(join(profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: { 'native-package': '1.0.0' } }) + expect(tripwire.pageErrors).toEqual([]) + } finally { await browser.close() } + } finally { + await scaffold?.close() + await rm(scratch, { recursive: true, force: true }) + } +}) diff --git a/apps/web/tests/plugin-install-cancel.e2e.ts b/apps/web/tests/plugin-install-cancel.e2e.ts new file mode 100644 index 0000000000..11388aa265 --- /dev/null +++ b/apps/web/tests/plugin-install-cancel.e2e.ts @@ -0,0 +1,84 @@ +// A real Host/Remote/browser composition with a controllable package-manager process. +// No model call is needed: installation state and profile files are the observable result. +import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts' +import { ZH_BROWSER_LOCALE } from './support.ts' + +it('cancels installation through the UI, restores files, and offers the spec again', async () => { + const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-cancel-')) + const overlay = join(scratch, 'cordis.patch.yml') + await writeFile(overlay, `- id: plugin-manager\n config: ${JSON.stringify({ pnpmCommand: process.execPath })}\n`) + let scaffold: WebScaffold | undefined + try { + scaffold = await launchWebScaffold({ profile: { packages: [] }, extraOverlayPath: overlay }) + const browser = await chromium.launch() + try { + const profile = join(scaffold.harnessHome, 'profiles', 'scaffold') + const manifestPath = join(profile, 'package.json') + const manifest = await readFile(manifestPath, 'utf8') + const lockPath = join(profile, 'pnpm-lock.yaml') + await writeFile(lockPath, 'original lockfile\n') + // Node stands in for the pnpm executable: `view` answers the check that precedes the run, + // and the same installer owns and stops the real `add` child. + await writeFile(join(profile, 'view'), 'console.log(JSON.stringify({ name: "slow-package", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }))\n') + await writeFile(join(profile, 'add'), ` + import('node:fs').then(fs => { + fs.writeFileSync('package.json', JSON.stringify({ ...JSON.parse(fs.readFileSync('package.json', 'utf8')), dependencies: { partial: '1.0.0' } })); + fs.writeFileSync('pnpm-lock.yaml', 'partial lockfile'); + console.log('Waiting for package download'); + setInterval(() => {}, 1000); + }); + `) + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await page.waitForSelector('[class*="frame"]') + if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape') + await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + const panel = page.locator('[data-plugin-panel]') + await panel.getByRole('button', { name: '添加插件', exact: true }).click() + // The dialog is named after its current screen, so it is found by role alone. + const dialog = page.getByRole('dialog') + await dialog.getByRole('textbox').fill('slow-package') + await dialog.getByRole('button', { name: '安装', exact: true }).click() + // The check passed: the running screen names the package and folds pnpm's output behind the details. + await dialog.getByText('版本 1.0.0', { exact: true }).waitFor() + await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click() + await dialog.getByText('Waiting for package download', { exact: true }).waitFor() + await dialog.getByRole('button', { name: '取消安装', exact: true }).click() + // The Host's confirmation returns the dialog to the spec and says so in a toast. + await page.getByText('已取消安装,插件未启用,下载的文件可能保留', { exact: true }).waitFor() + expect(await readFile(manifestPath, 'utf8')).toBe(manifest) + expect(await readFile(lockPath, 'utf8')).toBe('original lockfile\n') + expect(await dialog.getByRole('textbox').inputValue()).toBe('slow-package') + const snapshot = (await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) + .split(process.execPath).join('{{node}}') + .split(scaffold.harnessHome).join('{{harnessHome}}') + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/cancelled.expected.md', import.meta.url)), snapshot, webSnapshotMode()) + // The second run installs a bundle the way pnpm leaves one: the dependency in the manifest and the package under node_modules. + await writeFile(join(profile, 'add'), ` + import('node:fs').then(fs => { + fs.mkdirSync('node_modules/slow-package', { recursive: true }); + fs.writeFileSync('node_modules/slow-package/package.json', JSON.stringify({ name: 'slow-package', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })); + fs.writeFileSync('node_modules/slow-package/cordis.patch.yml', '[]\\n'); + fs.writeFileSync('package.json', JSON.stringify({ ...JSON.parse(fs.readFileSync('package.json', 'utf8')), dependencies: { 'slow-package': '1.0.0' } })); + console.log('Retry completed'); + }); + `) + await dialog.getByRole('button', { name: '安装', exact: true }).click() + await dialog.getByRole('button', { name: '立即启用', exact: true }).waitFor() + await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click() + await dialog.getByText('Retry completed', { exact: true }).waitFor() + expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toMatchObject({ dependencies: { 'slow-package': '1.0.0' } }) + expect(tripwire.pageErrors).toEqual([]) + } finally { await browser.close() } + } finally { + await scaffold?.close() + await rm(scratch, { recursive: true, force: true }) + } +}) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts new file mode 100644 index 0000000000..58989b599e --- /dev/null +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -0,0 +1,209 @@ +// Web e2e scenario: the plugin manager page behind the sidebar's Plugins entry over a +// managed scaffold profile: installed bundles, their rows, and bundle enablement. Zero +// model calls: everything is client state plus the profile files and the settings +// document, so there is no fixture and a stray stream would fail loud on the open llm seam. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { join } from 'node:path' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url)) +const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md') +const LIVE_EXPECTED = join(SNAPSHOT_DIR, 'live-enabled.expected.md') +const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url)) +const MODE = webSnapshotMode() + +describe('web e2e: plugin manager', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] }, + }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + /** Close any open settings dialog, so the sidebar and the main column are clickable. */ + async function closeSettings() { + if (await page.getByRole('dialog', { name: '设置' }).count() > 0) { + await page.keyboard.press('Escape') + await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0) + } + } + + /** Select the sidebar's Plugins entry and wait for the management page in the main column. */ + async function openPluginsPanel() { + await closeSettings() + await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + const panel = page.locator('[data-plugin-panel]') + await panel.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 }) + return panel + } + + /** One file under the harness home, or the empty string while it does not exist. */ + async function homeFile(...segments: string[]): Promise { + return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '') + } + + it('lists the installed bundles with their switches and leaves the installation\'s own to Settings', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-list')) + const panel = await openPluginsPanel() + + await panel.getByText('bundle', { exact: true }).waitFor({ timeout: 20_000 }) + const toggle = panel.getByRole('switch', { name: '启用 bundle' }) + expect(await toggle.getAttribute('aria-checked')).toBe('false') + // The profile's own group holds its one bundle; the installation's optional bundles form the built-in + // group, and its other bundles stay off the page. + expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(1) + expect(await panel.locator('[data-plugin-group="builtin"] [data-plugin-package]').count()).toBe(3) + // A bundle that is off still shows the rows its patch declares, without switches. + await panel.getByRole('button', { name: '查看 bundle' }).click() + await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 }) + expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).count()).toBe(0) + await panel.getByRole('button', { name: '卸载 bundle' }).waitFor({ timeout: 5_000 }) + await panel.getByRole('button', { name: '返回插件列表' }).click() + await expect.poll(() => panel.getByRole('button', { name: '卸载 bundle' }).count(), { timeout: 5_000 }).toBe(0) + + const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) + await compareOrRefreshGolden(MANAGER_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('checks a spec before installing it and words what the check refused', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-install')) + const panel = await openPluginsPanel() + await panel.getByRole('button', { name: '添加插件', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '添加插件' }) + await dialog.waitFor({ timeout: 10_000 }) + const field = dialog.getByRole('textbox', { name: '包名或地址' }) + const install = dialog.getByRole('button', { name: '安装', exact: true }) + expect(await install.isDisabled()).toBe(true) + // A name the list already shows is refused without asking the Host. + await field.fill('@fixture/bundle') + await install.click() + await dialog.getByRole('alert').waitFor({ timeout: 5_000 }) + expect(await dialog.getByRole('alert').textContent()).toBe('该插件已安装') + // A path the Host cannot read as a package is refused with its reason, and the spec stays editable. + await field.fill(join(scaffold.harnessHome, 'no-such-plugin')) + await install.click() + await expect.poll(() => dialog.getByRole('alert').textContent(), { timeout: 10_000 }).toBe('该路径不存在或不是有效的插件包') + expect(await field.isDisabled()).toBe(false) + // A name the registry would refuse never reaches it. + await field.fill('Not A Package') + await install.click() + await expect.poll(() => dialog.getByRole('alert').textContent(), { timeout: 10_000 }).toContain('无法识别这个包名或地址') + await dialog.getByRole('button', { name: '关闭' }).click() + await expect.poll(() => page.getByRole('dialog', { name: '添加插件' }).count(), { timeout: 5_000 }).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('enables a bundle into the profile manifest, mounts its rows live, and switches one of them', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-enable')) + const panel = await openPluginsPanel() + const toggle = panel.getByRole('switch', { name: '启用 bundle' }) + await toggle.waitFor({ timeout: 20_000 }) + const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row') + expect(mounted()?.fiber?.state).toBeUndefined() + + await toggle.click() + + const bundles = async () => (JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as { + dsh: { profile: { bundles: string[] } } + }).dsh.profile.bundles + await expect.poll(bundles, { timeout: 10_000 }).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@fixture/bundle']) + // A live profile: the row mounts once the whole tree recomposed, the switch is on, and nothing waits for a restart. + await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).toBe(2) + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('true') + expect(await panel.getByText(/下次启动生效/).count()).toBe(0) + const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) + await compareOrRefreshGolden(LIVE_EXPECTED, snapshot, MODE) + // The pack's page lists its rows as the Host runs them, each with a switch that writes the profile patch. + await panel.getByRole('button', { name: '查看 bundle' }).click() + const rowSwitch = panel.getByRole('switch', { name: '启用组件 fixture-row' }) + await rowSwitch.waitFor({ timeout: 10_000 }) + expect(await rowSwitch.getAttribute('aria-checked')).toBe('true') + await rowSwitch.click() + await expect.poll(async () => (await homeFile('profiles', 'scaffold', 'cordis.patch.yml')).includes('fixture-row'), { timeout: 10_000 }).toBe(true) + await expect.poll(() => rowSwitch.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('false') + // A disabled entry keeps its disposed fiber; only an active one counts as mounted. + await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).not.toBe(2) + await rowSwitch.click() + await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).toBe(2) + await panel.getByRole('button', { name: '返回插件列表' }).click() + + await toggle.click() + await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).not.toBe(2) + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md', 'live-enabled.expected.md']) + }) +}) + + +describe('web e2e: startup-applied plugin management', () => { + it('saves a bundle selection that waits for the next start and keeps its rows read-only', async () => { + const scaffold = await launchWebScaffold({ + profile: { hmr: false, packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] }, + }) + let browser: Browser | undefined + try { + browser = await chromium.launch() + const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const tripwire = watchConsole(page) + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-live')) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + const panel = page.locator('[data-plugin-panel]') + const toggle = panel.getByRole('switch', { name: '启用 bundle' }) + await toggle.waitFor({ timeout: 20_000 }) + const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row') + const bundles = async () => { + const text = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'package.json'), 'utf8') + return (JSON.parse(text) as { dsh: { profile: { bundles: string[] } } }).dsh.profile.bundles + } + expect(mounted()?.fiber?.state).toBeUndefined() + await toggle.click() + // The selection is saved and the switch turns on, but nothing mounts before the next start; a toast says so. + await expect.poll(bundles).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@fixture/bundle']) + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + await page.getByText('更改将在下次启动生效', { exact: true }).waitFor({ timeout: 10_000 }) + expect(mounted()?.fiber?.state).toBeUndefined() + // The pack's page lists its rows from their declarations, with no live entry to switch. + await panel.getByRole('button', { name: '查看 bundle' }).click() + await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 }) + expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).isDisabled()).toBe(true) + await panel.getByRole('button', { name: '返回插件列表' }).click() + + await toggle.click() + await expect.poll(bundles).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + expect(tripwire.pageErrors).toEqual([]) + } finally { + await browser?.close() + await scaffold.close() + } + }, 60_000) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index ea204ab0c9..7e3d6276df 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -56,16 +56,7 @@ import { writesCurrentSessionFixtures, type NormalizeContext, } from '@deepseek-ai/dsh-session-snapshot' -import { - auditStartupEntries, - composeEntries, - createProfileResolutionGeneration, - healProfilesModuleFallback, - loadOverlayPatches, - PluginPackages, - type Profile, - type ProfileResolutionMode, -} from '@deepseek-ai/dsh-app-boot' +import type { Profile, ProfileContext, ProfileResolutionMode } from '@deepseek-ai/dsh-app-boot' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { @@ -91,7 +82,22 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' import { provideCmdline } from '@deepseek-ai/dsh-cmdline' -import { REPO_ROOT, requireDist } from './support.ts' +import { REPO_ROOT, requireBuilt, requireDist } from './support.ts' + +type AppBoot = typeof import('@deepseek-ai/dsh-app-boot') +let builtAppBoot: AppBoot | undefined + +/** + * The launcher's own module, as built: the manager and HMR plugins the profile + * loads reload the tree through this copy's registry of the root Include, so + * the scaffold mounts through the same copy rather than the source import. + * Resolved on the first launch, which needs the build anyway, so the fixture + * helpers this module also exports load without one. + */ +function appBoot(): AppBoot { + builtAppBoot ??= requireBuilt('@deepseek-ai/dsh-app-boot') as AppBoot + return builtAppBoot +} // Host-side web e2e cannot import a browser package: doing so would pull that // package's complete TS project into this graph. Mirrored from @@ -308,6 +314,21 @@ export interface LaunchOptions { * profile layers named by {@link extraOverlayPath}. */ extraInstallAnchors?: string[] + /** + * Manage the scaffold profile the way the launcher does: a `profileContext` + * over the profile directory, whose manifest lists the shipped web bundles + * and each package directory here as an installed dependency (`file:` in the + * manifest, a symlink under the profile's `node_modules`); `enabled` also + * lists a bundle in `dsh.profile.bundles`. The plugin manager mounts on such + * a profile, and the root Include is mounted from the profile's own layers. + * The base bundle's `hmr` row turns on with the profile context, so + * configuration changes apply live; `hmr: false` disables that row through + * an overlay, leaving changes for the next start. + */ + profile?: { + hmr?: boolean + packages: { dir: string; enabled?: boolean }[] + } /** * Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row * in replay/refresh modes; ignored in record mode (the real adapter @@ -432,6 +453,10 @@ async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persiste */ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { requireDist() + const { + auditStartupEntries, composeEntries, createProfileResolutionGeneration, healProfilesModuleFallback, initProfile, + mountRootInclude, readProfileManifest, readProfilePatches, loadOverlayPatches, PluginPackages, + } = appBoot() const mode = webSnapshotMode() const replayFixture = options.replayFixture === undefined ? undefined @@ -523,9 +548,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise = {} + const bundles = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] + for (const entry of options.profile.packages) { + const manifest = JSON.parse(await readFile(join(entry.dir, 'package.json'), 'utf8')) as { name: string } + dependencies[manifest.name] = `file:${entry.dir}` + if (entry.enabled === true) bundles.push(manifest.name) + const link = join(profileDir, 'node_modules', manifest.name) + await mkdir(dirname(link), { recursive: true }) + await symlink(entry.dir, link, 'junction') + } + initProfile(profileDir, bundles) + const manifest = readProfileManifest('dsh', profileDir) + manifest.dependencies = dependencies + await writeFile(join(profileDir, 'package.json'), JSON.stringify(manifest, null, 2) + '\n') + profileContext = { + name: 'scaffold', dir: profileDir, patchPath: profile.patchPath, installAnchor: INSTALL_ANCHOR, + cwd: workspaceCwd, home: harnessHome, startedBundles: bundles, + overlays: overlayPatches, telemetryDisabledEnv: undefined, + } + // HMR gates file-driven reloads on application readiness, which the + // launcher commits after boot; this direct harness is ready at once. + ctx.provide('appReady', { onReady: (listener) => { listener(); return () => {} } }) + ctx.provide('profileContext', profileContext) + } // This direct Loader harness supplies the same root-path capability as app-boot. ctx.provide('dshHomePath', dshHomePath) // A host with no command line still provides one: the web bundle's startup @@ -722,16 +777,23 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { await dialog.getByRole('button', { name: '模型' }).click() await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true') expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull() - // Plugins is a read-only projection of the same assembled Loader tree. - // Capture one stable shipped row rather than the whole inventory so adding - // an unrelated plugin does not rewrite this surface's golden. + // Plugins: the configuration tab beside the read-only Plugin list, a + // projection of the same assembled Loader tree; management lives on the + // sidebar's Plugins panel (its own scenario file drives that page over a + // profile runtime). Capture one stable shipped row rather than the whole + // inventory so adding an unrelated plugin does not rewrite this surface's golden. await dialog.getByRole('button', { name: '插件', exact: true }).click() await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 }) await dialog.getByRole('tab', { name: '插件列表', exact: true }).click() - // The preset group opens first with its display-only switcher; the global - // plane starts collapsed and expands on demand. + // Both groups start collapsed; the preset group's header still carries its display-only switcher. const presetSwitcher = dialog.getByRole('button', { name: '选择要查看的 Agent 预设' }) await presetSwitcher.waitFor({ timeout: 10_000 }) // The shipped default's zh display name comes from the zh dictionaries. expect(await presetSwitcher.textContent()).toBe('标准模式(默认)') + const presetToggle = dialog.getByRole('button', { name: '会话插件', exact: true }) + expect(await presetToggle.getAttribute('aria-expanded')).toBe('false') + expect(await dialog.locator('[data-plugin-scope="preset"] [data-plugin-entry]').count()).toBe(0) + await presetToggle.click() await dialog.getByRole('button', { name: /^全局/ }).click() const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR) await pluginRow.waitFor({ timeout: 10_000 }) @@ -656,6 +661,13 @@ describe('web e2e: settings modal and General preferences', () => { const presetSwitcher = dialog.getByRole('button', { name: 'Choose the agent preset to inspect' }) await presetSwitcher.waitFor({ timeout: 10_000 }) expect(await presetSwitcher.textContent()).toBe('Standard mode (default)') + // The plugin manager speaks the en dictionary too: its sidebar entry + // and the unavailable notice a scaffold without a profile runtime shows. + await enPage.keyboard.press('Escape') + await expect.poll(() => enPage.getByRole('dialog', { name: 'Settings' }).count(), { timeout: 5_000 }).toBe(0) + await enPage.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click() + await enPage.getByText('This deployment runs without a manageable profile, so plugins cannot be installed or switched here.', { exact: true }) + .waitFor({ timeout: 10_000 }) // This page has no closing inventory spec to sweep its console, so the // scenario clears both tripwire channels itself. expect(enTripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 63486fe752..fc29e7f913 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -1,6 +1,7 @@ // Shared plumbing for the web smoke tests (dist location, free port, failure shots). import { existsSync, mkdirSync } from 'node:fs' import { createServer } from 'node:net' +import { createRequire } from 'node:module' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' @@ -10,6 +11,25 @@ export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.met export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) +const installationRequire = createRequire(join(REPO_ROOT, 'apps/cli/package.json')) + +/** + * The built copy of a workspace package, as the dsh installation resolves it. + * The Host plugins a scaffold profile loads run from built packages through + * Node's own loader; a scaffold call that must share their module state + * (app-boot keeps the root Include it mounted per context) has to run that + * same copy, not the source a bare import gets through the tsconfig paths, + * and not the test runner's own inlined copy of the built file either. + * `require` of an ES module goes through Node's loader and shares its + * module map with the plugins' imports; it needs a graph without top-level + * await, which the built Host packages keep. + * @param name - the workspace package name. + * @returns the package's built module namespace, for the caller to type as the package's own. + */ +export function requireBuilt(name: string): unknown { + return installationRequire(name) +} + /** * Browser language a page must advertise to boot into the product's Chinese * surface: with no stored preference the client derives its initial locale diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1da0af9c30..3844a5d8be 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -46,6 +46,9 @@ "tests/details-session-lifecycle.e2e.ts", "tests/document-preview.e2e.ts", "tests/plugin-config.e2e.ts", + "tests/plugin-manager.e2e.ts", + "tests/plugin-install-cancel.e2e.ts", + "tests/plugin-install-approve.e2e.ts", "tests/client-plugin-live.e2e.ts", "tests/settings-chrome.e2e.ts", "tests/models-settings.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 3d86b38045..476d6b3ccd 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 311454be67fa3d8e08b5af6690622fd054db97ea -config-catalog.zh.md: 0fada1d97f0980ac6330fb7553213d72cfbfad03 +config-catalog.md: ddfae5a3ce266c0e816c3a0e9879ad1bb8c74e3b +config-catalog.zh.md: 103f1578da64234793a3c720c8841c6930cf8f84 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 311454be67..ddfae5a3ce 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1810,16 +1810,20 @@ Source: [`packages/plan/plan-mode/src/index.ts:64`](../packages/plan/plan-mode/s Requires: `loader` · `profileContext` ```ts config-catalog -/** Limits for package diagnostics and lock acquisition. */ +/** The pnpm executable and the limits for package diagnostics and registry lookups. */ export interface Config { + /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */ + pnpmCommand?: string /** Maximum retained pnpm diagnostic bytes per operation. */ outputBytes?: number /** Maximum time to wait for another process's profile package operation. */ lockWaitMs?: number + /** Bound on one registry lookup an inspection runs, in milliseconds. */ + inspectTimeoutMs?: number } ``` -Source: [`packages/boot/plugin-manager/src/index.ts:21`](../packages/boot/plugin-manager/src/index.ts) +Source: [`packages/boot/plugin-manager/src/index.ts:33`](../packages/boot/plugin-manager/src/index.ts) @@ -3677,6 +3681,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-open-in-app` ([`packages/client/ui-open-in-app/src/index.ts`](../packages/client/ui-open-in-app/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission-presets` ([`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-plugin-manager` ([`packages/client/ui-plugin-manager/src/index.ts`](../packages/client/ui-plugin-manager/src/index.ts)) - `@deepseek-ai/dsh-client-ui-reference` ([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts)) - `@deepseek-ai/dsh-client-ui-renderer` ([`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts)) - `@deepseek-ai/dsh-client-ui-schedule` ([`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 0fada1d97f..103f1578da 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -31,7 +31,7 @@ export interface AcpConfig { } ``` -依赖:`Stream`(`@agentclientprotocol/sdk`) +Depends on: `Stream` (`@agentclientprotocol/sdk`) 来源:[`packages/acp/acp/src/index.ts:75`](../packages/acp/acp/src/index.ts) @@ -111,7 +111,7 @@ export interface Config { } ``` -依赖:[`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) +Depends on: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) 来源:[`packages/core/agent-loop/src/index.ts:318`](../packages/core/agent-loop/src/index.ts) @@ -181,7 +181,7 @@ export interface Config { } ``` -依赖:[`ToolPresentationMode`](subsystems/tools.zh.md) +Depends on: [`ToolPresentationMode`](subsystems/tools.zh.md) 来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) @@ -382,7 +382,7 @@ export interface Config { export type Config = LocalConfig ``` -依赖:[`LocalConfig`](#deepseek-aidsh-bash-local) +Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) 来源:[`packages/shell/bash-sandbox/src/index.ts:36`](../packages/shell/bash-sandbox/src/index.ts) @@ -585,7 +585,7 @@ export interface Config { export type Config = BrowserMcpConfig ``` -依赖:`BrowserMcpConfig` (`@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`) +Depends on: `BrowserMcpConfig` (`@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`) 来源:[`packages/experimental/browser-use-chrome-devtools-mcp/src/index.ts:14`](../packages/experimental/browser-use-chrome-devtools-mcp/src/index.ts) @@ -600,7 +600,7 @@ export type Config = BrowserMcpConfig export type Config = BrowserMcpConfig ``` -依赖:`BrowserMcpConfig` (`@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`) +Depends on: `BrowserMcpConfig` (`@deepseek-ai/dsh-experimental-browser-use-runtime/mcp`) 来源:[`packages/experimental/browser-use-playwright-mcp/src/index.ts:15`](../packages/experimental/browser-use-playwright-mcp/src/index.ts) @@ -642,7 +642,7 @@ export interface StagehandModelConfig { } ``` -依赖:`ModelConfig` (`@browserbasehq/stagehand`) +Depends on: `ModelConfig` (`@browserbasehq/stagehand`) 来源:[`packages/experimental/browser-use-stagehand-native/src/index.ts:28`](../packages/experimental/browser-use-stagehand-native/src/index.ts) @@ -666,7 +666,7 @@ export interface Config { } ``` -依赖:[`McpClient`](../packages/mcp/mcp-client/src/index.ts) +Depends on: [`McpClient`](../packages/mcp/mcp-client/src/index.ts) 来源:[`packages/experimental/computer-use-cua-driver-mcp/src/index.ts:20`](../packages/experimental/computer-use-cua-driver-mcp/src/index.ts) @@ -877,7 +877,7 @@ export interface Config { export type Config = LocalConfig ``` -依赖:[`LocalConfig`](#deepseek-aidsh-fs-local) +Depends on: [`LocalConfig`](#deepseek-aidsh-fs-local) 来源:[`packages/fs/fs-sandbox/src/index.ts:45`](../packages/fs/fs-sandbox/src/index.ts) @@ -935,7 +935,7 @@ export interface HmrConfig extends ChokidarOptions { } ``` -依赖: `ChokidarOptions` (`chokidar`) +Depends on: `ChokidarOptions` (`chokidar`) 来源: [`packages/boot/hmr/src/index.ts:51`](../packages/boot/hmr/src/index.ts) @@ -1214,7 +1214,7 @@ export interface DeepSeekCatalogModel { } ``` -依赖: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) +Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) 来源:[`packages/llm/llm-deepseek/src/config.ts:25`](../packages/llm/llm-deepseek/src/config.ts) @@ -1489,7 +1489,7 @@ export type PiAiThinkingFormat = NonNullable ``` -依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`) +Depends on: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) 来源:[`packages/llm/llm-pi-ai/src/config.ts:221`](../packages/llm/llm-pi-ai/src/config.ts) @@ -1568,7 +1568,7 @@ export interface ReplayModelConfig { } ``` -依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) +Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) 来源:[`packages/test-support/llm-replay/src/index.ts:1122`](../packages/test-support/llm-replay/src/index.ts) @@ -1756,7 +1756,7 @@ export interface PresetSpec { } ``` -依赖:[`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md) +Depends on: [`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md) 来源:[`packages/interaction/permission-presets/src/index.ts:156`](../packages/interaction/permission-presets/src/index.ts) @@ -1812,16 +1812,20 @@ export interface PlanModeConfig { 依赖: `loader` · `profileContext` ```ts config-catalog -/** Limits for package diagnostics and lock acquisition. */ +/** The pnpm executable and the limits for package diagnostics and registry lookups. */ export interface Config { + /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */ + pnpmCommand?: string /** Maximum retained pnpm diagnostic bytes per operation. */ outputBytes?: number /** Maximum time to wait for another process's profile package operation. */ lockWaitMs?: number + /** Bound on one registry lookup an inspection runs, in milliseconds. */ + inspectTimeoutMs?: number } ``` -来源: [`packages/boot/plugin-manager/src/index.ts:19`](../packages/boot/plugin-manager/src/index.ts) +来源: [`packages/boot/plugin-manager/src/index.ts:33`](../packages/boot/plugin-manager/src/index.ts) @@ -1926,7 +1930,7 @@ export interface Config { export type Config = LocalConfig ``` -依赖:[`LocalConfig`](#deepseek-aidsh-pwsh-local) +Depends on: [`LocalConfig`](#deepseek-aidsh-pwsh-local) 来源:[`packages/shell/pwsh-sandbox/src/index.ts:40`](../packages/shell/pwsh-sandbox/src/index.ts) @@ -2021,7 +2025,7 @@ export interface Config { } ``` -依赖:[`SandboxMode`](subsystems/sandbox.zh.md) +Depends on: [`SandboxMode`](subsystems/sandbox.zh.md) 来源:[`packages/sandbox/sandbox-policy/src/index.ts:71`](../packages/sandbox/sandbox-policy/src/index.ts) @@ -2061,7 +2065,7 @@ export interface JsonRpcConfig { } ``` -依赖:`Readable`(`node:stream`)· `Writable`(`node:stream`) +Depends on: `Readable` (`node:stream`) · `Writable` (`node:stream`) 来源:[`packages/sdk/server/src/index.ts:25`](../packages/sdk/server/src/index.ts) @@ -2193,7 +2197,7 @@ export type OpenAt = 'startup' | 'first-search' | 'never' export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -依赖:[`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) +Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) 来源:[`packages/session-query/session-query-sqlite/src/index.ts:92`](../packages/session-query/session-query-sqlite/src/index.ts) @@ -2260,7 +2264,7 @@ export enum SessionTelemetryMode { } ``` -依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterNodeConfigBase`(`@opentelemetry/otlp-exporter-base`) +Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) 来源:[`packages/session/session-telemetry-otel/src/index.ts:100`](../packages/session/session-telemetry-otel/src/index.ts) @@ -2295,7 +2299,7 @@ export interface Config { export type Config = SessionTitleLlmConfig ``` -依赖:[`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts) +Depends on: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts) 来源:[`packages/session/session-title-all-prompts-llm/src/index.ts:15`](../packages/session/session-title-all-prompts-llm/src/index.ts) @@ -2310,7 +2314,7 @@ export type Config = SessionTitleLlmConfig export type Config = SessionTitleLlmConfig ``` -依赖:[`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts) +Depends on: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts) 来源:[`packages/session/session-title-first-prompt-llm/src/index.ts:15`](../packages/session/session-title-first-prompt-llm/src/index.ts) @@ -3278,7 +3282,7 @@ export interface Config { } ``` -依赖:[`AgentOptions`](subsystems/core.zh.md) +Depends on: [`AgentOptions`](subsystems/core.zh.md) 来源:[`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) @@ -3679,6 +3683,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-open-in-app`([`packages/client/ui-open-in-app/src/index.ts`](../packages/client/ui-open-in-app/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission-presets`([`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-plugin-manager`([`packages/client/ui-plugin-manager/src/index.ts`](../packages/client/ui-plugin-manager/src/index.ts)) - `@deepseek-ai/dsh-client-ui-reference`([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts)) - `@deepseek-ai/dsh-client-ui-renderer`([`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts)) - `@deepseek-ai/dsh-client-ui-schedule`([`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index b99989f33c..060150d5cc 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 97c8c785d71aa6c8da694d157e8562c6f3057f2f -event-producer-consumer.zh.md: 5e6521856eb74c26cb01eb265b3ac9fc473fc594 +event-producer-consumer.md: ad9d4e00cb7b86ad2a080b7b602e035d4854b343 +event-producer-consumer.zh.md: 69ca8071bd4574fee9a50799b25c5771cf6644e8 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 97c8c785d7..ad9d4e00cb 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -50,6 +50,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../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:188`](../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:194`](../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:200`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 5e6521856e..69ca8071bd 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -52,6 +52,9 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../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:188`](../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:194`](../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:200`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 191ea8b1ff..21ea52cffa 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 9c52f02cc990f0be4d317f1740da0a4625ac11a0 -module-graph.zh.md: 7822bc5a576a24c41f33da055f57d948d9c8ed72 +module-graph.md: 719273c14784fcf5dd98b2fddabfb7666c666139 +module-graph.zh.md: b1098cc39fdc1d44f9d7edc3555d18a2f2c26bc5 diff --git a/docs/module-graph.md b/docs/module-graph.md index 9c52f02cc9..719273c147 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -169,6 +169,7 @@ flowchart TD pkg_client_ui_open_in_app["client-ui-open-in-app"] pkg_client_ui_permission_presets["client-ui-permission-presets"] pkg_client_ui_plan["client-ui-plan"] + pkg_client_ui_plugin_manager["client-ui-plugin-manager"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] @@ -1085,6 +1086,7 @@ flowchart TD pkg_acp --> pkg_session_persistence pkg_acp --> pkg_token_meter pkg_acp --> pkg_user_approval + pkg_plugin_manager --> pkg_brand pkg_plugin_manager --> pkg_hmr pkg_plugin_manager --> pkg_host_plugin_inventory pkg_plugin_manager --> pkg_subprocess @@ -1334,6 +1336,7 @@ flowchart TD | [`client-ui-open-in-app`](../packages/client/ui-open-in-app) | `client` | — | | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | — | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | — | +| [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager) | `client` | — | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | — | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | — | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | — | @@ -1543,7 +1546,7 @@ flowchart TD | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | -| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 7822bc5a57..b1098cc39f 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -171,6 +171,7 @@ flowchart TD pkg_client_ui_open_in_app["client-ui-open-in-app"] pkg_client_ui_permission_presets["client-ui-permission-presets"] pkg_client_ui_plan["client-ui-plan"] + pkg_client_ui_plugin_manager["client-ui-plugin-manager"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] @@ -1087,6 +1088,7 @@ flowchart TD pkg_acp --> pkg_session_persistence pkg_acp --> pkg_token_meter pkg_acp --> pkg_user_approval + pkg_plugin_manager --> pkg_brand pkg_plugin_manager --> pkg_hmr pkg_plugin_manager --> pkg_host_plugin_inventory pkg_plugin_manager --> pkg_subprocess @@ -1336,6 +1338,7 @@ flowchart TD | [`client-ui-open-in-app`](../packages/client/ui-open-in-app) | `client` | — | | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | — | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | — | +| [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager) | `client` | — | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | — | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | — | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | — | @@ -1545,7 +1548,7 @@ flowchart TD | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | -| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index a25900ac75..5835948324 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/boot.md -boot.md: 18f06789f450b0694329feb1fb8b7a2df7a9905a -boot.zh.md: afe4c6f79c3cbe2d8ef8570e512fa4669c10b1ff +boot.md: 6bceb5deb087d49dff6958d51549c7942d0e224a +boot.zh.md: c3addc59008e9f7e5812f5f2eefbc751cd731fba diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 18f06789f4..6bceb5deb0 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -65,11 +65,20 @@ Manage profile files and apply their declared reload lifecycle. */ @Remote async listPlugins(): Promise -/** Read installed bundles and bundles supplied by this dsh installation. - * @returns Package versions, activation selections and removal availability. +/** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. + * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected. + * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the + * bundle, and removal availability. */ @Remote listBundles(): Promise +/** Read what a spec names before installing it. + * @param spec One package spec: a registry name, an absolute path, a git address, or a tarball. + * @param signal Ends a registry lookup early. + * @returns The package the spec names, or why it is refused. + */ +@Remote async inspect(spec: string, signal?: AbortSignal): Promise + /** 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. @@ -84,13 +93,24 @@ Manage profile files and apply their declared reload lifecycle. */ @Remote setBundleEnabled(name: string, enabled: boolean): Promise -/** Install a package using the same pnpm implementation as dsh plugin. +/** + * Install a package using the same pnpm implementation as dsh plugin. A run + * that fails, is cancelled, or adds a package without a bundle patch restores + * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay. * @param spec One package spec, including local paths relative to the invocation directory. - * @param options Activation defaults to true; explicit build approvals persist for this profile before installation. + * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and + * the pending build scripts to allow for this profile before pnpm runs. * @returns Package-manager diagnostics and observed activation outcome. */ @Remote installBundle(spec: string, options?: InstallBundleOptions): Promise +/** Stop an installation this manager owns and wait until its files are back. + * @param requestId The id the installation was started with. + * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being + * applied, `not-running` for any other id. + */ +@Remote async cancelInstall(requestId: PluginInstallRequestId): Promise + /** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path. * @param name Installed dependency name. * @returns Removal diagnostics and the remaining profile state. @@ -143,4 +163,61 @@ Module replacements have finished loading. ``` Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts) + + + +### `plugin-manager/*` events + + + +#### `plugin-manager/changed` — emit + +The profile's plugins, bundles, or composition changed: a manager operation completed. A patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing here. + +```ts cordis-catalog +/** + * The profile's plugins, bundles, or composition changed: a manager + * operation completed. A patch generation applied outside the manager, + * by HMR's watcher after a CLI or hand edit, announces nothing here. + * @mode emit + * @param change - what changed. + */ +'plugin-manager/changed'(change: PluginChange): void +``` + +Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts) + + + +#### `plugin-manager/install-log` — emit + +One chunk of a pnpm run's output, streamed as the run produces it. + +```ts cordis-catalog +/** + * One chunk of a pnpm run's output, streamed as the run produces it. + * @mode emit + * @param chunk - the chunk and the run it belongs to. + */ +'plugin-manager/install-log'(chunk: PluginInstallLogChunk): void +``` + +Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts) + + + +#### `plugin-manager/install-state` — emit + +An installation moved between its Host phases. + +```ts cordis-catalog +/** + * An installation moved between its Host phases. + * @mode emit + * @param progress - the installation's request id and phase. + */ +'plugin-manager/install-state'(progress: PluginInstallProgress): void +``` + +Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts) diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index afe4c6f79c..c3addc5900 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -65,11 +65,20 @@ Manage profile files and apply their declared reload lifecycle. */ @Remote async listPlugins(): Promise -/** Read installed bundles and bundles supplied by this dsh installation. - * @returns Package versions, activation selections and removal availability. +/** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. + * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected. + * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the + * bundle, and removal availability. */ @Remote listBundles(): Promise +/** Read what a spec names before installing it. + * @param spec One package spec: a registry name, an absolute path, a git address, or a tarball. + * @param signal Ends a registry lookup early. + * @returns The package the spec names, or why it is refused. + */ +@Remote async inspect(spec: string, signal?: AbortSignal): Promise + /** 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. @@ -84,13 +93,24 @@ Manage profile files and apply their declared reload lifecycle. */ @Remote setBundleEnabled(name: string, enabled: boolean): Promise -/** Install a package using the same pnpm implementation as dsh plugin. +/** + * Install a package using the same pnpm implementation as dsh plugin. A run + * that fails, is cancelled, or adds a package without a bundle patch restores + * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay. * @param spec One package spec, including local paths relative to the invocation directory. - * @param options Activation defaults to true; explicit build approvals persist for this profile before installation. + * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and + * the pending build scripts to allow for this profile before pnpm runs. * @returns Package-manager diagnostics and observed activation outcome. */ @Remote installBundle(spec: string, options?: InstallBundleOptions): Promise +/** Stop an installation this manager owns and wait until its files are back. + * @param requestId The id the installation was started with. + * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being + * applied, `not-running` for any other id. + */ +@Remote async cancelInstall(requestId: PluginInstallRequestId): Promise + /** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path. * @param name Installed dependency name. * @returns Removal diagnostics and the remaining profile state. @@ -143,4 +163,61 @@ Module replacements have finished loading. ``` Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts) + + + +### `plugin-manager/*` events + + + +#### `plugin-manager/changed` — emit + +The profile's plugins, bundles, or composition changed: a manager operation completed. A patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing here. + +```ts cordis-catalog +/** + * The profile's plugins, bundles, or composition changed: a manager + * operation completed. A patch generation applied outside the manager, + * by HMR's watcher after a CLI or hand edit, announces nothing here. + * @mode emit + * @param change - what changed. + */ +'plugin-manager/changed'(change: PluginChange): void +``` + +Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts) + + + +#### `plugin-manager/install-log` — emit + +One chunk of a pnpm run's output, streamed as the run produces it. + +```ts cordis-catalog +/** + * One chunk of a pnpm run's output, streamed as the run produces it. + * @mode emit + * @param chunk - the chunk and the run it belongs to. + */ +'plugin-manager/install-log'(chunk: PluginInstallLogChunk): void +``` + +Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts) + + + +#### `plugin-manager/install-state` — emit + +An installation moved between its Host phases. + +```ts cordis-catalog +/** + * An installation moved between its Host phases. + * @mode emit + * @param progress - the installation's request id and phase. + */ +'plugin-manager/install-state'(progress: PluginInstallProgress): void +``` + +Source: [`packages/boot/plugin-manager/src/types.ts`](../../packages/boot/plugin-manager/src/types.ts) diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 015a8f2667..d4b8bd1830 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -22,7 +22,11 @@ import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' -export type { PluginInfo, BundleInfo, ChangeResult, InstallBundleOptions, PluginEntryId } from '@deepseek-ai/dsh-plugin-manager/types' +export type { + BundleInfo, BundleRowInfo, ChangeResult, InstallBundleOptions, InstallSpecKind, ManagementError, PackageResult, PluginChange, + PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallFailureKind, PluginInstallLogChunk, + PluginInstallProgress, PluginInstallRequestId, PluginSpecInspection, ReadOnlyReason, +} from '@deepseek-ai/dsh-plugin-manager/types' export type {} from '@deepseek-ai/dsh-plugin-manager/remote' export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type {} from '@deepseek-ai/dsh-agent-presets/remote' diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index 54bfc4e086..115cbff692 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -8,6 +8,7 @@ import type {} from '@deepseek-ai/dsh-api-session-controller/remote-events' import type {} from '@deepseek-ai/dsh-permission-presets/types' +import type {} from '@deepseek-ai/dsh-plugin-manager/types' import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol' /** @@ -33,6 +34,9 @@ export const API_REMOTE_FORWARDED_EVENTS = [ { event: 'cordis/inspect-query-resolved', mode: 'emit' }, { event: 'llm/adapters-updated', mode: 'emit' }, { event: 'permission-presets/catalog-changed', mode: 'emit' }, + { event: 'plugin-manager/changed', mode: 'emit' }, + { event: 'plugin-manager/install-log', mode: 'emit' }, + { event: 'plugin-manager/install-state', mode: 'emit' }, { event: 'settings/document-updated', mode: 'emit' }, { event: 'user-questions/request', mode: 'waterfall' }, ] as const satisfies readonly TypertForwardableEventEntry[] diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 66799352d2..958a70d828 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -73,6 +73,12 @@ }, { "path": "../terminal-controller/tsconfig.host.json" + }, + { + "path": "../../boot/plugin-manager" + }, + { + "path": "../../host/plugin-inventory" } ] } diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 7f9ee62293..482d7521af 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -36,6 +36,7 @@ export { composeEntries, createProfileResolutionGeneration, DEFAULT_PROFILE_BUNDLES, + OPTIONAL_BUNDLES, healProfilesModuleFallback, healIsolatedProfileModuleFallback, unlinkProfileModuleFallback, diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index adcba7362a..4a7133ab3e 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -158,6 +158,18 @@ const INSTALLATION_OWNED_PROFILE_TUPLES: Record = { /** The bundle list a `dsh plugin` init uses for a name with no shipped template. */ export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base'] +/** + * The bundles the dsh installation ships for a person to switch on: each a + * runtime dependency of the installation that declares `dsh.bundle.patch`, + * selected by no shipped template, and offered switched off by the plugin + * manager ([rationale](../../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). + */ +export const OPTIONAL_BUNDLES: readonly string[] = [ + '@deepseek-ai/dsh-experimental-agent-team-profile', + '@deepseek-ai/dsh-experimental-agent-team-web-profile', + '@deepseek-ai/dsh-experimental-auto-review', +] + const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer: # a top-level YAML array of loader patch entries (id-targeted config # overrides, disables, and insert lists; \`!!js\` expressions allowed). diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index e6598ae227..01fba3229d 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -335,6 +335,9 @@ describe('profile reconciliation settlement', () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) await expect(reconcileProfilePatches(ctx, [], NAME)).rejects.toThrow('profile reload requires the root Include entry') + // A Loader without the pinned root id is no better. + await ctx.plugin(Loader) + await expect(reconcileProfilePatches(ctx, [], NAME)).rejects.toThrow('profile reload requires the root Include entry') }) it('removes a previously failed entry without reporting its old activation error', async () => { diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 6163f01774..14c37ad51e 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md -README.md: cce53d2d0f981128f8749e70c1442abc1ee1cc8c -README.zh.md: f42a329342ff8dbd7404f236802a9a948d9fc45b +README.md: 66408c85273e57d78d94c98a3e8cdcefab1286fb +README.zh.md: 2b10860867b9ff992a96222b24432ebb99025015 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index cce53d2d0f..66408c8527 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -1,5 +1,5 @@ --- -description: "Enable profile plugins and install, remove or select bundles from the Web settings page or an agent." +description: "Enable profile plugins and install, remove or select bundles from the Web sidebar or an agent." kind: "package-reference" --- @@ -26,7 +26,7 @@ Manage the current profile's plugins without editing configuration by hand. Enab ## Use this package -Base-backed profiles provide the manager. In Web Settings, open Plugins and select Plugin list to manage bundles and uniquely addressable global plugin entries. Agent-preset rows remain read-only. The `plugin_manager` tool exposes the same operations and is disabled by default. +Base-backed profiles provide the manager. In Web, the sidebar's **Plugins** page ([ui-plugin-manager](../../client/ui-plugin-manager/README.md)) manages the profile's bundles and their uniquely addressable rows; the Settings Plugin list stays read-only. Agent-preset rows remain read-only. The `plugin_manager` tool exposes the same operations and is disabled by default. Enable the tool explicitly in the profile patch; agents using a preset also need its `tool-plugin-manager` entry enabled. @@ -37,12 +37,18 @@ Enable the tool explicitly in the profile patch; agents using a preset also need A plugin toggle updates only `disabled` in the last matching override in the profile's `cordis.patch.yml`, or appends an override when none matches. Matching uses the entry id and any module-name assertion. A bundle toggle changes `package.json`'s ordered `dsh.profile.bundles` list. Disabling retains the dependency; enabling appends the bundle at the end, which can change configuration precedence. Installation enables a new bundle by default. Home and invocation patches retain their higher priority. -When pnpm 11 blocks dependency scripts, the failed installation reports all pending package names in the profile, including earlier attempts. Web offers **Allow these scripts and retry**; the tool can grant permission on the user's behalf through `approvedBuilds` on `install_bundle`, after the user approves those scripts in the conversation. The service validates pending names; it does not verify conversation approval. Approval persists by package name in this profile, permits commands with the host user's permissions, and survives another installation failure. Only currently undecided names can be approved; existing denials and wildcard rules cannot be overridden through this action. Approval rejects YAML anchors or aliases inside `allowBuilds`. Installation cleanup retains these decisions. Retry preserves the original activation choice. +`inspect(spec)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same registry and proxy settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form. The answer carries the name, version, description, and whether the package declares a bundle, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`. A caller's `signal` or `inspectTimeoutMs` ends the lookup. + +`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`. `cancelInstall(requestId)` stops the run and answers `cancelled` only after pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies a failed run from its exit and output, and `bundle` names the package a finished run added. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read. + +When pnpm 11 blocks dependency scripts, the failed installation reports every pending package name in the profile under `pendingBuilds`, including names left by earlier attempts; a failed run restores `package.json` and `pnpm-lock.yaml` but deliberately not `pnpm-workspace.yaml`, where pnpm records them. The Web plugin page offers **Allow these scripts and retry**; the tool can grant permission on the user's behalf through `approvedBuilds` on `install_bundle`, after the user approves those scripts in the conversation. The service validates pending names; it does not verify conversation approval. Approval persists by package name in this profile, permits commands with the host user's permissions, and survives another installation failure. Only currently undecided names can be approved; existing denials and wildcard rules cannot be overridden through this action. Approval rejects YAML anchors or aliases inside `allowBuilds`. Retry preserves the original activation choice. ### Configuration | Field | Default | Meaning | |---|---|---| +| `pnpmCommand` | `pnpm` | The pnpm executable name or path, resolved through `PATH` like the `dsh plugin` command. | +| `inspectTimeoutMs` | `20000` | Bound on one registry lookup an inspection runs, in milliseconds. | | `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. | @@ -56,7 +62,7 @@ When pnpm 11 blocks dependency scripts, the failed installation reports all pend The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. Dependency-only changes do not trigger configuration reloads. -Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output. No invariant companion is published because the manager reads files and Loader state directly and owns no independent state projection. +Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. A failed or cancelled installation restores the manifest and lockfile it snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); a failed removal retains its partial changes and diagnostics. Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output. No invariant companion is published because the manager reads files and Loader state directly and owns no independent state projection. @@ -67,7 +73,8 @@ Results contain the last attempted stage, target, saved-state change, applicatio - [App boot](../app-boot/README.md) — profile layers and startup policy. - [Plugin inventory](../../host/plugin-inventory/README.md) — current Loader and preset observations. -- [Plugin settings](../../client/ui-settings-plugin-inventory/README.md) — Web controls. +- [Plugin manager page](../../client/ui-plugin-manager/README.md) — the Web sidebar page over this service. +- [Plugin settings](../../client/ui-settings-plugin-inventory/README.md) — the read-only Web inventory. ## Model Experience @@ -94,8 +101,8 @@ Tool results append to the transcript. Enabling or disabling other tools can cha - Package replacements require restarting the process to load a fresh JavaScript module generation. - 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. -- Package failures may leave dependencies partially changed. Inactive dependencies with missing files remain removable. Diagnostic logs remain under the profile's `.plugin-manager/logs` directory. -- Management results describe Host activation. Browser synchronization failures appear separately in the plugin list. +- A failed removal may leave dependencies partially changed, and a failed or cancelled installation can leave downloaded files under `node_modules` or the pnpm store. Inactive dependencies with missing files remain removable. Diagnostic logs remain under the profile's `.plugin-manager/logs` directory. +- Management results describe Host activation. Browser synchronization failures appear separately in the Settings plugin list. - Desktop package operations remain owned by the Desktop shell. @@ -105,13 +112,13 @@ Failures preserve completed steps and report the actual remaining state. Profile | Failed operation | Handling | |---|---| -| Install: pnpm or bundle validation fails | Attempt to remove only dependencies newly added by this operation and identified unambiguously. Do not restore existing packages. Report installation failure. | +| Install: pnpm or bundle validation fails | Restore `package.json` and `pnpm-lock.yaml` as snapshotted before pnpm ran; files pnpm downloaded may remain. Report installation failure. | | Enable: saving selection or loading fails | Keep the installed dependency and any saved selection. Report enablement failure; allow repair, disablement or removal. | | Remove: any step fails | Stop at the failed step. Preserve completed changes, retain remaining dependencies for retry, and report removal failure. Do not re-enable the bundle. | Installation finishes after pnpm and bundle validation succeed; subsequent enablement failure does not undo installation. Removal proceeds in order: remove the bundle from `dsh.profile.bundles`, unload its runtime contributions, then run `pnpm remove`. A failed step prevents subsequent steps. -Installation cleanup is attempted once. If it fails or the new dependency cannot be identified, preserve the actual state and report the original failure together with cleanup diagnostics and remaining dependencies. Do not recursively undo cleanup. Cleanup covers dependencies and bundle selection only; user-authored patch configuration, application data and diagnostic logs remain untouched. +Restoration rewrites only the two snapshotted files; user-authored patch configuration, application data, diagnostic logs and files pnpm downloaded remain untouched, and the next package operation prunes packages no manifest references. ### Dev Note diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index f42a329342..2b10860867 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -1,5 +1,5 @@ --- -description: "通过 Web 设置页或 agent 启停 profile 插件,并安装、删除或选择组合包。" +description: "通过 Web 侧边栏或 agent 启停 profile 插件,并安装、删除或选择组合包。" kind: "package-reference" --- @@ -26,7 +26,7 @@ kind: "package-reference" ## 使用本包 -基于 base 的 profile 提供管理服务。在 Web 设置中打开插件并选择插件列表,即可管理组合包和能唯一定位的全局插件条目。Agent 预设条目保持只读。`plugin_manager` 工具提供相同操作,默认禁用。 +基于 base 的 profile 提供管理服务。在 Web 中,侧边栏的**插件**页([ui-plugin-manager](../../client/ui-plugin-manager/README.zh.md))管理 profile 的组合包及其能唯一定位的行;设置页的插件列表保持只读。Agent 预设条目保持只读。`plugin_manager` 工具提供相同操作,默认禁用。 在 profile patch 中显式启用工具;使用预设的 Agent 还需要启用该预设中的 `tool-plugin-manager` 条目。 @@ -37,12 +37,18 @@ kind: "package-reference" 插件开关只更新 profile 的 `cordis.patch.yml` 中最后一条匹配覆盖项的 `disabled`;没有匹配项时追加。匹配依据是条目 id,以及覆盖项声明的模块名称。组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表。关闭保留依赖;开启追加到列表末尾,可能改变配置优先级。安装新组合包默认启用。home 和单次启动 patch 保留更高优先级。 -pnpm 11 阻止依赖脚本时,安装失败结果列出 profile 内所有待审批包名,包括此前安装留下的条目。Web 提供“允许这些脚本并重试”;工具可以在用户于对话中批准这些脚本后,通过 `install_bundle` 的 `approvedBuilds` 代表用户授权。service 验证包名仍在待审批列表中,不验证对话审批。授权按包名保存在当前 profile,允许脚本以宿主用户权限执行命令,并在再次安装失败后保留。该操作只能批准当前尚未决定的包名,不能覆盖已有拒绝或通配符规则。审批拒绝 `allowBuilds` 内的 YAML 锚点或别名。安装清理保留这些决定,重试沿用原来的启用选项。 +`inspect(spec)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的注册表与代理设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式。答复携带名称、版本、描述以及该包是否声明组合包,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。 + +`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`。`cancelInstall(requestId)` 停止运行,只在 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对失败运行分类,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。 + +pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 profile 中所有待决定的包名,包括先前尝试留下的;失败的运行会恢复 `package.json` 与 `pnpm-lock.yaml`,但有意不恢复 pnpm 记录这些名字的 `pnpm-workspace.yaml`。Web 插件页提供**允许这些脚本并重试**;工具可以在用户于对话中批准这些脚本后,通过 `install_bundle` 的 `approvedBuilds` 代为授权。服务只校验待决定的名字,不核实对话中的批准。授权按包名保存在当前 profile,允许以宿主用户的权限执行命令,并在再次安装失败后保留。只能批准当前未决定的名字;已有的拒绝与通配规则不能通过此操作覆盖。`allowBuilds` 里出现 YAML 锚点或别名时拒绝授权。重试保留原来的启用选择。 ### 配置 | 字段 | 默认值 | 含义 | |---|---|---| +| `pnpmCommand` | `pnpm` | pnpm 可执行文件名或路径,与 `dsh plugin` 命令一样通过 `PATH` 解析。 | +| `inspectTimeoutMs` | `20000` | 单次检查所做注册表查询的上限,单位毫秒。 | | `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 | | `lockWaitMs` | `120000` | 获取 profile 写锁的最长等待毫秒数。 | @@ -56,7 +62,7 @@ pnpm 11 阻止依赖脚本时,安装失败结果列出 profile 内所有待审 服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。仅依赖字段变化不会触发配置重载。 -结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。 +结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。失败或被取消的安装会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));失败的删除保留部分改动和诊断。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。 @@ -67,7 +73,8 @@ pnpm 11 阻止依赖脚本时,安装失败结果列出 profile 内所有待审 - [App boot](../app-boot/README.zh.md)——profile 配置层与启动策略。 - [Plugin inventory](../../host/plugin-inventory/README.zh.md)——当前 Loader 和预设状态。 -- [Plugin settings](../../client/ui-settings-plugin-inventory/README.zh.md)——Web 控件。 +- [插件管理页](../../client/ui-plugin-manager/README.zh.md)——基于本服务的 Web 侧边栏页面。 +- [Plugin settings](../../client/ui-settings-plugin-inventory/README.zh.md)——只读的 Web 清单。 ## 模型体验 @@ -90,12 +97,12 @@ pnpm 11 阻止依赖脚本时,安装失败结果列出 profile 内所有待审 -- Web 一次批准整个待审批列表,暂不支持逐包选择。 +- Web 一次批准显示出来的整组待决定包,没有逐包选择。 - 替换已有包后需要重启进程,以加载新的 JavaScript 模块版本。 - 仅启动时加载的 profile 不能删除当前进程启动时使用的包;停止进程后使用 `dsh plugin`。 - 管理器不能关闭自身所需的管理组件、修改其他 profile 或编辑 agent 预设组合。 -- 包管理失败可能留下部分依赖改动。文件缺失的未启用依赖仍可删除。诊断日志保留在 profile 的 `.plugin-manager/logs` 目录中。 -- 管理结果描述 Host 激活状态。浏览器同步失败会在插件列表中单独显示。 +- 失败的删除可能留下部分依赖改动,失败或被取消的安装可能在 `node_modules` 或 pnpm 缓存中留下已下载文件。文件缺失的未启用依赖仍可删除。诊断日志保留在 profile 的 `.plugin-manager/logs` 目录中。 +- 管理结果描述 Host 激活状态。浏览器同步失败会在设置的插件列表中单独显示。 - Desktop 包管理操作仍由 Desktop shell 负责。 @@ -105,13 +112,13 @@ pnpm 11 阻止依赖脚本时,安装失败结果列出 profile 内所有待审 | 失败操作 | 处理方式 | |---|---| -| 安装:pnpm 执行或组合包校验失败 | 只尝试删除本次新增且能够明确识别的依赖,不恢复已有包。报告安装失败。 | +| 安装:pnpm 执行或组合包校验失败 | 恢复 pnpm 运行前快照的 `package.json` 与 `pnpm-lock.yaml`;pnpm 已下载的文件可能保留。报告安装失败。 | | 启用:保存选择项或加载失败 | 保留已安装的依赖和已保存的选择项。报告启用失败,允许修正、停用或卸载。 | | 卸载:任一步失败 | 停在失败步骤,保留已完成的改动和待重试删除的依赖,报告卸载失败。不重新启用组合包。 | pnpm 执行和组合包校验成功即完成安装,后续启用失败不撤销安装。卸载依次执行:从 `dsh.profile.bundles` 移除组合包、卸载运行时贡献、执行 `pnpm remove`。任一步失败都不继续执行后续步骤。 -安装补偿清理只尝试一次。清理失败或无法明确识别新增依赖时,保留实际状态,同时报告原始失败、清理诊断及残留依赖,不递归撤销清理。清理范围仅限依赖和组合包选择项,不处理用户编写的 patch 配置、应用数据或诊断日志。 +恢复只重写这两份快照文件;用户编写的 patch 配置、应用数据、诊断日志以及 pnpm 已下载的文件保持原样,没有 manifest 引用的包由下一次包操作清理。 ### 开发备注 diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index 09338e1d25..2c98f8f1d4 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -67,7 +67,8 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-hmr": "workspace:^" + "@deepseek-ai/dsh-hmr": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-hmr": { @@ -83,6 +84,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-hmr": "workspace:^", - "@deepseek-ai/cordis-plugin-timer": "workspace:^" + "@deepseek-ai/cordis-plugin-timer": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^" } } diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 77d8859112..3ef65606ad 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -1,35 +1,51 @@ /** Current-profile plugin and bundle management over shared dsh plugin operations. */ -import { readFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { readFile, rm } from 'node:fs/promises' import { join } from 'node:path' -import { withFileLock } from '@deepseek-ai/dsh-atomic-write' +import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' 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 { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' -import { readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory' -import { readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches } from '@deepseek-ai/dsh-app-boot' +import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory' +import { + readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, +} from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-hmr' -import type { ProfileContext } from '@deepseek-ai/dsh-app-boot' -import { bundleManifest, runProfilePnpm, saveManifest } from './operations.ts' +import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot' +import { bundleManifest, runProfilePnpm, saveManifest, viewProfilePackage } from './operations.ts' +import { classifyInstallFailure } from './install-failure.ts' +import { InvalidInstallSpecError, parseInstallSpec } from './install-spec.ts' import { writePluginEnabled } from './patch.ts' import { ManagementFailure } from './failure.ts' import { approveBuilds, readPendingBuilds } from './build-approval.ts' -import type { BundleInfo, ChangeResult, InstallBundleOptions, ManagementError, PackageResult, PluginEntryId, PluginInfo } from './types.ts' +import type { + BundleInfo, BundleRowInfo, ChangeResult, InstallBundleOptions, ManagementError, PackageResult, PluginChange, PluginEntryId, PluginInfo, + PluginInspectProblem, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId, PluginSpecInspection, +} from './types.ts' export type * from './types.ts' +export { classifyInstallFailure, type InstallFailureFacts } from './install-failure.ts' +export { InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts' -/** Limits for package diagnostics and lock acquisition. */ +/** The pnpm executable and the limits for package diagnostics and registry lookups. */ export interface Config { + /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */ + pnpmCommand?: string /** Maximum retained pnpm diagnostic bytes per operation. */ outputBytes?: number /** Maximum time to wait for another process's profile package operation. */ lockWaitMs?: number + /** Bound on one registry lookup an inspection runs, in milliseconds. */ + inspectTimeoutMs?: number } const protectedModules = new Set([ '@deepseek-ai/dsh-plugin-manager', '@deepseek-ai/cordis-plugin-loader', '@deepseek-ai/cordis-plugin-include', '@deepseek-ai/dsh-api-gateway', '@deepseek-ai/dsh-host-webserver', '@deepseek-ai/dsh-client-modules', - '@deepseek-ai/dsh-client-ui-settings-plugin-inventory', + '@deepseek-ai/dsh-client-ui-settings-plugin-inventory', '@deepseek-ai/dsh-client-ui-plugin-manager', '@deepseek-ai/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/dsh-client-connection', @@ -37,15 +53,71 @@ const protectedModules = new Set([ '@deepseek-ai/dsh-hmr', ]) +/** The profile files an installation writes and a failed or cancelled one restores. */ +const RESTORED_FILES = ['package.json', 'pnpm-lock.yaml'] as const + +/** pnpm's colour escapes, which a JSON answer may be wrapped in. */ +const ANSI_SEQUENCE = /\x1b\[[0-9;]*m/g + /** Flatten only the groups addressable by the profile's patch composer. */ function flatten(rows: EntryOptions[]): EntryOptions[] { return rows.flatMap(row => [row, ...(row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : [])]) } /** Preserve the exact observed diagnostic, including non-Error failures. */ +function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } + +/** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */ function managementError(error: unknown): ManagementError { - return error instanceof ManagementFailure ? { code: error.code } - : { code: 'operation-error', diagnostic: error instanceof Error ? error.message : String(error) } + return error instanceof ManagementFailure ? { code: error.code } : { code: 'operation-error', diagnostic: messageOf(error) } +} + +/** The caller stopped an installation; its files are restored before this is thrown. */ +class InstallCancelledError extends Error { + constructor() { + super('Installation cancelled') + this.name = 'InstallCancelledError' + } +} + +/** One installation the manager owns until its call settles. */ +interface InstallControl { + readonly abort: AbortController + /** `applying` once pnpm has exited and the bundle is being selected and loaded, which cannot be stopped. */ + phase: 'installing' | 'applying' + /** Settlement of the install call, whichever way it ended. */ + settled: Promise +} + +/** A manifest field that is a string, when the manifest carries one. */ +function stringField(manifest: object, field: string): string | undefined { + const value = (manifest as Record)[field] + return typeof value === 'string' ? value : undefined +} + +/** The fields of the dsh installation's own manifest the manager reads. */ +interface InstallationManifest { + dependencies?: Record +} + +/** What a package manifest says about the package: identity, one-liner, and whether it is a bundle. */ +function inspectionOf(kind: 'registry' | 'path', manifest: object): Extract { + const dsh = (manifest as { dsh?: unknown }).dsh + const declared = typeof dsh === 'object' && dsh !== null ? dsh as { bundle?: unknown } : undefined + const bundle = declared !== undefined && typeof declared.bundle === 'object' && declared.bundle !== null + const name = stringField(manifest, 'name') + const version = stringField(manifest, 'version') + const description = stringField(manifest, 'description') + return { + status: 'accepted', kind, bundle, + ...name === undefined ? {} : { name }, + ...version === undefined ? {} : { version }, + ...description === undefined || description === '' ? {} : { description }, + } +} + +function refused(problem: PluginInspectProblem, reason: string): PluginSpecInspection { + return { status: 'refused', problem, reason } } declare module '@deepseek-ai/cordis' { @@ -59,16 +131,22 @@ declare module '@deepseek-ai/cordis' { export class PluginManager extends TypertRemoteService { static inject = ['loader', 'profileContext'] static Config: z = z.object({ + pnpmCommand: z.string().default('pnpm'), outputBytes: z.number().step(1).min(1).default(16384), lockWaitMs: z.number().step(1).min(0).default(120000), + inspectTimeoutMs: z.number().step(1).min(1000).default(20000), }) private readonly ownerEntryId: string | undefined - private readonly packageOperations = new Set>() + private readonly packageOperations = new Set>() private readonly profile: ProfileContext private readonly outputBytes: number private readonly lockWaitMs: number + private readonly inspectTimeoutMs: number + private readonly pnpmCommand: string private readonly ownerContext: Context private readonly abort = new AbortController() + /** Installations by request id, from their call until it settles. */ + private readonly installs = new Map() constructor(ctx: Context, config: Config) { super(ctx, 'pluginManager') @@ -77,6 +155,8 @@ export class PluginManager extends TypertRemoteService { this.profile = ctx.profileContext this.outputBytes = (config as Required).outputBytes this.lockWaitMs = (config as Required).lockWaitMs + this.inspectTimeoutMs = (config as Required).inspectTimeoutMs + this.pnpmCommand = (config as Required).pnpmCommand ctx.effect(() => async () => { this.abort.abort() await Promise.allSettled([...this.packageOperations]) @@ -105,40 +185,114 @@ export class PluginManager extends TypertRemoteService { }) } - /** Read installed bundles and bundles supplied by this dsh installation. - * @returns Package versions, activation selections and removal availability. + /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. + * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected. + * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the + * bundle, and removal availability. */ @Remote listBundles(): Promise { const manifest = readProfileManifest('dsh', this.profile.dir) const selected = manifest.dsh?.profile?.bundles ?? [] const dependencies = Object.keys(manifest.dependencies ?? {}) - const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as { dependencies?: Record } + const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest const names = [...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {})])] const bundles: BundleInfo[] = [] for (const name of names) { - const removable = dependencies.includes(name) && !Object.hasOwn(installation.dependencies ?? {}, name) + const installed = dependencies.includes(name) + const optional = OPTIONAL_BUNDLES.includes(name) + const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name) + const enabled = selected.includes(name) try { const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor) if (info === undefined) { - if (selected.includes(name) || dependencies.includes(name)) { - bundles.push({ name, enabled: selected.includes(name), removable, error: { code: 'not-bundle' } }) - } + if (enabled) bundles.push({ name, enabled, installed, optional, removable, error: { code: 'not-bundle' }, rows: [], overrides: [] }) continue } const readOnlyReason = this.protectsManager(name) ? 'management-required' as const : undefined bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }), - enabled: selected.includes(name), removable: removable && readOnlyReason === undefined, - ...(readOnlyReason === undefined ? {} : { readOnlyReason }) }) + ...(info.description === undefined || info.description === '' ? {} : { description: info.description }), + enabled, installed, optional, removable: removable && readOnlyReason === undefined, + ...(readOnlyReason === undefined ? {} : { readOnlyReason }), + ...this.declaredRows(name, info) }) } catch (error) { - if (selected.includes(name) || dependencies.includes(name)) { - bundles.push({ name, enabled: selected.includes(name), removable, error: managementError(error) }) + if (enabled || installed) { + bundles.push({ name, enabled, installed, optional, removable, error: managementError(error), rows: [], overrides: [] }) } } } return Promise.resolve(bundles) } + /** Read what a spec names before installing it. + * @param spec One package spec: a registry name, an absolute path, a git address, or a tarball. + * @param signal Ends a registry lookup early. + * @returns The package the spec names, or why it is refused. + */ + @Remote + async inspect(spec: string, signal?: AbortSignal): Promise { + let parsed + try { + parsed = parseInstallSpec(spec) + } catch (error) { + /* v8 ignore next 2 -- parseInstallSpec throws nothing but its own refusal */ + if (!(error instanceof InvalidInstallSpecError)) throw error + return refused('invalid-spec', error.reason) + } + const manifest = readProfileManifest('dsh', this.profile.dir) + const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest + const known = new Set([ + ...manifest.dsh?.profile?.bundles ?? [], ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(installation.dependencies ?? {}), + ]) + switch (parsed.kind) { + case 'git': return { status: 'accepted', kind: 'git', bundle: null } + case 'tarball': + if (parsed.path !== undefined && !existsSync(parsed.path)) return refused('not-a-package', 'the tarball does not exist') + return { status: 'accepted', kind: 'tarball', bundle: null } + case 'path': { + if (!existsSync(parsed.path)) return refused('not-a-package', 'the path does not exist') + let read: object + try { + read = JSON.parse(await readFile(join(parsed.path, 'package.json'), 'utf8')) as object + } catch (error) { + return refused('not-a-package', `no readable package.json at the path: ${messageOf(error)}`) + } + const inspection = inspectionOf('path', read) + if (inspection.name === undefined) return refused('not-a-package', 'the package.json names no package') + if (known.has(inspection.name)) return refused('already-installed', `${inspection.name} is already installed`) + if (!inspection.bundle) return refused('not-a-bundle', `${inspection.name} declares no dsh.bundle`) + return inspection + } + case 'registry': { + if (known.has(parsed.name)) return refused('already-installed', `${parsed.name} is already installed`) + const view = await viewProfilePackage(this.profile.dir, spec.trim(), { + command: this.pnpmCommand, timeoutMs: this.inspectTimeoutMs, ...signal === undefined ? {} : { signal }, + }) + const log = `${view.stderr}${view.cause === undefined ? '' : `${messageOf(view.cause)}\n`}`.trim() + if (view.exitCode !== 0 || view.cause !== undefined || view.timedOut) { + const kind = classifyInstallFailure({ log, timedOut: view.timedOut, ...view.cause === undefined ? {} : { cause: view.cause } }) + const reason = log || view.stdout.trim() || `pnpm view exited with ${String(view.exitCode)}` + if (kind === 'not-found' || kind === 'no-matching-version') return refused('not-found', reason) + if (kind === 'network') return refused('network', reason) + return refused('unknown', view.timedOut ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` : reason) + } + let answer: unknown + try { + answer = JSON.parse(view.stdout.replace(ANSI_SEQUENCE, '').trim() || 'null') + } catch (error) { + return refused('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 refused('unknown', 'pnpm view answered no package') + const inspection = inspectionOf('registry', latest) + const named = inspection.name === undefined ? { ...inspection, name: parsed.name } : inspection + if (!named.bundle) return refused('not-a-bundle', `${named.name} declares no dsh.bundle`) + return named + } + } + } + /** 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. @@ -154,7 +308,7 @@ export class PluginManager extends TypertRemoteService { result.warnings = await this.reload(enabled ? [row.patchId] : []) const current = (await this.listPlugins()).find(item => item.entryId === id) return current?.enabled !== enabled && this.ownerContext.get('hmr') !== undefined ? 'overridden' : undefined - }), { stage: 'enable', target: id, enabled }) + }), { stage: 'enable', target: id, enabled }, 'plugin') } /** Select or remove a bundle layer while retaining installed dependencies. @@ -167,27 +321,43 @@ export class PluginManager extends TypertRemoteService { return this.change(result => this.configure(async () => { await this.selectBundle(name, enabled) result.warnings = await this.reload(enabled ? this.bundleRows(name).map(row => row.id) : []) - }), { stage: 'enable', target: name, enabled }) + }), { stage: 'enable', target: name, enabled }, 'bundle') } - /** Install a package using the same pnpm implementation as dsh plugin. + /** + * Install a package using the same pnpm implementation as dsh plugin. A run + * that fails, is cancelled, or adds a package without a bundle patch restores + * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay. * @param spec One package spec, including local paths relative to the invocation directory. - * @param options Activation defaults to true; explicit build approvals persist for this profile before installation. + * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and + * the pending build scripts to allow for this profile before pnpm runs. * @returns Package-manager diagnostics and observed activation outcome. */ @Remote installBundle(spec: string, options?: InstallBundleOptions): Promise { - return this.change(async (result) => { + const requestId = options?.requestId + const control: InstallControl = { abort: new AbortController(), phase: 'installing', settled: Promise.resolve() } + const stopped = (): boolean => control.abort.signal.aborted + if (requestId !== undefined) this.installs.set(requestId, control) + const announce = (phase: PluginInstallProgress['phase']): void => { + if (requestId !== undefined) this.ownerContext.emit('plugin-manager/install-state', { requestId, phase }) + } + const result = this.change(async (result) => { if (spec.trim() === '' || spec.startsWith('-')) throw new ManagementFailure('invalid-spec') + if (stopped()) throw new InstallCancelledError() if (options?.approvedBuilds !== undefined) { await approveBuilds(this.profile.dir, options.approvedBuilds) result.approvedBuilds = options.approvedBuilds } + const files = await this.readRestoredFiles() const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {} + announce('installing') let name: string try { - result.packageResult = await this.runPnpm(['add', spec]) + result.packageResult = await this.runPnpm(['add', spec], control.abort.signal, requestId) + if (stopped()) throw new InstallCancelledError() if (result.packageResult.exitCode !== 0) { + // pnpm-workspace.yaml is not restored, so the names pnpm left undecided there can be offered for approval. try { result.pendingBuilds = await readPendingBuilds(this.profile.dir) } catch (error) { this.ownerContext.logger.warn('Could not read pending build approvals after pnpm failed', error) @@ -206,9 +376,13 @@ export class PluginManager extends TypertRemoteService { if (manifest?.dsh?.bundle?.patch === undefined) throw new ManagementFailure('not-bundle') loadOverlayPatches('dsh', join(dir, manifest.dsh.bundle.patch)) } catch (error) { - await this.cleanFailedInstall(before, result) + // pnpm has exited by now, so the files it rewrote go back as they were. + await this.restoreFiles(files) throw error } + control.phase = 'applying' + announce('applying') + result.bundle = name result.target = name result.stage = 'enable' return this.configure(async () => { @@ -216,29 +390,26 @@ export class PluginManager extends TypertRemoteService { if (Object.hasOwn(before, name)) return 'restart-required' if (options?.enabled !== false) result.warnings = await this.reload() }) - }, { stage: 'install', target: spec, enabled: options?.enabled !== false }) + }, { stage: 'install', target: spec, enabled: options?.enabled !== false }, 'install') + /* v8 ignore next -- change() folds every failure into its result; only a lock or disposal error rejects */ + control.settled = result.then(() => undefined, () => undefined) + return result.finally(() => { if (requestId !== undefined) this.installs.delete(requestId) }) } - private async cleanFailedInstall(before: Record, result: ChangeResult): Promise { - try { - const manifest = readProfileManifest('dsh', this.profile.dir) - const added = Object.keys(manifest.dependencies ?? {}).filter(name => !Object.hasOwn(before, name)) - result.remainingDependencies = added - const name = added[0] - const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as { dependencies?: Record } - if (added.length !== 1 || name === undefined || manifest.dsh?.profile?.bundles?.includes(name) - || Object.hasOwn(installation.dependencies ?? {}, name)) return - result.cleanup = { name } - result.cleanup.packageResult = await this.runPnpm(['remove', name]) - if (result.cleanup.packageResult.exitCode !== 0) { - result.cleanup.error = { code: 'operation-error', diagnostic: result.cleanup.packageResult.output } - } - result.remainingDependencies = Object.keys(readProfileManifest('dsh', this.profile.dir).dependencies ?? {}) - .filter(name => !Object.hasOwn(before, name)) - } catch (error) { - if (result.cleanup !== undefined) result.cleanup.error = managementError(error) - else result.warnings = [String(error)] - } + /** Stop an installation this manager owns and wait until its files are back. + * @param requestId The id the installation was started with. + * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being + * applied, `not-running` for any other id. + */ + @Remote + async cancelInstall(requestId: PluginInstallRequestId): Promise { + const control = this.installs.get(requestId) + if (control === undefined) return { status: 'not-running' } + if (control.phase === 'applying') return { status: 'too-late' } + this.ownerContext.emit('plugin-manager/install-state', { requestId, phase: 'cancelling' }) + control.abort.abort() + await control.settled + return { status: 'cancelled' } } /** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path. @@ -268,16 +439,81 @@ export class PluginManager extends TypertRemoteService { }) result.packageResult = await this.runPnpm(['remove', name]) if (result.packageResult.exitCode !== 0) throw new Error(result.packageResult.output) - }, { stage: 'remove', target: name }) + }, { stage: 'remove', target: name }, 'remove') } - private async runPnpm(args: readonly string[]): Promise { + /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */ + private declaredRows(name: string, info: ProfileManifest): Pick { + const patch = info.dsh?.bundle?.patch + /* v8 ignore next -- bundleManifest answers only manifests that declare a patch */ + if (patch === undefined) return { rows: [], overrides: [] } + const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir) + const patches: PatchOptions[] = loadOverlayPatches('dsh', join(dir, patch)) + // One entry per row id: the Loader keeps a single entry for an id, whichever layer declared it last. + const live = new Map() + for (const entry of this.ctx.loader.entries()) { + /* v8 ignore next -- the Loader gives every entry an id before it is listed */ + if (typeof entry.options.id === 'string') live.set(entry.options.id, pluginEntryId(entry.id)) + } + const rows: BundleRowInfo[] = [] + for (const row of flatten(composeEntries([patches.filter(item => item.insert !== undefined)]))) { + if (typeof row.id !== 'string' || typeof row.name !== 'string') continue + const entryId = live.get(row.id) + rows.push({ rowId: row.id, moduleName: row.name, ...entryId === undefined ? {} : { entryId } }) + } + const declared = new Set(rows.map(row => row.rowId)) + const overrides = [...new Set(patches.flatMap(item => + item.insert === undefined && typeof item.id === 'string' && !declared.has(item.id) ? [item.id] : []))] + return { rows, overrides } + } + + /** Run one pnpm command in the profile, streaming its output as install-log chunks. */ + private async runPnpm( + args: readonly string[], signal?: AbortSignal, requestId?: PluginInstallRequestId, + ): Promise { + const jobId = randomUUID() + const argv = ['pnpm', ...args] + const cwd = this.profile.dir + const identity = requestId === undefined ? {} : { requestId } const task = runProfilePnpm({ ...this.profile, profile: this.profile.name }, args, { - execution: 'service', signal: this.abort.signal, outputBytes: this.outputBytes, activateNewBundles: false, + execution: 'service', command: this.pnpmCommand, + signal: signal === undefined ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]), + outputBytes: this.outputBytes, activateNewBundles: false, + onOutput: (text, stream) => { + this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream, text }) + }, }) this.packageOperations.add(task) - try { return await task } - finally { this.packageOperations.delete(task) } + try { + const result = await task + this.ownerContext.emit('plugin-manager/install-log', { + ...identity, jobId, argv, cwd, stream: 'stdout', text: '', exitCode: signal?.aborted === true ? null : result.exitCode, + }) + return result.exitCode === 0 ? result : { ...result, kind: classifyInstallFailure({ log: result.output }) } + } catch (error) { + this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream: 'stderr', text: messageOf(error), exitCode: null }) + throw error + } finally { + this.packageOperations.delete(task) + } + } + + /** The profile files an installation may rewrite, as they are now; absent files read as undefined. */ + private async readRestoredFiles(): Promise> { + const files = new Map() + for (const name of RESTORED_FILES) { + const path = join(this.profile.dir, name) + files.set(path, existsSync(path) ? await readFile(path, 'utf8') : undefined) + } + return files + } + + /** Put the profile files back; pnpm has exited by the time this runs. */ + private async restoreFiles(files: Map): Promise { + for (const [path, content] of files) { + if (content === undefined) await rm(path, { force: true }) + else await writeFileAtomic(path, content, { mode: 0o600 }) + } } private async selectBundle(name: string, enabled: boolean): Promise { @@ -320,6 +556,7 @@ export class PluginManager extends TypertRemoteService { private async change( operation: (result: ChangeResult) => Promise, request: Pick, + reason: PluginChange['reason'], ): Promise { return withFileLock(join(this.profile.dir, 'package.json'), async () => { this.abort.signal.throwIfAborted() @@ -329,10 +566,15 @@ export class PluginManager extends TypertRemoteService { try { result.application = await operation(result) ?? result.application } catch (error) { - result.application = 'failed' - result.error = managementError(error) + if (error instanceof InstallCancelledError) { + result.application = 'cancelled' + } else { + result.application = 'failed' + result.error = managementError(error) + } } result.changed = before !== this.diskState() + this.ownerContext.emit('plugin-manager/changed', { reason }) return result }, { waitMs: this.lockWaitMs }) } @@ -344,7 +586,7 @@ export class PluginManager extends TypertRemoteService { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return '' throw error } - }).join('\u0000') + }).join('\0') } } diff --git a/packages/boot/plugin-manager/src/install-failure.ts b/packages/boot/plugin-manager/src/install-failure.ts new file mode 100644 index 0000000000..a855963fe7 --- /dev/null +++ b/packages/boot/plugin-manager/src/install-failure.ts @@ -0,0 +1,43 @@ +/** + * What a failed pnpm run was, read off how it ended and what it printed: + * pnpm names its failures with stable `ERR_PNPM_*` codes and Node's errno + * names, which the run's captured tail carries whatever the locale. + * @module @deepseek-ai/dsh-plugin-manager/install-failure + */ + +import type { PluginInstallFailureKind } from './types.ts' + +/** How a run ended, beyond its exit code. */ +export interface InstallFailureFacts { + /** The output tail the failure reports. */ + readonly log: string + /** The spawn error, when the child never ran. */ + readonly cause?: unknown + /** Whether the run outlived its bound. */ + readonly timedOut?: boolean +} + +/** Patterns in the order they decide: a specific code before the generic network family. */ +const LOG_KINDS: readonly [PluginInstallFailureKind, RegExp][] = [ + ['build-blocked', /ERR_PNPM_IGNORED_BUILDS|Ignored build scripts/], + ['not-found', /ERR_PNPM_FETCH_404|\bE404\b|404 Not Found|Not Found - GET/], + ['no-matching-version', /ERR_PNPM_NO_MATCHING_VERSION|\bETARGET\b|No matching version/], + ['disk-full', /\bENOSPC\b|no space left on device/i], + ['permission', /\bEACCES\b|\bEPERM\b|permission denied/i], + ['integrity', /ERR_PNPM_TARBALL_INTEGRITY|ERR_PNPM_BAD_TARBALL_SIZE|\bEINTEGRITY\b/], + ['network', /\bENOTFOUND\b|\bECONNRESET\b|\bETIMEDOUT\b|\bECONNREFUSED\b|\bEAI_AGAIN\b|ERR_PNPM_META_FETCH_FAIL|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_FETCH_TIMEOUT|socket hang up|Could not resolve host|unable to access/], +] + +/** + * Classify a failed run. + * @param facts - how the run ended and what it printed. + * @returns the kind, `unknown` when nothing in the facts names one. + */ +export function classifyInstallFailure(facts: InstallFailureFacts): PluginInstallFailureKind { + if (facts.timedOut === true) return 'timeout' + if ((facts.cause as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return 'pnpm-missing' + for (const [kind, pattern] of LOG_KINDS) { + if (pattern.test(facts.log)) return kind + } + return 'unknown' +} diff --git a/packages/boot/plugin-manager/src/install-spec.ts b/packages/boot/plugin-manager/src/install-spec.ts new file mode 100644 index 0000000000..fa43fd9487 --- /dev/null +++ b/packages/boot/plugin-manager/src/install-spec.ts @@ -0,0 +1,74 @@ +/** + * Reading an install spec before pnpm sees it: which of pnpm's spec forms it + * takes, and for a registry name whether it is one the registry can accept. + * @module @deepseek-ai/dsh-plugin-manager/install-spec + */ + +import { isAbsolute } from 'node:path' + +/** One spec read into its form and the parts an inspection needs. */ +export type ParsedInstallSpec = + | { readonly kind: 'registry'; readonly spec: string; readonly name: string; readonly range?: string } + | { readonly kind: 'path'; readonly spec: string; readonly path: string } + | { readonly kind: 'tarball'; readonly spec: string; readonly path?: string } + | { readonly kind: 'git'; readonly spec: string } + +/** The forms pnpm resolves through a git host: a host shorthand, a git URL, or a hosted repository URL. */ +const GIT_SHORTHAND = /^(?:github|gitlab|bitbucket|gist):/i +const GIT_URL = /^git(?:\+[a-z]+)?:\/\/|^git@[^:]+:/i +const HOSTED_REPOSITORY_URL = /^https?:\/\/[^/]+\/[^/]+\/[^/#]+(?:\.git)?(?:#.*)?$/i +/** A tarball, on disk or over HTTP. */ +const TARBALL_SPEC = /\.(?:tgz|tar\.gz)(?:#.*)?$/i +/** An npm package name: lowercase URL-safe segments, an optional scope, no leading dot or underscore. */ +const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/ +const PACKAGE_NAME_MAX_LENGTH = 214 + +/** A spec neither pnpm nor the registry would take; `reason` is what the person reads. */ +export class InvalidInstallSpecError extends Error { + /** + * @param spec - the spec as typed, trimmed. + * @param reason - why it is refused, as one sentence. + */ + constructor(readonly spec: string, readonly reason: string) { + super(`plugin-manager: ${reason}: ${spec}`) + this.name = 'InvalidInstallSpecError' + } +} + +function invalid(spec: string, reason: string): InvalidInstallSpecError { + return new InvalidInstallSpecError(spec, reason) +} + +/** + * Read a spec into its form. A path must be absolute: the Host's working + * directory means nothing to the person typing into a browser, and a + * relative path resolved against the profile would point inside it. + * @param raw - the spec as typed. + * @returns the parsed spec. + * @throws {InvalidInstallSpecError} for an empty spec, a relative path, a name the + * registry would refuse, or a URL that is neither a git host nor a tarball. + */ +export function parseInstallSpec(raw: string): ParsedInstallSpec { + const spec = raw.trim() + if (spec === '') throw invalid(spec, 'the package spec must not be empty') + const path = spec.replace(/^(?:file|link):/, '') + if (path !== spec || isAbsolute(path)) { + if (!isAbsolute(path)) throw invalid(spec, 'a local path must be absolute') + return TARBALL_SPEC.test(path) ? { kind: 'tarball', spec, path } : { kind: 'path', spec, path } + } + if (/^\.{1,2}(?:[\\/]|$)/.test(spec)) throw invalid(spec, 'a local path must be absolute') + const git = GIT_SHORTHAND.test(spec) || GIT_URL.test(spec) || HOSTED_REPOSITORY_URL.test(spec) + if (git && !TARBALL_SPEC.test(spec)) return { kind: 'git', spec } + if (/^https?:\/\//i.test(spec)) { + if (TARBALL_SPEC.test(spec)) return { kind: 'tarball', spec } + throw invalid(spec, 'a URL must point at a git repository or a tarball') + } + const at = spec.indexOf('@', 1) + const name = at === -1 ? spec : spec.slice(0, at) + const range = at === -1 ? undefined : spec.slice(at + 1) + if (name.length > PACKAGE_NAME_MAX_LENGTH || !PACKAGE_NAME.test(name)) { + throw invalid(spec, 'not a package name the registry accepts') + } + if (range === '') throw invalid(spec, 'a version after @ must not be empty') + return range === undefined ? { kind: 'registry', spec, name } : { kind: 'registry', spec, name, range } +} diff --git a/packages/boot/plugin-manager/src/operations.ts b/packages/boot/plugin-manager/src/operations.ts index 7d8228e199..0612448257 100644 --- a/packages/boot/plugin-manager/src/operations.ts +++ b/packages/boot/plugin-manager/src/operations.ts @@ -23,6 +23,8 @@ export interface PackageOperationContext { /** Output and cancellation policy for one pnpm operation. */ export interface PackageOperationOptions { + /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. Defaults to `pnpm`. */ + command?: string /** CLI inherits authentication and terminal descriptors; service scrubs secrets and captures output. */ execution: 'cli' | 'service' signal?: AbortSignal @@ -109,7 +111,7 @@ export async function runProfilePnpm( let output = Buffer.alloc(0) let truncated = false const cancellation = new AbortController() - const child = execa('pnpm', args.map(arg => anchorPathSpec(arg, context.cwd)), { + const child = execa(options.command ?? 'pnpm', args.map(arg => anchorPathSpec(arg, context.cwd)), { cwd: dir, env: options.execution === 'cli' ? process.env : scrubbedParentEnv(), extendEnv: false, reject: false, stdout: options.execution === 'cli' ? 'inherit' : 'pipe', stderr: options.execution === 'cli' ? 'inherit' : 'pipe', @@ -178,3 +180,47 @@ export async function runPluginCommand( return runProfilePnpm(context, args, options) }, options.lockWaitMs === undefined ? undefined : { waitMs: options.lockWaitMs }) } + +/** What one registry lookup answered. */ +export interface PackageViewResult { + /** pnpm's exit code, null when it ended without one or never started. */ + exitCode: number | null + stdout: string + stderr: string + /** The lookup ran past its bound and was killed. */ + timedOut: boolean + /** The failure of starting pnpm at all, when that is what happened. */ + cause?: unknown +} + +/** Bounds of one registry lookup. */ +export interface PackageViewOptions { + /** The pnpm executable name or path. Defaults to `pnpm`. */ + command?: string + /** Ends the lookup early; the caller's signal, when it has one. */ + signal?: AbortSignal + /** Bound on the lookup, in milliseconds. */ + timeoutMs: number +} + +/** + * Ask the registry what a spec names through `pnpm view`, run in the profile + * directory so the registry, proxy, and authentication settings of an install apply. + * @param dir Profile directory. + * @param spec One registry spec: a package name with an optional range. + * @param options 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 { + const result = await execa(options.command ?? 'pnpm', ['view', spec, 'name', 'version', 'description', 'dsh', '--json'], { + cwd: dir, env: scrubbedParentEnv(), extendEnv: false, reject: false, stdin: 'ignore', + timeout: options.timeoutMs, ...options.signal === undefined ? {} : { cancelSignal: options.signal }, + }) + const cause = result.exitCode === undefined && !result.timedOut && !result.isCanceled + ? Object.assign(new Error(result.shortMessage), { code: result.code }) + : undefined + return { + exitCode: result.exitCode ?? null, stdout: result.stdout, stderr: result.stderr, timedOut: result.timedOut, + ...cause === undefined ? {} : { cause }, + } +} diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index bbb84fb86f..52d29ebbcb 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -1,6 +1,8 @@ /** Public plugin management records shared with clients. */ +import type { Branded } from '@deepseek-ai/dsh-brand' import type { PluginInventoryEntry } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types' +import type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types' /** Reasons a profile control cannot modify its target. */ export type ReadOnlyReason = 'management-required' | 'unaddressable' @@ -17,49 +19,184 @@ export type PluginInfo = PluginInventoryEntry & ( | { patchId?: never; readOnlyReason: ReadOnlyReason } ) +/** One row a bundle's patch declares, with its live entry while the bundle contributes it. */ +export interface BundleRowInfo { + /** The row id as the patch declares it. */ + rowId: string + /** The module the row names. */ + moduleName: string + /** The Loader entry carrying this row, when exactly one live entry has its id. */ + entryId?: PluginEntryId +} + /** One installed or installation-provided bundle. */ export interface BundleInfo { name: string version?: string + /** `description` of the package manifest. */ + description?: string enabled: boolean + /** Whether the profile's own dependencies hold the package; false for a bundle the dsh installation supplies. */ + installed: boolean + /** + * Whether the installation ships the bundle for the person to switch on: named by the launcher's `OPTIONAL_BUNDLES`, + * held by the installation's dependencies, selected by no shipped template, and never removable. + */ + optional: boolean removable: boolean readOnlyReason?: ReadOnlyReason error?: ManagementError + /** The rows the bundle's patch inserts, in declaration order; empty when the patch cannot be read. */ + rows: BundleRowInfo[] + /** Ids of rows the bundle's patch changes without declaring them: the built-in rows it configures or disables. */ + overrides: string[] } +/** How a pnpm run failed, read off how it ended and what it printed. */ +export type PluginInstallFailureKind = + | 'pnpm-missing' + | 'timeout' + | 'not-found' + | 'no-matching-version' + | 'network' + | 'disk-full' + | 'permission' + | 'build-blocked' + | 'integrity' + | 'unknown' + /** Pnpm completion, including a retrieval path for unabridged diagnostics. */ export interface PackageResult { exitCode: number output: string truncated: boolean logPath: string + /** Present when the run failed: what kind of failure its exit and output describe. */ + kind?: PluginInstallFailureKind } /** Persisted change and independently observed application outcome. */ export interface ChangeResult { changed: boolean - application: 'applied' | 'restart-required' | 'overridden' | 'failed' + /** `cancelled` is an installation the caller stopped, its files restored. */ + application: 'applied' | 'restart-required' | 'overridden' | 'failed' | 'cancelled' /** Last attempted step; successful installation can proceed to enablement. */ stage: 'install' | 'enable' | 'remove' target: string enabled?: boolean error?: ManagementError - /** Pre-existing inactive entries or diagnostics from inspecting installation leftovers. */ + /** Pre-existing inactive entries the operation left as they were. */ warnings?: string[] - /** Newly added dependencies still declared after failed installation cleanup. */ - remainingDependencies?: string[] - /** One cleanup attempt, restricted to a newly added dependency. */ - cleanup?: { name: string; packageResult?: PackageResult; error?: ManagementError } packageResult?: PackageResult - /** Exact package names awaiting explicit script approval in the profile's pnpm settings. */ + /** The bundle an installation added, once pnpm and the bundle check accepted it. */ + bundle?: string + /** Exact package names awaiting explicit script approval in the profile's pnpm settings, read after a failed run. */ pendingBuilds?: string[] /** Package script permissions saved before this installation attempt. */ approvedBuilds?: string[] } -/** Bundle installation defaults to activation. */ +/** Identifies one installation from its start to its settlement, including its log chunks and cancellation. */ +export type PluginInstallRequestId = Branded<'PluginInstallRequestId'> + +/** Bundle installation defaults to activation; callers that offer cancellation supply their request id. */ export interface InstallBundleOptions { enabled?: boolean - /** Explicitly allow these pending packages' scripts for this profile, then retry installation. */ + requestId?: PluginInstallRequestId + /** Explicitly allow these pending packages' scripts for this profile, then install; a name no longer pending refuses the call. */ approvedBuilds?: string[] } + +/** The form one install spec takes, in pnpm's vocabulary. */ +export type InstallSpecKind = 'registry' | 'path' | 'git' | 'tarball' + +/** Why a spec was refused before anything installed. */ +export type PluginInspectProblem = + | 'invalid-spec' + | 'already-installed' + | 'not-found' + | 'not-a-package' + | 'not-a-bundle' + | 'network' + | 'unknown' + +/** + * What a spec names, read before installing it: the registry's answer for a + * name, a directory's manifest for a path, and only the form for a git or + * tarball spec, whose package is known once pnpm has fetched it. + */ +export type PluginSpecInspection = + | { + readonly status: 'accepted' + readonly kind: InstallSpecKind + readonly name?: string + readonly version?: string + /** `description` of the package manifest. */ + readonly description?: string + /** Whether the package declares a bundle patch; null when the spec's form does not say. */ + readonly bundle: boolean | null + } + | { + readonly status: 'refused' + readonly problem: PluginInspectProblem + /** What pnpm, the registry, or the file system said. */ + readonly reason: string + } + +/** The Host phase of one installation, before its install call settles. */ +export interface PluginInstallProgress { + readonly requestId: PluginInstallRequestId + readonly phase: 'installing' | 'cancelling' | 'applying' +} + +/** Cancellation is confirmed only after process exit and file restoration. */ +export interface PluginInstallCancellation { + readonly status: 'cancelled' | 'too-late' | 'not-running' +} + +/** One chunk of a pnpm run's output, as the run produces it. */ +export interface PluginInstallLogChunk { + /** The installation the run belongs to, when its caller supplied a request id. */ + readonly requestId?: PluginInstallRequestId + /** The run the chunk belongs to. */ + readonly jobId: string + /** The command line the run executes: pnpm's command name, then its arguments. */ + readonly argv: readonly string[] + /** The directory the run executes in: the profile directory. */ + readonly cwd: string + readonly stream: 'stdout' | 'stderr' + readonly text: string + /** Present on the run's last chunk: pnpm's exit code, null when it ended without one. */ + readonly exitCode?: number | null +} + +/** What changed in the profile, for consumers that show it. */ +export interface PluginChange { + /** The operation that changed it. */ + readonly reason: 'plugin' | 'bundle' | 'install' | 'remove' +} + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * The profile's plugins, bundles, or composition changed: a manager + * operation completed. A patch generation applied outside the manager, + * by HMR's watcher after a CLI or hand edit, announces nothing here. + * @mode emit + * @param change - what changed. + */ + 'plugin-manager/changed'(change: PluginChange): void + /** + * One chunk of a pnpm run's output, streamed as the run produces it. + * @mode emit + * @param chunk - the chunk and the run it belongs to. + */ + 'plugin-manager/install-log'(chunk: PluginInstallLogChunk): void + /** + * An installation moved between its Host phases. + * @mode emit + * @param progress - the installation's request id and phase. + */ + 'plugin-manager/install-state'(progress: PluginInstallProgress): void + } +} diff --git a/packages/boot/plugin-manager/tests/install-spec.spec.ts b/packages/boot/plugin-manager/tests/install-spec.spec.ts new file mode 100644 index 0000000000..f4ea1d7d9f --- /dev/null +++ b/packages/boot/plugin-manager/tests/install-spec.spec.ts @@ -0,0 +1,71 @@ +/** Reading install specs and classifying pnpm failures: pure, table-driven. */ + +import { describe, expect, it } from 'vitest' +import { classifyInstallFailure, InvalidInstallSpecError, parseInstallSpec } from '@deepseek-ai/dsh-plugin-manager' + +describe('parseInstallSpec', () => { + it('reads registry names with an optional range, scoped or not', () => { + expect(parseInstallSpec(' dsh-better-sidebar ')).toEqual({ kind: 'registry', spec: 'dsh-better-sidebar', name: 'dsh-better-sidebar' }) + expect(parseInstallSpec('@acme/dsh-tool@^1.2')).toEqual({ kind: 'registry', spec: '@acme/dsh-tool@^1.2', name: '@acme/dsh-tool', range: '^1.2' }) + expect(parseInstallSpec('pkg@latest')).toEqual({ kind: 'registry', spec: 'pkg@latest', name: 'pkg', range: 'latest' }) + }) + + it('reads absolute paths, with or without a file: or link: prefix, and tarballs on disk', () => { + expect(parseInstallSpec('/plugins/dsh-x')).toEqual({ kind: 'path', spec: '/plugins/dsh-x', path: '/plugins/dsh-x' }) + expect(parseInstallSpec('file:/plugins/dsh-x')).toEqual({ kind: 'path', spec: 'file:/plugins/dsh-x', path: '/plugins/dsh-x' }) + expect(parseInstallSpec('link:/plugins/dsh-x')).toEqual({ kind: 'path', spec: 'link:/plugins/dsh-x', path: '/plugins/dsh-x' }) + expect(parseInstallSpec('/packs/dsh-x-1.0.0.tgz')).toEqual({ kind: 'tarball', spec: '/packs/dsh-x-1.0.0.tgz', path: '/packs/dsh-x-1.0.0.tgz' }) + }) + + it('reads git hosts and tarball URLs', () => { + for (const spec of ['github:someone/dsh-plugin', 'gitlab:a/b#main', 'git+ssh://git@github.com/a/b.git', 'git://host/a/b', 'git@github.com:a/b.git', 'https://github.com/a/b', 'https://github.com/a/b.git#v1']) { + expect(parseInstallSpec(spec)).toEqual({ kind: 'git', spec }) + } + expect(parseInstallSpec('https://cdn.example.com/x/y/z/dsh-x-1.0.0.tgz')).toEqual({ kind: 'tarball', spec: 'https://cdn.example.com/x/y/z/dsh-x-1.0.0.tgz' }) + }) + + it('refuses what neither the registry nor pnpm would take, naming why', () => { + const refusal = (spec: string): string => { + try { + parseInstallSpec(spec) + } catch (error) { + expect(error).toBeInstanceOf(InvalidInstallSpecError) + const failure = error as InvalidInstallSpecError + expect(failure.spec).toBe(spec.trim()) + expect(failure.message).toBe(`plugin-manager: ${failure.reason}: ${spec.trim()}`) + return failure.reason + } + throw new Error(`${spec} was accepted`) + } + expect(refusal(' ')).toBe('the package spec must not be empty') + expect(refusal('./dsh-x')).toBe('a local path must be absolute') + expect(refusal('../dsh-x')).toBe('a local path must be absolute') + expect(refusal('file:./dsh-x')).toBe('a local path must be absolute') + expect(refusal('https://example.com/not-a-package')).toBe('a URL must point at a git repository or a tarball') + expect(refusal('Dsh-Upper')).toBe('not a package name the registry accepts') + expect(refusal('.hidden')).toBe('not a package name the registry accepts') + expect(refusal('has space')).toBe('not a package name the registry accepts') + expect(refusal('a'.repeat(215))).toBe('not a package name the registry accepts') + expect(refusal('pkg@')).toBe('a version after @ must not be empty') + }) +}) + +describe('classifyInstallFailure', () => { + it('names the run\'s end before reading its output, then the most specific code in the output', () => { + expect(classifyInstallFailure({ log: 'ENOSPC', timedOut: true })).toBe('timeout') + expect(classifyInstallFailure({ log: '', cause: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) })).toBe('pnpm-missing') + expect(classifyInstallFailure({ log: 'ERR_PNPM_IGNORED_BUILDS Ignored build scripts: node-pty' })).toBe('build-blocked') + expect(classifyInstallFailure({ log: 'npm error code E404\nnpm error 404 Not Found - GET https://registry/x' })).toBe('not-found') + expect(classifyInstallFailure({ log: 'ERR_PNPM_NO_MATCHING_VERSION No matching version found for x@9' })).toBe('no-matching-version') + expect(classifyInstallFailure({ log: 'npm error code ETARGET' })).toBe('no-matching-version') + expect(classifyInstallFailure({ log: 'ENOSPC: no space left on device, write' })).toBe('disk-full') + expect(classifyInstallFailure({ log: 'EACCES: permission denied, mkdir' })).toBe('permission') + expect(classifyInstallFailure({ log: 'ERR_PNPM_TARBALL_INTEGRITY Got sha512-...' })).toBe('integrity') + expect(classifyInstallFailure({ log: 'ERR_PNPM_META_FETCH_FAIL GET https://registry/x: request to https://registry/x failed, reason: getaddrinfo ENOTFOUND registry' })).toBe('network') + expect(classifyInstallFailure({ log: 'ECONNRESET' })).toBe('network') + expect(classifyInstallFailure({ log: 'ERR_PNPM_FETCH_502 GET https://registry/x: Bad Gateway' })).toBe('network') + expect(classifyInstallFailure({ log: 'fatal: unable to access https://github.com/a/b/: Could not resolve host' })).toBe('network') + expect(classifyInstallFailure({ log: 'exited with 1', cause: new Error('no code') })).toBe('unknown') + expect(classifyInstallFailure({ log: '' })).toBe('unknown') + }) +}) diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index d51aff3b71..bb17c379a3 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -3,13 +3,14 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { realpath } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { pathToFileURL } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import { expect, it, onTestFinished, vi } from 'vitest' import { - boot, composeEntries, initProfile, readProfilePatches, readProfileManifest, reconcileProfilePatches, + boot, composeEntries, initProfile, readProfilePatches, readProfileManifest, reconcileProfilePatches, OPTIONAL_BUNDLES, type ProfileContext, } from '@deepseek-ai/dsh-app-boot' -import PluginManager, { type Config } from '../src/index.ts' +import PluginManager, { type Config, type PluginChange, type PluginInstallLogChunk, type PluginInstallProgress, type PluginInstallRequestId } from '../src/index.ts' import Hmr from '@deepseek-ai/dsh-hmr' import Timer from '@deepseek-ai/cordis-plugin-timer' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' @@ -62,16 +63,49 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre } it('lists bundle versions and current-profile plugin targets', async () => { - const { manager } = await fixture() + const { manager, dir } = await fixture() const plugins = await manager.listPlugins() expect(plugins.find(row => row.entryId === 'include:managed')).toMatchObject({ patchId: 'managed', enabled: true }) - expect(plugins.find(row => row.entryId === 'include:manager')?.readOnlyReason).toBeDefined() + expect(plugins.find(row => row.entryId === 'include:manager')?.readOnlyReason).toBe('management-required') expect(await manager.listBundles()).toEqual([ - { name: 'core', version: '1.0.0', enabled: true, removable: false, readOnlyReason: 'management-required' }, - { name: 'extra', version: '1.0.0', enabled: true, removable: true }, + { + name: 'core', version: '1.0.0', enabled: true, installed: false, optional: false, removable: false, readOnlyReason: 'management-required', + rows: [{ rowId: 'manager', moduleName: 'cordis:manager', entryId: 'include:manager' }], overrides: [], + }, + { + name: 'extra', version: '1.0.0', enabled: true, installed: true, optional: false, removable: true, + rows: [{ rowId: 'managed', moduleName: pathToFileURL(join(dir, 'node_modules', 'extra', 'plugin.mjs')).href, entryId: 'include:managed' }], overrides: [], + }, ]) }) +it('describes a bundle by its manifest and patch: one-liner, rows without a live entry, and the built-in rows it changes', async () => { + const { manager, dir, bundle } = await fixture() + bundle('described', [{ id: 'described-row', name: './plugin.mjs' }]) + writeFileSync(join(dir, 'node_modules', 'described', 'package.json'), JSON.stringify({ + name: 'described', version: '2.0.0', description: 'Describes itself.', dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + // An anonymous row is not addressable and is left out of the rows. + writeFileSync(join(dir, 'node_modules', 'described', 'cordis.patch.yml'), JSON.stringify([ + { insert: [{ id: 'described-row', name: './plugin.mjs' }, { name: './plugin.mjs' }] }, { id: 'managed', disabled: true }, { id: 'described-row', config: {} }, + ])) + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, described: '2.0.0' } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + const moduleName = pathToFileURL(join(dir, 'node_modules', 'described', 'plugin.mjs')).href + expect((await manager.listBundles()).find(row => row.name === 'described')).toEqual({ + name: 'described', version: '2.0.0', description: 'Describes itself.', enabled: false, installed: true, optional: false, removable: true, + rows: [{ rowId: 'described-row', moduleName }], overrides: ['managed'], + }) + await manager.setBundleEnabled('described', true) + expect((await manager.listBundles()).find(row => row.name === 'described')?.rows).toEqual([ + { rowId: 'described-row', moduleName, entryId: 'include:described-row' }, + ]) + // Off again, the rows lose their entries. + await manager.setBundleEnabled('described', false) + expect((await manager.listBundles()).find(row => row.name === 'described')?.rows).toEqual([{ rowId: 'described-row', moduleName }]) +}) + it('turns a plugin off and on without duplicating patch overrides', async () => { const { manager, dir } = await fixture() const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId @@ -105,16 +139,17 @@ it('saves startup-only toggles and refuses removal of currently used packages', expect(await manager.setPluginEnabled(id, false)).toMatchObject({ application: 'restart-required' }) expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(true) await manager.setBundleEnabled('extra', false) - expect(await manager.removeBundle('extra')).toMatchObject({ changed: false, application: 'failed' }) + expect(await manager.removeBundle('extra')).toMatchObject({ changed: false, application: 'failed', error: { code: 'stop-profile' } }) }) it('refuses self-disable, unknown entries and removal of installation-owned bundles', async () => { const { manager } = await fixture() const id = (await manager.listPlugins()).find(row => row.entryId === 'include:manager')!.entryId - expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'failed' }) - expect(await manager.setPluginEnabled('missing' as typeof id, true)).toMatchObject({ changed: false, application: 'failed' }) - expect(await manager.removeBundle('core')).toMatchObject({ changed: false, application: 'failed' }) - expect(await manager.setBundleEnabled('unknown', true)).toMatchObject({ changed: false, application: 'failed' }) + expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'failed', error: { code: 'management-required' } }) + expect(await manager.setPluginEnabled('missing' as typeof id, true)).toMatchObject({ changed: false, application: 'failed', error: { code: 'unknown-plugin' } }) + expect(await manager.removeBundle('core')).toMatchObject({ changed: false, application: 'failed', error: { code: 'not-removable' } }) + // A name no bundle directory answers to fails with the resolver's own diagnostic. + expect(await manager.setBundleEnabled('unknown', true)).toMatchObject({ changed: false, application: 'failed', error: { code: 'operation-error' } }) }) it('installs only valid bundle declarations and honors installation without activation', async () => { @@ -131,7 +166,9 @@ it('installs only valid bundle declarations and honors installation without acti return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') } }) onTestFinished(() => { install.mockRestore() }) - expect(await manager.installBundle('new-bundle', { enabled: false })).toMatchObject({ changed: true, application: 'applied', packageResult: { exitCode: 0 } }) + expect(await manager.installBundle('new-bundle', { enabled: false })).toMatchObject({ + changed: true, application: 'applied', stage: 'enable', target: 'new-bundle', bundle: 'new-bundle', packageResult: { exitCode: 0 }, + }) expect((await manager.listBundles()).find(row => row.name === 'new-bundle')?.enabled).toBe(false) expect(await manager.setBundleEnabled('new-bundle', true)).toMatchObject({ application: 'applied' }) expect((await manager.listPlugins()).find(row => row.patchId === 'new-bundle')?.fiberPhase).toBe('active') @@ -139,37 +176,34 @@ it('installs only valid bundle declarations and honors installation without acti expect((await manager.listBundles()).find(row => row.name === 'another-bundle')?.enabled).toBe(true) }) -it('cleans a blocked installation and retries only after explicit profile build approval', async () => { +it('reports blocked scripts after a failed installation and retries only after explicit profile build approval', async () => { const { manager, dir, bundle } = await fixture() const policy = join(dir, 'pnpm-workspace.yaml') - const run = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => { + const run = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { const manifest = readProfileManifest('test', dir) const completion = { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') } - if (args[0] === 'remove') { - delete manifest.dependencies?.addon + manifest.dependencies = { ...manifest.dependencies, addon: '1.0.0' } + if (run.mock.calls.length === 1) { + writeFileSync(policy, 'allowBuilds:\n native: set this to true or false\n denied: false\n') + completion.exitCode = 1 + completion.output = 'ERR_PNPM_IGNORED_BUILDS' } else { - manifest.dependencies = { ...manifest.dependencies, addon: '1.0.0' } - if (run.mock.calls.length === 1) { - writeFileSync(policy, 'allowBuilds:\n native: set this to true or false\n denied: false\n') - completion.exitCode = 1 - completion.output = 'ERR_PNPM_IGNORED_BUILDS' - } else { - expect(parse(readFileSync(policy, 'utf8'))).toEqual({ allowBuilds: { native: true, denied: false } }) - bundle('addon', []) - } + expect(parse(readFileSync(policy, 'utf8'))).toEqual({ allowBuilds: { native: true, denied: false } }) + bundle('addon', []) } writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) return completion }) onTestFinished(() => { run.mockRestore() }) - expect(await manager.installBundle('addon')).toMatchObject({ - application: 'failed', pendingBuilds: ['native'], cleanup: { name: 'addon' }, - }) + // The failed run's manifest change is put back; the policy pnpm wrote stays, so its undecided names can be offered. + expect(await manager.installBundle('addon')).toMatchObject({ application: 'failed', pendingBuilds: ['native'] }) expect(readProfileManifest('test', dir).dependencies).not.toHaveProperty('addon') expect(parse(readFileSync(policy, 'utf8'))).toMatchObject({ allowBuilds: { native: 'set this to true or false' } }) expect(await manager.installBundle('addon', { approvedBuilds: ['denied'] })).toMatchObject({ application: 'failed', changed: false, error: { code: 'stale-approval' } }) - expect(run).toHaveBeenCalledTimes(2) - expect(await manager.installBundle('addon', { approvedBuilds: ['native'], enabled: false })).toMatchObject({ application: 'applied', changed: true }) + expect(run).toHaveBeenCalledTimes(1) + expect(await manager.installBundle('addon', { approvedBuilds: ['native'], enabled: false })).toMatchObject({ + application: 'applied', changed: true, approvedBuilds: ['native'], + }) expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain('addon') }) @@ -184,7 +218,7 @@ it('retains approved policy and reports it as changed when the registry fails be expect(parse(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8'))).toEqual({ allowBuilds: { native: true } }) }) -it('runs a real pnpm dependency script only after approval and cleanup retry', async () => { +it('runs a real pnpm dependency script only after approval and retry', async () => { const { manager, dir, profile } = await fixture('startup') const addon = join(profile.cwd, 'addon') mkdirSync(addon) @@ -198,7 +232,7 @@ it('runs a real pnpm dependency script only after approval and cleanup retry', a policy.set('storeDir', join(profile.cwd, 'store')) writeFileSync(join(dir, 'pnpm-workspace.yaml'), String(policy)) const blocked = await manager.installBundle('file:./addon', { enabled: false }) - expect(blocked, JSON.stringify(blocked)).toMatchObject({ application: 'failed', cleanup: { name: 'approval-fixture-addon' } }) + expect(blocked, JSON.stringify(blocked)).toMatchObject({ application: 'failed', packageResult: { kind: 'build-blocked' } }) expect(blocked.pendingBuilds).toHaveLength(1) const built = join(dir, 'node_modules', 'approval-fixture-addon', 'built.txt') expect(existsSync(built)).toBe(false) @@ -208,6 +242,14 @@ it('runs a real pnpm dependency script only after approval and cleanup retry', a expect(readFileSync(built, 'utf8')).toBe('built') }) +it.each(['[', 'allowBuilds: false\n'])('preserves pnpm diagnostics when pending approvals cannot be read: %s', async (policy) => { + const { manager, dir } = await fixture() + writeFileSync(join(dir, 'pnpm-workspace.yaml'), policy) + const run = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'original pnpm failure', truncated: false, logPath: '/log' }) + onTestFinished(() => { run.mockRestore() }) + expect(await manager.installBundle('addon')).toMatchObject({ application: 'failed', error: { diagnostic: 'original pnpm failure' } }) +}) + it('unloads before removing packages and retries inactive dependencies whose files are missing', async () => { const { manager, dir, ctx } = await fixture() const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { @@ -231,18 +273,31 @@ it('unloads before removing packages and retries inactive dependencies whose fil expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false) }) -it('reports package failure separately from partial disk changes', async () => { +it('restores the manifest and lockfile after a failed package run, classifying the failure', async () => { const { manager, dir } = await fixture() + const lockPath = join(dir, 'pnpm-lock.yaml') const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { const manifest = readProfileManifest('test', dir) manifest.dependencies = { ...manifest.dependencies, partial: '1' } writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) - return { exitCode: 42, output: 'fetch failed', truncated: false, logPath: join(dir, 'pnpm.log') } + writeFileSync(lockPath, 'partial lockfile\n') + return { exitCode: 42, output: 'ERR_PNPM_META_FETCH_FAIL GET https://registry/partial: ENOTFOUND', truncated: false, logPath: join(dir, 'pnpm.log') } }) onTestFinished(() => { install.mockRestore() }) - expect(await manager.installBundle('partial')).toMatchObject({ changed: true, application: 'failed', packageResult: { exitCode: 42 } }) - expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra']) - expect((await manager.listBundles()).find(row => row.name === 'partial')?.error?.diagnostic).toContain('cannot resolve profile bundle') + const before = readFileSync(join(dir, 'package.json'), 'utf8') + expect(await manager.installBundle('partial')).toMatchObject({ + changed: false, application: 'failed', stage: 'install', target: 'partial', + error: { code: 'operation-error', diagnostic: expect.stringContaining('ENOTFOUND') as string }, + packageResult: { exitCode: 42, kind: 'network' }, + }) + expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) + // A lockfile the run created is removed; one that existed is put back. + expect(existsSync(lockPath)).toBe(false) + writeFileSync(lockPath, 'original lockfile\n') + expect(await manager.installBundle('partial')).toMatchObject({ changed: false, application: 'failed' }) + expect(readFileSync(lockPath, 'utf8')).toBe('original lockfile\n') + expect((await manager.listBundles()).some(row => row.name === 'partial')).toBe(false) + expect(install).toHaveBeenCalledTimes(2) }) it('keeps saved changes after activation failure and allows a corrected configuration to retry', async () => { @@ -257,13 +312,14 @@ it('keeps saved changes after activation failure and allows a corrected configur }) -it('reports plain dependencies, missing versions and invalid selected bundles distinctly', async () => { +it('reports a selected plain dependency as a problem, omits an unselected one, and reports missing versions', async () => { const { manager, dir, profile } = await fixture() writeFileSync(profile.installAnchor, '{}') writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), '{"name":"extra"}') expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ enabled: true, error: { code: 'not-bundle' } }) expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ application: 'applied' }) - expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ enabled: false, removable: true, error: { code: 'not-bundle' } }) + // Switched off, a dependency without a bundle patch is a library the page has no business with. + expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false) expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed' }) writeFileSync(join(dir, 'node_modules', 'core', 'package.json'), '{"name":"core","dsh":{"bundle":{"patch":"./cordis.patch.yml"}}}') expect((await manager.listBundles())[0]?.version).toBeUndefined() @@ -322,37 +378,27 @@ it('addresses children inside profile groups and marks ambiguous ids read-only', it.each(['', '-g'])('rejects an invalid installation spec before calling pnpm: %j', async (spec) => { const { manager } = await fixture() - expect(await manager.installBundle(spec)).toMatchObject({ changed: false, application: 'failed' }) + expect(await manager.installBundle(spec)).toMatchObject({ changed: false, application: 'failed', error: { code: 'invalid-spec' } }) }) -it.each([0, 1])('cleans a newly added non-bundle once and exposes leftovers when cleanup exits %i', async (cleanupExit) => { +it('restores the manifest when the package pnpm added declares no bundle', async () => { const { manager, dir, bundle } = await fixture() - const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => { + const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { + bundle('plain', []) + writeFileSync(join(dir, 'node_modules', 'plain', 'package.json'), '{"name":"plain"}') const manifest = readProfileManifest('test', dir) - if (args[0] === 'add') { - bundle('plain', []) - writeFileSync(join(dir, 'node_modules', 'plain', 'package.json'), '{"name":"plain"}') - manifest.dependencies = { ...manifest.dependencies, plain: '1' } - } else if (cleanupExit === 0) { - delete manifest.dependencies?.plain - rmSync(join(dir, 'node_modules', 'plain'), { recursive: true }) - } + manifest.dependencies = { ...manifest.dependencies, plain: '1' } writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) - return { exitCode: args[0] === 'add' ? 0 : cleanupExit, output: 'package diagnostic', truncated: false, logPath: join(dir, `${args[0]}.log`) } + return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') } }) onTestFinished(() => { install.mockRestore() }) - expect(await manager.installBundle('plain')).toMatchObject({ changed: cleanupExit !== 0, application: 'failed', stage: 'install', - error: { code: 'not-bundle' }, packageResult: { exitCode: 0 }, - cleanup: { name: 'plain', packageResult: { exitCode: cleanupExit } }, - remainingDependencies: cleanupExit === 0 ? [] : ['plain'] }) - expect(install.mock.calls.map(call => call[1])).toEqual([['add', 'plain'], ['remove', 'plain']]) - expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra']) - const leftover = (await manager.listBundles()).find(row => row.name === 'plain') - if (cleanupExit === 0) expect(leftover).toBeUndefined() - else { - expect(leftover).toMatchObject({ enabled: false, removable: true, error: { code: 'not-bundle' } }) - expect(await manager.removeBundle('plain')).toMatchObject({ stage: 'remove', application: 'failed' }) - } + expect(await manager.installBundle('plain')).toMatchObject({ + changed: false, application: 'failed', stage: 'install', error: { code: 'not-bundle' }, packageResult: { exitCode: 0 }, + }) + // The manifest is put back rather than cleaned through another pnpm run. + expect(install).toHaveBeenCalledOnce() + expect(readProfileManifest('test', dir)).toMatchObject({ dependencies: { extra: '1.0.0' }, dsh: { profile: { bundles: ['core', 'extra'] } } }) + expect((await manager.listBundles()).some(row => row.name === 'plain')).toBe(false) }) it('never removes an existing dependency after installation validation fails', async () => { @@ -362,7 +408,6 @@ it('never removes an existing dependency after installation validation fails', a onTestFinished(() => { install.mockRestore() }) const result = await manager.installBundle('extra') expect(result).toMatchObject({ application: 'failed', stage: 'install', error: { code: 'not-bundle' } }) - expect(result.cleanup).toBeUndefined() expect(install).toHaveBeenCalledOnce() expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' }) }) @@ -378,8 +423,7 @@ it('keeps a valid installed bundle when its subsequent activation fails', async }) onTestFinished(() => { install.mockRestore() }) const result = await manager.installBundle('broken') - expect(result).toMatchObject({ application: 'failed', stage: 'enable', packageResult: { exitCode: 0 } }) - expect(result.cleanup).toBeUndefined() + expect(result).toMatchObject({ application: 'failed', stage: 'enable', target: 'broken', bundle: 'broken', packageResult: { exitCode: 0 } }) expect(install).toHaveBeenCalledOnce() expect((await manager.listBundles()).find(row => row.name === 'broken')).toMatchObject({ enabled: true, removable: true }) }) @@ -421,12 +465,179 @@ it('reports repeated installs as requiring restart and ambiguous package changes onTestFinished(() => { install.mockRestore() }) expect(await manager.installBundle('extra')).toMatchObject({ changed: false, application: 'restart-required' }) expect(await manager.installBundle('extra@1')).toMatchObject({ changed: false, application: 'restart-required' }) - expect(await manager.installBundle('extra-long@1')).toMatchObject({ changed: false, application: 'failed' }) + expect(await manager.installBundle('extra-long@1')).toMatchObject({ changed: false, application: 'failed', error: { code: 'ambiguous-install' } }) install.mockImplementationOnce(async () => { writeFileSync(join(dir, 'package.json'), '{}') return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') } }) - expect(await manager.installBundle('unknown')).toMatchObject({ changed: true, application: 'failed' }) + expect(await manager.installBundle('unknown')).toMatchObject({ changed: false, application: 'failed', error: { code: 'ambiguous-install' } }) + expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' }) +}) + +it('streams pnpm output, reports the installation phases, and names the installed bundle', async () => { + const { ctx, manager, dir, bundle } = await fixture() + const chunks: PluginInstallLogChunk[] = [] + const phases: PluginInstallProgress[] = [] + const changes: PluginChange[] = [] + ctx.on('plugin-manager/install-log', (chunk) => { chunks.push(chunk) }) + ctx.on('plugin-manager/install-state', (progress) => { phases.push(progress) }) + ctx.on('plugin-manager/changed', (change) => { changes.push(change) }) + const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args, options) => { + options.onOutput?.('Progress: resolved 1\n', 'stdout') + options.onOutput?.('warning\n', 'stderr') + const name = String(args[1]) + bundle(name, [{ id: name, name: './plugin.mjs', config: { service: name } }]) + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') } + }) + onTestFinished(() => { install.mockRestore() }) + const requestId = 'f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId + expect(await manager.installBundle('streamed', { enabled: false, requestId })).toMatchObject({ application: 'applied', changed: true, bundle: 'streamed' }) + expect(install).toHaveBeenCalledWith(expect.objectContaining({ profile: 'test' }), ['add', 'streamed'], + expect.objectContaining({ command: 'pnpm', execution: 'service' })) + const jobId = chunks[0]?.jobId + expect(chunks).toEqual([ + { requestId, jobId, argv: ['pnpm', 'add', 'streamed'], cwd: dir, stream: 'stdout', text: 'Progress: resolved 1\n' }, + { requestId, jobId, argv: ['pnpm', 'add', 'streamed'], cwd: dir, stream: 'stderr', text: 'warning\n' }, + { requestId, jobId, argv: ['pnpm', 'add', 'streamed'], cwd: dir, stream: 'stdout', text: '', exitCode: 0 }, + ]) + expect(phases).toEqual([{ requestId, phase: 'installing' }, { requestId, phase: 'applying' }]) + expect(changes).toEqual([{ reason: 'install' }]) + // A run without a request id streams too, unidentified. + await manager.removeBundle('streamed') + expect(chunks.at(-1)).toMatchObject({ argv: ['pnpm', 'remove', 'streamed'], stream: 'stdout', exitCode: 0 }) + expect(chunks.at(-1)).not.toHaveProperty('requestId') + expect(changes).toEqual([{ reason: 'install' }, { reason: 'remove' }]) +}) + +it('stops a run on request, restores the files, and answers not-running or too-late otherwise', async () => { + const { ctx, manager, dir, bundle } = await fixture() + const phases: PluginInstallProgress[] = [] + ctx.on('plugin-manager/install-state', (progress) => { phases.push(progress) }) + const started = Promise.withResolvers() + const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, _args, options) => { + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, slow: '1' } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + started.resolve(undefined) + await new Promise((resolve) => { options.signal?.addEventListener('abort', () => { resolve(undefined) }, { once: true }) }) + return { exitCode: 1, output: 'killed', truncated: false, logPath: join(dir, 'pnpm.log') } + }) + onTestFinished(() => { install.mockRestore() }) + const requestId = 'f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId + const before = readFileSync(join(dir, 'package.json'), 'utf8') + const run = manager.installBundle('slow', { requestId }) + await started.promise + expect(await manager.cancelInstall('00000000-0000-4000-8000-000000000000' as PluginInstallRequestId)).toEqual({ status: 'not-running' }) + expect(await manager.cancelInstall(requestId)).toEqual({ status: 'cancelled' }) + expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) + const cancelled = await run + expect(cancelled).toMatchObject({ application: 'cancelled', changed: false, stage: 'install', packageResult: { exitCode: 1 } }) + expect(cancelled.error).toBeUndefined() + expect(phases).toEqual([{ requestId, phase: 'installing' }, { requestId, phase: 'cancelling' }]) + expect(await manager.cancelInstall(requestId)).toEqual({ status: 'not-running' }) + // Once pnpm has exited and the bundle is being applied, the run cannot be stopped. + install.mockImplementation(async (_context, args) => { + const name = String(args[1]) + bundle(name, [{ id: name, name: './plugin.mjs', config: { service: name } }]) + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') } + }) + let tooLate: Promise | undefined + ctx.on('plugin-manager/install-state', (progress) => { if (progress.phase === 'applying') tooLate = manager.cancelInstall(progress.requestId) }) + expect(await manager.installBundle('late', { requestId })).toMatchObject({ application: 'applied', bundle: 'late' }) + expect(await tooLate).toEqual({ status: 'too-late' }) + // A request aborted before its turn under the lock never starts pnpm. + const early = manager.installBundle('never', { requestId }) + const queued = manager.installBundle('after', { requestId: '11111111-1111-4111-8111-111111111111' as PluginInstallRequestId }) + expect(await manager.cancelInstall('11111111-1111-4111-8111-111111111111' as PluginInstallRequestId)).toEqual({ status: 'cancelled' }) + await early + expect(await queued).toMatchObject({ application: 'cancelled', changed: false }) +}) + +it('reads what a spec names before installing it', async () => { + const { manager, dir, profile } = await fixture(undefined, false, undefined, { inspectTimeoutMs: 1000, pnpmCommand: 'pnpm-test' }) + const view = vi.spyOn(operations, 'viewProfilePackage') + onTestFinished(() => { view.mockRestore() }) + const answers = (stdout: string) => view.mockResolvedValueOnce({ exitCode: 0, stdout, stderr: '', timedOut: false }) + answers(JSON.stringify({ name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', dsh: { bundle: { patch: './cordis.patch.yml' } } })) + expect(await manager.inspect('dsh-x')).toEqual({ + status: 'accepted', kind: 'registry', name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', bundle: true, + }) + expect(view).toHaveBeenCalledWith(dir, 'dsh-x', { command: 'pnpm-test', timeoutMs: 1000 }) + 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', signal)).toEqual({ status: 'refused', problem: 'not-a-bundle', reason: 'dsh-lib declares no dsh.bundle' }) + expect(view).toHaveBeenLastCalledWith(dir, 'dsh-lib@^1', { command: 'pnpm-test', timeoutMs: 1000, signal }) + // 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 }) + const failure = (stderr: string, exitCode: number | null = 1, more: Partial = {}) => + view.mockResolvedValueOnce({ exitCode, stdout: '', stderr, timedOut: false, ...more }) + failure('npm error code E404\nnpm error 404 Not Found - GET https://registry/nope\n') + expect(await manager.inspect('nope')).toMatchObject({ status: 'refused', problem: 'not-found', reason: expect.stringContaining('E404') as string }) + failure('ERR_PNPM_NO_MATCHING_VERSION No matching version found for old@9\n') + expect(await manager.inspect('old@9')).toMatchObject({ status: 'refused', problem: 'not-found' }) + failure('ERR_PNPM_META_FETCH_FAIL request failed, reason: getaddrinfo ENOTFOUND registry\n') + expect(await manager.inspect('far')).toMatchObject({ status: 'refused', problem: 'network' }) + view.mockResolvedValueOnce({ exitCode: 3, stdout: 'plain text\n', stderr: '', timedOut: false }) + expect(await manager.inspect('odd')).toEqual({ status: 'refused', problem: 'unknown', reason: 'plain text' }) + failure('', 4) + expect(await manager.inspect('quiet')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view exited with 4' }) + failure('', null, { timedOut: true }) + expect(await manager.inspect('slow')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view timed out after 1000ms' }) + failure('', null, { cause: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) }) + expect(await manager.inspect('gone')).toMatchObject({ status: 'refused', problem: 'unknown', reason: expect.stringContaining('ENOENT') as string }) + answers('not json') + expect(await manager.inspect('garbled')).toMatchObject({ status: 'refused', problem: 'unknown', reason: expect.stringContaining('unreadable pnpm view output') as string }) + answers('"just a string"') + expect(await manager.inspect('scalar')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view answered no package' }) + answers('') + expect(await manager.inspect('silent')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view answered no package' }) + // What is installed, or supplied by the installation, is refused before the registry is asked. + expect(await manager.inspect('extra')).toEqual({ status: 'refused', problem: 'already-installed', reason: 'extra is already installed' }) + expect(await manager.inspect('./relative')).toEqual({ status: 'refused', problem: 'invalid-spec', reason: 'a local path must be absolute' }) + expect(await manager.inspect('github:acme/dsh-remote')).toEqual({ status: 'accepted', kind: 'git', bundle: null }) + const tarball = join(profile.home, 'pack.tgz') + expect(await manager.inspect(tarball)).toEqual({ status: 'refused', problem: 'not-a-package', reason: 'the tarball does not exist' }) + writeFileSync(tarball, '') + expect(await manager.inspect(tarball)).toEqual({ status: 'accepted', kind: 'tarball', bundle: null }) + expect(await manager.inspect('https://cdn.example.com/x/y/z/dsh-x-1.0.0.tgz')).toEqual({ status: 'accepted', kind: 'tarball', bundle: null }) + // A directory answers from its own manifest. + const local = join(profile.home, 'dev', 'dsh-local') + mkdirSync(local, { recursive: true }) + expect(await manager.inspect(join(profile.home, 'dev', 'missing'))).toEqual({ status: 'refused', problem: 'not-a-package', reason: 'the path does not exist' }) + expect(await manager.inspect(local)).toMatchObject({ status: 'refused', problem: 'not-a-package', reason: expect.stringContaining('no readable package.json') as string }) + writeFileSync(join(local, 'package.json'), '{"version":"1.0.0"}') + expect(await manager.inspect(local)).toEqual({ status: 'refused', problem: 'not-a-package', reason: 'the package.json names no package' }) + writeFileSync(join(local, 'package.json'), JSON.stringify({ name: 'dsh-local', version: '0.1.0', description: 'Local.' })) + expect(await manager.inspect(local)).toEqual({ status: 'refused', problem: 'not-a-bundle', reason: 'dsh-local declares no dsh.bundle' }) + writeFileSync(join(local, 'package.json'), JSON.stringify({ name: 'dsh-local', version: '0.1.0', description: 'Local.', dsh: { bundle: { patch: './p.yml' } } })) + expect(await manager.inspect(`file:${local}`)).toEqual({ status: 'accepted', kind: 'path', name: 'dsh-local', version: '0.1.0', description: 'Local.', bundle: true }) + writeFileSync(join(local, 'package.json'), JSON.stringify({ name: 'core', dsh: { bundle: { patch: './p.yml' } } })) + expect(await manager.inspect(local)).toEqual({ status: 'refused', problem: 'already-installed', reason: 'core is already installed' }) + // A profile and an installation that list nothing know nothing. + writeFileSync(join(dir, 'package.json'), '{}') + writeFileSync(profile.installAnchor, '{}') + expect(await manager.inspect(local)).toEqual({ status: 'accepted', kind: 'path', name: 'core', bundle: true }) + expect(view).toHaveBeenCalledTimes(13) +}) + +it('announces each manager operation as a change, and a patch generation applied outside it not at all', async () => { + const { ctx, manager, profile } = await fixture('startup') + const changes: PluginChange[] = [] + ctx.on('plugin-manager/changed', (change) => { changes.push(change) }) + await reconcileProfilePatches(ctx, readProfilePatches('test', profile), 'test') + expect(changes).toEqual([]) + const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId + await manager.setPluginEnabled(id, false) + expect(changes).toEqual([{ reason: 'plugin' }]) + await manager.setBundleEnabled('extra', false) + expect(changes).toEqual([{ reason: 'plugin' }, { reason: 'bundle' }]) }) it('handles missing patch files and retains non-Error package diagnostics', async () => { @@ -463,6 +674,29 @@ it('refuses removal of a hot-installed bundle after HMR is disabled', async () = expect(await manager.removeBundle('later')).toMatchObject({ changed: false, application: 'failed' }) }) +it('offers the launcher\'s optional bundles switched off and never removable', async () => { + const { manager, profile } = await fixture() + // The launcher names the bundles the installation ships; the fixture supplies one of them from the + // installation's own node_modules, which the resolver consults before the profile's and before the repository's. + const offered = OPTIONAL_BUNDLES[0]! + const supplied = join(profile.home, 'node_modules', offered) + mkdirSync(supplied, { recursive: true }) + writeFileSync(join(supplied, 'package.json'), JSON.stringify({ + name: offered, version: '3.0.0', description: 'Package one-liner.', dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + writeFileSync(join(supplied, 'cordis.patch.yml'), JSON.stringify([{ insert: [{ id: 'offered-row', name: './plugin.mjs', config: { service: 'offeredProbe' } }] }])) + writeFileSync(join(supplied, 'plugin.mjs'), 'export function apply(ctx, config) { ctx.provide(config?.service ?? "offeredProbe", true) }\n') + writeFileSync(profile.installAnchor, JSON.stringify({ name: 'installation', dependencies: { [offered]: '3.0.0' } })) + expect((await manager.listBundles()).find(row => row.name === offered)).toEqual({ + name: offered, version: '3.0.0', description: 'Package one-liner.', + enabled: false, installed: false, optional: true, removable: false, + rows: [{ rowId: 'offered-row', moduleName: pathToFileURL(join(supplied, 'plugin.mjs')).href }], overrides: [], + }) + expect(await manager.setBundleEnabled(offered, true)).toMatchObject({ application: 'applied' }) + expect((await manager.listBundles()).find(row => row.name === offered)).toMatchObject({ enabled: true, optional: true, removable: false }) + expect(await manager.removeBundle(offered)).toMatchObject({ changed: false, application: 'failed' }) +}) + it('omits installation-owned plain packages from the bundle inventory', async () => { const { manager, dir, profile, bundle } = await fixture() bundle('installation-plain', []) @@ -471,48 +705,6 @@ it('omits installation-owned plain packages from the bundle inventory', async () expect((await manager.listBundles()).some(row => row.name === 'installation-plain')).toBe(false) }) -it.each(['throw', 'remove-manifest-dependencies', 'invalid-manifest'] as const)('preserves the original installation failure when cleanup encounters %s', async (failure) => { - const { manager, dir, profile } = await fixture() - writeFileSync(profile.installAnchor, '{}') - const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => { - if (args[0] === 'add') { - const manifest = readProfileManifest('test', dir) - manifest.dependencies = { ...manifest.dependencies, partial: '1' } - writeFileSync(join(dir, 'package.json'), failure === 'invalid-manifest' ? '{' : JSON.stringify(manifest)) - } else if (failure === 'throw') throw new Error('cleanup unavailable') - else writeFileSync(join(dir, 'package.json'), '{}') - return { exitCode: args[0] === 'add' ? 42 : 0, output: 'fetch failed', truncated: false, logPath: '/operation.log' } - }) - onTestFinished(() => { install.mockRestore() }) - const result = await manager.installBundle('partial') - expect(result).toMatchObject({ application: 'failed', error: { code: 'operation-error', diagnostic: 'fetch failed' } }) - if (failure === 'throw') { - expect(result.cleanup).toMatchObject({ name: 'partial', error: { diagnostic: 'cleanup unavailable' } }) - expect(result.remainingDependencies).toEqual(['partial']) - } else if (failure === 'invalid-manifest') { - expect(result.warnings?.[0]).toContain('SyntaxError') - expect(install).toHaveBeenCalledOnce() - } else expect(result.remainingDependencies).toEqual([]) -}) - -it.each(['ambiguous', 'selected', 'installation-owned'] as const)('does not clean newly declared dependencies that are %s', async (reason) => { - const { manager, dir, profile } = await fixture() - const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { - const manifest = readProfileManifest('test', dir) - manifest.dependencies = { ...manifest.dependencies, partial: '1', ...reason === 'ambiguous' ? { another: '1' } : {} } - if (reason === 'selected') manifest.dsh!.profile!.bundles!.push('partial') - if (reason === 'installation-owned') writeFileSync(profile.installAnchor, '{"dependencies":{"partial":"1"}}') - writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) - return { exitCode: 42, output: 'fetch failed', truncated: false, logPath: '/operation.log' } - }) - onTestFinished(() => { install.mockRestore() }) - const result = await manager.installBundle('partial') - expect(result.application).toBe('failed') - expect(result.cleanup).toBeUndefined() - expect(result.remainingDependencies).toContain('partial') - expect(install).toHaveBeenCalledOnce() -}) - it('does not delete a bundle retained by a higher-priority overlay', async () => { const { ctx, manager, overlays, dir } = await fixture() const entry = [...ctx.loader.entries()].find(row => row.id === 'include:managed')! @@ -550,11 +742,3 @@ it('applies watched configuration while pnpm installation is still running', asy expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra', 'new-bundle']) expect(ctx.get('managedProbe')).toBeUndefined() }) - -it.each(['[', 'allowBuilds: false\n'])('preserves pnpm diagnostics when pending approvals cannot be read: %s', async (policy) => { - const { manager, dir } = await fixture() - writeFileSync(join(dir, 'pnpm-workspace.yaml'), policy) - const run = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'original pnpm failure', truncated: false, logPath: '/log' }) - onTestFinished(() => { run.mockRestore() }) - expect(await manager.installBundle('addon')).toMatchObject({ application: 'failed', error: { diagnostic: 'original pnpm failure' } }) -}) diff --git a/packages/boot/plugin-manager/tests/operations.spec.ts b/packages/boot/plugin-manager/tests/operations.spec.ts index 90ea282e41..74b399f148 100644 --- a/packages/boot/plugin-manager/tests/operations.spec.ts +++ b/packages/boot/plugin-manager/tests/operations.spec.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { PassThrough } from 'node:stream' import { expect, it, onTestFinished, vi } from 'vitest' import { initProfile, readProfileManifest } from '@deepseek-ai/dsh-app-boot' -import { anchorPathSpec, runPluginCommand, runProfilePnpm } from '../src/operations.ts' +import { anchorPathSpec, runPluginCommand, runProfilePnpm, viewProfilePackage } from '../src/operations.ts' const command = vi.hoisted(() => ({ run: vi.fn<(...args: unknown[]) => ReturnType>() })) vi.mock('execa', () => ({ execa: (...args: unknown[]) => command.run(...args) })) @@ -211,3 +211,25 @@ it('settles inherited CLI descriptors without requiring captured streams', async ) as unknown as ReturnType) expect(await runPluginCommand(context, ['approve-builds'], { execution: 'cli', outputBytes: 100 })).toMatchObject({ exitCode: 0, output: '' }) }) + +it('asks the registry through pnpm view in the profile directory and reports how the lookup ended', async () => { + 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(command.run).toHaveBeenLastCalledWith('pnpm', ['view', 'x@^1', 'name', 'version', 'description', 'dsh', '--json'], expect.objectContaining({ + cwd: dir, timeout: 5, reject: false, stdin: 'ignore', + })) + expect((command.run.mock.lastCall as unknown[])[2]).not.toHaveProperty('cancelSignal') + 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((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((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 }) + expect(missing).toMatchObject({ exitCode: null, timedOut: false }) + expect(missing.cause).toMatchObject({ message: 'spawn pnpm ENOENT', code: 'ENOENT' }) +}) diff --git a/packages/boot/plugin-manager/tsconfig.json b/packages/boot/plugin-manager/tsconfig.json index e74b867881..0ed6b9ecaa 100644 --- a/packages/boot/plugin-manager/tsconfig.json +++ b/packages/boot/plugin-manager/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../../vendor/timer" + }, + { + "path": "../../util/brand" } ] } diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 39ddaa47ad..d216de7755 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -245,6 +245,14 @@ - id: ui-settings-models name: '@deepseek-ai/dsh-client-ui-settings-models' + # Plugin management: the sidebar Plugins page installs, enables, disables, + # and removes the profile's bundles through the `pluginManager` Remote. + # Without a profile-backed Host it reports itself unavailable. + - id: ui-plugin-manager + name: '@deepseek-ai/dsh-client-ui-plugin-manager' + + # Read-only Plugin list tab in the Settings Plugins section; the built-in + # bundles the sidebar page leaves out are inspected here. - id: ui-settings-plugin-inventory name: '@deepseek-ai/dsh-client-ui-settings-plugin-inventory' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index d1bf1848ad..263a507063 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -79,6 +79,7 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 729dcb388e..d8bd6f1f2a 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: 75b33f62031fdd02e1594bbe6042df4735362183 -README.zh.md: 3cbd63f46ee3f0177b5b31715448a79187df1b02 +README.md: 42b9fc8bf7592a9e9d47281cf11c602a09028136 +README.zh.md: 5e135c26ec37579026352402b73cc021eafcbc68 diff --git a/packages/client/README.md b/packages/client/README.md index 75b33f6203..42b9fc8bf7 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -72,6 +72,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas | — | | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section | — | | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — | +| [`ui-plugin-manager/`](ui-plugin-manager/README.md) | Contributes the sidebar Plugins panel: install, enable, disable, retry, and compose installed packages | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — | | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — | | [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 3cbd63f46e..5e135c26ec 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -72,6 +72,7 @@ kind: "package-group" | [`ui-settings/`](ui-settings/README.zh.md) | 承载设置界面及其扩展区域 | — | | [`ui-settings-general/`](ui-settings-general/README.zh.md) | 提供常规设置分区 | — | | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — | +| [`ui-plugin-manager/`](ui-plugin-manager/README.zh.md) | 贡献侧栏的“插件”面板:安装、启用、停用、重试与组合已安装的包 | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向「插件」设置贡献只读的 Host Loader 清单标签页 | — | | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — | | [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — | diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 0f2e3f9d72..c1a6606d86 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -153,6 +153,7 @@ export const zh = { 'queue.steerFailed': '插话发送失败,请重试。', 'terminal.signal': '信号 {signal}', 'terminal.exitCode': '退出码 {code}', + 'terminal.noExitCode': '未正常退出', 'terminal.running': '运行中', 'terminal.failed': '失败', 'terminal.done': '已完成', @@ -313,6 +314,7 @@ export const en = { 'queue.steerFailed': 'Steering failed. Try again.', 'terminal.signal': 'signal {signal}', 'terminal.exitCode': 'exit code {code}', + 'terminal.noExitCode': 'no exit code', 'terminal.running': 'Running', 'terminal.failed': 'Failed', 'terminal.done': 'Done', diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml new file mode 100644 index 0000000000..507f807683 --- /dev/null +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md +README.md: 313ebf7df21ea73c0c20b1c84cac6767c2079037 +README.zh.md: 81b15b459d8bea4721ed2f84607b1c737101b3b8 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md new file mode 100644 index 0000000000..313ebf7df2 --- /dev/null +++ b/packages/client/ui-plugin-manager/README.md @@ -0,0 +1,108 @@ +--- +description: "Manage the profile's plugin bundles and their rows from the Web sidebar." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-plugin-manager + +English | [中文](README.zh.md) + +## Summary + +Use the **Plugins** entry in the Web sidebar to manage the profile's installed bundles and the official bundles the installation ships switched off. Switch bundles and their rows on and off, install a bundle after the Host has read what the spec names, watch pnpm's output, stop a run, and enable what it added. Uninstalling asks for confirmation. Global configuration remains in Settings. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built in** comes first and lists the bundles the installation ships for switching on, each tagged official, off until switched on and without an uninstall; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section. + +### Installing a bundle + +**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Not sure what to enter?** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`): a name the list already shows, a name the registry does not have, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. A failed install says what went wrong in one line — the registry or network could not be reached, the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. + +During installation, **Cancel install** asks the Host to stop the run and shows **Stopping installation…** until the Host confirms. Loading the bundle cannot be cancelled. Once confirmed, the dialog returns to the spec, ready to install again, and a toast says the installation was cancelled; the manifest and lockfile are back as they were, while downloaded files can remain. Closing the dialog is blocked while the Host owns the operation. A connection error does not confirm cancellation: the running screen says so and cancelling can be tried again. + +### Switching a bundle + +A bundle's page shows its full package name under the title, the spec that installs it elsewhere. A bundle's switch changes its layer selection. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The installation's own bundles are not on the page; the Settings Plugins section's Plugin list tab inspects them. + +### Switching one row of a bundle + +A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows show their fiber phase as the Host runs them. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over the row ids. + +----- + + +## Understand the implementation + +Package management uses the profile's dependency records: installed bundles can be toggled and removed; installation-owned bundles remain locked. This distinction does not select startup failure policy. + +
+Implementation internals — click to expand + +### Registration + +The browser plugin registers the `plugins` sidebar entry and its `main` panel through `ctx.slots.inject()`, so both follow late slot declaration, locale changes and teardown. The page is global and belongs to no Session. Display text comes from package metadata and the page's dictionary. + +### The store + +`PluginManagerController` owns the bundle views, busy keys, notices, install progress and the uninstall confirmation. Each read asks the inventory whether the Host manages a profile, then joins `listBundles` with `listPlugins` into one view per bundle, whose rows carry the live entry's enablement and fiber phase. It coalesces overlapping reads, refreshes after operations, on `plugin-manager/changed`, and on reconnect, and ignores late results after disposal. Install output is grouped by job id. The install dialog moves `idle → checking → starting → running → done | failed`, with `cancelling` and `applying` as the Host reports them. The check runs under an `AbortController` that going back or closing aborts, and its settlement is dropped; a run is stopped only through `pluginManager.cancelInstall`, whose answer the dialog waits for. A change the Host could not apply, a restart it waits for, and an override by a higher layer become toasts that retire on their own. + +
+ +----- + + +## Further Exploration + +These pages cover the sidebar, the Remote calls, and the Host-side manager. + +- [ui-sidebar](../ui-sidebar/README.md) — the panel list the Plugins entry registers into; [ui-layout](../ui-layout/README.md) — the main slot the page occupies. +- [api-remotes](../../api/remotes/README.md) — the Remote BFF surface behind `pluginManager.*` and `pluginInventory.*`. +- [plugin-manager](../../boot/plugin-manager/README.md) — the Host-side manager this page drives. + +----- + + +## Model Experience + +None, as the package is a browser-side management surface that registers nothing model-facing. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + + +These limits define the reach of the management view; they are current package constraints. + +- **Only bundles are managed** — a dependency without a bundle patch is refused before it installs; one the profile already holds is left off the page unless the profile selects it, and loading plain plugin modules stays a file operation. +- **Rows show a phase, not a reason** — a failed row reads as failed without the Host's error text; the Host log has it. +- **One install at a time** — the dialog runs one pnpm command; a second spec waits for the first to finish. +- **No version picker** — the spec is typed as pnpm accepts it; the page neither lists registry versions nor offers upgrades. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
+ +**Runtime invariant:** No companion is published. This package owns a sidebar panel over Host-owned facts. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md new file mode 100644 index 0000000000..81b15b459d --- /dev/null +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -0,0 +1,108 @@ +--- +description: "从 Web 侧栏管理 profile 的插件组合包及其行。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-plugin-manager + +[English](README.md) | 中文 + +## 概述 + +使用 Web 侧栏的**插件**入口管理 profile 已安装的组合包,以及安装随附、默认关闭的官方组合包。可以启停组合包及其行、在 Host 读出 spec 指向什么之后安装组合包、查看 pnpm 输出、停止一次运行,并启用它新增的包。卸载会要求确认。全局配置仍在设置中编辑。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置**排在前面,列出安装随附、供开启的组合包,每个带官方标签,开启前保持关闭,且没有卸载;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。 + +### 安装一个组合包 + +**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**不知道该填什么?**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。**安装**先让 Host 读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 + +安装期间可点击**取消安装**,对话框显示**正在停止安装…**,直到 Host 确认。加载组合包的阶段不可取消。确认后对话框回到 spec 输入界面,可再次安装,并用 toast 说明安装已取消;manifest 与 lockfile 已恢复原样,已下载文件可能保留。Host 仍在处理操作时不能关闭对话框。连接错误不代表取消成功:安装中界面会如此说明,可以再次尝试取消。 + +### 切换一个组合包 + +组合包页面在标题下方显示完整包名,也就是在别处安装它所需的 spec。组合包开关改变其层选择。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。随安装提供的组合包不在本页;设置中「插件」分区的「插件列表」标签页负责查看它们。 + +### 切换组合包里的一行 + +组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行按 Host 运行它们的 fiber 阶段显示状态。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按行 id 筛选的输入框。 + +----- + + +## 理解实现 + +插件管理依据 profile 的依赖记录:已安装组合包可启停、可移除,随安装提供的组合包保持锁定。这一区分不决定启动失败策略。 + +
+实现细节——点击展开 + +### 注册 + +浏览器插件通过 `ctx.slots.inject()` 注册 `plugins` 侧栏入口与它的 `main` 面板,使两者跟随 slot 延迟声明、本地化变化与销毁。页面为全局页面,不属于任何 Session。显示文本来自包元信息与页面字典。 + +### store + +`PluginManagerController` 拥有组合包视图、忙碌键、提示、安装进度和卸载确认。每次读取先问清单 Host 是否管理着 profile,再把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,其行携带存活条目的启停状态与 fiber 阶段。它合并重叠读取,在操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并在销毁后忽略晚到结果。安装输出按 job id 分组。安装对话框沿 `idle → checking → starting → running → done | failed` 推进,`cancelling` 与 `applying` 按 Host 的报告呈现。检查在一个 `AbortController` 下运行,返回编辑或关闭会中止它并丢弃其结果;运行只能通过 `pluginManager.cancelInstall` 停止,对话框等待其答复。Host 无法应用的变更、要等重启的变更、被更高层覆盖的变更,都是会自行消失的 toast。 + +
+ +----- + + +## 进一步探索 + +这些页面覆盖侧栏、Remote 调用与 Host 侧管理器。 + +- [ui-sidebar](../ui-sidebar/README.zh.md)——插件入口注册进的面板列表;[ui-layout](../ui-layout/README.zh.md)——页面占用的主 slot。 +- [api-remotes](../../api/remotes/README.zh.md)——`pluginManager.*` 与 `pluginInventory.*` 背后的 Remote BFF 面。 +- [plugin-manager](../../boot/plugin-manager/README.zh.md)——本页驱动的 Host 侧管理器。 + +----- + + +## 模型体验 + +无,本包是浏览器侧的管理界面,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无;本包既不组装也不发送 provider 请求。 + +## 已知限制与延期工作 + + + + +这些限制界定了管理视图的范围;它们是当前包的约束。 + +- **只管理组合包**——没有组合包 patch 的依赖在安装前就被拒绝;profile 里已有的这类依赖不上页面,除非 profile 选中了它;加载普通插件模块仍是文件操作。 +- **行只显示阶段,不显示原因**——失败的行只显示为失败,没有 Host 的错误文本;Host 日志里有。 +- **一次只能安装一个**——对话框一次运行一个 pnpm 命令;第二个 spec 要等前一个完成。 +- **没有版本选择器**——spec 按 pnpm 接受的写法输入;页面不列出注册表版本,也不提供升级。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
+ +**运行时不变式:** 不发布伴生检查。本包只拥有一个基于 Host 事实的侧栏面板。 diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json new file mode 100644 index 0000000000..727cad9599 --- /dev/null +++ b/packages/client/ui-plugin-manager/package.json @@ -0,0 +1,71 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-plugin-manager", + "description": "Plugin management for the dsh web client: the sidebar Plugins panel installs, enables, disables, retries, and composes installed plugin packages", + "version": "0.1.6-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-plugin-manager" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-sidebar" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-plugin-manager": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "clsx": "^2.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-util-crypto": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css new file mode 100644 index 0000000000..e44219c96f --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -0,0 +1,1074 @@ +/* Plugin manager page: a centred column with its header, notices, and the package cards. */ + +.page { + display: flex; + flex-direction: column; + align-items: center; + gap: 32px; + box-sizing: border-box; + height: 100%; + overflow: auto; + padding: 28px clamp(24px, 4vw, 48px) 48px; + color: var(--dsw-alias-label-primary); +} + +.page > * { + width: 100%; + max-width: 960px; +} + +.pageHead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.pageTitle { + margin: 0; + font-size: 20px; + font-weight: 500; + line-height: 28px; +} + +.pageIntro { + margin: 4px 0 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); +} + +.toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 16px; +} + +.status, +.failure p, +.empty { + margin: 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-tertiary); +} + +.failure { + display: flex; + align-items: center; + gap: 10px; + color: var(--dsw-alias-state-error-primary); +} + +.banner { + margin: 0; + padding: 8px 12px; + border-radius: 10px; + font-size: 12px; + line-height: 18px; + background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 12%, transparent); + color: var(--dsw-alias-label-primary); +} + +.group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.groupHead { + display: flex; + align-items: baseline; + gap: 8px; +} + +.groupTitle { + margin: 0; + font-size: 14px; + line-height: 22px; + font-weight: 500; +} + +.count { + font-size: 14px; + color: var(--dsw-alias-label-caption); + font-variant-numeric: tabular-nums; +} + +/* The group's explanation, folded behind a hover/focus tooltip. */ +.groupInfo { + display: inline-flex; + align-items: center; + align-self: center; + color: var(--dsw-alias-label-caption); +} + +.groupInfo:hover, +.groupInfo:focus-visible { + color: var(--dsw-alias-label-secondary); +} + +/* Center the status label geometrically: beside larger titles the capsule's line-box offset shows. */ +.statusTag { + height: 20px; + padding: 0 8px; + line-height: 1; +} + +/* The package an install just enabled: a ring that fades while the list scrolls to it. */ +.card[data-plugin-highlight] { + animation: dsh-plugin-highlight 2400ms ease-out; +} + +@keyframes dsh-plugin-highlight { + 0%, + 55% { + box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); + } + + 100% { + box-shadow: 0 0 0 0 transparent; + } +} + +@media (prefers-reduced-motion: reduce) { + .card[data-plugin-highlight] { + animation: none; + box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); + } +} + +.iconButton:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} + +.cards { + margin: 0; + padding: 0; + list-style: none; +} + +.cards { + display: flex; + flex-direction: column; + gap: 2px; +} + +.card { + min-width: 0; + /* The hover surface reaches 8px past the content on both sides while the + row's content stays aligned with the page header. */ + margin: 0 -8px; + border-radius: 12px; + /* The page itself scrolls on the base surface. Anything that scrolls inside + a card draws the l2 thumb (ui-theme styles/scrollbar.css rebinding contract). */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.cardHead { + display: flex; + align-items: center; + gap: 14px; + padding: 8px; +} + +.cardIcon { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + /* The same hairline frame as the component rows below. */ + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: 10px; + color: var(--dsw-alias-label-secondary); +} + +.cardMain { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.titleRow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.cardTitle { + font-size: 14px; + line-height: 20px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* + * A package card opens its page: the title button stretches over the whole + * card, and the controls at the card's end sit above that stretch, so the + * switch and the Add to… menu keep their own clicks. + */ +.cardLink { + position: relative; +} + +.cardLink:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.cardOpen { + max-width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-weight: 500; + text-align: left; + cursor: pointer; +} + +.cardOpen::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 12px; +} + +.cardOpen:focus-visible { + outline: none; +} + +.cardOpen:focus-visible::after { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 2px; +} + +.cardDesc { + font-size: 13px; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + overflow: hidden; +} + +.cardEnd { + position: relative; + z-index: 1; + display: inline-flex; + flex: none; + align-items: center; + gap: 8px; +} + +/* The toolbar's icon action, round like the message-row actions. */ +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 0; + border-radius: 28px; + background: transparent; + color: var(--dsw-alias-label-caption); + cursor: pointer; +} + +.iconButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + +.iconButton:disabled { + opacity: 0.5; + cursor: default; +} + +.iconWrap { + display: inline-flex; +} + +/* The destructive text action in a card body: red ink inside a 30% red frame, + and the outline hover rebinds to a red wash instead of the gray one. */ +.danger { + color: var(--dsw-alias-state-error-primary); + border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent); + --dsw-alias-interactive-bg-hover: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); +} + +/* Retry, uninstall, and the switch at the end of the page's head. */ +.detailActions { + display: flex; + flex: none; + align-items: center; + gap: 16px; +} + +.actions { + display: flex; + align-items: center; + gap: 16px; +} + +/* The delete of a row the person added: red on its own, not only on hover. */ +.deleteButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-state-error-primary); + cursor: pointer; +} + +.deleteButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger, var(--dsw-alias-interactive-bg-hover)); +} + +.deleteButton:disabled { + opacity: 0.5; + cursor: default; +} + +.deleteButton:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} + + +.reason { + margin: 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-error-primary); + overflow-wrap: anywhere; + /* The Host's captured tail, when no output streamed, keeps its lines. */ + white-space: pre-wrap; +} + +.partsHead .subLabel { + margin: 0; +} + +.partsFilter { + width: 200px; +} + +.partsFilter:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} + +/* Wider than the default dialog: pnpm's lines are long, and the terminal keeps them unwrapped. The card never + outgrows the viewport; the running screens scroll inside it so the head's controls stay reachable. */ +.installDialog { + width: min(600px, 100%); + max-height: calc(100vh - 48px); +} + +.installBody { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +/* Where pnpm installs: the profile directory the first run names. */ +.installLocation { + margin: 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); + overflow-wrap: anywhere; +} + +.installField { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; +} + +.installField input[type='text'] { + height: 36px; + padding: 0 12px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 10px; + background: var(--dsw-alias-bg-layer-3); + font: inherit; + font-size: 13px; + color: var(--dsw-alias-label-primary); +} + +.installField input[aria-invalid='true'] { + border-color: var(--dsw-alias-state-error-primary); +} + +/* The guide under the field: opened by a text button, it says what an ID is and shows the three forms with examples. */ +.guideToggle { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 4px; + padding: 0; + border: 0; + background: none; + font: inherit; + font-size: 12.5px; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.guideToggle:hover { + color: var(--dsw-alias-label-primary); +} + +.guideChevron { + transition: transform 160ms ease; +} + +.guideToggle[aria-expanded='true'] .guideChevron { + transform: rotate(180deg); +} + +.guide { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 12px; + background: var(--dsw-alias-bg-layer-1); +} + +.guideIntro, +.guideHint { + margin: 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +/* The security warning that closes the guide: plugins run with the person's own permissions. */ +.guideSafety { + display: flex; + align-items: flex-start; + gap: 6px; + margin: 0; + padding: 8px 10px; + border-radius: 8px; + background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 12%, transparent); + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)); +} + +.guideSafety > svg { + flex: none; + margin-top: 2px; +} + +.guideNote { + margin: 0; + padding: 8px 10px; + border-radius: 8px; + background: var(--dsw-alias-bg-layer-3); + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); +} + +.guideList { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.guideItem { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 0; + border-top: 0.5px solid var(--dsw-alias-border-l4); +} + +.guideIndex { + flex: none; + width: 20px; + height: 20px; + border-radius: 999px; + corner-shape: round; + background: var(--dsw-alias-bg-layer-3); + font-size: 11px; + line-height: 20px; + text-align: center; + color: var(--dsw-alias-label-tertiary); +} + +.guideMain { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.guideTitle { + font-size: 12.5px; + font-weight: 600; + color: var(--dsw-alias-label-primary); +} + +.guideExample { + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12px; + color: var(--dsw-alias-label-secondary); + overflow-wrap: anywhere; +} + +/* What the check refused, under the field. */ +.inputError { + margin: -4px 0 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-error-primary); +} + +/* The footer's only action spans the dialog, as the primary action of a short form does. */ +.wide { + width: 100%; + justify-content: center; +} + +/* The installing, installed, and failed screens: a head row, the centred + state, the subject card, then the details toggle beside the screen's action. */ +.wizard { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 16px; + min-width: 0; + min-height: 0; + padding: 16px 20px 20px; +} + +/* Everything under the head: it scrolls when the runs' output makes the screen taller than the card. */ +.wizardScroll { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 16px; + min-height: 0; + overflow-y: auto; +} + +.wizardHead { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; +} + +.wizardBack { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0; + border: 0; + background: none; + font: inherit; + font-size: 15px; + font-weight: 600; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.wizardClose { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 6px; + background: none; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.wizardClose:hover { + background: var(--dsw-alias-bg-layer-3); + color: var(--dsw-alias-label-primary); +} + +.wizardHero { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 8px 0 4px; + text-align: center; +} + +.wizardIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + color: var(--dsw-alias-label-secondary); +} + +.wizardIcon[data-tone='done'] { + color: var(--dsw-alias-state-success-primary); +} + +.wizardIcon[data-tone='failed'] { + color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)); +} + +.wizardTitle { + margin: 0; + font-size: 18px; + line-height: 26px; + font-weight: 600; +} + +.wizardSub { + margin: 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); + overflow-wrap: anywhere; +} + +.subject { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 16px; + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: 12px; + text-align: center; +} + +.subjectName { + margin: 0; + font-size: 15px; + line-height: 22px; + font-weight: 600; + overflow-wrap: anywhere; +} + +.subjectDesc, +.subjectMeta { + margin: 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); + overflow-wrap: anywhere; +} + +.wizardFoot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.detailsToggle { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0; + border: 0; + background: none; + font: inherit; + font-size: 13px; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.detailsChevron { + transition: transform 160ms ease; +} + +.detailsToggle[aria-expanded='true'] .detailsChevron { + transform: rotate(180deg); +} + +.detailsBody { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.spinnerLarge { + width: 28px; + height: 28px; + border: 3px solid color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent); + border-top-color: var(--dsw-alias-label-primary); + border-radius: 50%; + corner-shape: round; + animation: spin 900ms linear infinite; +} + +.spinner { + width: 14px; + height: 14px; + flex: none; + border: 2px solid color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent); + border-top-color: var(--dsw-alias-label-primary); + border-radius: 50%; + corner-shape: round; + animation: spin 900ms linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .spinner, + .spinnerLarge { + animation: none; + } + + .detailsChevron { + transition: none; + } +} + +.result, +.resultWarn { + margin: 0; + padding: 8px 12px; + border-radius: 10px; + font-size: 13px; + line-height: 20px; + background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); + color: var(--dsw-alias-label-primary); +} + +.resultWarn { + background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 12%, transparent); +} + +/* The approval a blocked run asks for: the packages whose install scripts pnpm did not run, and the one action. */ +.approval { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px; + border: 0.5px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 40%, transparent); + border-radius: 12px; + background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 8%, transparent); +} + +.approvalTitle { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--dsw-alias-label-primary); +} + +.approvalText { + margin: 0; + font-size: 12.5px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); +} + +.approvalCaution { + margin: 0; + font-size: 12.5px; + line-height: 18px; + font-weight: 600; + color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)); +} + +.approvalList { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.approvalList code { + display: inline-block; + padding: 2px 8px; + border-radius: 6px; + background: var(--dsw-alias-bg-layer-3); + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12px; + color: var(--dsw-alias-label-primary); +} + +/* The install terminal: the shared TerminalBlock at the bash card's smaller + size, its output capped so an expanded log scrolls inside the dialog. */ +.terminal { + --dsl-terminal-font: var(--dsw-font-markdown-code-block-small); + --dsl-terminal-line-height: 18px; + --dsl-terminal-output-max-height: 240px; + margin: 4px 0 0; + border: 0.5px solid var(--dsw-alias-border-l1); +} + +.dependents { + margin: 8px 0 0; + padding-left: 18px; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); +} + +.dangerButton { + --dsw-alias-button-primary-fill: var(--dsw-alias-state-error-primary); + --dsw-alias-button-primary-hover: var(--dsw-alias-state-error-primary); +} + +.detail { + display: flex; + flex-direction: column; +} + +.crumb { + display: inline-flex; + align-items: center; + gap: 6px; + border: 0; + padding: 0; + background: none; + color: var(--dsw-alias-label-tertiary); + font: inherit; + font-size: 12.5px; + cursor: pointer; +} + +.crumb:hover { + color: var(--dsw-alias-label-primary); +} + +.crumb:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 2px; +} + +.crumbIcon { + transform: rotate(90deg); +} + +/* Head row: the pack's icon at one edge, its actions at the other; the name + block sits under the icon (figma detail handoff). */ +.detailHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 32px 0 0; +} + +.detailMain { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + margin-top: 20px; +} + +.detailTitle { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 500; +} + +.versionTag { + flex: none; + padding: 1px 6px; + border-radius: 6px; + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; + font-variant-numeric: tabular-nums; +} + +.detailDesc { + margin: 0; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 22px; +} + +/* The package name under the title: the exact spec that installs the package + elsewhere, where a title of its own or a shortened name hides it. */ +.detailName { + margin: 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-caption); + overflow-wrap: anywhere; +} + +.detailName code { + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); +} + +/* The unknown-package note under the name block. */ +.detail > .detailDesc { + margin-top: 12px; +} + +.detailSections { + display: flex; + flex-direction: column; + gap: 32px; + margin-top: 32px; +} + +.detailSection { + display: flex; + flex-direction: column; + gap: 12px; +} + +.sectionHead { + display: flex; + align-items: baseline; + gap: 10px; +} + +.sectionTitle { + margin: 0; + font-size: 14px; + line-height: 20px; + font-weight: 500; +} + +.sectionCount { + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); +} + +/* The way into a package's page. */ +.cardArrow { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +/* Rows sit on the page ground, one hairline apart, so they read on a white page too. */ +.rows { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.row { + padding: 12px 2px; + border-bottom: 0.5px solid var(--dsw-alias-border-l2); +} + +.row:last-child { + border-bottom: 0; +} + +.rowLine { + display: flex; + align-items: center; + gap: 16px; + min-width: 0; +} + +.rowIcon { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: 10px; + color: var(--dsw-alias-label-secondary); +} + +.rowMain { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.rowId { + font-size: 13.5px; + line-height: 20px; + font-weight: 500; + color: var(--dsw-alias-label-primary); + overflow-wrap: anywhere; +} + +.row[data-state='off'] .rowId { + color: var(--dsw-alias-label-secondary); +} + +.rowModule { + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 11.5px; + line-height: 16px; + color: var(--dsw-alias-label-tertiary); + overflow-wrap: anywhere; +} + +.rowState { + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + font-size: 12.5px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); + white-space: nowrap; +} + +.row[data-state='failed'] .rowState { + color: var(--dsw-alias-state-error-primary); +} + + + +.rowFailure { + margin: 4px 0 0 56px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-error-primary); + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx new file mode 100644 index 0000000000..7fc7facb79 --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -0,0 +1,799 @@ +/** + * Global plugin management: cards for the profile's installed bundles and for + * the official bundles the installation ships switched off, their row + * switches, the install dialog with its guide and folded pnpm output, the + * uninstall confirmation, and the toasts an action's outcome becomes. A + * bundle's page lists the rows it contributes as the Host runs them. + */ + +import { useEffect, useId, useState, type ReactNode } from 'react' +import type { PluginInstallFailureKind } from '@deepseek-ai/dsh-api-remotes/client' +import { + Button, IconCheckOutline16, IconChevronDownOutline14, IconChevronLeftOutline14, IconCloseOutline16, IconCordisPluginOutline14, + IconPluginPinwheelOutline16, IconPlusOutline16, IconRefreshOutline16, IconTrashOutline16, IconWarningOutline16, + Input, Modal, StateDot, Switch, Tag, TerminalBlock, Toast, + type StateDotState, type TerminalBlockLabels, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PluginManagerLocaleKey } from './locales.ts' +import { + isInstallPending, rowKey, + type ConfirmState, type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView, + type PluginManagerFace, +} from './manager-store.ts' +import { managementText, noticeText, shortName, type Translate } from './presentation.ts' +import css from './PluginManagerPage.module.css' + +/** Full component props assembled by the main slot renderer. */ +export type PluginManagerPageProps = + PropsRuntime<'main'> + & PropsLocale<'pluginManager'> + & InjectFace + +type RowPhase = NonNullable + +/** How long the list marks a package an install just enabled. */ +const HIGHLIGHT_MS = 2_400 + +/** How long a toast holds: long enough to read a failure that names what broke. */ +function toastHoldMs(text: string): number { + return Math.min(8_000, Math.max(3_000, text.length * 80)) +} + +const PHASE_KEYS = { + pending: 'rowPhasePending', + loading: 'rowPhaseLoading', + active: 'rowPhaseActive', + failed: 'rowPhaseFailed', + unloading: 'rowPhaseUnloading', +} satisfies Record + +/** Status dot naming a live root-fiber phase: pending and unloading fibers do nothing; only loading is in progress. */ +const PHASE_STATES = { + pending: 'idle', + loading: 'ongoing', + active: 'done', + failed: 'error', + unloading: 'idle', +} satisfies Record + +/** The count line over a pack's components: the total, then only the states that occur. */ +function partsSummary(rows: readonly PackageRow[], t: Translate): string { + const failed = rows.filter(row => row.phase === 'failed').length + const off = rows.filter(row => !row.enabled).length + const running = rows.filter(row => row.enabled && row.phase === 'active').length + return [ + t('partsCountTotal', { count: String(rows.length) }), + ...running > 0 ? [t('partsCountRunning', { count: String(running) })] : [], + ...off > 0 ? [t('partsCountOff', { count: String(off) })] : [], + ...failed > 0 ? [t('partsCountFailed', { count: String(failed) })] : [], + ].join(' · ') +} + +/** Switching for a pack's rows: which rows have a write in flight, and the write. */ +interface RowToggles { + readonly busy: (row: PackageRow) => boolean + readonly onSetEnabled: (row: PackageRow, enabled: boolean) => void +} + +/** Rows beyond this count get a filter box above the list. */ +const ROW_FILTER_THRESHOLD = 10 + +/** A row's switch: locked, saying why, when the Host refuses to address the row through the profile patch. */ +function RowSwitch({ row, t, busy, onChange }: { + readonly row: PackageRow + readonly t: Translate + readonly busy: boolean + readonly onChange: (enabled: boolean) => void +}): ReactNode { + const locked = row.readOnlyReason !== undefined || row.entryId === undefined + return ( + + ) +} + +/** What a row's state line says: off, or the phase its fiber is in. */ +function rowStateText(row: PackageRow, t: Translate): string { + if (!row.enabled) return t('partOff') + return row.phase === null ? t('rowStateIdle') : t(PHASE_KEYS[row.phase]) +} + +/** The dot beside a row: its fiber phase, or idle. */ +function rowDotState(row: PackageRow): StateDotState { + if (!row.enabled || row.phase === null) return 'idle' + return PHASE_STATES[row.phase] +} + +/** + * A pack's rows as a list in the order the pack declares them: a state dot, + * the row id, one line saying its state, and, when the pack is on, a switch. + * A pack like base carries close to a hundred rows, so a long list gets a filter. + */ +function RowsSection({ rows, t, toggle }: { + readonly rows: readonly PackageRow[] + readonly t: Translate + readonly toggle?: RowToggles | undefined +}): ReactNode { + const [filter, setFilter] = useState('') + const query = filter.trim().toLowerCase() + const shown = query === '' ? rows : rows.filter(row => row.rowId.toLowerCase().includes(query)) + return ( +
+
+

{t('partsLabel')}

+ {rows.length === 0 ? null : {partsSummary(rows, t)}} +
+ {rows.length === 0 ?

{t('partsEmpty')}

: null} + {rows.length > ROW_FILTER_THRESHOLD + ? ( + { setFilter(event.target.value) }} + /> + ) + : null} + {rows.length > 0 && shown.length === 0 ?

{t('partsFilterEmpty')}

: null} + {shown.length === 0 + ? null + : ( +
    + {shown.map(row => ( +
  • +
    + +
    + {row.rowId} + {row.moduleName} +
    + + + {rowStateText(row, t)} + + {toggle === undefined + ? null + : { toggle.onSetEnabled(row, enabled) }} />} +
    +
  • + ))} +
+ )} +
+ ) +} + +/** + * A bundle's enable switch on its card and its page: locked, saying why, for + * one the Host protects; off and locked for one it cannot read. + */ +function EnableSwitch({ pkg, title, t, busy, onSetEnabled }: { + readonly pkg: PackageView + readonly title: string + readonly t: Translate + readonly busy: boolean + readonly onSetEnabled: (enabled: boolean) => void +}): ReactNode { + return ( + + ) +} + +/** The status one card carries: running, off, or a problem the Host reported. */ +function packageStatus(pkg: PackageView): 'running' | 'disabled' | 'problem' { + if (pkg.error !== undefined) return 'problem' + return pkg.enabled ? 'running' : 'disabled' +} + +/** One package as a card that opens its page: its name, its one-liner, its tags, and its bundle switch. */ +function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: { + readonly pkg: PackageView + readonly t: Translate + readonly busy: boolean + readonly highlighted: boolean + readonly onOpen: () => void + readonly onSetEnabled: (enabled: boolean) => void +}): ReactNode { + const title = shortName(pkg.name) + const status = packageStatus(pkg) + return ( +
  • +
    + +
    +
    + + {pkg.optional ? {t('statusOfficial')} : null} + {status === 'problem' ? {t('statusProblem')} : null} +
    + {pkg.description === undefined ? null : {pkg.description}} +
    +
    + +
    +
    +
  • + ) +} + +/** + * One package's page: the crumb back to the list; its icon with its switch + * and, for a package the profile installed, uninstall; its title beside its + * version tag, its official tag, and its problem tag; the + * package name the title stands for, which is what installs it elsewhere; + * its one-liner; the Host's problem when it reports one; and its rows with + * their switches. + */ +function PackageDetail({ + pkg, t, busy, rowBusy, + onBack, onSetEnabled, onUninstall, onSetRowEnabled, +}: { + readonly pkg: PackageView + readonly t: Translate + readonly busy: boolean + /** Whether a row has a write in flight. */ + readonly rowBusy: (row: PackageRow) => boolean + readonly onBack: () => void + readonly onSetEnabled: (enabled: boolean) => void + readonly onUninstall: () => void + readonly onSetRowEnabled: (row: PackageRow, enabled: boolean) => void +}): ReactNode { + const title = shortName(pkg.name) + const status = packageStatus(pkg) + return ( +
    + +
    + +
    + {pkg.installed + ? ( + + ) + : null} + +
    +
    +
    +
    +

    {title}

    + {pkg.version === undefined ? null : {t('versionTag', { version: pkg.version })}} + {pkg.optional ? {t('statusOfficial')} : null} + {status === 'problem' ? {t('statusProblem')} : null} +
    +

    {pkg.name}

    +

    {pkg.description ?? t('noDescription')}

    +
    + {pkg.error === undefined ? null :

    {t('reasonLabel')}: {managementText(pkg.error, t)}

    } + {pkg.readOnlyReason === undefined ? null :

    {managementText({ code: pkg.readOnlyReason }, t)}

    } +
    + busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined} + /> +
    +
    + ) +} + +/** Output lines an install run's terminal shows before its middle folds: the first and last six of a long pnpm log. */ +const INSTALL_TERMINAL_LINES = 12 + +/** The install terminal's display copy, from the tab's dictionary. */ +function terminalLabels(t: Translate): TerminalBlockLabels { + return { + /* v8 ignore next -- the Host reports a killed pnpm as a null exit code, never a signal name; the label interface needs one */ + signal: signal => t('terminalSignal', { signal }), + exitCode: code => t('terminalExitCode', { code: String(code) }), + noExitCode: t('terminalNoExitCode'), + running: t('terminalRunning'), + failed: t('terminalFailed'), + done: t('terminalDone'), + copy: t('terminalCopy'), + copied: t('terminalCopied'), + noOutput: t('terminalNoOutput'), + collapseAria: t('terminalCollapseAria'), + collapse: t('terminalCollapse'), + expandAria: hidden => t('terminalExpandAria', { n: String(hidden) }), + expand: hidden => t('terminalExpand', { n: String(hidden) }), + } +} + +/** The sentence under the field for a spec the check refused. */ +const INPUT_PROBLEM_KEYS = { + 'invalid-spec': 'installProblemInvalid', + 'already-installed': 'installProblemInstalled', + 'not-found': 'installProblemNotFound', + 'not-a-package': 'installProblemNotPackage', + 'not-a-bundle': 'installProblemNotBundle', + 'network': 'installProblemNetwork', + 'unknown': 'installProblemUnknown', +} satisfies Record + +/** One row of the install guide: a spec form's title, its example, and where the person finds it. */ +interface GuideExample { + readonly key: string + readonly titleKey: PluginManagerLocaleKey + readonly exampleKey: PluginManagerLocaleKey + readonly hintKey: PluginManagerLocaleKey +} + +/** The spec forms the install guide shows, each with an example the person can drop into the field. */ +const GUIDE_EXAMPLES = [ + { key: 'id', titleKey: 'installGuideIdTitle', exampleKey: 'installGuideIdExample', hintKey: 'installGuideIdHint' }, + { key: 'git', titleKey: 'installGuideGitTitle', exampleKey: 'installGuideGitExample', hintKey: 'installGuideGitHint' }, + { key: 'path', titleKey: 'installGuidePathTitle', exampleKey: 'installGuidePathExample', hintKey: 'installGuidePathHint' }, +] as const satisfies readonly GuideExample[] + +/** The one-line reading of a classified pnpm failure. */ +const FAILURE_KIND_KEYS = { + 'pnpm-missing': 'installFailurePnpmMissing', + 'timeout': 'installFailureTimeout', + 'not-found': 'installFailureNotFound', + 'no-matching-version': 'installFailureNoMatchingVersion', + 'network': 'installFailureNetwork', + 'disk-full': 'installFailureDiskFull', + 'permission': 'installFailurePermission', + 'build-blocked': 'installFailureBuildBlocked', + 'integrity': 'installFailureIntegrity', + 'unknown': 'installFailureGeneric', +} satisfies Record + +/** The heading of each screen past the spec. */ +const SCREEN_TITLE_KEYS = { + starting: 'installStarting', + running: 'installingTitle', + cancelling: 'installCancelling', + applying: 'installApplying', + done: 'installedTitle', + failed: 'installFailedTitle', +} satisfies Record, PluginManagerLocaleKey> + +/** What the spec's kind reads as when the package carries no description of its own. */ +const SUBJECT_KIND_KEYS = { + registry: undefined, + path: 'installSubjectPath', + git: 'installSubjectGit', + tarball: 'installSubjectTarball', +} satisfies Record + +/** + * The failed screen's one line: a pnpm failure by its kind, a refusal by its + * code, any other failure in the Host's words; the run's output stays behind the details. + */ +function failureText(failure: InstallState['failure'], t: Translate): string { + if (failure === null) return t('installFailureGeneric') + // Blocked scripts the Host could not name leave the person to allow them in the profile's pnpm settings by hand. + if (failure.kind === 'build-blocked' && !failure.pendingBuilds?.length) return t('installFailureBuildBlockedManual') + if (failure.kind !== undefined) return t(FAILURE_KIND_KEYS[failure.kind]) + if (failure.code !== undefined) return managementText({ code: failure.code, diagnostic: failure.reason }, t) + return failure.reason === '' ? t('installFailureGeneric') : failure.reason +} + +/** The package the install is about: its name, one-liner, and version, as the Host read them before installing. */ +function SubjectCard({ subject, t }: { readonly subject: InstallSubject; readonly t: Translate }): ReactNode { + const title = subject.name ?? subject.spec + const kindKey = SUBJECT_KIND_KEYS[subject.kind] + const description = subject.description ?? (kindKey === undefined ? undefined : t(kindKey)) + return ( +
    +

    {title}

    + {description === undefined ? null :

    {description}

    } + {subject.version === undefined ? null :

    {t('installVersion', { version: subject.version })}

    } +
    + ) +} + +/** + * The install dialog: the spec and its check, then the installing, installed, + * and failed screens over the same subject card. A failed run that left + * install scripts undecided shows them for approval in place of plain retry. + */ +function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onToggleDetails, onEnableNow, onApproveBuilds }: { + readonly install: InstallState + readonly t: Translate + readonly onClose: () => void + readonly onEditSpec: (text: string) => void + readonly onRun: () => void + readonly onCancel: () => void + readonly onToggleDetails: () => void + readonly onEnableNow: () => void + readonly onApproveBuilds: () => void +}): ReactNode { + const errorId = useId() + const guideId = useId() + const approvalId = useId() + const [guideOpen, setGuideOpen] = useState(false) + const { phase } = install + if (phase === 'idle' || phase === 'checking') { + const checking = phase === 'checking' + const empty = install.spec.trim() === '' + return ( + + {checking ? + ) + } + const heading = t(SCREEN_TITLE_KEYS[phase]) + const pending = isInstallPending(phase) + // Only a run the Host acknowledged can be stopped; before that, and while it stops or applies, the controls wait. + const stoppable = phase === 'running' || phase === 'failed' + const unconfirmed = install.failure?.cancelUnconfirmed === true ? install.failure.reason : undefined + const pendingBuilds = phase === 'failed' ? install.failure?.pendingBuilds ?? [] : [] + const approvable = pendingBuilds.length > 0 + const firstRun = install.runs[0] + return ( + +
    +
    + {phase === 'done' + ? + : ( + + )} + +
    +
    +
    +
    + {install.subject === null ? null : } + {approvable + ? ( +
    +

    {t('installApprovalTitle')}

    +

    {t('installApprovalDescription')}

    +
      + {pendingBuilds.map(name =>
    • {name}
    • )} +
    +

    {t('installApprovalConsequence')}

    +

    {t('installApprovalCaution')}

    + +
    + ) + : null} + {phase === 'done' && install.installed === null + ?

    {t('installDoneNothing')}

    + : null} + {phase === 'done' && install.restartRequired + ?

    {t('installDoneRestart')}

    + : null} + {phase === 'done' && install.approvedBuilds.length > 0 + ?

    {t('installDoneApproved', { names: install.approvedBuilds.join(', ') })}

    + : null} +
    + + {pending + ? ( + + ) + : null} + {phase === 'failed' && !approvable ? : null} +
    + {install.detailsOpen + ? ( +
    +

    {firstRun === undefined ? t('terminalNoOutput') : t('installLocation', { dir: firstRun.cwd })}

    + {install.runs.map(run => ( + + ))} +
    + ) + : null} + {phase !== 'done' + ? null + : install.installed !== null + ? + : } +
    +
    +
    + ) +} + +/** The confirmation an uninstall waits on. */ +function ConfirmDialog({ confirm, t, onConfirm, onCancel }: { + readonly confirm: ConfirmState + readonly t: Translate + readonly onConfirm: () => void + readonly onCancel: () => void +}): ReactNode { + const name = shortName(confirm.packageName) + return ( + + + + + )} + /> + ) +} + +/** Render the plugin manager: the installed bundles, the install dialog, and the confirmation. */ +export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { + const { t, ensure } = props + const state = props.usePluginManager(snapshot => snapshot) + // The package whose page is open; one that leaves the list (uninstalled) drops back to the cards. + const [openPackage, setOpenPackage] = useState(null) + useEffect(() => { ensure() }, [ensure]) + // A package an install just enabled: scroll it into view and mark it for a moment. + const { highlight, clearHighlight } = { highlight: state.highlight, clearHighlight: props.clearHighlight } + useEffect(() => { + if (highlight === null) return + const card = document.querySelector(`[data-plugin-package="${highlight}"]`) + if (card !== null && typeof card.scrollIntoView === 'function') card.scrollIntoView({ block: 'center', behavior: 'smooth' }) + const timer = setTimeout(clearHighlight, HIGHLIGHT_MS) + return () => { clearTimeout(timer) } + }, [highlight, clearHighlight]) + const noticeLine = state.notice === null ? null : noticeText(state.notice, t) + + // The page manages what the person installed, what the installation ships for them to switch on, and a + // selected name the Host cannot read; the installation's other bundles are inspected in the Settings + // Plugins section's Plugin list tab. + const listed = state.packages.filter(pkg => pkg.installed || pkg.optional || pkg.error !== undefined) + const mine = listed.filter(pkg => pkg.installed || !pkg.optional) + const builtin = listed.filter(pkg => pkg.optional && !pkg.installed) + const loaded = state.status === 'ready' || state.status === 'error' + const openPkg = openPackage === null ? undefined : listed.find(pkg => pkg.name === openPackage) + const setRowEnabled = (row: PackageRow, enabled: boolean): void => { + /* v8 ignore next -- a row without a live entry has its switch disabled */ + if (row.entryId !== undefined) props.setRowEnabled(row.entryId, enabled) + } + // One group of cards under its heading and count; the built-in group comes first, and a group with nothing in it takes no room. + const renderGroup = (id: 'bundles' | 'builtin', heading: string, packages: readonly PackageView[]): ReactNode => packages.length === 0 + ? null + : ( +
    +
    +

    {heading}

    + {packages.length} +
    +
      + {packages.map(pkg => ( + { setOpenPackage(pkg.name) }} + onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }} + /> + ))} +
    +
    + ) + + return ( +
    + {openPkg === undefined + ? ( +
    +
    +

    {t('title')}

    +

    {t('intro')}

    +
    +
    + + +
    +
    + ) + : null} + {state.status === 'loading' ?

    {t('loading')}

    : null} + {state.status === 'unavailable' ?

    {t('unavailable')}

    : null} + {state.status === 'error' + ? ( +
    +

    {t('error')}

    + +
    + ) + : null} + {state.notice === null || noticeLine === null + ? null + : ( + } + holdMs={toastHoldMs(noticeLine)} + onDone={props.dismissNotice} + /> + )} + {loaded && openPkg !== undefined + ? ( + row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))} + onBack={() => { setOpenPackage(null) }} + onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }} + onUninstall={() => { props.uninstall(openPkg.name) }} + onSetRowEnabled={setRowEnabled} + /> + ) + : null} + {loaded && openPkg === undefined + ? listed.length === 0 + ?

    {t('empty')}

    + : ( + <> + {renderGroup('builtin', t('builtinTitle'), builtin)} + {renderGroup('bundles', t('bundlesTitle'), mine)} + + ) + : null} + + {state.confirm === null + ? null + : ( + + )} +
    + ) +} diff --git a/packages/client/ui-plugin-manager/src/client/PluginsPanelIcon.tsx b/packages/client/ui-plugin-manager/src/client/PluginsPanelIcon.tsx new file mode 100644 index 0000000000..c4fec0df7f --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/PluginsPanelIcon.tsx @@ -0,0 +1,15 @@ +/** The sidebar's Plugins entry icon; the sidebar owns the button, label, and selected state around it. */ + +import type { ReactNode } from 'react' +import { IconPluginPinwheelOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' + +/** + * Render the plugin glyph at the size the sidebar asks for. + * @param props - the sidebar's icon share: the requested edge and whether the panel is selected. + * @returns the icon element. + */ +export function PluginsPanelIcon({ size }: PropsRuntime<'sidebar.panellist'>): ReactNode { + return +} diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts new file mode 100644 index 0000000000..7b7e9cc5e7 --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -0,0 +1,90 @@ +/** + * Plugin manager, browser half: the **Plugins** entry of the sidebar and the + * management page it opens in the main column. The page installs, enables, + * disables, and removes the bundles of the Host's profile through the + * `pluginManager` Remote and switches their rows in the profile's user layer. + * Configuring a plugin stays in Settings. + */ + +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: the root `main` keyed slot the page registers into, declared by +// ui-layout with the panel id brand, and the `sidebar.panellist` list the +// entry registers into, declared by ui-sidebar. +import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: the ctx.remote Context merge and the forwarded-event key face. +import type {} from '@deepseek-ai/dsh-api-remotes/client' +// Type-only: the forwarded events' own declaration (`$on`'s key face resolves +// through the owning package's client-safe types subpath). +import type {} from '@deepseek-ai/dsh-plugin-manager/types' +import { PluginManagerPage } from './PluginManagerPage.tsx' +import { PluginsPanelIcon } from './PluginsPanelIcon.tsx' +import { PluginManagerController } from './manager-store.ts' +import { en, zh, type PluginManagerLocaleKey } from './locales.ts' + +export type { PluginManagerPageProps } from './PluginManagerPage.tsx' +export type { PluginManagerFace } from './manager-store.ts' +export type { PluginManagerLocaleKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Plugin manager tab copy. */ + 'pluginManager': PluginManagerLocaleKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'pluginManager' + +/** The id shared by the sidebar entry and the main panel it opens. */ +export const PANEL_ID = 'plugins' as MainPanelId + +/** Services required by the sidebar registration and the Remote methods; the inventory says whether the Host manages a profile. */ +export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory'] + +/** + * Contribute the Plugins entry to the sidebar with the management page it + * opens, and keep it current on the Host's change events. + * @param ctx - the browser plugin context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugin-manager: dictionaries') + const t = ctx.locale.bind(NS) + const controller = new PluginManagerController(ctx) + ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-manager: controller') + // The Host says when what is installed, enabled, or composed changed — from + // this page, the CLI, or another browser — and streams install output. + ctx.effect(() => { + // A page never rendered holds no snapshot to refresh. + const refresh = (): void => { + if (controller.getSnapshot().status !== 'idle') void controller.load() + } + const disposers = [ + 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.on('connection/reset', refresh), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-plugin-manager: host invalidations') + + // The page is a global panel: it belongs to the profile, not to a Session, + // and the sidebar's entry selects it. How a plugin is configured stays in + // Settings; this page is what is installed and switched on. + ctx.slots.inject('main', () => ctx.slots.register({ + name: 'main', + key: PANEL_ID, + locale: NS, + inject: () => controller.inject(), + }, PluginManagerPage)) + ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ + name: 'sidebar.panellist', + id: PANEL_ID, + order: 0, + label: () => t('panel'), + locale: NS, + }, PluginsPanelIcon)) + +} diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts new file mode 100644 index 0000000000..dea192c26f --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -0,0 +1,302 @@ +/** Plugin management copy and display names of shipped global rows. */ + +/** Simplified Chinese dictionary and key source of truth. */ +export const zh = { + panel: '插件', + title: '插件', + intro: '添加和管理插件', + loading: '正在读取插件…', + error: '暂时无法读取插件。', + unavailable: '本部署没有可管理的 profile,无法安装或启停插件。', + retry: '重试', + refresh: '刷新', + empty: '还没有安装任何插件。', + addPlugin: '添加插件', + restartNotice: '更改将在下次启动生效', + overriddenNotice: '{name} 已保存,但被更高优先级的配置覆盖,当前未生效', + bundlesTitle: '已安装', + builtinTitle: '内置', + statusProblem: '异常', + statusOfficial: '官方', + reasonLabel: '原因', + versionTag: 'v{version}', + noDescription: '暂无描述。', + partsLabel: '包含的组件', + partsEmpty: '这个插件包不包含任何组件。', + partsCountTotal: '共 {count} 个', + partsCountRunning: '{count} 运行中', + partsCountOff: '{count} 已停用', + partOff: '已关闭', + partsCountFailed: '{count} 异常', + partsFilter: '筛选组件', + partsFilterEmpty: '没有匹配的组件。', + partToggle: '启用组件 {name}', + rowPhasePending: '等待依赖', + rowPhaseLoading: '加载中', + rowPhaseActive: '运行中', + rowPhaseFailed: '异常', + rowPhaseUnloading: '卸载中', + enableToggle: '启用 {name}', + openDetail: '查看 {name}', + backToList: '返回插件列表', + crumbRoot: '插件列表', + rowStateIdle: '未运行', + uninstall: '卸载', + uninstallLabel: '卸载 {name}', + installTitle: '添加插件', + installDescription: '输入插件的包名、GitHub 仓库地址或本地目录路径。', + installSpecLabel: '包名或地址', + installSpecPlaceholder: '例如 dsh-better-sidebar', + installGuideToggle: '不知道该填什么?', + installGuideHide: '收起引导', + installGuideIntro: '从插件的 README 或发布页面复制以下任意一项。', + installGuideIdNote: '包名即 npm 包名,常见形式为 dsh-xxx 或 @作者/插件名;插件的显示名称不是包名。', + installGuideIdTitle: '包名', + installGuideIdExample: 'dsh-better-sidebar', + installGuideIdHint: 'README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。', + installGuideGitTitle: 'GitHub 仓库地址', + installGuideGitExample: 'https://github.com/author/dsh-plugin', + installGuideGitHint: '插件在 GitHub 上的开源仓库地址,也支持其他 Git 仓库。', + installGuidePathTitle: '本地插件目录', + installGuidePathExample: '/Users/name/my-plugin', + installGuidePathHint: '本机上插件目录的绝对路径,适用于自行开发或已下载的插件。', + installGuideFill: '填入示例', + installGuideFillAria: '填入示例 {example}', + installGuideSafety: '请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。', + installRun: '安装', + installChecking: '正在检查…', + installProblemInvalid: '无法识别这个包名或地址:{reason}', + installProblemInstalled: '该插件已安装', + installProblemNotFound: '未找到相关插件', + installProblemNotPackage: '该路径不存在或不是有效的插件包', + installProblemNotBundle: '这个包没有声明组合包,无法作为插件安装:{reason}', + installProblemNetwork: '无法连接插件源,请检查网络后重试', + installProblemUnknown: '无法获取插件信息:{reason}', + installingTitle: '插件安装中…', + installedTitle: '已安装', + installFailedTitle: '插件安装失败', + installEdit: '编辑', + installEditAria: '返回编辑', + installCancel: '取消安装', + installStarting: '正在准备安装…', + installCancelling: '正在停止安装…', + installApplying: '正在应用配置,请稍候…', + installCancelledShort: '已取消', + installCancelled: '已取消安装,插件未启用,下载的文件可能保留', + installCancelUnconfirmed: '尚未确认安装已停止,请重试取消或等待安装结果。{reason}', + installEnableNow: '立即启用', + installDetailsShow: '查看安装详情', + installDetailsHide: '收起安装详情', + installVersion: '版本 {version}', + installSubjectPath: '本地目录', + installSubjectGit: 'Git 仓库', + installSubjectTarball: '压缩包', + installLocation: '安装位置:{dir}', + installRetry: '重试', + installFailureNetwork: '网络连接失败', + installFailureNotFound: '未找到相关插件', + installFailureNoMatchingVersion: '没有匹配的版本', + installFailureDiskFull: '磁盘空间不足,安装已停止', + installFailurePermission: '没有写入权限,无法安装', + installFailureBuildBlocked: '有依赖的安装脚本需要你允许后才能继续', + installFailureBuildBlockedManual: '有依赖的安装脚本被 pnpm 拦下,请在 profile 的 pnpm-workspace.yaml 的 allowBuilds 中放行后重试', + installFailureIntegrity: '下载的安装包校验失败', + installFailureTimeout: '安装超时', + installFailurePnpmMissing: '没有找到 pnpm,无法安装', + installFailureGeneric: '安装过程中出错,原因见安装详情', + terminalRunning: '运行中', + terminalFailed: '失败', + terminalDone: '已完成', + terminalCopy: '复制', + terminalCopied: '复制成功', + terminalNoOutput: '无输出', + terminalCollapseAria: '收起输出', + terminalCollapse: '收起', + terminalExpandAria: '展开其余 {n} 行输出', + terminalExpand: '… 其余 {n} 行', + terminalExitCode: '退出码 {code}', + terminalSignal: '信号 {signal}', + terminalNoExitCode: '未正常退出', + installDoneNothing: '安装完成,没有新增依赖。', + installDoneRestart: '已安装,下次启动后加载。', + installDoneApproved: '已允许运行安装脚本:{names}', + installApprovalTitle: '需要允许安装脚本', + installApprovalDescription: '以下包声明了安装脚本,pnpm 默认不运行。', + installApprovalConsequence: '允许后,脚本会以你的权限在本机运行,授权保存在当前 profile,之后不再询问。', + installApprovalCaution: '只在信任这些包时允许。', + installApproveAndRetry: '允许这些脚本并重试', + installClose: '完成', + close: '关闭', + cancel: '取消', + confirmUninstallTitle: '卸载「{name}」?', + confirmUninstallDescription: '卸载后它提供的功能会消失。', + confirmUninstall: '卸载', + failedEnable: '启用失败:{reason}', + failedDisable: '停用失败:{reason}', + failedUninstall: '卸载失败:{reason}', + failedRowEnable: '组件启用失败:{reason}', + failedRowDisable: '组件停用失败:{reason}', + reasonManagementRequired: '插件管理所需,不能停用或卸载', + reasonUnaddressable: '当前 profile 的 patch 无法唯一定位这一项', + reasonUnknownPlugin: '找不到该插件', + reasonInvalidSpec: '请输入有效的包名或地址', + reasonAmbiguousInstall: '无法从依赖变更中确定安装了哪一个包', + reasonNotBundle: '这个包没有声明组合包,不能作为插件管理', + reasonNotRemovable: '这个包不属于当前 profile,或者是插件管理所需的组件', + reasonStopProfile: '这个 profile 没有启用 HMR,正在使用的包要停止后用 dsh plugin 卸载', + reasonBundleInUse: '其他配置仍在使用这个组合包的组件,请先停用它们', + reasonStaleApproval: '待允许的安装脚本列表已变化,请重新安装以刷新', + reasonOperationError: 'Host 报告了一个错误', +} satisfies Record + +/** Plugin manager locale key union. */ +export type PluginManagerLocaleKey = keyof typeof zh + +/** English dictionary checked against the Chinese key set. */ +export const en = { + panel: 'Plugins', + title: 'Plugins', + intro: 'Add and manage plugins', + loading: 'Reading plugins…', + error: 'Plugins are temporarily unavailable.', + unavailable: 'This deployment runs without a manageable profile, so plugins cannot be installed or switched here.', + retry: 'Retry', + refresh: 'Refresh', + empty: 'No plugins are installed yet.', + addPlugin: 'Add plugin', + restartNotice: 'The change takes effect at the next start', + overriddenNotice: '{name} was saved, but a higher-priority configuration overrides it, so it is not in effect', + bundlesTitle: 'Installed', + builtinTitle: 'Built in', + statusProblem: 'Problem', + statusOfficial: 'Official', + reasonLabel: 'Reason', + versionTag: 'v{version}', + noDescription: 'No description.', + partsLabel: 'Components', + partsEmpty: 'This plugin pack contains no components.', + partsCountTotal: '{count} total', + partsCountRunning: '{count} running', + partsCountOff: '{count} off', + partOff: 'Off', + partsCountFailed: '{count} failed', + partsFilter: 'Filter components', + partsFilterEmpty: 'No component matches.', + partToggle: 'Enable component {name}', + rowPhasePending: 'Waiting for dependencies', + rowPhaseLoading: 'Loading', + rowPhaseActive: 'Running', + rowPhaseFailed: 'Problem', + rowPhaseUnloading: 'Unloading', + enableToggle: 'Enable {name}', + openDetail: 'View {name}', + backToList: 'Back to plugins', + crumbRoot: 'Plugins', + rowStateIdle: 'Not running', + uninstall: 'Uninstall', + uninstallLabel: 'Uninstall {name}', + installTitle: 'Add plugin', + installDescription: 'Enter the plugin\'s package name, GitHub repository address, or local directory path.', + installSpecLabel: 'Package name or address', + installSpecPlaceholder: 'for example dsh-better-sidebar', + installGuideToggle: 'Not sure what to enter?', + installGuideHide: 'Hide the guide', + installGuideIntro: 'Copy any one of these from the plugin\'s README or release page.', + installGuideIdNote: 'The package name is the npm package name, usually dsh-xxx or @author/plugin; the display name is not one.', + installGuideIdTitle: 'Package name', + installGuideIdExample: 'dsh-better-sidebar', + installGuideIdHint: 'The part after dsh plugin add or pnpm add in the README\'s install command.', + installGuideGitTitle: 'GitHub repository address', + installGuideGitExample: 'https://github.com/author/dsh-plugin', + installGuideGitHint: 'The address of the plugin\'s open-source repository on GitHub; other Git hosts work too.', + installGuidePathTitle: 'Local plugin directory', + installGuidePathExample: '/Users/name/my-plugin', + installGuidePathHint: 'The absolute path of a plugin directory on this machine, developed here or downloaded.', + installGuideFill: 'Use example', + installGuideFillAria: 'Use the example {example}', + installGuideSafety: 'Install only plugins you trust: they run with your permissions and can damage DeepSeek Harness or leak your data.', + installRun: 'Install', + installChecking: 'Checking…', + installProblemInvalid: 'This is not a package name or address that can be installed: {reason}', + installProblemInstalled: 'This plugin is already installed', + installProblemNotFound: 'No such plugin was found', + installProblemNotPackage: 'The path does not exist or is not a valid plugin package', + installProblemNotBundle: 'This package declares no bundle, so it cannot be installed as a plugin: {reason}', + installProblemNetwork: 'The plugin registry could not be reached; check the network and try again', + installProblemUnknown: 'The plugin could not be looked up: {reason}', + installingTitle: 'Installing the plugin…', + installedTitle: 'Installed', + installFailedTitle: 'The plugin could not be installed', + installEdit: 'Edit', + installEditAria: 'Back to editing', + installCancel: 'Cancel install', + installStarting: 'Preparing installation…', + installCancelling: 'Stopping installation…', + installApplying: 'Applying configuration, please wait…', + installCancelledShort: 'Cancelled', + installCancelled: 'Installation cancelled; the plugin is not enabled, and downloaded files may remain', + installCancelUnconfirmed: 'Installation has not been confirmed stopped. Retry cancellation or wait for the installation result. {reason}', + installEnableNow: 'Enable now', + installDetailsShow: 'Show install details', + installDetailsHide: 'Hide install details', + installVersion: 'Version {version}', + installSubjectPath: 'Local directory', + installSubjectGit: 'Git repository', + installSubjectTarball: 'Tarball', + installLocation: 'Installs into {dir}', + installRetry: 'Retry', + installFailureNetwork: 'The network connection failed', + installFailureNotFound: 'No such plugin was found', + installFailureNoMatchingVersion: 'No version matches the request', + installFailureDiskFull: 'The disk is full; the install stopped', + installFailurePermission: 'No write permission; the plugin cannot be installed', + installFailureBuildBlocked: 'A dependency\'s install scripts need your permission before the install can continue', + installFailureBuildBlockedManual: 'pnpm blocked install scripts; allow them under allowBuilds in pnpm-workspace.yaml and retry', + installFailureIntegrity: 'The downloaded package failed its integrity check', + installFailureTimeout: 'The install timed out', + installFailurePnpmMissing: 'pnpm was not found, so nothing can be installed', + installFailureGeneric: 'Something went wrong during the install; the details say what', + terminalRunning: 'Running', + terminalFailed: 'Failed', + terminalDone: 'Done', + terminalCopy: 'Copy', + terminalCopied: 'Copied', + terminalNoOutput: 'No output', + terminalCollapseAria: 'Collapse output', + terminalCollapse: 'Collapse', + terminalExpandAria: 'Expand the remaining {n} output lines', + terminalExpand: '… {n} more lines', + terminalExitCode: 'exit code {code}', + terminalSignal: 'signal {signal}', + terminalNoExitCode: 'no exit code', + installDoneNothing: 'Install finished with no new dependency.', + installDoneRestart: 'Installed; it loads at the next start.', + installDoneApproved: 'Install scripts allowed for {names}', + installApprovalTitle: 'Install scripts need permission', + installApprovalDescription: 'These packages have install scripts that pnpm did not run.', + installApprovalConsequence: 'Once allowed, the scripts run here with your permissions, and the permission is saved in this profile.', + installApprovalCaution: 'Allow only packages you trust.', + installApproveAndRetry: 'Allow these scripts and retry', + installClose: 'Done', + close: 'Close', + cancel: 'Cancel', + confirmUninstallTitle: 'Uninstall "{name}"?', + confirmUninstallDescription: 'What it provides goes away once it is uninstalled.', + confirmUninstall: 'Uninstall', + failedEnable: 'Could not enable: {reason}', + failedDisable: 'Could not disable: {reason}', + failedUninstall: 'Could not uninstall: {reason}', + failedRowEnable: 'Could not enable the component: {reason}', + failedRowDisable: 'Could not disable the component: {reason}', + reasonManagementRequired: 'Plugin management needs it; it cannot be switched off or uninstalled.', + reasonUnaddressable: 'The profile patch cannot address this one uniquely.', + reasonUnknownPlugin: 'No such plugin.', + reasonInvalidSpec: 'Enter a valid package name or address.', + reasonAmbiguousInstall: 'Which package was installed cannot be told from the dependency change.', + reasonNotBundle: 'This package declares no bundle, so it cannot be managed as a plugin.', + reasonNotRemovable: 'This package is not owned by the profile, or plugin management needs it.', + reasonStopProfile: 'This profile runs without HMR; stop it and uninstall the package with dsh plugin.', + reasonBundleInUse: 'Other configuration still uses this bundle\'s components; switch them off first.', + reasonStaleApproval: 'The pending script approvals changed; install again to refresh them.', + reasonOperationError: 'The Host reported an error.', +} satisfies Record diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts new file mode 100644 index 0000000000..b00841984d --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -0,0 +1,715 @@ +/** + * The plugin manager's state: the Host's bundles joined with its plugin + * entries, the action in flight, the install run, and the confirmation an + * uninstall waits on. Every fact comes from the Host — the store re-reads + * after each action and after every `plugin-manager/changed` event, so a + * change made on another surface shows here without a manual refresh. + */ + +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { + BundleInfo, + ChangeResult, + ManagementError, + PluginEntryId, + PluginInfo, + PluginInspectProblem, + PluginInstallFailureKind, + PluginInstallLogChunk, + PluginInstallProgress, + PluginInstallRequestId, + PluginSpecInspection, + ReadOnlyReason, +} from '@deepseek-ai/dsh-api-remotes/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { shortName } from './presentation.ts' + +/** The action a failed notice names. */ +export type FailedAction = 'enable' | 'disable' | 'uninstall' | 'rowEnable' | 'rowDisable' + +/** What the last action left to say, shown as a toast; `seq` tells one showing from the next. */ +export type ManagerNotice = + | { readonly kind: 'restart'; readonly packageName: string; readonly seq: number } + | { readonly kind: 'overridden'; readonly packageName: string; readonly seq: number } + | { readonly kind: 'cancelled'; readonly seq: number } + | { + readonly kind: 'failed' + /** What was being done when it failed. */ + readonly action: FailedAction + /** The Host's refusal, when the Host refused; absent when the transport failed. */ + readonly code?: ManagementError['code'] + /** The Host's diagnostic or the transport's words, shown verbatim; empty when the code says it all. */ + readonly reason: string + readonly packageName?: string + readonly seq: number + } + +/** One row a bundle contributes, as the page lists it: the patch's declaration joined with its live entry. */ +export interface PackageRow { + /** The Loader entry carrying the row while the bundle is on; absent for a row of a bundle that is off. */ + readonly entryId?: PluginEntryId + /** The row id as the bundle declares it. */ + readonly rowId: string + /** The module the row names. */ + readonly moduleName: string + /** Whether the entry runs; false for a row without a live entry. */ + readonly enabled: boolean + /** The entry's fiber phase, null without a live fiber. */ + readonly phase: PluginInfo['fiberPhase'] + /** Why the Host refuses to switch the row, when it does. */ + readonly readOnlyReason?: ReadOnlyReason +} + +/** One bundle as the page shows it: the Host's bundle joined with the entries its rows run as. */ +export interface PackageView { + readonly name: string + readonly version?: string + readonly description?: string + /** Whether the profile's own dependencies hold the package; false for a bundle the installation supplies. */ + readonly installed: boolean + /** Whether the installation ships the bundle for the person to switch on: official, off until selected, never removable. */ + readonly optional: boolean + /** Whether the bundle is in the profile's layer list. */ + readonly enabled: boolean + /** Why the Host refuses to switch the bundle off or remove it, when it does. */ + readonly readOnlyReason?: ReadOnlyReason + /** Why the Host cannot read the bundle, when it cannot. */ + readonly error?: ManagementError + readonly rows: readonly PackageRow[] +} + +/** The typed spec as the Host read it, on the installing, installed, and failed screens. */ +export type InstallSubject = Extract & { readonly spec: string } + +/** Why the typed spec was refused before anything installed. */ +export interface InstallInputError { + readonly problem: PluginInspectProblem + readonly reason: string +} + +/** One pnpm run of an install, as the dialog's terminal draws it. */ +export interface InstallRun { + readonly jobId: string + /** The command line the Host ran, space-joined. */ + readonly command: string + /** The directory the Host ran pnpm in: the profile directory. */ + readonly cwd: string + /** stdout and stderr interleaved as they arrived, pnpm's colour escapes included. */ + readonly output: string + /** pnpm's exit code once the run settled, null when it ended by a signal or never started; absent while it runs. */ + readonly exitCode?: number | null +} + +/** + * The install dialog: the spec is typed while `idle`, read by the Host while + * `checking`, then installed through the Host-owned phases — `starting` until + * the Host acknowledges the run, `running`, `cancelling` while the Host stops + * it, `applying` once the bundle is being loaded, which closed the cancellation + * window — and the outcome shown as `done` or `failed`. A refused check, a + * confirmed cancellation, and the back control return to `idle` with the spec kept. + */ +export interface InstallState { + readonly open: boolean + /** The package spec as typed. */ + readonly spec: string + readonly phase: 'idle' | 'checking' | 'starting' | 'running' | 'cancelling' | 'applying' | 'done' | 'failed' + /** Identifies this dialog's installation, including log and cancellation messages. */ + readonly requestId?: PluginInstallRequestId + /** Why the spec was refused before installing; shown under the field. */ + readonly inputError: InstallInputError | null + /** What the spec names, once the Host has read it. */ + readonly subject: InstallSubject | null + /** The pnpm runs of the open install, in the order they started. */ + readonly runs: readonly InstallRun[] + /** Whether the run's command and output are unfolded. */ + readonly detailsOpen: boolean + /** The bundle the finished run added, left off until enabled from the installed screen. */ + readonly installed: string | null + /** Whether the finished run's bundle waits for the next start to load. */ + readonly restartRequired: boolean + /** + * The run's failure, once one settled the dialog: the Host's refusal + * `code` with its diagnostic as `reason`, or the transport's words alone; + * `kind` classifies a pnpm failure, and `pendingBuilds` names the packages + * whose install scripts pnpm left undecided, offered for approval. + * `cancelUnconfirmed` is a stop the Host did not confirm, shown over the + * running screen while the run goes on. + */ + readonly failure: { + readonly reason: string + readonly code?: ManagementError['code'] + readonly kind?: PluginInstallFailureKind + readonly pendingBuilds?: readonly string[] + readonly cancelUnconfirmed?: true + } | null + /** The packages whose install scripts the finished run was allowed to execute, saved for this profile. */ + readonly approvedBuilds: readonly string[] + /** Enabling the newly installed bundle from the installed screen is crossing the wire. */ + readonly enabling: boolean +} + +/** + * Whether an installation is still owned by the Host. + * @param phase - the dialog's current installation phase. + * @returns true until an authoritative result settles the installation. + */ +export function isInstallPending(phase: InstallState['phase']): boolean { + return phase === 'starting' || phase === 'running' || phase === 'cancelling' || phase === 'applying' +} + +/** A destructive action waiting for the user's confirmation: a package's uninstall. */ +export interface ConfirmState { + readonly action: 'uninstall' + readonly packageName: string +} + +/** What the tab renders. */ +export interface PluginManagerState { + /** `unavailable` when the Host runs without a managed profile; `error` keeps the last packages. */ + readonly status: 'idle' | 'loading' | 'ready' | 'error' | 'unavailable' + readonly packages: readonly PackageView[] + /** Package names and row keys with an action crossing the wire. */ + readonly busy: readonly string[] + readonly notice: ManagerNotice | null + readonly install: InstallState + readonly confirm: ConfirmState | null + /** The package the list scrolls to and marks, once an install enabled it. */ + readonly highlight: string | null +} + +/** The registration-side face the tab's slot entry injects. */ +export interface PluginManagerFace { + hooks: { + /** Tab snapshot bound by the renderer as usePluginManager. */ + pluginManager: SnapshotStore + } + /** Read the Host once the tab first renders. */ + ensure: () => void + /** Read the Host again. */ + refresh: () => void + openInstall: () => void + /** Close the dialog; a check in flight is dropped, a Host-owned run has to be cancelled first. */ + closeInstall: () => void + editInstallSpec: (text: string) => void + /** Check the spec with the Host, then install it; from the failed screen, run it again. */ + runInstall: () => void + /** Allow the install scripts the failed run left pending, saved for this profile, and run the same spec again. */ + approveBuildsAndRetry: () => void + /** Leave the check or the failed screen for the spec, or ask the Host to stop the run and wait for its cleanup. */ + cancelInstall: () => void + toggleInstallDetails: () => void + /** Enable the bundle the finished install added, then close the dialog and mark it in the list. */ + enableInstalled: () => void + /** Drop the list mark once it has been shown. */ + clearHighlight: () => void + /** Put a bundle into, or take it out of, the profile's layer list. */ + setEnabled: (packageName: string, enabled: boolean) => void + /** Ask before removing a package from the profile. */ + uninstall: (packageName: string) => void + confirm: () => void + cancelConfirm: () => void + /** Switch one of a bundle's rows on or off in the profile's user layer. */ + setRowEnabled: (entryId: PluginEntryId, enabled: boolean) => void + dismissNotice: () => void +} + +/** A Remote answer as the generated client returns it. */ +type Answer = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details?: unknown } } + +/** A refused answer or a change the Host could not apply, carrying what it said and, for a refusal, its code. */ +class RemoteAnswerError extends Error { + constructor(readonly reason: string, readonly code?: ManagementError['code']) { + super(reason) + this.name = 'RemoteAnswerError' + } +} + +/** The dialog's reading of a failed change: the Host's code and diagnostic, and the run's classified failure. */ +function failureOf( + error: ManagementError | undefined, kind: PluginInstallFailureKind | undefined, pendingBuilds?: readonly string[], +): NonNullable { + return { + reason: error?.diagnostic ?? '', + ...error === undefined ? {} : { code: error.code }, + ...kind === undefined ? {} : { kind }, + ...pendingBuilds === undefined || pendingBuilds.length === 0 ? {} : { pendingBuilds }, + } +} + +/** The notice a thrown failure becomes: a refusal keeps its code, anything else its words. */ +function failedNotice(error: unknown, subject: { action: FailedAction; packageName?: string }, seq: number): ManagerNotice { + const code = error instanceof RemoteAnswerError ? error.code : undefined + return { kind: 'failed', reason: reasonOf(error), ...code === undefined ? {} : { code }, ...subject, seq } +} + +/** The runs with every one still open settled at `exitCode`. */ +function settledRuns(runs: readonly InstallRun[], exitCode: number | null): readonly InstallRun[] { + return runs.map(run => run.exitCode === undefined ? { ...run, exitCode } : run) +} + +/** + * The key one row occupies in the busy list. + * @param entryId - the row's Loader entry id. + * @returns the busy key. + */ +export function rowKey(entryId: string): string { + return `row:${entryId}` +} + +/** + * One bundle as the page shows it: its rows joined with the Host's entries. + * @param bundle - the Host's bundle. + * @param plugins - the Host's plugin entries. + * @returns the package view. + */ +export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]): PackageView { + const rows = bundle.rows.map((row): PackageRow => { + const live = row.entryId === undefined ? undefined : plugins.find(plugin => plugin.entryId === row.entryId) + return { + rowId: row.rowId, + moduleName: row.moduleName, + enabled: live?.enabled ?? false, + phase: live?.fiberPhase ?? null, + ...row.entryId === undefined ? {} : { entryId: row.entryId }, + ...live?.readOnlyReason === undefined ? {} : { readOnlyReason: live.readOnlyReason }, + } + }) + return { + name: bundle.name, + installed: bundle.installed, + optional: bundle.optional, + enabled: bundle.enabled, + rows, + ...bundle.version === undefined ? {} : { version: bundle.version }, + ...bundle.description === undefined ? {} : { description: bundle.description }, + ...bundle.readOnlyReason === undefined ? {} : { readOnlyReason: bundle.readOnlyReason }, + ...bundle.error === undefined ? {} : { error: bundle.error }, + } +} + +/** + * The order the list shows packages in: by the short name a person reads, so a + * card stays put when its bundle is switched, whatever order the Host answers in. + * @param packages - the Host's bundles as views. + * @returns the views sorted by short name. + */ +export function sortPackages(packages: readonly PackageView[]): PackageView[] { + return [...packages].sort((a, b) => shortName(a.name).localeCompare(shortName(b.name))) +} + +const IDLE_INSTALL: InstallState = { + open: false, spec: '', phase: 'idle', inputError: null, subject: null, runs: [], detailsOpen: false, + installed: null, restartRequired: false, failure: null, approvedBuilds: [], enabling: false, +} + +/** Reads and mutates the profile's plugins through the `pluginManager` Remote. */ +export class PluginManagerController { + private readonly store: SnapshotStore + private inFlight: Promise | undefined + private rerun = false + private generation = 0 + private disposed = false + private pendingConfirm: (() => Promise) | undefined + /** Cancels the check the dialog has in flight. */ + private inspectAbort: AbortController | undefined + private noticeSeq = 0 + + /** + * @param ctx - the tab plugin's context, whose `remote.pluginManager` and `remote.pluginInventory` namespaces answer. + */ + constructor( + private readonly ctx: ClientContext, + ) { + this.store = createSnapshotStore({ + status: 'idle', packages: [], busy: [], notice: null, + install: IDLE_INSTALL, confirm: null, highlight: null, + }) + } + + /** + * Read the tab's state. + * @returns the current sync snapshot (stable reference until the next change). + */ + getSnapshot(): PluginManagerState { + return this.store.getSnapshot() + } + + /** Stop publishing and drop every late settlement. */ + dispose(): void { + this.disposed = true + this.generation += 1 + } + + /** + * Build the face the tab's slot registration injects. + * @returns the tab's snapshot source and its actions. + */ + inject(): PluginManagerFace { + return { + hooks: { pluginManager: this.store }, + ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() }, + refresh: () => { void this.load() }, + openInstall: () => { + if (!isInstallPending(this.getSnapshot().install.phase)) this.patch({ install: { ...IDLE_INSTALL, open: true } }) + }, + closeInstall: () => { + if (isInstallPending(this.getSnapshot().install.phase)) return + this.abortInspect() + this.patch({ install: IDLE_INSTALL }) + }, + editInstallSpec: (text) => { + const install = this.getSnapshot().install + // Typing while the Host checks or installs is not possible; a new spec after an outcome starts over. + if (install.phase === 'checking' || isInstallPending(install.phase)) return + this.patchInstall(install.phase === 'idle' ? { spec: text, inputError: null } : { ...IDLE_INSTALL, open: true, spec: text }) + }, + runInstall: () => { void this.runInstall() }, + approveBuildsAndRetry: () => { void this.approveBuildsAndRetry() }, + cancelInstall: () => { void this.cancelInstall() }, + toggleInstallDetails: () => { this.patchInstall({ detailsOpen: !this.getSnapshot().install.detailsOpen }) }, + enableInstalled: () => { void this.enableInstalled() }, + clearHighlight: () => { if (this.getSnapshot().highlight !== null) this.patch({ highlight: null }) }, + setEnabled: (packageName, enabled) => { + void this.run(packageName, { packageName, action: enabled ? 'enable' : 'disable' }, async () => { + this.applied(await this.ctx.remote.pluginManager.setBundleEnabled(packageName, enabled), packageName) + }) + }, + uninstall: (packageName) => { + this.pendingConfirm = () => this.run(packageName, { packageName, action: 'uninstall' }, async () => { + this.applied(await this.ctx.remote.pluginManager.removeBundle(packageName), packageName) + }) + this.patch({ confirm: { action: 'uninstall', packageName } }) + }, + confirm: () => { void this.confirm() }, + cancelConfirm: () => { this.pendingConfirm = undefined; this.patch({ confirm: null }) }, + setRowEnabled: (entryId, enabled) => { + void this.run(rowKey(entryId), { packageName: entryId, action: enabled ? 'rowEnable' : 'rowDisable' }, async () => { + this.applied(await this.ctx.remote.pluginManager.setPluginEnabled(entryId, enabled), entryId) + }) + }, + dismissNotice: () => { this.patch({ notice: null }) }, + } + } + + /** + * Follow the Host's cancellation window for this dialog's installation. + * @param progress - a request id and phase received from the Host. + */ + installProgress(progress: PluginInstallProgress): void { + const install = this.getSnapshot().install + if (install.requestId !== progress.requestId || !isInstallPending(install.phase)) return + // A queued start notification cannot undo the local user's cancellation request. + if (install.phase === 'cancelling' && progress.phase === 'installing') return + this.patchInstall({ phase: progress.phase === 'installing' ? 'running' : progress.phase }) + } + + /** + * Fold a chunk belonging to this installation into its pnpm command. + * A final chunk may arrive after the install answer and still updates an existing run. + * @param chunk - the chunk the Host forwarded. + */ + appendLog(chunk: PluginInstallLogChunk): void { + const install = this.getSnapshot().install + if (chunk.requestId !== install.requestId) return + const index = install.runs.findIndex(run => run.jobId === chunk.jobId) + if (index === -1 && !isInstallPending(install.phase)) return + const settled = chunk.exitCode === undefined ? {} : { exitCode: chunk.exitCode } + const runs = index === -1 + ? [...install.runs, { jobId: chunk.jobId, command: chunk.argv.join(' '), cwd: chunk.cwd, output: chunk.text, ...settled }] + : install.runs.map((run, at) => at === index ? { ...run, output: run.output + chunk.text, ...settled } : run) + this.patchInstall({ runs }) + } + + /** + * Read the bundles and the entries their rows run as. A call during an + * in-flight read marks one rerun after it settles. + * @returns settlement after this call's freshness is reflected. + */ + load(): Promise { + if (this.disposed) return Promise.resolve() + if (this.inFlight !== undefined) { + this.rerun = true + return this.inFlight + } + const run = Promise.resolve().then(() => this.read()) + this.inFlight = run + return run + } + + private async read(): Promise { + try { + do { + this.rerun = false + const generation = ++this.generation + if (this.getSnapshot().status === 'idle') this.patch({ status: 'loading' }) + // The manager Remote is mounted whether or not the Host manages a + // profile; the inventory says whether it does. + const inventory = await this.ctx.remote.pluginInventory.list() + if (generation !== this.generation) return + if (!inventory.ok) { + this.patch({ status: 'error' }) + continue + } + if (inventory.value.managementAvailable !== true) { + this.patch({ status: 'unavailable', packages: [] }) + continue + } + const [bundles, plugins] = await Promise.all([ + this.ctx.remote.pluginManager.listBundles(), + this.ctx.remote.pluginManager.listPlugins(), + ]) + if (generation !== this.generation) return + if (!bundles.ok || !plugins.ok) { + this.patch({ status: 'error' }) + continue + } + this.patch({ + status: 'ready', + packages: sortPackages(bundles.value.map(bundle => packageView(bundle, plugins.value))), + }) + } while (this.shouldRerun()) + } finally { + this.inFlight = undefined + } + } + + private shouldRerun(): boolean { + return this.rerun + } + + private async confirm(): Promise { + const pending = this.pendingConfirm + this.pendingConfirm = undefined + this.patch({ confirm: null }) + if (pending !== undefined) await pending() + } + + /** Drop the check in flight; its answer is ignored. */ + private abortInspect(): void { + this.inspectAbort?.abort() + this.inspectAbort = undefined + } + + /** Whether a settlement arrives too late to matter: the store is disposed, or the dialog moved on. */ + private gone(signal: AbortSignal): boolean { + return this.disposed || signal.aborted + } + + /** + * Check the typed spec, then install it. The Host reads what the spec + * names first; a refused spec returns to the field with the reason, an + * accepted one becomes the subject the next screens show while pnpm runs. + */ + private async runInstall(): Promise { + const state = this.getSnapshot() + const install = state.install + const spec = install.spec.trim() + if (install.phase === 'checking' || isInstallPending(install.phase) || spec === '') return + // A name the list already shows is refused at once, before the Host is asked. + if (state.packages.some(pkg => pkg.name === spec)) { + this.patchInstall({ phase: 'idle', inputError: { problem: 'already-installed', reason: spec } }) + return + } + this.abortInspect() + const controller = new AbortController() + this.inspectAbort = controller + this.patchInstall({ + phase: 'checking', inputError: null, subject: null, runs: [], detailsOpen: false, + installed: null, restartRequired: false, failure: null, approvedBuilds: [], + }) + const inspected = await this.ctx.remote.pluginManager.inspect(spec, controller.signal) + if (this.gone(controller.signal)) return + this.inspectAbort = undefined + if (!inspected.ok) { + this.patchInstall({ phase: 'idle', inputError: { problem: 'unknown', reason: inspected.error.message } }) + return + } + if (inspected.value.status === 'refused') { + this.patchInstall({ phase: 'idle', inputError: { problem: inspected.value.problem, reason: inspected.value.reason } }) + return + } + await this.startInstall({ spec, ...inspected.value }) + } + + /** + * Hand the checked spec to the Host and settle the dialog from its answer. + * `approvedBuilds` names the pending install scripts the person allowed; + * the Host saves that permission for this profile before pnpm runs. + */ + private async startInstall(subject: InstallSubject, approvedBuilds?: readonly string[]): Promise { + const { spec } = subject + const requestId = randomUUID() as PluginInstallRequestId + this.patchInstall({ phase: 'starting', requestId, subject, runs: [], failure: null, installed: null, approvedBuilds: [] }) + // The Host announces `plugin-manager/changed` while the run is still on + // the wire, and every such event reads again; those reads must not cancel + // the run's settlement. + const result = await this.ctx.remote.pluginManager.installBundle(spec, { + enabled: false, requestId, ...approvedBuilds === undefined ? {} : { approvedBuilds: [...approvedBuilds] }, + }) + if (this.disposed || this.getSnapshot().install.requestId !== requestId) return + const runs = this.getSnapshot().install.runs + if (!result.ok) { + this.patchInstall({ phase: 'failed', runs: settledRuns(runs, null), failure: { reason: result.error.message } }) + } else if (result.value.application === 'cancelled') { + this.offerSpecAgain({ kind: 'cancelled', seq: ++this.noticeSeq }) + } else if (result.value.application === 'failed') { + // A run whose last chunk never reached the dialog settles from the answer. + const packages = result.value.packageResult + this.patchInstall({ + phase: 'failed', + runs: settledRuns(runs, packages?.exitCode ?? null), + failure: failureOf(result.value.error, packages?.kind, result.value.pendingBuilds), + }) + } else { + this.patchInstall({ + phase: 'done', + runs: settledRuns(runs, 0), + installed: result.value.bundle ?? null, + restartRequired: result.value.application === 'restart-required', + approvedBuilds: result.value.approvedBuilds ?? [], + }) + } + void this.load() + } + + /** + * Allow the install scripts the failed run left pending and run the same + * spec again. Only the failed screen with pending names offers this. + */ + private async approveBuildsAndRetry(): Promise { + const install = this.getSnapshot().install + const pending = install.failure?.pendingBuilds + if (install.phase !== 'failed' || install.subject === null || pending === undefined || pending.length === 0) return + await this.startInstall(install.subject, pending) + } + + /** + * Leave the check or the failed screen for the spec at once; a Host-owned + * run is asked to stop and the dialog waits for the Host's word, since + * neither a dropped RPC nor a closed connection means pnpm has stopped. + */ + private async cancelInstall(): Promise { + const install = this.getSnapshot().install + if (install.phase === 'checking' || install.phase === 'failed') { + this.abortInspect() + this.offerSpecAgain() + return + } + if (install.phase !== 'running' || install.requestId === undefined) return + const requestId = install.requestId + this.patchInstall({ phase: 'cancelling', failure: null }) + const result = await this.ctx.remote.pluginManager.cancelInstall(requestId) + const current = this.getSnapshot().install + if (this.disposed || current.requestId !== requestId || !isInstallPending(current.phase)) return + if (!result.ok) { + this.patchInstall({ phase: 'running', failure: { reason: result.error.message, cancelUnconfirmed: true } }) + return + } + if (result.value.status === 'cancelled') { + this.offerSpecAgain({ kind: 'cancelled', seq: ++this.noticeSeq }) + void this.load() + } else if (result.value.status === 'too-late') { + this.patchInstall({ phase: 'applying' }) + } else { + this.patchInstall({ phase: 'running', failure: { reason: '', cancelUnconfirmed: true } }) + } + } + + /** + * Back to the spec: the check, the run, and its request are forgotten and + * the spec is kept, with a toast when there is something to say — the Host + * stopped the run. + */ + private offerSpecAgain(notice: ManagerNotice | null = null): void { + const { open, spec } = this.getSnapshot().install + this.patch({ install: { ...IDLE_INSTALL, open, spec }, ...notice === null ? {} : { notice } }) + } + + /** + * Enable the bundle the finished install added, then close the dialog and + * mark it in the list. A refusal toasts and still closes: the list shows + * what did not switch on. + */ + private async enableInstalled(): Promise { + const install = this.getSnapshot().install + if (install.phase !== 'done' || install.enabling) return + const name = install.installed + this.patchInstall({ enabling: true }) + if (name !== null) { + const result = await this.ctx.remote.pluginManager.setBundleEnabled(name, true) + if (this.disposed) return + try { + this.applied(result, name) + } catch (error) { + this.patch({ notice: failedNotice(error, { packageName: name, action: 'enable' }, ++this.noticeSeq) }) + } + } + this.patch({ install: IDLE_INSTALL, highlight: name }) + await this.load() + } + + /** + * Run one action under a busy key, turn its failure into the notice, and + * re-read the Host afterwards whatever happened. + */ + private async run( + key: string, + subject: { action: FailedAction; packageName?: string }, + action: () => Promise, + ): Promise { + if (this.disposed || this.getSnapshot().busy.includes(key)) return + this.patch({ busy: [...this.getSnapshot().busy, key], notice: null }) + try { + await action() + } catch (error) { + // `patch` drops the notice after disposal. + this.patch({ notice: failedNotice(error, subject, ++this.noticeSeq) }) + } finally { + this.patch({ busy: this.getSnapshot().busy.filter(entry => entry !== key) }) + } + await this.load() + } + + /** + * Publish a change's outcome: a refused answer or a change the Host could + * not apply throws for {@link run} to report; a change that waits for the + * next start, that a higher layer overrides, or that the Host stopped is + * said in passing. + */ + private applied(answer: Answer, packageName: string): void { + if (!answer.ok) throw new RemoteAnswerError(answer.error.message) + const result = answer.value + switch (result.application) { + case 'failed': + throw new RemoteAnswerError(result.error?.diagnostic ?? '', result.error?.code) + case 'cancelled': + this.patch({ notice: { kind: 'cancelled', seq: ++this.noticeSeq } }) + return + case 'restart-required': + this.patch({ notice: { kind: 'restart', packageName, seq: ++this.noticeSeq } }) + return + case 'overridden': + this.patch({ notice: { kind: 'overridden', packageName, seq: ++this.noticeSeq } }) + return + case 'applied': + return + } + } + + private patch(next: Partial): void { + if (this.disposed) return + this.store.set({ ...this.getSnapshot(), ...next }) + } + + private patchInstall(next: Partial): void { + this.patch({ install: { ...this.getSnapshot().install, ...next } }) + } +} + +/** What a thrown failure said: a refused answer's reason, else the error's message. */ +function reasonOf(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts new file mode 100644 index 0000000000..ef35175c10 --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -0,0 +1,73 @@ +/** Display labels and toast sentences for global plugin management. */ + +import type { ManagementError } from '@deepseek-ai/dsh-api-remotes/client' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { PluginManagerLocaleKey } from './locales.ts' +import type { FailedAction, ManagerNotice } from './manager-store.ts' + +/** The translate seat of the manager's dictionary. */ +export type Translate = PropsLocale<'pluginManager'>['t'] + +/** The sentence each of the Host's refusal codes reads as. */ +const CODE_KEYS = { + 'management-required': 'reasonManagementRequired', + 'unaddressable': 'reasonUnaddressable', + 'unknown-plugin': 'reasonUnknownPlugin', + 'invalid-spec': 'reasonInvalidSpec', + 'ambiguous-install': 'reasonAmbiguousInstall', + 'not-bundle': 'reasonNotBundle', + 'not-removable': 'reasonNotRemovable', + 'stop-profile': 'reasonStopProfile', + 'bundle-in-use': 'reasonBundleInUse', + 'stale-approval': 'reasonStaleApproval', + 'operation-error': 'reasonOperationError', +} satisfies Record + +/** The sentence a failed action opens with, by what was being done. */ +const FAILED_KEYS = { + enable: 'failedEnable', + disable: 'failedDisable', + uninstall: 'failedUninstall', + rowEnable: 'failedRowEnable', + rowDisable: 'failedRowDisable', +} satisfies Record + +/** + * What a management error reads as: the code's sentence, or, for an + * operation error, the Host's diagnostic as it is. + * @param error - the Host's code and its diagnostic, when it has one. + * @param t - the manager's translate seat. + * @returns the sentence. + */ +export function managementText(error: { readonly code: ManagementError['code']; readonly diagnostic?: string }, t: Translate): string { + if (error.code !== 'operation-error') return t(CODE_KEYS[error.code]) + return error.diagnostic === undefined || error.diagnostic === '' ? t('reasonOperationError') : error.diagnostic +} + +/** + * Compact a package name to what a person calls it. + * @param name - the package name. + * @returns the unscoped name without the harness prefixes. + */ +export function shortName(name: string): string { + const unscoped = name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name + return unscoped.replace(/^dsh-(?:host-|client-)?/, '') +} + +/** + * The sentence one notice shows. + * @param notice - the last action's outcome. + * @param t - the manager's translate seat. + * @returns the sentence. + */ +export function noticeText(notice: ManagerNotice, t: Translate): string { + switch (notice.kind) { + case 'restart': return t('restartNotice') + case 'overridden': return t('overriddenNotice', { name: notice.packageName }) + case 'cancelled': return t('installCancelled') + case 'failed': { + const reason = notice.code === undefined ? notice.reason : managementText({ code: notice.code, diagnostic: notice.reason }, t) + return t(FAILED_KEYS[notice.action], { reason: reason === '' ? t('reasonOperationError') : reason }) + } + } +} diff --git a/packages/client/ui-plugin-manager/src/css-modules.d.ts b/packages/client/ui-plugin-manager/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-plugin-manager/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-plugin-manager/src/index.ts b/packages/client/ui-plugin-manager/src/index.ts new file mode 100644 index 0000000000..bcfd623c2a --- /dev/null +++ b/packages/client/ui-plugin-manager/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the plugin-manager tab's browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the plugin manager tab. */ +export function apply(): void {} diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx new file mode 100644 index 0000000000..879efbbb0b --- /dev/null +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +import { Context, Service } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts' +import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' +import { PluginsPanelIcon } from '../src/client/PluginsPanelIcon.tsx' +import type { PluginManagerFace } from '../src/client/manager-store.ts' +import { apply as hostApply } from '../src/index.ts' + +usePinnedBrowserLanguages('zh-CN') +afterEach(cleanup) + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + const locale = new LocaleRuntime(ctx) + ctx.provide('locale', locale) + class LocaleHolder extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'localeHolder') + } + } + new LocaleHolder(ctx) + const list = vi.fn(() => Promise.resolve({ ok: true as const, value: { entries: [], managementAvailable: true } })) + const remote = new TestRemote(ctx, { + pluginInventory: { list }, + pluginManager: { + listBundles: vi.fn(() => Promise.resolve({ ok: true as const, value: [] })), + listPlugins: vi.fn(() => Promise.resolve({ ok: true as const, value: [] })), + }, + }) + return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote } +} + +function declare(slots: SlotRegistry): () => void { + return slots.register({ + name: 'root', + children: { + 'main': { kind: 'keyed', scope: 'root' }, + 'sidebar.panellist': { kind: 'list', scope: 'root' }, + }, + } as never, () => null) +} + +describe('ui-plugin-manager browser plugin', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + + it('declares only the services the page and its Remote methods use', () => { + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory']) + }) + + it('registers the sidebar entry and its page, which reads the Host only once rendered and follows Host changes', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + + const entry = b.slots.entries('main')[0]! + expect(entry.component).toBe(PluginManagerPage) + expect(entry.options).toMatchObject({ key: PANEL_ID }) + expect(entry.locale).toBe(NS) + // The sidebar entry addresses the page by the same id and speaks the dictionary. + const icon = b.slots.entries('sidebar.panellist')[0]! + expect(icon.component).toBe(PluginsPanelIcon) + const unread = () => { throw new Error('The sidebar icon must not read application state') } + const glyph = render() + expect(glyph.container.querySelector('svg')?.getAttribute('width')).toBe('18') + expect(icon.options).toMatchObject({ id: PANEL_ID, order: 0 }) + expect(icon.locale).toBe(NS) + expect(resolveSlotLabel(icon.options.label)).toBe('插件') + const face = (entry.inject as unknown as () => PluginManagerFace)() + // A Host change before the first render is not a reason to read. + b.remote.emit('plugin-manager/changed', [{ reason: 'install' }]) + b.ctx.emit('connection/reset') + await Promise.resolve() + expect(b.list).not.toHaveBeenCalled() + face.ensure() + await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') }) + expect(b.list).toHaveBeenCalledTimes(1) + b.remote.emit('plugin-manager/changed', [{ reason: 'bundle' }]) + await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) }) + b.ctx.emit('connection/reset') + await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(3) }) + + // Install output folds into an open run only. + face.openInstall() + face.editInstallSpec('pkg') + 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([]) + + await fiber.dispose() + expect(b.slots.entries('main')).toHaveLength(0) + expect(b.slots.entries('sidebar.panellist')).toHaveLength(0) + b.remote.emit('plugin-manager/changed', [{ reason: 'install' }]) + await Promise.resolve() + expect(b.list).toHaveBeenCalledTimes(3) + }) +}) diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx new file mode 100644 index 0000000000..01283c6b06 --- /dev/null +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -0,0 +1,574 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PluginEntryId } from '@deepseek-ai/dsh-api-remotes/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' +import type { PluginManagerPageProps } from '../src/client/PluginManagerPage.tsx' +import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts' +import { en, type PluginManagerLocaleKey } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = ((key: PluginManagerLocaleKey, params?: Record): string => + Object.entries(params ?? {}).reduce( + (text, [name, value]) => text.replaceAll(`{${name}}`, value), + en[key], + )) as PluginManagerPageProps['t'] + +function pkg(overrides: Partial = {}): PackageView { + return { + name: 'dsh-better-sidebar', + version: '0.16.0', + installed: true, + optional: false, + enabled: true, + rows: [], + ...overrides, + } +} + +function row(overrides: Partial = {}): PackageRow { + return { entryId: 'include:sidebar' as PluginEntryId, rowId: 'sidebar', moduleName: 'dsh-better-sidebar', enabled: true, phase: 'active', ...overrides } +} + +const IDLE_INSTALL: InstallState = { + open: false, spec: '', phase: 'idle', inputError: null, subject: null, runs: [], detailsOpen: false, + installed: null, restartRequired: false, failure: null, approvedBuilds: [], enabling: false, +} + +const READY: PluginManagerState = { + status: 'ready', + packages: [], + busy: [], + notice: null, + install: IDLE_INSTALL, + confirm: null, + highlight: null, +} + +function renderTab(state: Partial = {}) { + const store = createSnapshotStore({ ...READY, ...state }) + const actions = { + ensure: vi.fn(), + refresh: vi.fn(), + openInstall: vi.fn(), + closeInstall: vi.fn(), + editInstallSpec: vi.fn(), + runInstall: vi.fn(), + cancelInstall: vi.fn(), + toggleInstallDetails: vi.fn(), + approveBuildsAndRetry: vi.fn(), + enableInstalled: vi.fn(), + clearHighlight: vi.fn(), + setEnabled: vi.fn(), + uninstall: vi.fn(), + confirm: vi.fn(), + cancelConfirm: vi.fn(), + setRowEnabled: vi.fn(), + dismissNotice: vi.fn(), + } + const props = { + t, + ...actions, + usePluginManager: bindSnapshotSelector(store), + } as unknown as PluginManagerPageProps + render() + return { store, actions, set: (next: Partial) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) } } +} + +describe('PluginManagerPage', () => { + it('asks the store once mounted and renders the loading, unavailable, error, and empty states', () => { + const { actions, set } = renderTab({ status: 'loading' }) + expect(actions.ensure).toHaveBeenCalledTimes(1) + expect(screen.getByText(en.loading)).toBeTruthy() + expect(screen.getByRole('button', { name: en.addPlugin })).toHaveProperty('disabled', true) + set({ status: 'unavailable' }) + expect(screen.getByRole('status').textContent).toBe(en.unavailable) + set({ status: 'error' }) + expect(screen.getByRole('alert').textContent).toBe(en.error) + fireEvent.click(screen.getByRole('button', { name: en.retry })) + expect(actions.refresh).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: en.refresh })) + expect(actions.refresh).toHaveBeenCalledTimes(2) + set({ status: 'ready' }) + expect(screen.getByText(en.empty)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.addPlugin })) + expect(actions.openInstall).toHaveBeenCalledTimes(1) + }) + + it('lists the installed bundles as cards, the installation\'s offered ones as built in, and tags a problem the Host reports', () => { + const { actions } = renderTab({ + packages: [ + pkg({ description: 'A sidebar.' }), + pkg({ name: 'dsh-broken', enabled: false, error: { code: 'not-bundle' } }), + pkg({ name: '@deepseek-ai/dsh-web-app', installed: false }), + pkg({ name: 'dsh-protected', readOnlyReason: 'management-required' }), + pkg({ name: '@acme/dsh-tool', enabled: false }), + // Selected by the profile but not a bundle: a problem the person can switch off, in the profile's own group. + pkg({ name: 'dsh-selected', installed: false, error: { code: 'not-bundle' } }), + pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, enabled: false }), + ], + busy: ['dsh-protected'], + }) + const cards = screen.getAllByRole('listitem') + // The built-in group comes first. + expect(cards.map(card => card.getAttribute('data-plugin-package'))).toEqual([ + '@deepseek-ai/dsh-experimental-agent-team-profile', 'dsh-better-sidebar', 'dsh-broken', 'dsh-protected', '@acme/dsh-tool', 'dsh-selected', + ]) + expect(cards.map(card => card.getAttribute('data-plugin-status'))).toEqual(['disabled', 'running', 'problem', 'running', 'disabled', 'problem']) + // Each group heads with its title and its bare count; the built-in card carries the official tag. + expect(screen.getByRole('heading', { name: en.bundlesTitle })).toBeTruthy() + expect(screen.getByRole('heading', { name: en.builtinTitle })).toBeTruthy() + expect([...document.querySelectorAll('[data-plugin-count]')].map(count => count.textContent)).toEqual(['1', '5']) + expect(screen.getAllByText(en.statusOfficial)).toHaveLength(1) + // A scoped name reads without its scope and harness prefix. + expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'tool') })).toHaveProperty('disabled', false) + expect(screen.getByText('A sidebar.')).toBeTruthy() + expect(screen.getAllByText(en.statusProblem)).toHaveLength(2) + // The switch acts on the bundle; a bundle the Host cannot read stays off, a protected one stays as it is. + fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'better-sidebar') })) + expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false) + expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'broken') })).toHaveProperty('disabled', true) + const locked = screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'protected') }) + expect(locked).toHaveProperty('disabled', true) + expect(locked.getAttribute('title')).toBe(en.reasonManagementRequired) + }) + + it('opens a built-in bundle\'s page with its official tag and no uninstall, and switches it on', () => { + const { actions } = renderTab({ + packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, enabled: false })], + }) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'experimental-agent-team-profile') })) + const detail = document.querySelector('[data-plugin-detail]') as HTMLElement + expect(within(detail).getByText(en.statusOfficial)).toBeTruthy() + expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', 'experimental-agent-team-profile') })).toBeNull() + fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'experimental-agent-team-profile') })) + expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-agent-team-profile', true) + }) + + it('opens a guide under the field and drops an example into it', () => { + const { actions } = renderTab({ install: { ...IDLE_INSTALL, open: true } }) + expect(screen.queryByText(en.installGuideIntro)).toBeNull() + const toggle = screen.getByRole('button', { name: en.installGuideToggle }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(toggle) + expect(screen.getByRole('button', { name: en.installGuideHide }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText(en.installGuideIdNote)).toBeTruthy() + expect(screen.getByText(en.installGuideGitExample)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.installGuideFillAria.replace('{example}', en.installGuideIdExample) })) + expect(actions.editInstallSpec).toHaveBeenCalledExactlyOnceWith(en.installGuideIdExample) + fireEvent.click(screen.getByRole('button', { name: en.installGuideHide })) + expect(screen.queryByText(en.installGuideIntro)).toBeNull() + }) + + it('opens a bundle\'s page with its facts and rows, and uninstalls from it', () => { + const { actions, set } = renderTab({ + packages: [pkg({ + description: 'A sidebar.', + rows: [row(), row({ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', entryId: 'include:theme' as PluginEntryId, enabled: false, phase: null })], + })], + }) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) + const detail = document.querySelector('[data-plugin-detail="dsh-better-sidebar"]') as HTMLElement + expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('better-sidebar') + // The version sits beside the name as a tag; the crumb only leads back. + expect(within(detail).getByText('v0.16.0')).toBeTruthy() + // The full package name stays visible under the short name. + expect(document.querySelector('[data-plugin-name]')?.textContent).toBe('dsh-better-sidebar') + expect(within(detail).getByRole('button', { name: en.backToList }).textContent).toBe(en.crumbRoot) + expect(within(detail).getByText('A sidebar.')).toBeTruthy() + // The rows, in order, with their state and their module. + const rows = within(detail).getAllByRole('listitem').filter(item => item.hasAttribute('data-plugin-row')) + expect(rows.map(item => item.getAttribute('data-plugin-row'))).toEqual(['include:sidebar', 'include:theme']) + expect(rows[1]?.getAttribute('data-state')).toBe('off') + expect(within(detail).getByText(en.partsCountTotal.replace('{count}', '2'), { exact: false })).toBeTruthy() + expect(within(detail).getByText('dsh-better-sidebar/theme')).toBeTruthy() + expect(within(detail).getByText(en.rowPhaseActive)).toBeTruthy() + expect(within(detail).getByText(en.partOff)).toBeTruthy() + fireEvent.click(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'better-sidebar') })) + expect(actions.uninstall).toHaveBeenCalledWith('dsh-better-sidebar') + fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'better-sidebar') })) + expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false) + // A problem and a protection the Host reports read on the page in the dictionary's words; the page leaves with the crumb. + set({ packages: [pkg({ error: { code: 'operation-error', diagnostic: 'unreadable' }, readOnlyReason: 'management-required' })] }) + expect(within(detail).getByText(`${en.reasonLabel}: unreadable`)).toBeTruthy() + expect(within(detail).getByText(en.reasonManagementRequired)).toBeTruthy() + expect(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'better-sidebar') })).toHaveProperty('disabled', true) + expect(within(detail).getByText(en.partsEmpty)).toBeTruthy() + set({ packages: [pkg({ error: { code: 'not-bundle' } })] }) + expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonNotBundle}`)).toBeTruthy() + set({ packages: [pkg({ error: { code: 'operation-error' } })] }) + expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonOperationError}`)).toBeTruthy() + fireEvent.click(within(detail).getByRole('button', { name: en.backToList })) + expect(document.querySelector('[data-plugin-detail]')).toBeNull() + // A bundle without a description or a version says so; one that leaves the list drops back to the cards. + const { version: _version, ...unversioned } = pkg() + set({ packages: [unversioned] }) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) + expect(screen.getByText(en.noDescription)).toBeTruthy() + expect(document.querySelector('[data-plugin-version]')).toBeNull() + set({ packages: [] }) + expect(document.querySelector('[data-plugin-detail]')).toBeNull() + expect(screen.getByText(en.empty)).toBeTruthy() + }) + + it('switches the rows of a bundle that is on, filters a long list, and locks what the Host will not address', () => { + const rows = Array.from({ length: 12 }, (_row, index): PackageRow => { + const live = row({ + rowId: `row-${String(index)}`, entryId: `include:row-${String(index)}` as PluginEntryId, + ...index === 1 ? { readOnlyReason: 'unaddressable' as const } : {}, + ...index === 3 ? { phase: 'failed' as const } : {}, + ...index === 4 ? { phase: 'loading' as const } : {}, + }) + if (index !== 2) return live + // The third row has no live entry: nothing to switch. + const { entryId: _entryId, ...unmounted } = live + return { ...unmounted, enabled: false, phase: null } + }) + const { actions, set } = renderTab({ packages: [pkg({ rows })], busy: [rowKey('include:row-5')] }) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) + const detail = document.querySelector('[data-plugin-detail]') as HTMLElement + expect(within(detail).getByText(`${en.partsCountTotal.replace('{count}', '12')} · ${en.partsCountRunning.replace('{count}', '9')} · ${en.partsCountOff.replace('{count}', '1')} · ${en.partsCountFailed.replace('{count}', '1')}`)).toBeTruthy() + fireEvent.click(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-0') })) + expect(actions.setRowEnabled).toHaveBeenCalledWith('include:row-0', false) + // A protected row, a row without a live entry, and a row with a write in flight cannot be switched. + const locked = within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-1') }) + expect(locked).toHaveProperty('disabled', true) + expect(locked.getAttribute('title')).toBe(en.reasonUnaddressable) + expect(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-2') })).toHaveProperty('disabled', true) + fireEvent.click(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-2') })) + expect(actions.setRowEnabled).toHaveBeenCalledTimes(1) + expect(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-5') })).toHaveProperty('disabled', true) + expect(within(detail).getByText(en.rowPhaseFailed)).toBeTruthy() + expect(within(detail).getByText(en.rowPhaseLoading)).toBeTruthy() + expect(document.querySelector('[data-plugin-row="include:row-3"]')?.getAttribute('data-state')).toBe('failed') + // A long list gets a filter; nothing matching says so. + const filter = within(detail).getByRole('searchbox', { name: en.partsFilter }) + fireEvent.change(filter, { target: { value: 'ROW-1' } }) + expect(within(detail).getAllByRole('listitem').filter(item => item.hasAttribute('data-plugin-row'))).toHaveLength(3) + fireEvent.change(filter, { target: { value: 'nothing' } }) + expect(within(detail).getByText(en.partsFilterEmpty)).toBeTruthy() + fireEvent.change(filter, { target: { value: '' } }) + // A bundle that is off shows its rows without switches. + set({ packages: [pkg({ enabled: false, rows: rows.slice(0, 2).map(item => ({ ...item, enabled: false, phase: null })) })] }) + expect(within(detail).queryByRole('switch', { name: en.partToggle.replace('{name}', 'row-0') })).toBeNull() + expect(within(detail).getAllByText(en.partOff)).toHaveLength(2) + // A row without a fiber, on a bundle that is on, reads idle. + set({ packages: [pkg({ rows: [row({ phase: null })] })] }) + expect(within(detail).getByText(en.rowStateIdle)).toBeTruthy() + }) + + it('takes a spec, checks it, and words what the check refused', () => { + const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true } }) + expect(screen.getByText(en.installDescription)).toBeTruthy() + const install = () => screen.getByRole('button', { name: en.installRun }) + expect(install()).toHaveProperty('disabled', true) + const field = screen.getByRole('textbox', { name: en.installSpecLabel }) + fireEvent.change(field, { target: { value: 'dsh-x' } }) + expect(actions.editInstallSpec).toHaveBeenCalledWith('dsh-x') + expect(document.querySelector('[data-terminal]')).toBeNull() + expect(screen.queryByRole('checkbox')).toBeNull() + + set({ install: { ...IDLE_INSTALL, open: true, spec: ' dsh-x ' } }) + fireEvent.keyDown(screen.getByRole('textbox', { name: en.installSpecLabel }), { key: 'Enter' }) + fireEvent.click(install()) + expect(actions.runInstall).toHaveBeenCalledTimes(2) + // The check keeps the field and the button inert. + set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'checking' } }) + expect(screen.getByRole('textbox', { name: en.installSpecLabel })).toHaveProperty('disabled', true) + const checking = screen.getByRole('button', { name: en.installChecking }) + expect(checking).toHaveProperty('disabled', true) + fireEvent.keyDown(screen.getByRole('textbox', { name: en.installSpecLabel }), { key: 'Enter' }) + expect(actions.runInstall).toHaveBeenCalledTimes(2) + + // Each refusal reads under the field. + const problems: [string, string][] = [ + ['invalid-spec', en.installProblemInvalid.replace('{reason}', 'r')], + ['already-installed', en.installProblemInstalled], + ['not-found', en.installProblemNotFound], + ['not-a-package', en.installProblemNotPackage], + ['not-a-bundle', en.installProblemNotBundle.replace('{reason}', 'r')], + ['network', en.installProblemNetwork], + ['unknown', en.installProblemUnknown.replace('{reason}', 'r')], + ] + for (const [problem, sentence] of problems) { + set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', inputError: { problem: problem as never, reason: 'r' } } }) + expect(screen.getByRole('alert').textContent).toBe(sentence) + expect(screen.getByRole('textbox', { name: en.installSpecLabel }).getAttribute('aria-invalid')).toBe('true') + } + fireEvent.click(screen.getByRole('button', { name: en.close })) + expect(actions.closeInstall).toHaveBeenCalledTimes(1) + }) + + it('shows the subject while installing, folds the pnpm output behind the details, and stops through the Host', () => { + const subject = { spec: 'dsh-x', status: 'accepted', kind: 'registry', name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', bundle: true } as const + const run = { jobId: 'j1', command: 'pnpm add dsh-x', cwd: '/home/u/.dsh/profiles/web', output: 'Progress: resolved \x1b[96m1\x1b[39m\n' } + const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', subject, runs: [run] } }) + expect(screen.getByRole('status').textContent).toBe(en.installingTitle) + expect(screen.getByText('dsh-x')).toBeTruthy() + expect(screen.getByText('A sidebar.')).toBeTruthy() + expect(screen.getByText(en.installVersion.replace('{version}', '1.4.2'))).toBeTruthy() + expect(screen.queryByRole('textbox')).toBeNull() + // The output stays folded until asked for. + expect(document.querySelector('[data-terminal]')).toBeNull() + const details = screen.getByRole('button', { name: en.installDetailsShow }) + expect(details.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(details) + expect(actions.toggleInstallDetails).toHaveBeenCalledTimes(1) + set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', subject, runs: [run], detailsOpen: true } }) + expect(screen.getByRole('button', { name: en.installDetailsHide }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText(en.installLocation.replace('{dir}', '/home/u/.dsh/profiles/web'))).toBeTruthy() + // The run streams as a terminal: its command line, its coloured output so far, the running label. + expect(screen.getByText('pnpm add dsh-x')).toBeTruthy() + const terminal = document.querySelector('[data-terminal]') as HTMLElement + expect(terminal.hasAttribute('data-running')).toBe(true) + expect(within(terminal).getByText('1').getAttribute('style')).toContain('--dsw-static-blue-500') + expect(within(terminal).getByText(en.terminalRunning)).toBeTruthy() + // A long log folds its middle behind an expand control, so the dialog keeps its height while pnpm talks. + const lines = Array.from({ length: 15 }, (_line, index) => `line ${String(index + 1)}`).join('\n') + set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', subject, detailsOpen: true, runs: [{ ...run, output: `${lines}\n` }] } }) + expect(screen.queryByText('line 8')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.terminalExpandAria.replace('{n}', '3') })) + expect(screen.getByText('line 8')).toBeTruthy() + // Before the first chunk there is no location to name. + set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', subject, detailsOpen: true } }) + expect(screen.getByText(en.terminalNoOutput)).toBeTruthy() + // Cancel and the back control each ask the Host to stop the run; close waits for the Host's word. + fireEvent.click(screen.getByRole('button', { name: en.installCancel })) + fireEvent.click(screen.getByRole('button', { name: en.installEditAria })) + expect(actions.cancelInstall).toHaveBeenCalledTimes(2) + expect(screen.getByRole('button', { name: en.close })).toHaveProperty('disabled', true) + expect(actions.closeInstall).not.toHaveBeenCalled() + }) + + it('waits with the Host through starting, stopping, and applying, and words an unconfirmed stop', () => { + const subject = { spec: 'slow', status: 'accepted', kind: 'registry', name: 'slow', bundle: true } as const + const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'starting', subject } }) + // Before the Host acknowledges the run there is nothing to stop: cancel, back, and close all wait. + expect(screen.getByRole('status').textContent).toBe(en.installStarting) + expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: en.installEditAria })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: en.close })).toHaveProperty('disabled', true) + set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'running', subject } }) + fireEvent.click(screen.getByRole('button', { name: en.installCancel })) + fireEvent.click(screen.getByRole('button', { name: en.installEditAria })) + expect(actions.cancelInstall).toHaveBeenCalledTimes(2) + // While the Host stops the run the terminal reads as cancelled rather than failed. + set({ + install: { + ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'cancelling', subject, detailsOpen: true, + runs: [{ jobId: 'j', command: 'pnpm add slow', cwd: '/p', output: '', exitCode: null }], + }, + }) + expect(screen.getByRole('status').textContent).toBe(en.installCancelling) + expect(screen.getByRole('button', { name: en.installCancelling })).toHaveProperty('disabled', true) + expect(within(document.querySelector('[data-terminal]') as HTMLElement).getByText(en.installCancelledShort)).toBeTruthy() + set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'applying', subject } }) + expect(screen.getByRole('status').textContent).toBe(en.installApplying) + expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', true) + // A stop the Host could not confirm says so over the running screen. + set({ install: { ...IDLE_INSTALL, open: true, spec: 'slow', phase: 'running', subject, failure: { reason: 'offline', cancelUnconfirmed: true } } }) + expect(screen.getByRole('alert').textContent).toContain('offline') + expect(screen.getByRole('button', { name: en.installCancel })).toHaveProperty('disabled', false) + }) + + it('offers to enable what a finished install added, and says when it waits for a restart', () => { + const subject = { spec: '/plugins/dsh-x', status: 'accepted', kind: 'path', name: 'dsh-x', bundle: true } as const + const { actions, set } = renderTab({ + install: { + ...IDLE_INSTALL, + open: true, + spec: '/plugins/dsh-x', + phase: 'done', + subject, + runs: [{ jobId: 'j1', command: 'pnpm add /plugins/dsh-x', cwd: '/p', output: 'Done in 1s\n', exitCode: 0 }], + installed: 'dsh-x', + }, + }) + expect(screen.getByText(en.installedTitle)).toBeTruthy() + // A path without a description reads by its kind. + expect(screen.getByText('dsh-x')).toBeTruthy() + expect(screen.getByText(en.installSubjectPath)).toBeTruthy() + expect(screen.queryByText(en.installDoneRestart)).toBeNull() + // No way back to the spec from here; enabling is the one action. + expect(screen.queryByRole('button', { name: en.installEditAria })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.installEnableNow })) + expect(actions.enableInstalled).toHaveBeenCalledTimes(1) + set({ install: { ...IDLE_INSTALL, open: true, spec: '/plugins/dsh-x', phase: 'done', subject, installed: 'dsh-x', restartRequired: true, enabling: true } }) + expect(screen.getByRole('button', { name: en.installEnableNow })).toHaveProperty('disabled', true) + expect(screen.getByText(en.installDoneRestart)).toBeTruthy() + + // A run that named no bundle leaves only Done. + set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'done', subject: { spec: 'dsh-x', status: 'accepted', kind: 'registry', name: 'dsh-x', bundle: true } } }) + expect(screen.getByText(en.installDoneNothing)).toBeTruthy() + expect(screen.queryByRole('button', { name: en.installEnableNow })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.installClose })) + expect(actions.closeInstall).toHaveBeenCalledTimes(1) + }) + + it('asks to allow the scripts a blocked install left pending, retries with them, and says what was allowed', () => { + const subject = { spec: 'dsh-x', status: 'accepted', kind: 'registry', name: 'dsh-x', bundle: true } as const + const { actions, set } = renderTab({ + install: { + ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'failed', subject, + failure: { reason: 'ERR', kind: 'build-blocked', pendingBuilds: ['native', '@scope/other'] }, + }, + }) + expect(screen.getByText(en.installFailureBuildBlocked)).toBeTruthy() + const group = screen.getByRole('group', { name: en.installApprovalTitle }) + expect(within(group).getByText('native')).toBeTruthy() + expect(within(group).getByText('@scope/other')).toBeTruthy() + expect(within(group).getByText(en.installApprovalCaution)).toBeTruthy() + // Plain retry would fail the same way, so only the approval is offered. + expect(screen.queryByRole('button', { name: en.installRetry })).toBeNull() + fireEvent.click(within(group).getByRole('button', { name: en.installApproveAndRetry })) + expect(actions.approveBuildsAndRetry).toHaveBeenCalledTimes(1) + // Without the pending names the failure reads as the manual instruction, and plain retry is back. + set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'failed', subject, failure: { reason: 'ERR', kind: 'build-blocked' } } }) + expect(screen.getByText(en.installFailureBuildBlockedManual)).toBeTruthy() + expect(screen.queryByRole('group', { name: en.installApprovalTitle })).toBeNull() + expect(screen.getByRole('button', { name: en.installRetry })).toBeTruthy() + // The installed screen says which scripts were allowed. + set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'done', subject, installed: 'dsh-x', approvedBuilds: ['native'] } }) + expect(screen.getByText(en.installDoneApproved.replace('{names}', 'native'))).toBeTruthy() + }) + + it('words a failed install by its kind, else in the Host\'s words, and retries it', () => { + const subject = { spec: 'github:a/b', status: 'accepted', kind: 'git', bundle: null } as const + const { actions, set } = renderTab({ + install: { + ...IDLE_INSTALL, + open: true, + spec: 'github:a/b', + phase: 'failed', + subject, + detailsOpen: true, + runs: [{ jobId: 'j1', command: 'pnpm add github:a/b', cwd: '/p', output: 'ERR\n', exitCode: 1 }], + failure: { reason: 'ERR\n', kind: 'network' }, + }, + }) + expect(screen.getByRole('alert').textContent).toBe(en.installFailedTitle) + expect(screen.getByText(en.installFailureNetwork)).toBeTruthy() + // A git spec without a manifest reads by its address and kind. + expect(screen.getByText('github:a/b')).toBeTruthy() + expect(screen.getByText(en.installSubjectGit)).toBeTruthy() + expect(screen.getByText(en.terminalExitCode.replace('{code}', '1'))).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.installRetry })) + expect(actions.runInstall).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: en.installEditAria })) + expect(actions.cancelInstall).toHaveBeenCalledTimes(1) + + const kinds: [string, string][] = [ + ['pnpm-missing', en.installFailurePnpmMissing], ['timeout', en.installFailureTimeout], + ['not-found', en.installFailureNotFound], ['no-matching-version', en.installFailureNoMatchingVersion], + ['disk-full', en.installFailureDiskFull], ['permission', en.installFailurePermission], + ['build-blocked', en.installFailureBuildBlockedManual], ['integrity', en.installFailureIntegrity], ['unknown', en.installFailureGeneric], + ] + for (const [kind, sentence] of kinds) { + set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: 'r', kind: kind as never } } }) + expect(screen.getByText(sentence)).toBeTruthy() + } + // A failure without a kind reads by its code, else in the Host's words; without words, or without a failure at all, generically. + set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: '', code: 'not-bundle' } } }) + expect(screen.getByText(en.reasonNotBundle)).toBeTruthy() + set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: 'ERR_PNPM_ADDING_TO_ROOT', code: 'operation-error' } } }) + expect(screen.getByText('ERR_PNPM_ADDING_TO_ROOT')).toBeTruthy() + set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: 'the transport said so' } } }) + expect(screen.getByText('the transport said so')).toBeTruthy() + set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: { reason: '' } } }) + expect(screen.getByText(en.installFailureGeneric)).toBeTruthy() + set({ install: { ...IDLE_INSTALL, open: true, spec: 'x', phase: 'failed', failure: null } }) + expect(screen.getByText(en.installFailureGeneric)).toBeTruthy() + // A tarball spec reads by its kind too. + set({ install: { ...IDLE_INSTALL, open: true, spec: '/p/x.tgz', phase: 'failed', subject: { spec: '/p/x.tgz', status: 'accepted', kind: 'tarball', bundle: null }, failure: null } }) + expect(screen.getByText(en.installSubjectTarball)).toBeTruthy() + }) + + it('scrolls to and marks the package an install enabled, then lets the mark go', () => { + vi.useFakeTimers() + const scrollIntoView = vi.fn() + const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView') + Object.defineProperty(Element.prototype, 'scrollIntoView', { value: scrollIntoView, configurable: true }) + try { + const { actions, set } = renderTab({ packages: [pkg()] }) + // A package the list does not show has nothing to scroll to; the mark still times out. + set({ highlight: 'missing' }) + expect(scrollIntoView).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(2_400) }) + expect(actions.clearHighlight).toHaveBeenCalledTimes(1) + set({ highlight: 'dsh-better-sidebar' }) + expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')?.hasAttribute('data-plugin-highlight')).toBe(true) + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center', behavior: 'smooth' }) + act(() => { vi.advanceTimersByTime(2_400) }) + expect(actions.clearHighlight).toHaveBeenCalledTimes(2) + set({ highlight: null }) + expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')?.hasAttribute('data-plugin-highlight')).toBe(false) + } finally { + if (descriptor === undefined) delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView + else Object.defineProperty(Element.prototype, 'scrollIntoView', descriptor) + vi.useRealTimers() + } + }) + + it('marks a card without a scrollIntoView to call', () => { + vi.useFakeTimers() + const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView') + delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView + try { + const { set } = renderTab({ packages: [pkg()] }) + set({ highlight: 'dsh-better-sidebar' }) + expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')?.hasAttribute('data-plugin-highlight')).toBe(true) + } finally { + if (descriptor !== undefined) Object.defineProperty(Element.prototype, 'scrollIntoView', descriptor) + vi.useRealTimers() + } + }) + + it('confirms an uninstall by the package\'s title and runs the action through it', () => { + const { actions, set } = renderTab({ + packages: [pkg(), pkg({ name: 'dsh-other' })], + confirm: { action: 'uninstall', packageName: 'dsh-better-sidebar' }, + }) + expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'better-sidebar') })).toBeTruthy() + expect(screen.getByText(en.confirmUninstallDescription)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.cancel })) + expect(actions.cancelConfirm).toHaveBeenCalledTimes(1) + set({ confirm: { action: 'uninstall', packageName: 'dsh-other' } }) + expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'other') })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.confirmUninstall })) + expect(actions.confirm).toHaveBeenCalledTimes(1) + }) + + it('words every notice as a toast that dismisses itself', () => { + vi.useFakeTimers() + try { + const { actions, set } = renderTab({ notice: { kind: 'restart', packageName: 'x', seq: 1 } }) + expect(screen.getByRole('alert').textContent).toContain(en.restartNotice) + set({ notice: { kind: 'overridden', packageName: 'pkg-1', seq: 2 } }) + expect(screen.getByRole('alert').textContent).toContain(en.overriddenNotice.replace('{name}', 'pkg-1')) + set({ notice: { kind: 'cancelled', seq: 3 } }) + expect(screen.getByRole('alert').textContent).toContain(en.installCancelled) + // A failure names what was being done; a refusal is worded by its code, a silent one generically. + set({ notice: { kind: 'failed', action: 'enable', reason: 'the tree rejected it', packageName: 'pkg-1', seq: 4 } }) + expect(screen.getByRole('alert').textContent).toContain(en.failedEnable.replace('{reason}', 'the tree rejected it')) + set({ notice: { kind: 'failed', action: 'uninstall', code: 'bundle-in-use', reason: '', packageName: 'pkg-1', seq: 5 } }) + expect(screen.getByRole('alert').textContent).toContain(en.failedUninstall.replace('{reason}', en.reasonBundleInUse)) + set({ notice: { kind: 'failed', action: 'rowDisable', code: 'operation-error', reason: 'EACCES', packageName: 'pkg-1', seq: 6 } }) + expect(screen.getByRole('alert').textContent).toContain(en.failedRowDisable.replace('{reason}', 'EACCES')) + set({ notice: { kind: 'failed', action: 'disable', reason: '', packageName: 'pkg-1', seq: 7 } }) + expect(screen.getByRole('alert').textContent).toContain(en.failedDisable.replace('{reason}', en.reasonOperationError)) + set({ notice: { kind: 'failed', action: 'rowEnable', reason: 'x', packageName: 'pkg-1', seq: 8 } }) + expect(screen.getByRole('alert').textContent).toContain(en.failedRowEnable.replace('{reason}', 'x')) + // No button to press: the toast retires on its own and the store forgets it. + expect(screen.queryByRole('button', { name: /got it/i })).toBeNull() + expect(actions.dismissNotice).not.toHaveBeenCalled() + // The hold grows with the text, up to eight seconds, then the fade. + act(() => { vi.advanceTimersByTime(8_000 + 1_000) }) + expect(actions.dismissNotice).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts new file mode 100644 index 0000000000..7dafd75d0f --- /dev/null +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -0,0 +1,721 @@ +/** + * The manager store: what it reads, how actions cross the wire, which + * outcomes become toasts, and how the install run folds its output. + */ + +import { describe, expect, it, vi } from 'vitest' +import type { BundleInfo, ChangeResult, ManagementError, PluginEntryId, PluginInfo, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { packageView, PluginManagerController, rowKey, sortPackages } from '../src/client/manager-store.ts' + +const ROW_ENTRY = 'include:sidebar' as PluginEntryId + +const BUNDLE: BundleInfo = { + name: 'dsh-better-sidebar', + version: '0.16.0', + description: 'A sidebar.', + enabled: false, + installed: true, + optional: false, + removable: true, + rows: [{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY }, { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme' }], + overrides: [], +} + +const PLUGINS: PluginInfo[] = [ + { entryId: ROW_ENTRY, moduleName: 'dsh-better-sidebar', enabled: true, fiberPhase: 'active', patchId: 'sidebar' }, + { entryId: 'include:core' as PluginEntryId, moduleName: '@deepseek-ai/dsh-base', enabled: true, fiberPhase: 'active', readOnlyReason: 'management-required' }, +] + +/** What the check answers for a registry name. */ +const INSPECTED = { status: 'accepted' as const, kind: 'registry' as const, name: 'dsh-better-sidebar', version: '1.0.0', bundle: true } + +const APPLIED: ChangeResult = { changed: true, application: 'applied', stage: 'enable', target: 'dsh-better-sidebar' } + +/** A change the Host could not apply, with the refusal it names. */ +function failed(error?: ManagementError, packageResult?: ChangeResult['packageResult']): ChangeResult { + return { + changed: false, application: 'failed', stage: 'enable', target: 'dsh-better-sidebar', + ...error === undefined ? {} : { error }, ...packageResult === undefined ? {} : { packageResult }, + } +} + +function ok(value: T) { + return { ok: true as const, value } +} + +function refused(code: string, message: string, details: object = {}) { + // The double's code map is keyed by literal codes; a spec-chosen string stands in. + return { ok: false as const, error: new RemoteError(code as never, message, details as never) } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { resolve = res }) + return { promise, resolve } +} + +function bench(overrides: Partial>> = {}) { + const inventory = { list: overrides.inventory ?? vi.fn(() => Promise.resolve(ok({ entries: [], managementAvailable: true }))) } + const plugins = { + listBundles: vi.fn(() => Promise.resolve(ok([BUNDLE]))), + listPlugins: vi.fn(() => Promise.resolve(ok(PLUGINS))), + inspect: vi.fn(() => Promise.resolve(ok(INSPECTED))), + installBundle: vi.fn(() => Promise.resolve(ok({ ...APPLIED, bundle: 'dsh-new' }))), + cancelInstall: vi.fn(() => Promise.resolve(ok({ status: 'cancelled' }))), + removeBundle: vi.fn(() => Promise.resolve(ok(APPLIED))), + setBundleEnabled: vi.fn(() => Promise.resolve(ok(APPLIED))), + setPluginEnabled: vi.fn(() => Promise.resolve(ok(APPLIED))), + ...overrides, + } + const ctx = { remote: { pluginManager: plugins, pluginInventory: inventory } } as never + const controller = new PluginManagerController(ctx) + const face = controller.inject() + const state = () => controller.getSnapshot() + /** The request id of the run the dialog just handed to the Host. */ + const started = async (): Promise => { + await vi.waitFor(() => { expect(state().install.phase).toBe('starting') }) + return state().install.requestId as PluginInstallRequestId + } + return { plugins, inventory, controller, face, state, started } +} + +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.', + installed: true, optional: 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 }, + ], + }) + // 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, optional: false, removable: 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, optional: 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' }, + { rowId: 'gone', moduleName: 'x', entryId: 'include:gone', enabled: false, phase: null }, + ], + }) + }) +}) + +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, optional: false, removable: true, 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)) + expect(sortPackages(views).map(pkg => pkg.name)).toEqual(['@acme/dsh-alpha', 'dsh-better-sidebar', 'dsh-zeta']) + // The store lists what it read in that order, whatever the Host's order. + const { state, controller } = bench({ listBundles: vi.fn(() => Promise.resolve(ok([zeta, BUNDLE, alpha]))) }) + await controller.load() + expect(state().packages.map(pkg => pkg.name)).toEqual(['@acme/dsh-alpha', 'dsh-better-sidebar', 'dsh-zeta']) + }) +}) + +describe('PluginManagerController', () => { + it('starts idle, reads the inventory then the bundles and entries on first use, and folds concurrent loads', async () => { + const gate = deferred>>() + const { plugins, inventory, face, state, controller } = bench({ + listBundles: vi.fn().mockReturnValueOnce(gate.promise).mockResolvedValue(ok([BUNDLE])), + }) + expect(state().status).toBe('idle') + face.ensure() + face.ensure() + await Promise.resolve() + expect(state().status).toBe('loading') + const mid = controller.load() + gate.resolve(ok([BUNDLE])) + await mid + expect(state().status).toBe('ready') + expect(state().packages).toEqual([packageView(BUNDLE, PLUGINS)]) + expect(inventory.list).toHaveBeenCalledTimes(2) + expect(plugins.listBundles).toHaveBeenCalledTimes(2) + expect(plugins.listPlugins).toHaveBeenCalledTimes(2) + face.ensure() + expect(plugins.listBundles).toHaveBeenCalledTimes(2) + }) + + it('reports a Host without a managed profile as unavailable and keeps the last packages across a failed read', async () => { + const { inventory, plugins, face, state, controller } = bench() + await controller.load() + expect(state().packages).toHaveLength(1) + inventory.list.mockResolvedValueOnce(ok({ entries: [] })) + await controller.load() + expect(state()).toMatchObject({ status: 'unavailable', packages: [] }) + inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline')) + await controller.load() + expect(state().status).toBe('error') + await controller.load() + expect(state().status).toBe('ready') + plugins.listPlugins.mockResolvedValueOnce(refused('gateway/internal', 'offline') as never) + await controller.load() + expect(state()).toMatchObject({ status: 'error', packages: [packageView(BUNDLE, PLUGINS)] }) + plugins.listBundles.mockResolvedValueOnce(refused('gateway/internal', 'offline') as never) + await controller.load() + expect(state().status).toBe('error') + face.refresh() + await vi.waitFor(() => { expect(state().status).toBe('ready') }) + }) + + it('enables a bundle, marks it busy meanwhile, and says when a restart is needed or a layer overrides it', async () => { + const gate = deferred>>() + const { plugins, face, state, controller } = bench({ + setBundleEnabled: vi.fn() + .mockReturnValueOnce(gate.promise) + .mockResolvedValueOnce(ok({ ...APPLIED, application: 'restart-required' })) + .mockResolvedValueOnce(ok({ ...APPLIED, application: 'overridden' })), + }) + await controller.load() + face.setEnabled(BUNDLE.name, true) + face.setEnabled(BUNDLE.name, true) + await Promise.resolve() + expect(state().busy).toEqual([BUNDLE.name]) + expect(plugins.setBundleEnabled).toHaveBeenCalledExactlyOnceWith(BUNDLE.name, true) + gate.resolve(ok(APPLIED)) + await vi.waitFor(() => { expect(state().busy).toEqual([]) }) + expect(state().notice).toBeNull() + expect(plugins.listBundles).toHaveBeenCalledTimes(2) + face.setEnabled(BUNDLE.name, false) + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'restart', packageName: BUNDLE.name, seq: 1 }) }) + face.setEnabled(BUNDLE.name, false) + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'overridden', packageName: BUNDLE.name, seq: 2 }) }) + }) + + it('turns a change the Host could not apply, or a refused answer, into a notice carrying its code and words', async () => { + const { face, state, controller } = bench({ + setBundleEnabled: vi.fn() + .mockResolvedValueOnce(ok(failed({ code: 'operation-error', diagnostic: 'the tree rejected it' }))) + .mockResolvedValueOnce(refused('gateway/internal', 'offline')) + .mockRejectedValueOnce(new Error('transport down')) + .mockRejectedValueOnce('odd') + .mockResolvedValueOnce(ok(failed({ code: 'bundle-in-use' }))) + .mockResolvedValueOnce(ok(failed())) + .mockResolvedValueOnce(ok({ ...failed(), application: 'cancelled' })), + }) + await controller.load() + face.setEnabled(BUNDLE.name, true) + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', code: 'operation-error', reason: 'the tree rejected it', packageName: BUNDLE.name, seq: 1 }) }) + face.setEnabled(BUNDLE.name, false) + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'disable', reason: 'offline', packageName: BUNDLE.name, seq: 2 }) }) + face.setEnabled(BUNDLE.name, true) + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', reason: 'transport down', packageName: BUNDLE.name, seq: 3 }) }) + face.setEnabled(BUNDLE.name, true) + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', reason: 'odd', packageName: BUNDLE.name, seq: 4 }) }) + // A refusal keeps its code with no words of its own; a failure without a code has neither. + face.setEnabled(BUNDLE.name, true) + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', code: 'bundle-in-use', reason: '', packageName: BUNDLE.name, seq: 5 }) }) + face.setEnabled(BUNDLE.name, true) + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', reason: '', packageName: BUNDLE.name, seq: 6 }) }) + // A change the Host stopped is said in passing. + face.setEnabled(BUNDLE.name, true) + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'cancelled', seq: 7 }) }) + face.dismissNotice() + expect(state().notice).toBeNull() + }) + + it('always asks before uninstalling, and cancelling runs nothing', async () => { + const { plugins, face, state, controller } = bench() + await controller.load() + face.uninstall(BUNDLE.name) + expect(state().confirm).toEqual({ action: 'uninstall', packageName: BUNDLE.name }) + face.cancelConfirm() + expect(state().confirm).toBeNull() + face.confirm() + expect(plugins.removeBundle).not.toHaveBeenCalled() + face.uninstall(BUNDLE.name) + face.confirm() + expect(state().confirm).toBeNull() + await vi.waitFor(() => { expect(plugins.removeBundle).toHaveBeenCalledExactlyOnceWith(BUNDLE.name) }) + await vi.waitFor(() => { expect(state().busy).toEqual([]) }) + // A refused removal names the action it was. + plugins.removeBundle.mockResolvedValueOnce(ok({ ...failed(), stage: 'remove', error: { code: 'not-removable' } }) as never) + face.uninstall(BUNDLE.name) + face.confirm() + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'uninstall', code: 'not-removable', reason: '', packageName: BUNDLE.name, seq: 1 }) }) + }) + + it('switches rows under their own busy keys and reports what the Host said', async () => { + const gate = deferred>>() + const { plugins, face, state, controller } = bench({ + setPluginEnabled: vi.fn().mockReturnValueOnce(gate.promise).mockResolvedValueOnce(ok(failed({ code: 'unaddressable' }))), + }) + await controller.load() + face.setRowEnabled(ROW_ENTRY, false) + face.setRowEnabled(ROW_ENTRY, false) + await Promise.resolve() + expect(state().busy).toEqual([rowKey(ROW_ENTRY)]) + expect(plugins.setPluginEnabled).toHaveBeenCalledExactlyOnceWith(ROW_ENTRY, false) + gate.resolve(ok(APPLIED)) + await vi.waitFor(() => { expect(state().busy).toEqual([]) }) + face.setRowEnabled(ROW_ENTRY, true) + await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'rowEnable', code: 'unaddressable', reason: '', packageName: ROW_ENTRY, seq: 1 }) }) + }) + + it('checks the spec, hands the run to the Host, and folds the chunks that carry its request id', async () => { + const gate = deferred>>() + const { plugins, face, state, controller, started } = bench({ installBundle: vi.fn().mockReturnValueOnce(gate.promise) }) + await controller.load() + face.runInstall() + expect(plugins.inspect).not.toHaveBeenCalled() + face.openInstall() + expect(state().install).toMatchObject({ open: true, spec: '', phase: 'idle', inputError: null, subject: null }) + face.editInstallSpec(' dsh-new ') + face.runInstall() + face.runInstall() + expect(state().install.phase).toBe('checking') + // Neither typing nor a second run reaches the Host while it checks. + face.editInstallSpec('other') + expect(state().install.spec).toBe(' dsh-new ') + expect(plugins.inspect).toHaveBeenCalledTimes(1) + expect(plugins.inspect).toHaveBeenCalledWith('dsh-new', expect.any(AbortSignal)) + const requestId = await started() + expect(state().install.subject).toEqual({ spec: 'dsh-new', ...INSPECTED }) + expect(plugins.installBundle).toHaveBeenCalledTimes(1) + expect(plugins.installBundle).toHaveBeenCalledWith('dsh-new', { enabled: false, requestId }) + // The Host's acknowledgement makes the run stoppable; a chunk of another request is not this run's. + controller.installProgress({ requestId, phase: 'installing' }) + expect(state().install.phase).toBe('running') + controller.appendLog({ requestId: 'other' as PluginInstallRequestId, jobId: 'j1', argv: [], cwd: '/p', stream: 'stdout', text: 'x' }) + expect(state().install.runs).toEqual([]) + const argv = ['pnpm', 'add', 'dsh-new'] + controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', stream: 'stdout', text: 'Progress\n' }) + // A second run of the same install is its own terminal; a later chunk lands on the run it names. + controller.appendLog({ requestId, jobId: 'j2', argv: ['pnpm', 'remove', 'lib'], cwd: '/p', stream: 'stdout', text: '- lib\n', exitCode: 0 }) + controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', stream: 'stderr', text: 'Done\n' }) + expect(state().install.runs).toEqual([ + { jobId: 'j1', command: 'pnpm add dsh-new', cwd: '/p', output: 'Progress\nDone\n' }, + { jobId: 'j2', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 }, + ]) + face.toggleInstallDetails() + expect(state().install.detailsOpen).toBe(true) + gate.resolve(ok({ ...APPLIED, bundle: 'dsh-new' })) + await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) + expect(state().install).toMatchObject({ installed: 'dsh-new', restartRequired: false, detailsOpen: true }) + // The finished install settled its run; a trailing last chunk still lands + // on it, while a chunk for a run the dialog never saw is dropped. + controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', stream: 'stdout', text: '', exitCode: 0 }) + controller.appendLog({ requestId, jobId: 'j3', argv, cwd: '/p', stream: 'stdout', text: 'stray' }) + expect(state().install.runs).toEqual([ + { jobId: 'j1', command: 'pnpm add dsh-new', cwd: '/p', output: 'Progress\nDone\n', exitCode: 0 }, + { jobId: 'j2', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 }, + ]) + await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) }) + // Cancelling from the finished screen does nothing, nor does the Host's late progress; a new spec after it starts over. + face.cancelInstall() + controller.installProgress({ requestId, phase: 'applying' }) + expect(state().install.phase).toBe('done') + face.editInstallSpec('another') + expect(state().install).toMatchObject({ phase: 'idle', spec: 'another', runs: [], installed: null, subject: null }) + face.closeInstall() + expect(state().install.open).toBe(false) + }) + + it('refuses a spec the list already shows without asking the Host, and words what the Host refused', async () => { + const { plugins, face, state, controller } = bench({ + inspect: vi.fn() + .mockResolvedValueOnce(ok({ status: 'refused', problem: 'not-found', reason: 'E404' })) + .mockResolvedValueOnce(ok({ status: 'refused', problem: 'not-a-bundle', reason: 'plain declares no dsh.bundle' })) + .mockResolvedValueOnce(refused('gateway/internal', 'offline')), + }) + await controller.load() + face.openInstall() + face.editInstallSpec(BUNDLE.name) + face.runInstall() + expect(plugins.inspect).not.toHaveBeenCalled() + expect(state().install).toMatchObject({ phase: 'idle', inputError: { problem: 'already-installed', reason: BUNDLE.name } }) + // Typing clears the refusal. + face.editInstallSpec('nope') + expect(state().install.inputError).toBeNull() + face.runInstall() + await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'not-found', reason: 'E404' }) }) + expect(state().install.phase).toBe('idle') + expect(plugins.installBundle).not.toHaveBeenCalled() + face.editInstallSpec('plain') + face.runInstall() + await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'not-a-bundle', reason: 'plain declares no dsh.bundle' }) }) + // A refused answer, rather than a refused spec, reads as unknown with the transport's words. + face.editInstallSpec('x') + face.runInstall() + await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'unknown', reason: 'offline' }) }) + }) + + it('leaves the check or the failed screen for the spec at once', async () => { + const inspectGate = deferred>>() + const { plugins, face, state, controller } = bench({ + inspect: vi.fn().mockReturnValueOnce(inspectGate.promise).mockResolvedValue(ok(INSPECTED)), + installBundle: vi.fn().mockResolvedValue(ok(failed({ code: 'operation-error', diagnostic: 'ERR' }, { exitCode: 1, output: 'ERR', truncated: false, logPath: '/l', kind: 'network' }))), + }) + await controller.load() + face.openInstall() + face.editInstallSpec('dsh-x') + face.runInstall() + const checkSignal = (plugins.inspect.mock.calls[0] as unknown[])[1] as AbortSignal + face.cancelInstall() + expect(checkSignal.aborted).toBe(true) + expect(state().install).toMatchObject({ open: true, phase: 'idle', spec: 'dsh-x', inputError: null }) + // The settlement of the dropped check changes nothing. + inspectGate.resolve(ok(INSPECTED)) + await Promise.resolve() + await Promise.resolve() + expect(state().install.phase).toBe('idle') + expect(plugins.installBundle).not.toHaveBeenCalled() + // Closing during a check drops it too. + face.runInstall() + face.closeInstall() + expect(state().install.open).toBe(false) + expect((plugins.inspect.mock.calls[1] as unknown[])[1]).toMatchObject({ aborted: true }) + // From the failed screen the same control goes back to the spec. + face.openInstall() + face.editInstallSpec('dsh-x') + face.runInstall() + await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) + expect(state().install.failure).toEqual({ reason: 'ERR', code: 'operation-error', kind: 'network' }) + face.cancelInstall() + expect(state().install).toMatchObject({ open: true, phase: 'idle', spec: 'dsh-x', failure: null, subject: null }) + }) + + it('asks the Host to stop a run, keeps the spec once it confirms, and forgets the stopped run', async () => { + const first = deferred>>() + const second = deferred>>() + const cancellation = deferred>>() + const { plugins, face, state, controller, started } = bench({ + installBundle: vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise), + cancelInstall: vi.fn().mockReturnValueOnce(cancellation.promise), + }) + face.openInstall() + face.editInstallSpec('slow') + face.runInstall() + const requestId = await started() + // Before the Host acknowledges the run there is nothing to stop, and the dialog cannot close. + face.cancelInstall() + face.closeInstall() + expect(plugins.cancelInstall).not.toHaveBeenCalled() + expect(state().install).toMatchObject({ open: true, phase: 'starting' }) + controller.installProgress({ requestId, phase: 'installing' }) + face.cancelInstall() + face.cancelInstall() + face.closeInstall() + face.openInstall() + expect(plugins.cancelInstall).toHaveBeenCalledExactlyOnceWith(requestId) + expect(state().install).toMatchObject({ phase: 'cancelling', open: true, spec: 'slow' }) + // A queued start cannot undo the request to stop; the Host's own cancelling phase is the same. + controller.installProgress({ requestId, phase: 'installing' }) + controller.installProgress({ requestId, phase: 'cancelling' }) + expect(state().install.phase).toBe('cancelling') + cancellation.resolve(ok({ status: 'cancelled' })) + await vi.waitFor(() => { expect(state().install.phase).toBe('idle') }) + // The spec is offered again, the run is forgotten, and a toast says the Host stopped it. + expect(state().install).toMatchObject({ open: true, spec: 'slow', subject: null, runs: [] }) + expect(state().install.requestId).toBeUndefined() + expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 }) + face.runInstall() + const nextId = await started() + expect(nextId).not.toBe(requestId) + // The stopped run's answer, progress, and chunks belong to a request the dialog no longer has. + first.resolve(ok({ ...failed(), application: 'cancelled' })) + await Promise.resolve() + await Promise.resolve() + expect(state().install).toMatchObject({ requestId: nextId, phase: 'starting' }) + controller.installProgress({ requestId, phase: 'applying' }) + controller.appendLog({ requestId, jobId: 'old', argv: [], cwd: '/p', stream: 'stdout', text: 'late' }) + expect(state().install).toMatchObject({ phase: 'starting', runs: [] }) + second.resolve(ok({ ...APPLIED, application: 'restart-required', bundle: 'slow' })) + await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) + expect(state().install).toMatchObject({ installed: 'slow', restartRequired: true }) + }) + + it.each(['too-late', 'not-running', 'offline'] as const)('keeps a stop the Host answered %s apart from a stopped run', async (status) => { + const pending = deferred>>() + const { plugins, face, state, controller, started } = bench({ + installBundle: vi.fn().mockReturnValue(pending.promise), + cancelInstall: vi.fn().mockResolvedValue(status === 'offline' ? refused('gateway/internal', 'offline') : ok({ status })), + }) + face.openInstall() + face.editInstallSpec('slow') + face.runInstall() + const requestId = await started() + controller.installProgress({ requestId, phase: 'installing' }) + face.cancelInstall() + await vi.waitFor(() => { expect(state().install.phase).toBe(status === 'too-late' ? 'applying' : 'running') }) + expect(plugins.cancelInstall).toHaveBeenCalledOnce() + // A stop the Host did not confirm says so over the running screen, with the transport's words when it has them. + expect(state().install.failure).toEqual( + status === 'too-late' ? null : { reason: status === 'offline' ? 'offline' : '', cancelUnconfirmed: true }, + ) + // The Host's own word that it stopped the run still ends it. + pending.resolve(ok({ ...failed(), application: 'cancelled' })) + await vi.waitFor(() => { expect(state().install.phase).toBe('idle') }) + expect(state().install).toMatchObject({ open: true, spec: 'slow', failure: null }) + expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 }) + }) + + it.each([false, true])('drops a stop the Host confirms once the run settled, or after disposal (%s)', async (dispose) => { + const answer = deferred>>() + const cancellation = deferred>>() + const { face, state, controller, started } = bench({ + installBundle: vi.fn().mockReturnValue(answer.promise), + cancelInstall: vi.fn().mockReturnValue(cancellation.promise), + }) + await controller.load() + face.openInstall() + face.editInstallSpec('slow') + face.runInstall() + controller.installProgress({ requestId: await started(), phase: 'installing' }) + face.cancelInstall() + expect(state().install.phase).toBe('cancelling') + if (dispose) controller.dispose() + answer.resolve(ok({ ...APPLIED, bundle: 'slow' })) + if (!dispose) await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) + cancellation.resolve(ok({ status: 'cancelled' })) + await Promise.resolve() + await Promise.resolve() + expect(state().install.phase).toBe(dispose ? 'cancelling' : 'done') + expect(state().notice).toBeNull() + }) + + it('enables what a finished install added from its screen, closes, and marks it in the list', async () => { + const { plugins, face, state, controller } = bench({ + installBundle: vi.fn() + .mockResolvedValueOnce(ok({ ...APPLIED, bundle: 'dsh-a' })) + .mockResolvedValueOnce(ok({ ...APPLIED, bundle: 'dsh-a' })) + .mockResolvedValueOnce(ok({ ...APPLIED, application: 'overridden' })), + setBundleEnabled: vi.fn() + .mockResolvedValueOnce(ok({ ...APPLIED, application: 'restart-required' })) + .mockResolvedValueOnce(ok(failed({ code: 'operation-error', diagnostic: 'the tree rejected it' }))), + }) + await controller.load() + face.enableInstalled() + expect(plugins.setBundleEnabled).not.toHaveBeenCalled() + face.openInstall() + face.editInstallSpec('dsh-a') + face.runInstall() + await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) + face.enableInstalled() + face.enableInstalled() + expect(state().install.enabling).toBe(true) + await vi.waitFor(() => { expect(state().install.open).toBe(false) }) + expect(plugins.setBundleEnabled).toHaveBeenCalledExactlyOnceWith('dsh-a', true) + // A restart it waits for is said in passing; the list marks it. + expect(state().notice).toEqual({ kind: 'restart', packageName: 'dsh-a', seq: 1 }) + expect(state().highlight).toBe('dsh-a') + face.clearHighlight() + face.clearHighlight() + expect(state().highlight).toBeNull() + + // A refusal toasts it and still closes. + face.openInstall() + face.editInstallSpec('dsh-a') + face.runInstall() + await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) + face.enableInstalled() + await vi.waitFor(() => { expect(state().install.open).toBe(false) }) + expect(plugins.setBundleEnabled).toHaveBeenCalledTimes(2) + expect(state().notice).toEqual({ kind: 'failed', action: 'enable', code: 'operation-error', reason: 'the tree rejected it', packageName: 'dsh-a', seq: 2 }) + expect(state().highlight).toBe('dsh-a') + + // An install that named no bundle has nothing to enable or mark: the screen just closes. + face.openInstall() + face.editInstallSpec('lib') + face.runInstall() + await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) + expect(state().install.installed).toBeNull() + face.enableInstalled() + await vi.waitFor(() => { expect(state().install.open).toBe(false) }) + expect(plugins.setBundleEnabled).toHaveBeenCalledTimes(2) + expect(state().highlight).toBeNull() + }) + + it('offers the scripts a blocked run left pending, and retries the same spec with them allowed', async () => { + const gates: ReturnType> | ReturnType>>>[] = [] + const { face, state, controller, plugins, started } = bench({ + installBundle: vi.fn(() => { + const gate = deferred> | ReturnType>>() + gates.push(gate) + return gate.promise + }), + }) + await controller.load() + face.openInstall() + face.editInstallSpec('x') + // Before the failed screen offers anything, the action does nothing. + face.approveBuildsAndRetry() + face.runInstall() + const first = await started() + gates[0]!.resolve(ok({ + ...failed({ code: 'operation-error', diagnostic: 'ERR_PNPM_IGNORED_BUILDS' }, + { exitCode: 1, output: 'ERR_PNPM_IGNORED_BUILDS', truncated: false, logPath: '/l', kind: 'build-blocked' }), + pendingBuilds: ['native'], + })) + await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) + expect(state().install.failure).toEqual({ reason: 'ERR_PNPM_IGNORED_BUILDS', code: 'operation-error', kind: 'build-blocked', pendingBuilds: ['native'] }) + // The retry keeps the subject the check produced and carries the approved names under a new request id. + face.approveBuildsAndRetry() + const second = await started() + expect(second).not.toBe(first) + expect(plugins.installBundle).toHaveBeenLastCalledWith('x', { enabled: false, requestId: second, approvedBuilds: ['native'] }) + expect(state().install).toMatchObject({ subject: { spec: 'x', name: 'dsh-better-sidebar' }, failure: null }) + gates[1]!.resolve(ok({ ...APPLIED, bundle: 'dsh-better-sidebar', approvedBuilds: ['native'] })) + await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) + expect(state().install).toMatchObject({ installed: 'dsh-better-sidebar', approvedBuilds: ['native'] }) + expect(plugins.installBundle).toHaveBeenCalledTimes(2) + }) + + it('drops an enable from the installed screen that settles after disposal', async () => { + const enableGate = deferred>>() + const { plugins, face, state, controller } = bench({ setBundleEnabled: vi.fn().mockReturnValueOnce(enableGate.promise) }) + await controller.load() + face.openInstall() + face.editInstallSpec('dsh-a') + face.runInstall() + await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) + face.enableInstalled() + expect(plugins.setBundleEnabled).toHaveBeenCalledWith('dsh-new', true) + const before = state() + controller.dispose() + enableGate.resolve(ok(APPLIED)) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(state()).toBe(before) + }) + + it('settles an install while reads run beside it', async () => { + const gate = deferred>>() + const { plugins, face, state, controller, started } = bench({ installBundle: vi.fn().mockReturnValueOnce(gate.promise) }) + await controller.load() + face.openInstall() + face.editInstallSpec('pkg') + face.runInstall() + await started() + // The Host announces the change before the run answers; the read it triggers must not drop the answer. + await controller.load() + gate.resolve(ok({ ...APPLIED, bundle: 'pkg' })) + await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) + expect(plugins.listBundles).toHaveBeenCalledTimes(3) + }) + + it('keeps the Host words and kind of a failed install, and settles a run whose last chunk never came', async () => { + // Every run waits on its own gate, so chunks can land while it is installing. + const gates: ReturnType> | ReturnType>>>[] = [] + const { face, state, controller, plugins, started } = bench({ + installBundle: vi.fn(() => { + const gate = deferred> | ReturnType>>() + gates.push(gate) + return gate.promise + }), + }) + const argv = ['pnpm', 'add', 'x'] + await controller.load() + face.openInstall() + face.editInstallSpec('x') + let requestId = '' as PluginInstallRequestId + const installing = async (): Promise => { + face.runInstall() + requestId = await started() + } + const answer = (value: ReturnType> | ReturnType): void => { + gates[gates.length - 1]?.resolve(value) + } + // No chunk arrived: the Host's words are the reason, and there is no run; the kind is kept. + await installing() + answer(ok(failed({ code: 'operation-error', diagnostic: 'ERR_PNPM' }, { exitCode: 1, output: 'ERR_PNPM', truncated: false, logPath: '/l', kind: 'network' }))) + await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) + expect(state().install.runs).toEqual([]) + expect(state().install.failure).toEqual({ reason: 'ERR_PNPM', code: 'operation-error', kind: 'network' }) + expect(state().install.subject).toEqual({ spec: 'x', ...INSPECTED }) + // A refused answer, not a failed change, keeps the transport's words and settles the run without an exit code. + await installing() + controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stderr', text: 'streamed' }) + answer(refused('gateway/internal', 'offline')) + await vi.waitFor(() => { expect(state().install.failure).toEqual({ reason: 'offline' }) }) + expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'streamed', exitCode: null }]) + // A pnpm failure settles the open run with the code the answer names. + await installing() + controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stdout', text: 'Done' }) + answer(ok(failed({ code: 'operation-error', diagnostic: 'tail' }, { exitCode: 7, output: 'tail', truncated: false, logPath: '/l', kind: 'unknown' }))) + await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) + expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'Done', exitCode: 7 }]) + // A failure after pnpm carries no package result: a run whose last chunk came keeps its own exit code, one still open settles without. + await installing() + controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stdout', text: 'partial', exitCode: 0 }) + controller.appendLog({ requestId, jobId: 'k', argv, cwd: '/p', stream: 'stdout', text: 'open' }) + answer(ok(failed({ code: 'not-bundle' }))) + await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) + expect(state().install.runs).toEqual([ + { jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'partial', exitCode: 0 }, + { jobId: 'k', command: 'pnpm add x', cwd: '/p', output: 'open', exitCode: null }, + ]) + expect(state().install.failure).toEqual({ reason: '', code: 'not-bundle' }) + // A failure the Host does not explain has neither code nor words. + await installing() + answer(ok(failed())) + await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) + expect(state().install.failure).toEqual({ reason: '' }) + expect(plugins.installBundle).toHaveBeenCalledTimes(5) + // Editing the spec after a failure starts over too. + face.editInstallSpec('y') + expect(state().install).toMatchObject({ phase: 'idle', spec: 'y', runs: [], failure: null }) + }) + + it('drops every late settlement after disposal', async () => { + const enableGate = deferred>>() + const installGate = deferred>>() + const { face, state, controller, started } = bench({ + setBundleEnabled: vi.fn().mockReturnValueOnce(enableGate.promise), + installBundle: vi.fn().mockReturnValueOnce(installGate.promise), + }) + await controller.load() + face.openInstall() + face.editInstallSpec('x') + face.runInstall() + await started() + face.setEnabled(BUNDLE.name, true) + const before = state() + controller.dispose() + enableGate.resolve(ok(APPLIED)) + installGate.resolve(ok(APPLIED)) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(state()).toBe(before) + await controller.load() + expect(state()).toBe(before) + face.setEnabled(BUNDLE.name, false) + expect(state()).toBe(before) + }) + + it('drops a read that settles after disposal', async () => { + const gate = deferred>>() + const { state, controller } = bench({ inventory: vi.fn().mockReturnValueOnce(gate.promise) }) + const loading = controller.load() + await Promise.resolve() + const before = state() + controller.dispose() + gate.resolve(ok({ entries: [], managementAvailable: true })) + await loading + expect(state()).toBe(before) + }) + + it('drops a bundle read that settles after disposal', async () => { + const gate = deferred>>() + const { state, controller } = bench({ listBundles: vi.fn().mockReturnValueOnce(gate.promise) }) + const loading = controller.load() + await vi.waitFor(() => { expect(state().status).toBe('loading') }) + await Promise.resolve() + const before = state() + controller.dispose() + gate.resolve(ok([BUNDLE])) + await loading + expect(state()).toBe(before) + }) +}) diff --git a/packages/client/ui-plugin-manager/tsconfig.json b/packages/client/ui-plugin-manager/tsconfig.json new file mode 100644 index 0000000000..3cd74d0ad4 --- /dev/null +++ b/packages/client/ui-plugin-manager/tsconfig.json @@ -0,0 +1,48 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../boot/plugin-manager" + }, + { + "path": "../../test-support/client-runtime" + }, + { + "path": "../locale" + }, + { + "path": "../store" + }, + { + "path": "../ui-layout" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-sidebar" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../util/crypto" + } + ] +} diff --git a/packages/client/ui-plugin-manager/tsdown.config.ts b/packages/client/ui-plugin-manager/tsdown.config.ts new file mode 100644 index 0000000000..ffe90a5b59 --- /dev/null +++ b/packages/client/ui-plugin-manager/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-plugin-manager', ['lib/types/index.js']) diff --git a/packages/client/ui-primitives/src/TerminalBlock.module.css b/packages/client/ui-primitives/src/TerminalBlock.module.css index 5198a5148a..3524b1dadc 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.module.css +++ b/packages/client/ui-primitives/src/TerminalBlock.module.css @@ -59,9 +59,9 @@ } /* Full-width l2 hairline between the command banner and the body — the same - divider the IN/OUT card draws between its sections. A running card is - banner-only, so it draws none. */ -.block:not([data-running]) .header { + divider the IN/OUT card draws between its sections. A banner-only card (a + running command that has printed nothing yet) draws none. */ +.block[data-body] .header { border-bottom: 0.5px solid var(--dsw-alias-border-l2); } diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index 173a7e317f..dfeddcd27f 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -19,6 +19,8 @@ export interface TerminalBlockLabels { signal: (signal: string) => string /** Status pill text for a non-zero exit code. */ exitCode: (exitCode: number) => string + /** Status pill text for a command that ended without an exit code: killed by a signal the view does not know, or never started. */ + noExitCode: string /** Run-state text while the command is still running. */ running: string /** Run-state text for a signal or non-zero-exit settle. */ @@ -50,11 +52,14 @@ export interface TerminalBlockProps { home?: string | undefined /** The command's output text; may contain ANSI escape sequences. */ output?: string | undefined - /** Settled exit code; a non-zero value renders the status pill. */ - exitCode?: number | undefined + /** Settled exit code; a non-zero value renders the status pill, and null (settled without one) the no-exit-code pill. */ + exitCode?: number | null | undefined /** Settled terminating signal name; any value renders the status pill, taking precedence over the exit code. */ signal?: string | undefined - /** The command is still running: the block shows the prompt line alone. */ + /** + * The command is still running: the block shows the prompt line, and the + * output printed so far when there is any, with no copy control until it settles. + */ running?: boolean | undefined /** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */ maxLines?: number | undefined @@ -84,17 +89,18 @@ function promptLabel(cwd: string, home: string | undefined): string { * Status pill text for a settled command, or undefined when the command * settled cleanly (exit 0, no signal) and needs no pill — the same * distinction the bash tool's own exit-status markers draw. - * @param exitCode - settled exit code, when known. + * @param exitCode - settled exit code, when known; null when the command settled without one. * @param signal - settled terminating signal name, when known. * @param labels - display copy for the pill text. * @returns the pill text, or undefined for a clean exit. */ function statusText( - exitCode: number | undefined, + exitCode: number | null | undefined, signal: string | undefined, labels: TerminalBlockLabels, ): string | undefined { if (signal !== undefined) return labels.signal(signal) + if (exitCode === null) return labels.noExitCode if (exitCode !== undefined && exitCode !== 0) return labels.exitCode(exitCode) return undefined } @@ -110,14 +116,14 @@ function statusText( * settled command whose exit status never reached the view counts as a clean * settle: the view says it finished and says nothing went wrong. * @param running - the command has not settled. - * @param exitCode - settled exit code, when known. + * @param exitCode - settled exit code, when known; null when the command settled without one. * @param signal - settled terminating signal name, when known. * @param labels - display copy for the text label. * @returns the dot's state and its text label, since the dot is aria-hidden. */ function runState( running: boolean, - exitCode: number | undefined, + exitCode: number | null | undefined, signal: string | undefined, labels: TerminalBlockLabels, ): { state: StateDotState; label: string } { @@ -194,9 +200,17 @@ export function TerminalBlock({ // for invisible bytes, and hide the placeholder that belongs there. const empty = lines.every(line => line.every(span => span.text.trim() === '')) const { hidden, capped, headLines, tailLines } = headTailCap(lines.length, maxLines, expanded) + // A running command with nothing printed yet is banner-only; once it has + // printed, the output streams in under the banner as it would in a terminal. + const body = !running || !empty return ( -
    +
    {state.label} @@ -227,7 +241,7 @@ export function TerminalBlock({ )}
    - {!running && (empty + {body && (empty ?
    {copy.noOutput}
    : (
    diff --git a/packages/client/ui-primitives/src/ansi.ts b/packages/client/ui-primitives/src/ansi.ts index 534d2ab42f..2b5ae35a5d 100644 --- a/packages/client/ui-primitives/src/ansi.ts +++ b/packages/client/ui-primitives/src/ansi.ts @@ -37,8 +37,10 @@ export type AnsiLine = readonly AnsiSpan[] * semantic. Black and white both resolve to the primary label color so text * stays legible under either theme instead of matching the surface it sits * on; bright black takes the tertiary label color (the muted-gray role). - * Magenta and cyan have no token equivalent in this design system and fall - * through to anser's literal rgb, as do all 256-palette and truecolor values. + * Cyan and bright cyan take two static blues: the design system has no cyan, + * and anser's literal bright cyan is unreadable on the light theme's code + * surface. Magenta has no token equivalent and falls through to anser's + * literal rgb, as do all 256-palette and truecolor values. */ const TOKEN_BY_BASIC_RGB: Record = { '0,0,0': 'var(--dsw-alias-label-primary)', @@ -52,6 +54,8 @@ const TOKEN_BY_BASIC_RGB: Record = { '255,255,85': 'var(--dsw-alias-state-warn-secondary)', '0,0,187': 'var(--dsw-alias-state-business-primary)', '85,85,255': 'var(--dsw-static-blue-400)', + '0,187,187': 'var(--dsw-static-blue-600)', + '85,255,255': 'var(--dsw-static-blue-500)', } /** diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index f0e80410e3..a50520f12d 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -903,6 +903,18 @@ export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => ( ) +/** Info companion to the question outline: the same ring with an "i" glyph. */ +export const IconInfoOutline14 = ({ size = 14, className }: IconProps) => ( + + + + + +) + /** Alarm clock outline for active scheduled-task indicators. */ export const IconAlarmClockOutline16 = ({ size = 16, className }: IconProps) => ( ( ) + +/** Plugin pinwheel (design handoff for the plugin manager): four interleaved arcs on the 16 grid. */ +export const IconPluginPinwheelOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + +) diff --git a/packages/client/ui-primitives/tests/ansi.client.spec.ts b/packages/client/ui-primitives/tests/ansi.client.spec.ts index 6f1f924d38..ff86137186 100644 --- a/packages/client/ui-primitives/tests/ansi.client.spec.ts +++ b/packages/client/ui-primitives/tests/ansi.client.spec.ts @@ -52,6 +52,8 @@ describe('parseAnsiLines: basic colors mapped onto theme tokens', () => { ['93', 'bright yellow', 'var(--dsw-alias-state-warn-secondary)'], ['34', 'blue', 'var(--dsw-alias-state-business-primary)'], ['94', 'bright blue', 'var(--dsw-static-blue-400)'], + ['36', 'cyan', 'var(--dsw-static-blue-600)'], + ['96', 'bright cyan', 'var(--dsw-static-blue-500)'], ])('SGR %s (%s) resolves to %s', (code, _name, token) => { expect(onlySpan(sgr(code, 'x'))).toEqual({ text: 'x', style: { color: token } }) }) @@ -60,7 +62,6 @@ describe('parseAnsiLines: basic colors mapped onto theme tokens', () => { describe('parseAnsiLines: colors with no token equivalent', () => { it.each<[string, string, string]>([ ['35', 'magenta', 'rgb(187, 0, 187)'], - ['36', 'cyan', 'rgb(0, 187, 187)'], ['38;5;208', '256-palette orange', 'rgb(255, 135, 0)'], ['38;2;10;20;30', 'truecolor', 'rgb(10, 20, 30)'], ])('SGR %s (%s) falls through to %s', (code, _name, literal) => { diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index d380e7e422..fb4afeedb2 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -17,8 +17,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full icon set (46 deepsuite + 21 figma extracts + twelve product glyphs outside those sets)', () => { - expect(iconNames.length).toBe(79) + it('exports the full icon set (46 deepsuite + 21 figma extracts + fourteen product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(81) // The composer menu's own glyphs, pinned by name. expect(iconNames).toEqual(expect.arrayContaining(['IconPlanOutline14', 'IconCompactOutline16', 'IconShieldOutline16'])) }) diff --git a/packages/client/ui-primitives/tests/labels.client.ts b/packages/client/ui-primitives/tests/labels.client.ts index bb27e9dd34..7849d8e9c7 100644 --- a/packages/client/ui-primitives/tests/labels.client.ts +++ b/packages/client/ui-primitives/tests/labels.client.ts @@ -43,6 +43,7 @@ export const searchBlockLabels: SearchBlockLabels = { export const terminalBlockLabels: TerminalBlockLabels = { signal: signal => `信号 ${signal}`, exitCode: code => `退出码 ${code}`, + noExitCode: '未正常退出', running: '运行中', failed: '失败', done: '已完成', copy: '复制', copied: '复制成功', noOutput: '无输出', collapseAria: '收起输出', collapse: '收起', diff --git a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx index 45de33c8f0..48f34678e9 100644 --- a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx @@ -89,13 +89,21 @@ describe('TerminalBlock prompt label', () => { }) describe('TerminalBlock states', () => { - it('running shows the command line only: no output, no placeholder, no copy', () => { - const view = render() + it('running with nothing printed yet shows the command line only: no placeholder, no copy, no body', () => { + const view = render() expect(view.getByText('sleep 5')).toBeTruthy() - expect(view.queryByText('partial')).toBeNull() expect(view.queryByText('无输出')).toBeNull() expect(view.queryByRole('button')).toBeNull() expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('') + expect(view.container.firstElementChild?.hasAttribute('data-body')).toBe(false) + }) + + it('running shows the output printed so far under the banner, still without a copy control', () => { + const view = render() + expect(outputLines(view.container)).toEqual(['Progress: resolved 1']) + expect(view.queryByRole('button')).toBeNull() + expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('') + expect(view.container.firstElementChild?.getAttribute('data-body')).toBe('') }) it('running still shows a settled-looking status pill when one is supplied', () => { @@ -186,6 +194,12 @@ describe('TerminalBlock status pill', () => { expect(screen.getByText('退出码 1')).toBeTruthy() }) + it('renders the no-exit-code pill and the error dot for a command that settled without one', () => { + const view = render() + expect(view.getByText('未正常退出')).toBeTruthy() + expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' }) + }) + it('renders the signal pill, which outranks the exit code', () => { render() expect(screen.getByText('信号 SIGKILL')).toBeTruthy() diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css index 95de406381..6608e0f9ef 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css @@ -38,7 +38,8 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Rail trigger: the same 36x36 circle box as the other rail controls. */ +/* Rail trigger: the same 36x36 box and 12px rounding as the other rail + controls. */ .trigger.rail { flex: none; width: 36px; @@ -47,8 +48,6 @@ justify-content: center; gap: 0; padding: 0; - border-radius: 50%; - corner-shape: round; } .triggerLabel { diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml index 5b8b54d3f4..079727d35c 100644 --- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml +++ b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-plugin-inventory/README.md -README.md: dd8c778341feb3969186f1d916362516fa3dccf4 -README.zh.md: e16760937ff003c7cfff95129846a8249849f2c6 +README.md: a82f177b2a0a463a395cef470b526ea3117625fd +README.zh.md: c6395d015a7c60b0b0738eae96ae99fe1135cedc diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md index dd8c778341..a82f177b2a 100644 --- a/packages/client/ui-settings-plugin-inventory/README.md +++ b/packages/client/ui-settings-plugin-inventory/README.md @@ -1,5 +1,5 @@ --- -description: "Scope-grouped plugin inventory and current-profile management tab in Web Plugins settings for the dsh web client: agent-preset compositions first, the global plane behind a disclosure, search across both." +description: "Scope-grouped read-only plugin inventory tab in Web Plugins settings for the dsh web client: agent-preset compositions first, the global plane behind a disclosure, search across both." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The **Plugin list** tab lets Web users inspect plugins without changing their configuration. It presents agent presets first and collapses the global inventory until needed. Cards retain the package name as the primary title, identify instances by stable entry id, and expose enablement, source details, runtime status, disabled conditions, and discovery failures; preset-provided global entries name their presets. Search covers both groups and points to matches in other presets. The tab handles loading, empty, no-match, failure, and retry states without exposing transport details, and still shows the global inventory without a preset roster. +The **Plugin list** tab lets Web users inspect plugins without changing their configuration. It lists agent presets, then the global inventory; both start collapsed and open on demand or during a search. Cards retain the package name as the primary title, identify instances by stable entry id, and expose enablement, source details, runtime status, disabled conditions, and discovery failures; preset-provided global entries name their presets. Search covers both groups and points to matches in other presets. The tab handles loading, empty, no-match, failure, and retry states without exposing transport details, and still shows the global inventory without a preset roster. ## Table of Contents @@ -51,7 +51,7 @@ The Plugin list also shows synchronization failures on the current page. Its ret
    Implementation internals — click to expand -The tab reads the Host inventory on first selection, without Remote calls during plugin activation. Hosts exposing [Plugin Manager](../../boot/plugin-manager/README.md) also provide bundle installation, removal and switches for uniquely addressable global entries. Operations refresh observed state and show failures, overrides and pending restarts; a successful inventory refresh preserves the last operation error; preset compositions remain read-only. +The tab is a read-only projection of a Host-owned snapshot; it performs no Remote read during plugin activation and takes the snapshot on first selection. ### Registration @@ -59,8 +59,6 @@ The browser plugin registers one localized `settings.plugins.tab` contribution w ### Rendering -Management error and read-only codes use the current locale dictionary. The page displays raw package and Loader diagnostics, cleanup results and leftover dependencies alongside localized status text. Invalid profile dependencies remain removable with their switches disabled. - Row keys are scope-qualified (`global:`, `preset::`), so one module appearing in both scopes keeps distinct disclosure state; a declared entry id appears in expanded details and supplies the collapsed subtitle after removal of a leading composition `include:` marker, while a row without one stays unlabeled. The preset-provided marking is derived client-side: a global entry carries it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision.
    @@ -95,8 +93,8 @@ None; this package neither assembles nor sends a provider request. These limits define the freshness and reach of the inventory view; they are current package constraints. -- **Inventory refresh** — the tab does not subscribe to Loader changes or automatically refetch after reconnect; switching tabs preserves the current snapshot, while reopening Settings or completing a management operation obtains a new one. -- **Preset compositions remain read-only**: global controls require the current-profile manager; Desktop retains its shell-owned package controls. +- **One snapshot per Settings mount or retry** — the tab does not subscribe to Loader changes or automatically refetch after reconnect; switching tabs preserves the current snapshot, while reopening Settings obtains a new one. +- **Read-only in both planes** — the tab shows global and preset enablement but mutates neither; enable/disable controls that write a custom preset's own composition file are deliberate follow-up work. ### Dev Note @@ -108,4 +106,4 @@ None. -**Runtime invariant:** No companion is published. This package renders Host-owned state and forwards mutations to Plugin Manager. +**Runtime invariant:** No companion is published. This package owns a read-only Settings contribution. diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md index e16760937f..c6395d015a 100644 --- a/packages/client/ui-settings-plugin-inventory/README.zh.md +++ b/packages/client/ui-settings-plugin-inventory/README.zh.md @@ -1,5 +1,5 @@ --- -description: "dsh Web 客户端设置中按作用域分组的插件清单与当前 profile 管理标签页:Agent 预设组合在前,全局平面收在折叠分组里,搜索跨两组。" +description: "dsh Web 客户端设置中按作用域分组的只读插件清单标签页:Agent 预设组合在前,全局平面收在折叠分组里,搜索跨两组。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -**插件列表**标签页让 Web 用户查看插件,而不改变其配置。它优先展示 agent(智能体)预设,并让全局清单保持收起,仅在需要时展开。卡片保留包名作为主标题,以稳定的条目 id 标识实例,并展示启停状态、出处、运行状态、禁用条件与发现失败;由预设提供的全局条目会列出对应预设。搜索覆盖两个分组,并指出其他预设中的匹配。标签页处理加载、空结果、无匹配、失败与重试状态,且不暴露传输细节;没有预设 roster 时仍会展示全局清单。 +**插件列表**标签页让 Web 用户查看插件,而不改变其配置。它先列 agent(智能体)预设分组,再列全局清单;两个分组默认都收起,按需展开,搜索期间保持展开。卡片保留包名作为主标题,以稳定的条目 id 标识实例,并展示启停状态、出处、运行状态、禁用条件与发现失败;由预设提供的全局条目会列出对应预设。搜索覆盖两个分组,并指出其他预设中的匹配。标签页处理加载、空结果、无匹配、失败与重试状态,且不暴露传输细节;没有预设 roster 时仍会展示全局清单。 ## 目录 @@ -51,7 +51,7 @@ kind: "package-reference"
    实现细节——点击展开 -该标签页首次选择时读取宿主清单,插件激活期间不执行 Remote 调用。提供[插件管理器](../../boot/plugin-manager/README.zh.md)的宿主还支持组合包安装、删除及可唯一定位的全局条目开关。操作后刷新实际状态,并显示失败、覆盖与待重启结果;清单刷新成功时仍保留最近一次操作错误;预设组合保持只读。 +该标签页是宿主拥有快照的只读投影;插件激活期间不执行任何 Remote 读取,首次选择时才取快照。 ### 注册 @@ -59,8 +59,6 @@ kind: "package-reference" ### 渲染 -管理错误码和只读原因使用当前语言的词典。页面在本地化状态旁显示原始包管理与 Loader 诊断、清理结果和残留依赖。无效的 profile 依赖仍可删除,但启停开关不可用。 - 行 key 按作用域限定(`global:`、`preset::`),因此同一模块出现在两个作用域时保持各自的展开状态;声明的条目 id 出现在展开详情中,并在去掉开头的组合 `include:` 标记后作为收起次标题,没有 id 的行不显示次级标签。预设提供标记在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才携带它,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。
    @@ -95,8 +93,8 @@ kind: "package-reference" 这些限制定义清单视图的新鲜度与触达范围;它们是当前包约束。 -- **清单刷新**:标签页不订阅 Loader 变化,也不会在重连后自动重新读取;切换标签页会保留当前快照,重新打开 Settings 或完成管理操作则会取得新快照。 -- **预设组合保持只读**:全局控件需要当前 profile 管理器;Desktop 保留 shell 持有的包管理控件。 +- **每次 Settings 挂载或重试只读取一份快照**:标签页不订阅 Loader 变化,也不会在重连后自动重新读取;切换标签页会保留当前快照,重新打开 Settings 则会取得新快照。 +- **两个平面都只读**:标签页展示全局与预设的启停状态但都不修改;写回自定义预设组合文件的启停控件是刻意留作后续的工作。 ### 开发备注 @@ -108,4 +106,4 @@ kind: "package-reference" -**运行时不变式:** 不发布伴生入口。本包呈现宿主状态,并将修改转发给插件管理器。 +**运行时不变式:** 不发布伴生入口。本包只持有一个只读 Settings contribution。 diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index e67fe0761d..29bcc87205 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", - "description": "Cordis Loader inventory and current-profile management in Web Plugins settings", + "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", "version": "0.1.6-alpha.1", "publishConfig": { "access": "public" diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index e2e3c41f5b..a8bcce64c8 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -429,64 +429,3 @@ grid-template-columns: minmax(0, 1fr); } } - -.manager { - display: flex; - flex-direction: column; - gap: 10px; - font-size: 13px; -} - -.manager h3, -.manager p { - margin: 0; -} - -.install, -.bundleList li { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; -} - -.install > input { - flex: 1; - min-width: 180px; -} - -.manager input:not([type='checkbox']), -.manager button { - border: 0.5px solid var(--dsw-alias-border-l3); - border-radius: 6px; - padding: 6px 10px; - background: var(--dsw-alias-bg-layer-1); - color: var(--dsw-alias-label-primary); - font: inherit; -} - -.manager button:disabled { - opacity: 0.5; -} - -.bundleList { - list-style: none; - margin: 0; - padding: 0; -} - -.bundleList li { - padding: 8px 0; - border-bottom: 0.5px solid var(--dsw-alias-border-l3); -} - -.bundleList li > span { - flex: 1; - min-width: 200px; - overflow-wrap: anywhere; -} - -.manager code { - display: block; - overflow-wrap: anywhere; -} diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index df0ba2626b..3489d452c7 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -12,7 +12,6 @@ import { import type { StateDotState, TagTone } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { PluginInventoryLocaleKey } from './locales.ts' -import { BundleManager, usePluginManagement, type PluginManagement } from './management.tsx' import css from './PluginInventorySettingsTab.module.css' type PluginInventoryEntry = PluginInventorySnapshot['entries'][number] @@ -21,8 +20,6 @@ type AgentPresetRow = AgentPresetGroup['rows'][number] /** Registration-side Remote face used by the section. */ export interface PluginInventorySettingsTabInjected { - /** Persistent controls, available only on profile-backed Hosts. */ - management?: PluginManagement /** Page-local module synchronization, independent from the Host inventory. */ hooks: { clientSync: ObservableSnapshot } /** Retry the latest client graph without changing the Host composition. */ @@ -207,7 +204,7 @@ function StateTag({ kind, label }: { readonly kind: EnablementKind; readonly lab /** Render the read-only plugin inventory: agent presets first, then the global plane. */ export function PluginInventorySettingsTab( - { list, presetName, management, t, useClientSync, retryClient }: PluginInventorySettingsTabProps, + { list, presetName, t, useClientSync, retryClient }: PluginInventorySettingsTabProps, ): ReactNode { const clientSync = useClientSync(snapshot => snapshot) const sectionId = useId() @@ -219,8 +216,6 @@ export function PluginInventorySettingsTab( const [presetOpen, setPresetOpen] = useState(null) const [globalOpen, setGlobalOpen] = useState(null) const [state, setState] = useState({ status: 'loading' }) - const manageable = state.status === 'ready' && state.snapshot.managementAvailable === true - const managerState = usePluginManagement(management, manageable, request) useEffect(() => { let current = true @@ -229,7 +224,7 @@ export function PluginInventorySettingsTab( () => { if (current) setState({ status: 'error' }) }, ) return () => { current = false } - }, [list, request, managerState.refresh]) + }, [list, request]) const normalizedQuery = query.trim().toLocaleLowerCase() const searching = normalizedQuery.length > 0 @@ -271,8 +266,9 @@ export function PluginInventorySettingsTab( const otherMatchCount = otherPresetMatches .reduce((total, preset) => total + preset.rows.filter(rowMatch).length, 0) - const presetEffectiveOpen = searching || (presetOpen ?? true) - const globalEffectiveOpen = searching || (globalOpen ?? presets.length === 0) + // Both groups start collapsed; a search opens them for as long as it lasts. + const presetEffectiveOpen = searching || (presetOpen ?? false) + const globalEffectiveOpen = searching || (globalOpen ?? false) const nothingMatches = searching && globalCount === 0 && selectedRows.length === 0 && otherPresetMatches.length === 0 @@ -358,15 +354,6 @@ export function PluginInventorySettingsTab( )} > - {management === undefined || !manageable ? null : (() => { - const control = managerState.plugins.find(row => row.entryId === entry.entryId) - return control?.patchId === undefined - ?

    {control?.readOnlyReason === undefined ? null : t(control.readOnlyReason)}

    - : - })()} - {management !== undefined && manageable ? : null} {clientSync.syncing ?

    {t('clientSyncing')}

    : null} {clientSync.failures.length === 0 ? null : (
    diff --git a/packages/client/ui-settings-plugin-inventory/src/client/index.ts b/packages/client/ui-settings-plugin-inventory/src/client/index.ts index c0eb173587..026f7da686 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/index.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/index.ts @@ -1,4 +1,4 @@ -/** Host plugin inventory and current-profile management in Web Settings. */ +/** Read-only Host plugin inventory registered into Web Settings. */ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-modules/client' @@ -11,7 +11,6 @@ import type {} from '@deepseek-ai/dsh-client-ui-agent-preset/client' // Inline-safe shared fold: shipped ids map to dictionary keys in one home. import { presetDisplayText } from '@deepseek-ai/dsh-agent-presets/display' import { PluginInventorySettingsTab, type PluginInventorySettingsTabInjected } from './PluginInventorySettingsTab.tsx' -import type { PluginManagement } from './management.tsx' import { en, zh, type PluginInventoryLocaleKey } from './locales.ts' export type { PluginInventorySettingsTabInjected, PluginInventorySettingsTabProps } from './PluginInventorySettingsTab.tsx' @@ -28,7 +27,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { export const NS = 'settings.pluginInventory' /** Services required by the Settings registration and generated Remote face. */ -export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory', 'remote.pluginManager', 'modules'] +export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory', 'modules'] /** Contribute the lazy inventory tab to the Plugins settings section. */ export function apply(ctx: ClientContext): void { @@ -47,21 +46,8 @@ export function apply(ctx: ClientContext): void { const agentPresetCopy = ctx.locale.bind('settings.agentPreset') const presetName: PluginInventorySettingsTabInjected['presetName'] = preset => presetDisplayText(preset, agentPresetCopy).name - const unwrap = async (response: Promise<{ ok: true; value: T } | { ok: false; error: Error }>): Promise => { - const result = await response - if (!result.ok) throw result.error - return result.value - } - const management: PluginManagement = { - listPlugins: () => unwrap(ctx.remote.pluginManager.listPlugins()), - listBundles: () => unwrap(ctx.remote.pluginManager.listBundles()), - setPluginEnabled: (id, enabled) => unwrap(ctx.remote.pluginManager.setPluginEnabled(id, enabled)), - setBundleEnabled: (name, enabled) => unwrap(ctx.remote.pluginManager.setBundleEnabled(name, enabled)), - installBundle: (spec, options) => unwrap(ctx.remote.pluginManager.installBundle(spec, options)), - removeBundle: name => unwrap(ctx.remote.pluginManager.removeBundle(name)), - } const injected = (): PluginInventorySettingsTabInjected => ({ - list, presetName, management, + list, presetName, hooks: { clientSync: ctx.modules.entries.state }, retryClient: () => { void ctx.modules.entries.retry().catch((error: unknown) => { ctx.logger.error(error) }) }, }) diff --git a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts index 6e960da5b3..11e8986eef 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts @@ -2,25 +2,6 @@ /** Simplified Chinese dictionary and key source of truth. */ export const zh = { - 'management-required': '管理插件所需,不能停用或删除。', - unaddressable: '当前 profile patch 无法唯一定位此插件。', - 'unknown-plugin': '找不到该插件。', 'invalid-spec': '请输入有效的包名称或路径。', - 'ambiguous-install': '无法从依赖变更中确定一个安装目标。', - 'not-bundle': '该依赖未声明组合包配置。', - 'not-removable': '该依赖不属于当前 profile,或是管理所需的组件。', - 'bundle-in-use': '其他配置仍在使用该组合包,请先停用相应插件。', - 'stop-profile': '请停止当前 profile,再通过 dsh plugin 删除仍在使用的依赖。', - 'stale-approval': '构建审批已变化,请重新安装以刷新待审批的包。', - 'operation-error': '请查看诊断信息。', - installFailed: '安装失败', enableFailed: '启停失败', removeFailed: '删除失败', - cleanup: '清理新增依赖', remainingDependencies: '剩余新增依赖', existingFailures: '其他诊断', - bundles: '组合包', packageSpec: 'npm 包名称或本地路径', install: '安装', remove: '删除', - enableAfterInstall: '安装后启用', applying: '正在应用更改…', - buildApproval: '构建脚本审批', - buildApprovalDescription: '以下依赖的安装脚本尚未获准运行。允许后,脚本可使用你的系统权限执行命令,授权保存在当前 profile 中。确认信任这些依赖后再重试。', - approveBuildsAndRetry: '允许这些脚本并重试', - operationFailed: '操作失败', applied: '已生效', 'restart-required': '重启后生效', overridden: '被其他配置覆盖', - pluginSwitch: '启停插件 {name}', bundleSwitch: '启停组合包 {name}', tab: '插件列表', loading: '正在读取插件…', clientSyncing: '正在同步本页面的插件…', @@ -67,25 +48,6 @@ export type PluginInventoryLocaleKey = keyof typeof zh /** English dictionary checked against the Chinese key set. */ export const en = { - 'management-required': 'Required for plugin management; cannot be disabled or removed.', - unaddressable: 'The current profile patch cannot uniquely address this plugin.', - 'unknown-plugin': 'Plugin not found.', 'invalid-spec': 'Enter a valid package name or path.', - 'ambiguous-install': 'Cannot identify one installation target from the dependency changes.', - 'not-bundle': 'This dependency declares no bundle configuration.', - 'not-removable': 'This dependency is not owned by the current profile or is required for management.', - 'bundle-in-use': 'Other configuration still uses this bundle. Disable those plugins first.', - 'stop-profile': 'Stop this profile, then remove the dependency still in use with dsh plugin.', - 'stale-approval': 'Build approvals changed. Retry installation to refresh the pending packages.', - 'operation-error': 'See the diagnostics for details.', - installFailed: 'Installation failed', enableFailed: 'Enablement failed', removeFailed: 'Removal failed', - cleanup: 'New dependency cleanup', remainingDependencies: 'Remaining new dependencies', existingFailures: 'Other diagnostics', - bundles: 'Bundles', packageSpec: 'npm package name or local path', install: 'Install', remove: 'Remove', - enableAfterInstall: 'Enable after installation', applying: 'Applying changes…', - buildApproval: 'Build script approval', - buildApprovalDescription: 'These dependencies need permission to run installation scripts. Approval lets scripts run commands with your system permissions and is saved for this profile. Retry only if you trust these dependencies.', - approveBuildsAndRetry: 'Allow these scripts and retry', - operationFailed: 'Failed', applied: 'Applied', 'restart-required': 'Restart required', overridden: 'Overridden by another configuration', - pluginSwitch: 'Toggle plugin {name}', bundleSwitch: 'Toggle bundle {name}', tab: 'Plugin list', loading: 'Reading plugins…', clientSyncing: 'Syncing plugins on this page…', diff --git a/packages/client/ui-settings-plugin-inventory/src/client/management.tsx b/packages/client/ui-settings-plugin-inventory/src/client/management.tsx deleted file mode 100644 index 81b777bb98..0000000000 --- a/packages/client/ui-settings-plugin-inventory/src/client/management.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/** Minimal profile package controls over the shared manager Remote. */ -import { useEffect, useRef, useState } from 'react' -import type { BundleInfo, ChangeResult, InstallBundleOptions, PluginEntryId, PluginInfo } from '@deepseek-ai/dsh-api-remotes/client' -import type { PluginInventorySettingsTabProps } from './PluginInventorySettingsTab.tsx' -import css from './PluginInventorySettingsTab.module.css' - -/** Operations consumed by the inventory page; all mutations execute on the Host. */ -export interface PluginManagement { - listPlugins(): Promise - listBundles(): Promise - setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise - setBundleEnabled(name: string, enabled: boolean): Promise - installBundle(spec: string, options: InstallBundleOptions): Promise - removeBundle(name: string): Promise -} - -/** Load management state only when the Host inventory advertises the service. - * @param manager Optional Remote operations. - * @param available Current Host availability. - * @param revision Inventory refresh revision. - * @returns Current state and a serialized mutation action. - */ -export function usePluginManagement(manager: PluginManagement | undefined, available: boolean, revision: number) { - const [plugins, setPlugins] = useState([]) - const [bundles, setBundles] = useState([]) - const [busy, setBusy] = useState(false) - const submitting = useRef(false) - const [result, setResult] = useState() - const [error, setError] = useState() - const [readError, setReadError] = useState() - const [refresh, setRefresh] = useState(0) - useEffect(() => { - if (!available || manager === undefined) return - let current = true - void Promise.all([manager.listPlugins(), manager.listBundles()]).then(([plugins, bundles]) => { - if (!current) return - setPlugins(plugins) - setBundles(bundles) - setReadError(undefined) - }, (error: unknown) => { if (current) setReadError(error instanceof Error ? error.message : String(error)) }) - return () => { current = false } - }, [manager, available, revision, refresh]) - const run = async (operation: () => Promise): Promise => { - if (submitting.current) return - submitting.current = true - setBusy(true) - setError(undefined) - setResult(undefined) - try { setResult(await operation()) } - catch (error) { setError(error instanceof Error ? error.message : String(error)) } - finally { submitting.current = false; setBusy(false); setRefresh(value => value + 1) } - } - return { plugins, bundles, busy, result, error: error ?? readError, run, refresh } -} - -/** Minimal bundle installation and enablement form. */ -export function BundleManager({ manager, state, t }: { - manager: PluginManagement - state: ReturnType - t: PluginInventorySettingsTabProps['t'] -}) { - const [spec, setSpec] = useState('') - const [enabled, setEnabled] = useState(true) - const result = state.result - const pendingBuilds = result?.pendingBuilds - return
    -

    {t('bundles')}

    -
    { - event.preventDefault() - void state.run(() => manager.installBundle(spec, { enabled })) - }}> - { setSpec(event.target.value) }} /> - - -
    - {state.busy ?

    {t('applying')}

    : null} - {state.error === undefined ? null :

    {state.error}

    } - {state.result === undefined ? null :

    - {t(state.result.application === 'failed' ? 'operationFailed' : state.result.application)} {state.result.target} - {state.result.error === undefined ? null : {t(`${state.result.stage}Failed`)}: {t(state.result.error.code)} {state.result.error.diagnostic}} - {state.result.packageResult === undefined ? null : {state.result.packageResult.logPath}} - {state.result.cleanup === undefined ? null : {t('cleanup')}: {state.result.cleanup.name} — {t(state.result.cleanup.error === undefined ? 'applied' : 'operationFailed')} - {state.result.cleanup.error?.diagnostic} - {state.result.cleanup.packageResult === undefined ? null : {state.result.cleanup.packageResult.logPath}} - } - {state.result.remainingDependencies?.length ? {t('remainingDependencies')}: {state.result.remainingDependencies.join(', ')} : null} - {state.result.warnings?.length ? {t('existingFailures')}: {state.result.warnings.join('\n')} : null} -

    } - {result !== undefined && pendingBuilds !== undefined && pendingBuilds.length > 0 ?
    -

    {t('buildApprovalDescription')}

    -
      {pendingBuilds.map(name =>
    • {name}
    • )}
    - -
    : null} -
      {state.bundles.map(bundle =>
    • - {bundle.name} {bundle.version} - - - {bundle.error === undefined ? null :

      {t(bundle.error.code)} {bundle.error.diagnostic}

      } - {bundle.readOnlyReason === undefined ? null :

      {t(bundle.readOnlyReason)}

      } -
    • )}
    -
    -} diff --git a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx index 8e546e14f9..0dcaa5758a 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx @@ -10,7 +10,6 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, NS } from '../src/client/index.ts' import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx' -import type { PluginEntryId } from '@deepseek-ai/dsh-api-remotes/client' import type { PluginInventorySettingsTabInjected } from '../src/client/PluginInventorySettingsTab.tsx' import { apply as hostApply } from '../src/index.ts' @@ -38,16 +37,7 @@ async function bench() { const list = vi.fn<() => Promise>() .mockResolvedValue({ ok: true, value: EMPTY }) ctx.provide('remote.pluginInventory', { list }) - const listBundles = vi.fn().mockResolvedValue({ ok: true, value: [] }) - const managed = { - listBundles, listPlugins: vi.fn().mockResolvedValue({ ok: true, value: [] }), - setPluginEnabled: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'applied', stage: 'enable', target: 'plugin' } }), - setBundleEnabled: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'applied', stage: 'enable', target: 'bundle' } }), - installBundle: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'restart-required', stage: 'install', target: 'install' } }), - removeBundle: vi.fn().mockResolvedValue({ ok: true, value: { changed: true, application: 'applied', stage: 'remove', target: 'remove' } }), - } - ctx.provide('remote.pluginManager', managed) - return { ctx, retryClient, slots: ctx.get('slots') as SlotRegistry, locale, list, listBundles, managed } + return { ctx, retryClient, slots: ctx.get('slots') as SlotRegistry, locale, list } } function declare(slots: SlotRegistry): () => void { @@ -63,7 +53,7 @@ describe('ui-settings-plugin-inventory browser plugin', () => { }) it('declares only the services used by the Settings Remote contribution', () => { - expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory', 'remote.pluginManager', 'modules']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory', 'modules']) }) it('registers a localized tab without reading the Remote eagerly', async () => { @@ -87,20 +77,6 @@ describe('ui-settings-plugin-inventory browser plugin', () => { retryError.mockRestore() await expect(injected.list()).resolves.toEqual(EMPTY) expect(b.list).toHaveBeenCalledOnce() - await expect(injected.management?.listBundles()).resolves.toEqual([]) - expect(b.listBundles).toHaveBeenCalledOnce() - const manager = injected.management! - await expect(manager.listPlugins()).resolves.toEqual([]) - await expect(manager.setPluginEnabled('include:plugin' as PluginEntryId, false)).resolves.toMatchObject({ stage: 'enable', target: 'plugin' }) - expect(b.managed.setPluginEnabled).toHaveBeenCalledWith('include:plugin', false) - await expect(manager.setBundleEnabled('extra', false)).resolves.toMatchObject({ stage: 'enable', target: 'bundle' }) - expect(b.managed.setBundleEnabled).toHaveBeenCalledWith('extra', false) - await expect(manager.installBundle('extra@1', { enabled: false })).resolves.toMatchObject({ application: 'restart-required' }) - expect(b.managed.installBundle).toHaveBeenCalledWith('extra@1', { enabled: false }) - await expect(manager.removeBundle('extra')).resolves.toMatchObject({ stage: 'remove', target: 'remove' }) - expect(b.managed.removeBundle).toHaveBeenCalledWith('extra') - b.listBundles.mockResolvedValueOnce({ ok: false, error: new Error('manager unavailable') }) - await expect(manager.listBundles()).rejects.toThrow('manager unavailable') b.list.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'unavailable' } }) await expect(injected.list()).rejects.toThrow('pluginInventory.list failed: REMOTE_ERROR: unavailable') diff --git a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx index 1c79671a9e..ec5bfea9b7 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx @@ -2,15 +2,14 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ClientEntryState } from '@deepseek-ai/dsh-client-modules/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { act, cleanup, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx' import type { PluginInventorySettingsTabInjected, PluginInventorySettingsTabProps, } from '../src/client/PluginInventorySettingsTab.tsx' -import { en, zh, type PluginInventoryLocaleKey } from '../src/client/locales.ts' -import { BundleManager, usePluginManagement, type PluginManagement } from '../src/client/management.tsx' +import { en, type PluginInventoryLocaleKey } from '../src/client/locales.ts' afterEach(cleanup) @@ -39,7 +38,7 @@ const SNAPSHOT = { entries: [ { entryId: 'telemetry', moduleName: '@fixture/telemetry', enabled: true, fiberPhase: 'failed' }, { entryId: 'timer', moduleName: 'cordis:timer', enabled: true, fiberPhase: 'active' }, - { entryId: '8a1b2c3d', moduleName: '@deepseek-ai/dsh-hmr', enabled: true, fiberPhase: 'active' }, + { entryId: '8a1b2c3d', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' }, { entryId: 'unobserved', moduleName: '@fixture/unobserved-name', enabled: true, fiberPhase: null }, { entryId: 'bash-host', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: false, fiberPhase: null }, { entryId: 'fs-host', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: false, fiberPhase: null }, @@ -88,9 +87,10 @@ async function renderReady(snapshot: Snapshot = SNAPSHOT): Promise screen.getByRole('button', { name: (name: string) => name.startsWith(en.globalTitle) }) +const presetToggle = (): HTMLElement => screen.getByRole('button', { name: en.presetTitle }) describe('PluginInventorySettingsTab', () => { - it('shows the default preset first and keeps the global plane collapsed', async () => { + it('shows the default preset first with both groups collapsed', async () => { const view = await renderReady() const switcher = screen.getByRole('button', { name: en.switcherLabel }) @@ -106,7 +106,11 @@ describe('PluginInventorySettingsTab', () => { expect(screen.getByText(en.presetSubtitle)).toBeTruthy() expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('6') - // Only the preset group lists rows while the global plane stays collapsed. + // Both groups start collapsed; opening the preset group lists its rows while the global plane stays folded. + expect(presetToggle().getAttribute('aria-expanded')).toBe('false') + expect(screen.queryAllByRole('listitem')).toHaveLength(0) + fireEvent.click(presetToggle()) + expect(presetToggle().getAttribute('aria-expanded')).toBe('true') expect(screen.getAllByRole('listitem')).toHaveLength(6) expect(screen.getAllByText(en.enabledTag)).toHaveLength(3) expect(screen.getByText(en.conditionalTag)).toBeTruthy() @@ -156,6 +160,7 @@ describe('PluginInventorySettingsTab', () => { }], }) + fireEvent.click(presetToggle()) expect(screen.getByRole('button', { name: 'tool-subagent, tool-subagent-primary, Enabled' }) .getAttribute('aria-expanded')).toBe('false') const secondary = screen.getByRole('button', { name: `tool-subagent, ${longId}, Disabled` }) @@ -218,6 +223,7 @@ describe('PluginInventorySettingsTab', () => { pickPreset('ptc') expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('3') + fireEvent.click(presetToggle()) fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash, Enabled' })) // An unnamed preset labels its source by id. expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('ptc') @@ -227,16 +233,18 @@ describe('PluginInventorySettingsTab', () => { expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('0') }) - it('collapses the preset group until a search forces it open', async () => { + it('keeps the preset group folded until opened or searched', async () => { const view = await renderReady() - const toggle = screen.getByRole('button', { name: en.presetTitle }) + const toggle = presetToggle() + // The header keeps its count while the rows are folded away. + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('6') + expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(0) + fireEvent.click(toggle) expect(toggle.getAttribute('aria-expanded')).toBe('true') fireEvent.click(toggle) expect(toggle.getAttribute('aria-expanded')).toBe('false') - // The header keeps its count while the rows are folded away. - expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('6') - expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(0) fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: 'pwsh' } }) expect(toggle.getAttribute('aria-expanded')).toBe('true') @@ -266,6 +274,7 @@ describe('PluginInventorySettingsTab', () => { ]) fireEvent.keyDown(document, { key: 'Escape' }) + fireEvent.click(presetToggle()) fireEvent.click(screen.getByRole('button', { name: 'pwsh, pwsh, Conditional' })) expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('Localized standard') @@ -317,15 +326,18 @@ describe('PluginInventorySettingsTab', () => { expect(screen.queryAllByRole('listitem')).toHaveLength(0) }) - it('renders a rosterless deployment as one expanded global list', async () => { + it('renders a rosterless deployment as one global list, folded until opened', async () => { const view = await renderReady({ entries: [ - { entryId: 'hmr', moduleName: '@deepseek-ai/dsh-hmr', enabled: true, fiberPhase: 'active' }, + { entryId: 'hmr', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' }, { entryId: 'off', moduleName: '@fixture/off', enabled: false, fiberPhase: null }, ], } as unknown as Snapshot) expect(screen.queryByRole('button', { name: en.switcherLabel })).toBeNull() + expect(globalToggle().getAttribute('aria-expanded')).toBe('false') + expect(screen.queryAllByRole('listitem')).toHaveLength(0) + fireEvent.click(globalToggle()) expect(globalToggle().getAttribute('aria-expanded')).toBe('true') expect(screen.getAllByRole('listitem')).toHaveLength(2) @@ -350,6 +362,8 @@ describe('PluginInventorySettingsTab', () => { expect(screen.queryByRole('button', { name: (name: string) => name.startsWith(en.globalTitle) })).toBeNull() expect(screen.queryByText(en.empty)).toBeNull() + expect(screen.queryAllByRole('listitem')).toHaveLength(0) + fireEvent.click(presetToggle()) expect(screen.getAllByRole('listitem')).toHaveLength(1) }) @@ -385,176 +399,6 @@ describe('PluginInventorySettingsTab', () => { }) }) -describe('persistent profile management', () => { - it('runs bundle and plugin controls through the same manager and shows failed results', async () => { - let enabled = true - let selected = true - let installed = true - const list: PluginInventorySettingsTabInjected['list'] = async () => ({ - managementAvailable: true, - entries: [{ entryId: 'managed' as Snapshot['entries'][number]['entryId'], moduleName: '@fixture/managed', enabled, fiberPhase: enabled ? 'active' : null }], - }) - const management = { - listPlugins: async () => [{ ...(await list()).entries[0]!, patchId: 'managed' }], - listBundles: async () => installed ? [{ name: 'extra', version: '1', enabled: selected, removable: true }] : [], - setPluginEnabled: vi.fn(async (_id: Snapshot['entries'][number]['entryId'], next: boolean) => { - enabled = next - return { changed: true, application: 'applied' as const, stage: 'enable' as const, target: 'managed', enabled: next } - }), - setBundleEnabled: vi.fn(async (_name: string, next: boolean) => { - selected = next - return { changed: true, application: 'applied' as const, stage: 'enable' as const, target: 'extra', enabled: next } - }), - installBundle: vi.fn(async () => ({ changed: false, application: 'failed' as const, stage: 'install' as const, target: 'new-bundle', error: { code: 'operation-error' as const, diagnostic: 'Registry unavailable.' } })), - removeBundle: vi.fn(async () => { - installed = false - return { changed: true, application: 'applied' as const, stage: 'remove' as const, target: 'extra' } - }), - } satisfies NonNullable - render() - fireEvent.click(await screen.findByRole('switch', { name: 'Toggle bundle extra' })) - await waitFor(() => { expect(management.setBundleEnabled).toHaveBeenCalledWith('extra', false) }) - await waitFor(() => { expect(screen.getByRole('switch', { name: 'Toggle bundle extra' }).checked).toBe(false) }) - fireEvent.click(screen.getByRole('button', { name: /managed, managed/ })) - fireEvent.click(await screen.findByRole('switch', { name: 'Toggle plugin managed' })) - await waitFor(() => { expect(management.setPluginEnabled).toHaveBeenCalledWith('managed', false) }) - await waitFor(() => { expect(screen.getByRole('switch', { name: 'Toggle plugin managed' }).checked).toBe(false) }) - fireEvent.change(screen.getByRole('textbox', { name: 'npm package name or local path' }), { target: { value: 'new-bundle' } }) - fireEvent.click(screen.getByRole('checkbox', { name: 'Enable after installation' })) - fireEvent.click(screen.getByRole('button', { name: 'Install' })) - await waitFor(() => { expect(management.installBundle).toHaveBeenCalledWith('new-bundle', { enabled: false }) }) - expect((await screen.findByRole('alert')).textContent).toContain('Registry unavailable.') - fireEvent.click(screen.getByRole('button', { name: 'Remove' })) - await waitFor(() => { expect(management.removeBundle).toHaveBeenCalledWith('extra') }) - await waitFor(() => { expect(screen.queryByRole('switch', { name: 'Toggle bundle extra' })).toBeNull() }) - }) -}) - -function managementFixture() { - const applied = { changed: true, application: 'applied' as const, stage: 'enable' as const, target: 'example' } - return { - listPlugins: vi.fn(async () => []), - listBundles: vi.fn(async () => []), - setPluginEnabled: vi.fn(async () => applied), setBundleEnabled: vi.fn(async () => applied), - installBundle: vi.fn(async () => applied), removeBundle: vi.fn(async () => applied), - } -} - -it('requires a click to approve the displayed packages and retries the original install options', async () => { - const manager = managementFixture() - const result = { changed: false, application: 'failed' as const, stage: 'install' as const, - target: 'addon@1', enabled: false, pendingBuilds: ['native', '@scope/helper'] } - function Form() { - const state = usePluginManagement(manager, true, 0) - return - } - render(
    ) - expect(screen.getByText('native')).toBeDefined() - expect(screen.getByText('@scope/helper')).toBeDefined() - expect(manager.installBundle).not.toHaveBeenCalled() - fireEvent.change(screen.getByRole('textbox', { name: en.packageSpec }), { target: { value: 'unrelated' } }) - fireEvent.click(screen.getByRole('button', { name: en.approveBuildsAndRetry })) - await waitFor(() => { expect(manager.installBundle).toHaveBeenCalledWith('addon@1', { enabled: false, approvedBuilds: ['native', '@scope/helper'] }) }) - await waitFor(() => { expect(screen.queryByRole('group', { name: en.buildApproval })).toBeNull() }) -}) - -it('ignores inventory completion after unmount and reports current read failures', async () => { - for (const failure of [undefined, new Error('late failure')]) { - const read = Promise.withResolvers>>() - const manager = managementFixture() - vi.mocked(manager.listBundles).mockReturnValue(read.promise) - const hook = renderHook(() => usePluginManagement(manager, true, 0)) - hook.unmount() - await act(async () => { - if (failure === undefined) read.resolve([]) - else read.reject(failure) - await read.promise.catch(() => {}) - }) - } - for (const failure of [new Error('inventory unavailable'), 'inventory rejected']) { - const manager = managementFixture() - vi.mocked(manager.listBundles).mockRejectedValue(failure) - const hook = renderHook(() => usePluginManagement(manager, true, 0)) - await waitFor(() => { expect(hook.result.current.error).toBe(failure instanceof Error ? failure.message : failure) }) - hook.unmount() - } -}) - -it('blocks duplicate submissions and refreshes after a rejected management request', async () => { - const manager = managementFixture() - const hook = renderHook(() => usePluginManagement(manager, true, 0)) - const pending = Promise.withResolvers>>() - const operation = vi.fn(() => pending.promise) - await act(async () => { - const first = hook.result.current.run(operation) - await hook.result.current.run(operation) - pending.resolve({ changed: true, application: 'applied', stage: 'remove', target: 'removed' }) - await first - }) - expect(operation).toHaveBeenCalledOnce() - expect(hook.result.current.result?.target).toBe('removed') - for (const failure of [new Error('request unavailable'), 'request rejected']) { - await act(async () => { await hook.result.current.run(async () => { throw failure }) }) - expect(hook.result.current.error).toBe(failure instanceof Error ? failure.message : failure) - expect(hook.result.current.busy).toBe(false) - } -}) - -it('shows package diagnostics, read errors and protected or missing bundles', () => { - const manager = managementFixture() - const state: ReturnType = { - plugins: [], bundles: [ - { name: 'missing', enabled: false, removable: true, error: { code: 'operation-error', diagnostic: 'package files missing' } }, - { name: 'core', enabled: true, removable: false, readOnlyReason: 'management-required' }, - ], busy: false, refresh: 0, error: 'inventory unavailable', run: async () => {}, - result: { changed: true, application: 'failed', stage: 'install', target: 'example', error: { code: 'not-bundle' }, - packageResult: { exitCode: 1, output: 'failed', truncated: false, logPath: '/profile/operation/pnpm.log' } }, - } - render() - expect(screen.getByText('inventory unavailable')).toBeDefined() - expect(screen.getByText('/profile/operation/pnpm.log')).toBeDefined() - expect(screen.getByText(/package files missing/)).toBeDefined() - expect(screen.getByText(en['management-required'])).toBeDefined() - expect(screen.getByRole('switch', { name: 'Toggle bundle missing' }).disabled).toBe(true) - expect(screen.getByRole('switch', { name: 'Toggle bundle core' }).disabled).toBe(true) -}) - -it.each([['en', en], ['zh', zh]] as const)('renders management codes and cleanup results using the %s dictionary', (_locale, dictionary) => { - const translate = ((key: PluginInventoryLocaleKey) => dictionary[key]) as PluginInventorySettingsTabProps['t'] - render( {}, error: undefined, - result: { changed: true, application: 'failed', stage: 'install', target: 'plain', - error: { code: 'not-bundle' }, warnings: ['pending external service'], remainingDependencies: ['plain'], - cleanup: { name: 'plain', error: { code: 'operation-error', diagnostic: 'EACCES' }, - packageResult: { exitCode: 1, output: 'EACCES', truncated: false, logPath: '/cleanup.log' } } }, - }} />) - expect(screen.getAllByText(dictionary['not-bundle'], { exact: false }).length).toBeGreaterThan(0) - expect(screen.getByText(dictionary.cleanup, { exact: false })).toBeDefined() - expect(screen.getByText('/cleanup.log')).toBeDefined() - expect(screen.getByRole('switch').disabled).toBe(true) - expect(screen.getByRole('button', { name: dictionary.remove }).disabled).toBe(false) -}) - -it('localizes protected plugin explanations in expanded details', async () => { - const list: PluginInventorySettingsTabInjected['list'] = async () => ({ managementAvailable: true, - entries: [{ entryId: 'managed' as Snapshot['entries'][number]['entryId'], moduleName: '@fixture/managed', enabled: true, fiberPhase: 'active' }], - }) - const manager = managementFixture() - manager.listPlugins.mockResolvedValue([{ ...(await list()).entries[0]!, readOnlyReason: 'management-required' }]) - render() - fireEvent.click(await screen.findByRole('button', { name: /managed, managed/ })) - expect(await screen.findByText(en['management-required'])).toBeDefined() -}) - -it('shows a completed dependency cleanup without a subprocess log', () => { - render( {}, - result: { stage: 'install', target: 'plain', changed: false, application: 'failed', cleanup: { name: 'plain' } }, - }} />) - expect(screen.getByRole('alert').textContent).toContain('New dependency cleanup: plain — Applied') -}) - it('shows current-page sync errors and retries without re-reading Host inventory', async () => { const list = vi.fn(async () => ({ entries: [] })) const sync = createSnapshotStore({ syncing: true, failures: [] }) @@ -570,12 +414,3 @@ it('shows current-page sync errors and retries without re-reading Host inventory act(() => { sync.set({ syncing: false, failures: [] }) }) expect(screen.queryByRole('alert')).toBeNull() }) - -it.each([en, zh])('renders stale build approval in the selected locale', (dictionary) => { - const localized = ((key: PluginInventoryLocaleKey) => dictionary[key]) as PluginInventorySettingsTabProps['t'] - render( {}, - result: { stage: 'install', target: 'addon', changed: false, application: 'failed', error: { code: 'stale-approval' } }, - }} />) - expect(screen.getByRole('alert').textContent).toContain(dictionary['stale-approval']) -}) diff --git a/packages/client/ui-settings-plugins/README.i18n.yaml b/packages/client/ui-settings-plugins/README.i18n.yaml index 583ce65bf8..20eb4a95ae 100644 --- a/packages/client/ui-settings-plugins/README.i18n.yaml +++ b/packages/client/ui-settings-plugins/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-plugins/README.md -README.md: cea5dd292634fe033ba9f12c9e8fe5daedc1d2ab -README.zh.md: ab35e92fb05fb28d4639ac761facf7bf9b53ee11 +README.md: b4b587e427aa23f2e8ce02706a4b87249955300d +README.zh.md: b8e7352c15bafbead42fc4b585b57de05fc3d0fa diff --git a/packages/client/ui-settings-plugins/README.md b/packages/client/ui-settings-plugins/README.md index cea5dd2926..b4b587e427 100644 --- a/packages/client/ui-settings-plugins/README.md +++ b/packages/client/ui-settings-plugins/README.md @@ -70,6 +70,7 @@ These pages cover the settings base, the inventory tab, and the durable seams be - [ui-settings](../ui-settings/README.md) — the domain base declaring `settings.plugins.tab` and the settings scope. - [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) — the read-only Plugin list tab in the same section. +- [ui-plugin-manager](../ui-plugin-manager/README.md) — the sidebar page for installed packages and global rows. - [settings](../../settings/README.md) — the durable user-settings seam and its file provider. - [credentials](../../credentials/README.md) — the credential-reference seam secret fields write through. - [ui-settings-general](../ui-settings-general/README.md) — the settings shell hosting this section. diff --git a/packages/client/ui-settings-plugins/README.zh.md b/packages/client/ui-settings-plugins/README.zh.md index ab35e92fb0..b8e7352c15 100644 --- a/packages/client/ui-settings-plugins/README.zh.md +++ b/packages/client/ui-settings-plugins/README.zh.md @@ -70,6 +70,7 @@ subagent 卡会同时暂存其权限开关与精确模型复选框。启用时 - [ui-settings](../ui-settings/README.zh.md)——声明 `settings.plugins.tab` 与 settings scope 的领域底座。 - [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.zh.md)——同一分区中的只读「插件列表」标签页。 +- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——管理已安装包与全局行的侧栏页面。 - [settings](../../settings/README.zh.md)——持久化用户设置 seam 及其文件提供方。 - [credentials](../../credentials/README.zh.md)——secret 字段写入所经的凭据引用 seam。 - [ui-settings-general](../ui-settings-general/README.zh.md)——承载本分区的设置外壳。 diff --git a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css index 0bd1beb9bf..bd6006ea56 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css +++ b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css @@ -83,3 +83,26 @@ font-size: 13px; color: var(--dsw-alias-label-tertiary); } + +/* Configurable tab: the card list. */ + +.configurable { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* Preset detail page: the settings section over the same cards. */ + +.presetSettings { + display: flex; + flex-direction: column; + gap: 8px; +} + +.presetSettingsTitle { + margin: 0; + font-size: 15px; + line-height: 22px; + font-weight: 600; +} diff --git a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.tsx b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.tsx index 95a242af92..b1a2e9abf9 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.tsx +++ b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.tsx @@ -29,7 +29,7 @@ export type PluginsSettingsSectionProps = & PropsRenderSlots<'settings.plugins.tab'> & InjectFace -/** Render one Plugins page whose contents arrive from feature-owned tabs. */ +/** Render one Plugins page whose contents arrive from feature-owned tabs; one contribution shows as the page itself. */ export function PluginsSettingsSection({ t, renderSlot, useTabs }: PluginsSettingsSectionProps) { const tabsId = useId() const tabRefs = useRef>([]) @@ -37,6 +37,7 @@ export function PluginsSettingsSection({ t, renderSlot, useTabs }: PluginsSettin const [activeId, setActiveId] = useState() const [visitedIds, setVisitedIds] = useState>(() => new Set()) const active = rows.find(row => row.id === activeId)?.id ?? rows[0]?.id + const single = rows.length === 1 ? rows[0] : undefined // A tab mounts only when first selected, then stays mounted while hidden so // local drafts, disclosure state, search, and the inventory snapshot survive @@ -53,7 +54,9 @@ export function PluginsSettingsSection({ t, renderSlot, useTabs }: PluginsSettin

    {t('title')}

    {t('intro')}

    - {rows.length === 0 ?

    {t('empty')}

    : ( + {rows.length === 0 ?

    {t('empty')}

    : single !== undefined ? ( +
    {renderSlot('settings.plugins.tab', {}, { only: single.id })}
    + ) : ( <>
    {rows.map((row, index) => { diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 760de2c4cd..68d85c63ec 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -232,6 +232,8 @@ .collapsed .iconButton { width: 36px; height: 36px; + /* Rail hovers share the panel rows' and New Session's 12px rounding. */ + border-radius: 12px; } /* Rail logo swap: collapsed, the toggle rests as the whale mark (brand ink, @@ -287,7 +289,7 @@ gap: 6px; height: 38px; padding: 8px 16px; - margin: 0 2px 8px; + margin: 0 2px 12px; box-sizing: border-box; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 12px; @@ -348,18 +350,20 @@ margin-bottom: 8px; } +/* Panel entries share the New Session bar's 12px radius and 2px inset, the + session rows' hover/selected surface, and the primary ink. */ .panelRow { display: flex; align-items: center; gap: 8px; - width: 100%; + margin: 0 2px; min-height: 36px; padding: 7px 8px; box-sizing: border-box; border: none; - border-radius: 8px; + border-radius: 12px; background: transparent; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-primary); font: inherit; line-height: 22px; text-align: left; @@ -371,9 +375,8 @@ } .panelRow.panelActive { - background: var(--dsw-alias-interactive-bg-active); + background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); - font-weight: 500; } .panelRow:focus-visible { @@ -404,6 +407,7 @@ width: 36px; height: 36px; justify-content: center; + margin: 0; padding: 0; color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts index 4ccd522d99..ca77777c64 100644 --- a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts @@ -18,6 +18,7 @@ export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlo return { signal: signal => t('terminal.signal', { signal }), exitCode: code => t('terminal.exitCode', { code }), + noExitCode: t('terminal.noExitCode'), running: t('terminal.running'), failed: t('terminal.failed'), done: t('terminal.done'), diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css index 0984f81be5..3c4109b0ee 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css @@ -255,6 +255,8 @@ .rail .iconButton { width: 36px; height: 36px; + /* Rail hovers share the shell rail's 12px rounding. */ + border-radius: 12px; color: var(--dsw-alias-label-primary); } @@ -264,6 +266,9 @@ padding: 0; margin: 0 0 12px; gap: 0; + /* The collapsed search frame clips its button's hover, so it must carry the + rail's 12px rounding itself. */ + border-radius: 12px; border-color: transparent; background: transparent; } @@ -271,6 +276,7 @@ .rail .searchButton { width: 36px; height: 36px; + border-radius: 12px; color: var(--dsw-alias-label-primary); } diff --git a/packages/experimental/agent-team-profile/README.i18n.yaml b/packages/experimental/agent-team-profile/README.i18n.yaml index addd6193d7..362d87d9f8 100644 --- a/packages/experimental/agent-team-profile/README.i18n.yaml +++ b/packages/experimental/agent-team-profile/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team-profile/README.md -README.md: 86ac08c5d2278013ecbc371301bbb6a3a01a65d9 -README.zh.md: 973f5f2342b32109d26291d762f4ad7b9b4c3306 +README.md: 9aa294991d76d63a596c1d9afed2f9991f424f20 +README.zh.md: 98db275bbf583b02d1f370008345fa68882dbba1 diff --git a/packages/experimental/agent-team-profile/README.md b/packages/experimental/agent-team-profile/README.md index 86ac08c5d2..9aa294991d 100644 --- a/packages/experimental/agent-team-profile/README.md +++ b/packages/experimental/agent-team-profile/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-experimental-agent-team-profile` is a published experimental profile layer that enables [Agent Teams](../agent-team/README.md) over `@deepseek-ai/dsh-base`. Its patch inserts the Team domain and Team-scoped tools and disables ordinary subagent delegation and the overlapping global continuable-child controls. Workflow remains available with fresh children. Add it explicitly to an initialized profile; no shipped profile enables it by default. +`dsh-experimental-agent-team-profile` is a published experimental profile layer that enables [Agent Teams](../agent-team/README.md) over `@deepseek-ai/dsh-base`. Its patch inserts the Team domain and Team-scoped tools and disables ordinary subagent delegation and the overlapping global continuable-child controls. Workflow remains available with fresh children. The dsh installation ships it as an optional bundle that no shipped profile enables; switch it on from the Web sidebar's Plugins page, or add it explicitly to an initialized profile. ## Table of Contents @@ -91,7 +91,7 @@ The bundle's composition is prefix-stable while its patch, Team identity, and co -- **Opt-in only** — the package is public, but no shipped CLI, Web, SDK, ACP, or Python profile enables it. +- **Opt-in only** — the package ships with the installation switched off; no shipped CLI, Web, SDK, ACP, or Python profile enables it. - **Workflow child tools** — the [Team tool visibility limitation](../tool-agent-team/README.md#known-limitations-and-deferred-work) also applies to workflow children. - **Shared checkout** — every teammate observes the same working directory; this bundle adds no worktree isolation or filesystem locking. - **Base profile required** — the patch depends on row ids and Subagent providers supplied by `dsh-base`; it is not a standalone profile. diff --git a/packages/experimental/agent-team-profile/README.zh.md b/packages/experimental/agent-team-profile/README.zh.md index 973f5f2342..98db275bbf 100644 --- a/packages/experimental/agent-team-profile/README.zh.md +++ b/packages/experimental/agent-team-profile/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -`dsh-experimental-agent-team-profile` 是在 `@deepseek-ai/dsh-base` 之上启用 [Agent Teams](../agent-team/README.zh.md) 的公开实验性 profile 层。它的 patch 会插入 Team domain 与 Team-scoped 工具,并禁用普通 subagent 委派和名称重叠的全局 continuable-child control。Workflow 仍可创建 fresh 子代理。必须将本包显式添加到已初始化的 profile;随附 profile 默认都不会启用它。 +`dsh-experimental-agent-team-profile` 是在 `@deepseek-ai/dsh-base` 之上启用 [Agent Teams](../agent-team/README.zh.md) 的公开实验性 profile 层。它的 patch 会插入 Team domain 与 Team-scoped 工具,并禁用普通 subagent 委派和名称重叠的全局 continuable-child control。Workflow 仍可创建 fresh 子代理。dsh 安装随附本包作为可选组合包,随附 profile 都不会启用它;可在 Web 侧栏的插件页开启,或显式添加到已初始化的 profile。 ## 目录 @@ -91,7 +91,7 @@ Team 策略与 schema 由 [`@deepseek-ai/dsh-experimental-tool-agent-team`](../t -- **仅显式启用**——本包公开发布,但随附 CLI、Web、SDK、ACP 与 Python profile 都不会启用它。 +- **仅显式启用**——本包随安装提供但默认关闭;随附 CLI、Web、SDK、ACP 与 Python profile 都不会启用它。 - **Workflow 子代理工具**——[Team 工具可见性限制](../tool-agent-team/README.zh.md#known-limitations-and-deferred-work)也适用于 workflow 子代理。 - **共享 checkout**——所有 teammate 都观察同一个工作目录;本 bundle 不提供 worktree 隔离或文件系统锁。 - **需要 base profile**——本 patch 依赖 `dsh-base` 提供的配置行 id 与 Subagent 提供方;它不是独立 profile。 diff --git a/packages/experimental/agent-team-web-profile/README.i18n.yaml b/packages/experimental/agent-team-web-profile/README.i18n.yaml index 7cfcbc8da1..9febb49882 100644 --- a/packages/experimental/agent-team-web-profile/README.i18n.yaml +++ b/packages/experimental/agent-team-web-profile/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team-web-profile/README.md -README.md: 16d71b5b30f5b18cb35e3eb289757d48a5271385 -README.zh.md: dd6c99453f9dacde19c299ab30c3a89d9aca02a3 +README.md: e21a7eeebc83a97d3e4630c95a9aeaa4213bc227 +README.zh.md: 9966697cb7225083a6fff515675b9dd9ecc51c82 diff --git a/packages/experimental/agent-team-web-profile/README.md b/packages/experimental/agent-team-web-profile/README.md index 16d71b5b30..e21a7eeebc 100644 --- a/packages/experimental/agent-team-web-profile/README.md +++ b/packages/experimental/agent-team-web-profile/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-experimental-agent-team-web-profile` is the published experimental Web layer for [Agent Teams](../agent-team/README.md). Add it after `@deepseek-ai/dsh-web-app` and [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.md) to show the Team roster, task board, and teammate navigation in the browser. Removing either experimental layer leaves the stable base and Web composition unchanged. No shipped Web profile enables it by default. +`dsh-experimental-agent-team-web-profile` is the published experimental Web layer for [Agent Teams](../agent-team/README.md). Add it after `@deepseek-ai/dsh-web-app` and [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.md) to show the Team roster, task board, and teammate navigation in the browser. Removing either experimental layer leaves the stable base and Web composition unchanged. The dsh installation ships it as an optional bundle that no shipped Web profile enables; switch it on from the Web sidebar's Plugins page after the Host layer. ## Table of Contents @@ -85,7 +85,7 @@ This Web bundle adds no model request content; the Host-side Team tools own prom - **Ordered composition** — `dsh-base`, `dsh-web-app`, `dsh-experimental-agent-team-profile`, and this package must remain in that order. - **Preset-scoped legacy controls** — stable Web presets still mount continuable Subagent controls inside the preset scope. Top-level Host profile overrides do not replace those scoped registrations, so the Team roster and legacy child controls can both appear until Web has a Team-aware preset. The [Web Agent Teams decision](../../../.agents/notes/archived/feature/2026-08-06-agent-teams-web.md) records this deferred composition work. -- **Opt-in only** — the package is public, but no shipped Web profile enables either Agent Teams layer. +- **Opt-in only** — the package ships with the installation switched off; no shipped Web profile enables either Agent Teams layer. ### Dev Note diff --git a/packages/experimental/agent-team-web-profile/README.zh.md b/packages/experimental/agent-team-web-profile/README.zh.md index dd6c99453f..9966697cb7 100644 --- a/packages/experimental/agent-team-web-profile/README.zh.md +++ b/packages/experimental/agent-team-web-profile/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -`dsh-experimental-agent-team-web-profile` 是 [Agent Teams](../agent-team/README.zh.md) 公开发布的实验性 Web 层。把它放在 `@deepseek-ai/dsh-web-app` 与 [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.zh.md) 之后,即可在浏览器中显示 Team roster、任务板与 teammate 导航。移除任一实验层都会让稳定的 base 与 Web composition 保持不变。随附 Web profile 默认不会启用它。 +`dsh-experimental-agent-team-web-profile` 是 [Agent Teams](../agent-team/README.zh.md) 公开发布的实验性 Web 层。把它放在 `@deepseek-ai/dsh-web-app` 与 [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.zh.md) 之后,即可在浏览器中显示 Team roster、任务板与 teammate 导航。移除任一实验层都会让稳定的 base 与 Web composition 保持不变。dsh 安装随附本包作为可选组合包,随附 Web profile 不会启用它;在开启 Host 层后可在 Web 侧栏的插件页开启。 ## 目录 @@ -85,7 +85,7 @@ dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-web-profil - **有序组合**——`dsh-base`、`dsh-web-app`、`dsh-experimental-agent-team-profile` 与本包必须保持这个顺序。 - **Preset-scoped 旧控制项**——稳定 Web preset 仍会在 preset scope 内挂载 continuable Subagent 控制项。顶层 Host profile override 不会替换这些 scoped registration,因此在 Web 获得 Team-aware preset 前,Team roster 与旧 child 控制项可能同时出现。[Web Agent Teams 决策](../../../.agents/notes/archived/feature/2026-08-06-agent-teams-web.md)记录了这项暂缓的 composition 工作。 -- **仅显式启用**——本包公开发布,但随附 Web profile 默认不会启用任何 Agent Teams 层。 +- **仅显式启用**——本包随安装提供但默认关闭;随附 Web profile 不会启用任何 Agent Teams 层。 ### 开发备注 diff --git a/packages/experimental/auto-review/README.i18n.yaml b/packages/experimental/auto-review/README.i18n.yaml index 4d7b9e5dd1..654d67557a 100644 --- a/packages/experimental/auto-review/README.i18n.yaml +++ b/packages/experimental/auto-review/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/auto-review/README.md -README.md: 857ea568577e5071d2ea282849cf4b817a26a1b4 -README.zh.md: e05e81b0765743e444912c98b0170fc1bb746503 +README.md: db4d1d3c5a8ace23f41b3d522824b3163156fc7a +README.zh.md: c4fd7e59b25f22adb4b1947fc0b27ff844c3ebf2 diff --git a/packages/experimental/auto-review/README.md b/packages/experimental/auto-review/README.md index 857ea56857..db4d1d3c5a 100644 --- a/packages/experimental/auto-review/README.md +++ b/packages/experimental/auto-review/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Add Auto review to the current-session permission pickers in a Web profile. Before each native or PTC inner tool call, the current agent's provider and model assess the pending action; an allowed call executes with Full access. Default Web keeps its three permission modes until this layer is explicitly installed. Auto review is experimental: it can allow unsafe actions, deny useful work, and spend additional tokens. +Add Auto review to the current-session permission pickers in a Web profile. Before each native or PTC inner tool call, the current agent's provider and model assess the pending action; an allowed call executes with Full access. The dsh installation ships this layer switched off; default Web keeps its three permission modes until it is switched on from the Web sidebar's Plugins page or installed explicitly. Auto review is experimental: it can allow unsafe actions, deny useful work, and spend additional tokens. ## Table of Contents @@ -112,7 +112,7 @@ The denial appends an ordinary tool result; it does not rewrite earlier context -- Auto requires an explicitly installed Web layer; it is absent from default Web, Headless, General settings, and new-session defaults. +- Auto requires this Web layer switched on; it is absent from default Web, Headless, General settings, and new-session defaults. - Auto provides no file sandbox. The outer `run_code` transport and direct Node effects inside a PTC program do not pass through inner-tool review. - Model classification can be wrong. There are no deterministic tool exemptions, persistent grants, manual fallback, configurable policy, or retry layer. - In-process Auto children review their own calls. Out-of-process children retain their native permission systems after the parent delegation call is allowed. diff --git a/packages/experimental/auto-review/README.zh.md b/packages/experimental/auto-review/README.zh.md index e05e81b076..c4fd7e59b2 100644 --- a/packages/experimental/auto-review/README.zh.md +++ b/packages/experimental/auto-review/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -为 Web profile 当前会话权限选择器添加 Auto review。每次原生或 PTC inner 工具调用前,当前 agent 的 provider 与模型会评估待执行动作;获准调用以 Full access 执行。在显式安装此层之前,默认 Web 保持三种权限模式。Auto review 是实验功能:它可能误放行不安全动作、误拒绝有用操作,并消耗额外 token。 +为 Web profile 当前会话权限选择器添加 Auto review。每次原生或 PTC inner 工具调用前,当前 agent 的 provider 与模型会评估待执行动作;获准调用以 Full access 执行。dsh 安装随附此层但默认关闭;在 Web 侧栏插件页开启或显式安装之前,默认 Web 保持三种权限模式。Auto review 是实验功能:它可能误放行不安全动作、误拒绝有用操作,并消耗额外 token。 ## 目录 @@ -112,7 +112,7 @@ Reviewer 使用最新 `request/header.config` 的 provider 与模型,并沿用 -- Auto 需要显式安装 Web 层;默认 Web、Headless、通用设置与新会话默认值都不包含它。 +- Auto 需要开启此 Web 层;默认 Web、Headless、通用设置与新会话默认值都不包含它。 - Auto 不提供文件沙箱。外层 `run_code` transport 及PTC 程序内直接 Node 效果不经过 inner-tool review。 - 模型分类可能出错。不提供确定性工具豁免、持久 grant、人工 fallback、可配置策略或重试层。 - 进程内 Auto child 独立审查自身调用。进程外 child 在父委派调用获准后保留原生权限系统。 diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index ad4a1299a7..0f03800cb1 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -42,9 +42,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "react": "^18.2.0" - }, "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", @@ -75,6 +72,7 @@ "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0", "react-dom": "^18.2.0" }, "files": [ diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 02957f9a87..17151804db 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -920,10 +920,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ThemeTokens', declaration: 'export type ThemeTokens = Record;', }, - { - name: 'Translate', - declaration: 'export type Translate = (key: K, params?: Record) => string;', - }, { name: 'TranslateNS', declaration: 'export type TranslateNS = Translate>;', diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index ecbb245cba..e080b567cd 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1463,6 +1463,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', occupants: [ 'client-ui-conversation ConversationPanel key \'conversation\'', + 'client-ui-plugin-manager PluginManagerPage', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main\', () => ctx.slots.register(\n { name: \'main\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', @@ -2298,7 +2299,9 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ hookContext: '', slotInject: '', declaredBy: 'an entry in \'sidebar\' (client-ui-sidebar), so it exists while that entry is mounted', - occupants: [], + occupants: [ + 'client-ui-plugin-manager PluginsPanelIcon', + ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.panellist\', () => ctx.slots.register(\n { name: \'sidebar.panellist\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:32', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 3b81712bf8..515cc0f70d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1446,9 +1446,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote listBundles(): Promise', - description: 'Read installed bundles and bundles supplied by this dsh installation.', + description: 'Read the profile\'s installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.', parameters: [], - returns: 'Package versions, activation selections and removal availability.', + returns: 'Package versions, one-liners, rows, activation selections, whether the installation offers the bundle, and removal availability.', + }, + { + signature: '@Remote async inspect(spec: string, signal?: AbortSignal): Promise', + description: 'Read what a spec names before installing it.', + parameters: [{ name: 'spec', description: 'One package spec: a registry name, an absolute path, a git address, or a tarball.' }, { name: 'signal', description: 'Ends a registry lookup early.' }], + returns: 'The package the spec names, or why it is refused.', }, { signature: '@Remote setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise', @@ -1464,10 +1470,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote installBundle(spec: string, options?: InstallBundleOptions): Promise', - description: 'Install a package using the same pnpm implementation as dsh plugin.', - parameters: [{ name: 'spec', description: 'One package spec, including local paths relative to the invocation directory.' }, { name: 'options', description: 'Activation defaults to true; explicit build approvals persist for this profile before installation.' }], + description: 'Install a package using the same pnpm implementation as dsh plugin. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.', + parameters: [{ name: 'spec', description: 'One package spec, including local paths relative to the invocation directory.' }, { name: 'options', description: 'Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and the pending build scripts to allow for this profile before pnpm runs.' }], returns: 'Package-manager diagnostics and observed activation outcome.', }, + { + signature: '@Remote async cancelInstall(requestId: PluginInstallRequestId): Promise', + description: 'Stop an installation this manager owns and wait until its files are back.', + parameters: [{ name: 'requestId', description: 'The id the installation was started with.' }], + returns: '`cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being applied, `not-running` for any other id.', + }, { signature: '@Remote removeBundle(name: string): Promise', description: 'Unload and remove a profile-owned bundle dependency through dsh plugin\'s pnpm path.', @@ -3613,6 +3625,30 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'The selectable process catalog changed. Payload-free by design: consumers subscribe first, then re-read the complete catalog.', parameters: [], }, + { + name: 'plugin-manager/changed', + mode: 'emit', + signature: '\'plugin-manager/changed\'(change: PluginChange): void', + summary: 'The profile\'s plugins, bundles, or composition changed: a manager operation completed.', + description: 'The profile\'s plugins, bundles, or composition changed: a manager operation completed. A patch generation applied outside the manager, by HMR\'s watcher after a CLI or hand edit, announces nothing here.', + parameters: [{ name: 'change', description: 'what changed.' }], + }, + { + name: 'plugin-manager/install-log', + mode: 'emit', + signature: '\'plugin-manager/install-log\'(chunk: PluginInstallLogChunk): void', + summary: 'One chunk of a pnpm run\'s output, streamed as the run produces it.', + description: 'One chunk of a pnpm run\'s output, streamed as the run produces it.', + parameters: [{ name: 'chunk', description: 'the chunk and the run it belongs to.' }], + }, + { + name: 'plugin-manager/install-state', + mode: 'emit', + signature: '\'plugin-manager/install-state\'(progress: PluginInstallProgress): void', + summary: 'An installation moved between its Host phases.', + description: 'An installation moved between its Host phases.', + parameters: [{ name: 'progress', description: 'the installation\'s request id and phase.' }], + }, { name: 'session-telemetry/record', mode: 'waterfall', @@ -4091,11 +4127,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BundleInfo', - declaration: 'export interface BundleInfo {\n name: string;\n version?: string;\n enabled: boolean;\n removable: boolean;\n readOnlyReason?: ReadOnlyReason;\n error?: ManagementError;\n}', + declaration: 'export interface BundleInfo {\n name: string;\n version?: string;\n description?: string;\n enabled: boolean;\n installed: boolean;\n optional: boolean;\n removable: boolean;\n readOnlyReason?: ReadOnlyReason;\n error?: ManagementError;\n rows: BundleRowInfo[];\n overrides: string[];\n}', + }, + { + name: 'BundleRowInfo', + declaration: 'export interface BundleRowInfo {\n rowId: string;\n moduleName: string;\n entryId?: PluginEntryId;\n}', }, { name: 'ChangeResult', - declaration: 'export interface ChangeResult {\n changed: boolean;\n application: \'applied\' | \'restart-required\' | \'overridden\' | \'failed\';\n stage: \'install\' | \'enable\' | \'remove\';\n target: string;\n enabled?: boolean;\n error?: ManagementError;\n warnings?: string[];\n remainingDependencies?: string[];\n cleanup?: {\n name: string;\n packageResult?: PackageResult;\n error?: ManagementError;\n };\n packageResult?: PackageResult;\n pendingBuilds?: string[];\n approvedBuilds?: string[];\n}', + declaration: 'export interface ChangeResult {\n changed: boolean;\n application: \'applied\' | \'restart-required\' | \'overridden\' | \'failed\' | \'cancelled\';\n stage: \'install\' | \'enable\' | \'remove\';\n target: string;\n enabled?: boolean;\n error?: ManagementError;\n warnings?: string[];\n packageResult?: PackageResult;\n bundle?: string;\n pendingBuilds?: string[];\n approvedBuilds?: string[];\n}', }, { name: 'ClientArtifactBaseline', @@ -4659,7 +4699,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InstallBundleOptions', - declaration: 'export interface InstallBundleOptions {\n enabled?: boolean;\n approvedBuilds?: string[];\n}', + declaration: 'export interface InstallBundleOptions {\n enabled?: boolean;\n requestId?: PluginInstallRequestId;\n approvedBuilds?: string[];\n}', + }, + { + name: 'InstallSpecKind', + declaration: 'export type InstallSpecKind = \'registry\' | \'path\' | \'git\' | \'tarball\';', }, { name: 'InvariantFailure', @@ -5027,12 +5071,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PackageResult', - declaration: 'export interface PackageResult {\n exitCode: number;\n output: string;\n truncated: boolean;\n logPath: string;\n}', + declaration: 'export interface PackageResult {\n exitCode: number;\n output: string;\n truncated: boolean;\n logPath: string;\n kind?: PluginInstallFailureKind;\n}', }, { name: 'PermissionCatalog', declaration: 'export interface PermissionCatalog {\n options: PresetOption[];\n}', }, + { + name: 'PluginChange', + declaration: 'export interface PluginChange {\n readonly reason: \'plugin\' | \'bundle\' | \'install\' | \'remove\';\n}', + }, { name: 'PluginEntryId', declaration: 'export type PluginEntryId = Branded<\'PluginEntryId\'>;', @@ -5045,10 +5093,38 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PluginInfo', declaration: 'export type PluginInfo = PluginInventoryEntry & ({\n patchId: string;\n readOnlyReason?: never;\n} | {\n patchId?: never;\n readOnlyReason: ReadOnlyReason;\n});', }, + { + name: 'PluginInspectProblem', + declaration: 'export type PluginInspectProblem = \'invalid-spec\' | \'already-installed\' | \'not-found\' | \'not-a-package\' | \'not-a-bundle\' | \'network\' | \'unknown\';', + }, + { + name: 'PluginInstallCancellation', + declaration: 'export interface PluginInstallCancellation {\n readonly status: \'cancelled\' | \'too-late\' | \'not-running\';\n}', + }, + { + name: 'PluginInstallFailureKind', + declaration: 'export type PluginInstallFailureKind = \'pnpm-missing\' | \'timeout\' | \'not-found\' | \'no-matching-version\' | \'network\' | \'disk-full\' | \'permission\' | \'build-blocked\' | \'integrity\' | \'unknown\';', + }, + { + name: 'PluginInstallLogChunk', + declaration: 'export interface PluginInstallLogChunk {\n readonly requestId?: PluginInstallRequestId;\n readonly jobId: string;\n readonly argv: readonly string[];\n readonly cwd: string;\n readonly stream: \'stdout\' | \'stderr\';\n readonly text: string;\n readonly exitCode?: number | null;\n}', + }, + { + name: 'PluginInstallProgress', + declaration: 'export interface PluginInstallProgress {\n readonly requestId: PluginInstallRequestId;\n readonly phase: \'installing\' | \'cancelling\' | \'applying\';\n}', + }, + { + name: 'PluginInstallRequestId', + declaration: 'export type PluginInstallRequestId = Branded<\'PluginInstallRequestId\'>;', + }, { name: 'PluginInventoryEntry', declaration: 'export interface PluginInventoryEntry {\n readonly entryId: PluginEntryId;\n readonly moduleName: string;\n readonly enabled: boolean;\n readonly fiberPhase: PluginFiberPhase;\n}', }, + { + 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} | {\n readonly status: \'refused\';\n readonly problem: PluginInspectProblem;\n readonly reason: string;\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};', diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index 42261b9ae7..83d8a1cc97 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/packages/host/plugin-inventory/src/index.ts @@ -17,8 +17,12 @@ import type { export type * from './types.ts' -/** Brand an existing Loader-tree entry id at the owning boundary. */ -function pluginEntryId(value: string): PluginEntryId { +/** + * Brand an existing Loader-tree entry id at the owning boundary. + * @param value - the entry id as the Loader tree spells it. + * @returns the same id as the inventory's branded entry id. + */ +export function pluginEntryId(value: string): PluginEntryId { return value as PluginEntryId } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc2f55dae0..a1fb756407 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,6 +213,15 @@ importers: '@deepseek-ai/dsh-cordis-client-runner': specifier: workspace:^ version: link:../../packages/extensions/cordis-client-runner + '@deepseek-ai/dsh-experimental-agent-team-profile': + specifier: workspace:^ + version: link:../../packages/experimental/agent-team-profile + '@deepseek-ai/dsh-experimental-agent-team-web-profile': + specifier: workspace:^ + version: link:../../packages/experimental/agent-team-web-profile + '@deepseek-ai/dsh-experimental-auto-review': + specifier: workspace:^ + version: link:../../packages/experimental/auto-review '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../packages/fs/fs-local @@ -415,9 +424,6 @@ importers: '@deepseek-ai/dsh-experimental-agent-team': specifier: workspace:^ version: link:../../packages/experimental/agent-team - '@deepseek-ai/dsh-experimental-agent-team-profile': - specifier: workspace:^ - version: link:../../packages/experimental/agent-team-profile '@deepseek-ai/dsh-experimental-ptc-runtime-python': specifier: workspace:^ version: link:../../packages/experimental/ptc-runtime-python @@ -1525,6 +1531,9 @@ importers: '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-hmr': specifier: workspace:^ version: link:../hmr @@ -2109,6 +2118,9 @@ importers: '@deepseek-ai/dsh-client-ui-plan': specifier: workspace:^ version: link:../../client/ui-plan + '@deepseek-ai/dsh-client-ui-plugin-manager': + specifier: workspace:^ + version: link:../../client/ui-plugin-manager '@deepseek-ai/dsh-client-ui-reference': specifier: workspace:^ version: link:../../client/ui-reference @@ -3579,6 +3591,60 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-plugin-manager: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:^ + version: link:../ui-layout + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-sidebar': + specifier: workspace:^ + version: link:../ui-sidebar + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-plugin-manager': + specifier: workspace:^ + version: link:../../boot/plugin-manager + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-primitives: devDependencies: '@deepseek-ai/cordis': @@ -6138,10 +6204,6 @@ importers: version: 4.22.4 packages/experimental/client-ui-agent-team: - dependencies: - react: - specifier: ^18.2.0 - version: 18.3.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -6188,6 +6250,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index ff6365dfb8..c536240a6c 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -88,6 +88,22 @@ describe('experimental workspace constraints', () => { }, ) + it('allows the dsh installation to ship the optional bundles the launcher names, and nothing else experimental', () => { + const listed = { '@deepseek-ai/dsh-experimental-prototype': 'workspace:^' } + const installation = { dir: 'apps/cli', manifest: { name: '@deepseek-ai/dsh', dependencies: listed } } satisfies WorkspaceManifest + expect(checkExperimentalDependencyIsolation([experimental, installation], ['@deepseek-ai/dsh-experimental-prototype'])).toEqual([]) + expect(checkExperimentalDependencyIsolation([experimental, installation], [])).toEqual([ + '@deepseek-ai/dsh: dependencies.@deepseek-ai/dsh-experimental-prototype must not reference an experimental package', + ]) + // Only a plain dependency edge is offered; a peer would make the bundle a requirement of every consumer. + expect(checkExperimentalDependencyIsolation([experimental, { + dir: 'apps/cli', + manifest: { name: '@deepseek-ai/dsh', peerDependencies: listed }, + }], ['@deepseek-ai/dsh-experimental-prototype'])).toEqual([ + '@deepseek-ai/dsh: peerDependencies.@deepseek-ai/dsh-experimental-prototype must not reference an experimental package', + ]) + }) + it('allows development and experimental consumers but rejects the Python release runtime', () => { const manifests: WorkspaceManifest[] = [experimental, { dir: 'packages/core/test-only', diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 392954d3f5..7023c0c8f1 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -13,6 +13,7 @@ import { PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES, } from './experimental-package-policy.ts' import { hasTypertRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts' +import { OPTIONAL_BUNDLES } from '../packages/boot/app-boot/src/profile.ts' import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts' const root = resolve(import.meta.dirname, '..') @@ -502,11 +503,16 @@ const dependencySections = ['dependencies', 'devDependencies', 'peerDependencies const runtimeDependencySections = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const /** - * Prevent an official runtime from requiring a package its release omits. + * Prevent an official runtime from requiring an experimental package. The dsh installation's `dependencies` + * may hold the bundles the launcher's `OPTIONAL_BUNDLES` names: shipped switched off, they are not a requirement + * ([rationale](../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). * @param manifests - release, private experimental, and deployment-root manifests. + * @param optionalBundles - the bundles the installation ships switched off; the launcher's list by default. * @returns One error for each forbidden runtime dependency. */ -export function checkExperimentalDependencyIsolation(manifests: readonly WorkspaceManifest[]): string[] { +export function checkExperimentalDependencyIsolation( + manifests: readonly WorkspaceManifest[], optionalBundles: readonly string[] = OPTIONAL_BUNDLES, +): string[] { const experimentalNames = new Set(manifests .filter(entry => experimentalPackageDirectory.test(entry.dir)) .map(entry => entry.manifest.name) @@ -514,9 +520,11 @@ export function checkExperimentalDependencyIsolation(manifests: readonly Workspa const errors: string[] = [] for (const { dir, manifest } of manifests) { if (!standardReleaseMemberDirectory.test(dir) && dir !== 'python/sdk-runtime') continue + const offered = manifest.name === '@deepseek-ai/dsh' ? new Set(optionalBundles) : new Set() for (const section of runtimeDependencySections) { for (const name of Object.keys(manifest[section] ?? {})) { if (!experimentalNames.has(name)) continue + if (section === 'dependencies' && offered.has(name)) continue errors.push(`${manifest.name ?? dir}: ${section}.${name} must not reference an experimental package`) } } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 09a70b8737..4ac777ee4a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -200,6 +200,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { */ export const EVENT_SCOPE_PAGE: Record = { hmr: 'boot.md', + 'plugin-manager': 'boot.md', 'agent': 'core.md', 'agent-loop': 'core.md', 'agent-preset': 'core.md', @@ -263,6 +264,13 @@ export const LINK_MAP: Readonly> = { ChangeResult: 'boot.md', InstallBundleOptions: 'boot.md', PluginEntryId: 'boot.md', + BundleRowInfo: 'boot.md', + PluginInstallCancellation: 'boot.md', + PluginInstallRequestId: 'boot.md', + PluginSpecInspection: 'boot.md', + PluginChange: 'boot.md', + PluginInstallLogChunk: 'boot.md', + PluginInstallProgress: 'boot.md', BrowserUseProviderName: 'browser-use.md', ComputerUseProviderName: 'computer-use.md', Agent: 'core.md', @@ -720,6 +728,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ /** Project types deliberately documented outside the subsystems catalog. */ export const TYPE_LINK_EXEMPTIONS: Readonly> = { Profile: 'resolved profile layers are owned by packages/boot/app-boot/README.md', + PatchOptions: 'Include patch entries are owned by vendor/include (vendored upstream)', McpResourceProvider: 'scoped resource provider is owned by packages/mcp/mcp-resources/README.md', 'z.ZodType': 'Zod response validation API is owned by https://zod.dev/packages/zod', Socket: 'Node.js byte stream API is owned by https://nodejs.org/api/net.html#class-netsocket', diff --git a/scripts/release/installed-product-isolation.spec.ts b/scripts/release/installed-product-isolation.spec.ts index 7c90ce64ef..5d8b4e4f51 100644 --- a/scripts/release/installed-product-isolation.spec.ts +++ b/scripts/release/installed-product-isolation.spec.ts @@ -52,6 +52,20 @@ describe('installed default-product isolation', () => { }, ) + it('skips the entry package\'s optional bundles, which must be installed, and still rejects other experimental edges', () => { + const root = fixture() + const entry = writePackage(root, '@deepseek-ai/dsh', { dependencies: { core: '1.0.0', [experimental]: '1.0.0' } }) + writePackage(root, 'core', {}) + writePackage(root, experimental, { dependencies: { '@deepseek-ai/dsh-experimental-inner': '1.0.0' } }) + expect(verifyInstalledProductIsolation(entry, [experimental])).toBe(2) + + writePackage(root, 'core', { dependencies: { [experimental]: '1.0.0' } }) + expect(() => verifyInstalledProductIsolation(entry, [experimental])).toThrow(`core -> ${experimental}`) + + const missing = writePackage(root, '@deepseek-ai/dsh-missing', { dependencies: { absent: '1.0.0' } }) + expect(() => verifyInstalledProductIsolation(missing, ['absent'])).toThrow('optional bundle is missing: @deepseek-ai/dsh-missing -> absent') + }) + it('rejects experimental identities hidden behind an installed alias', () => { const root = fixture() const entry = writePackage(root, '@deepseek-ai/dsh', { dependencies: { safeName: 'file:../prototype' } }) diff --git a/scripts/release/installed-product-isolation.ts b/scripts/release/installed-product-isolation.ts index 931c8828a9..e2877c23d2 100644 --- a/scripts/release/installed-product-isolation.ts +++ b/scripts/release/installed-product-isolation.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs' import { createRequire } from 'node:module' import { join } from 'node:path' +import { OPTIONAL_BUNDLES } from '../../packages/boot/app-boot/src/profile.ts' interface InstalledManifest { name: string @@ -18,7 +19,7 @@ interface InstalledManifest { * @param directory - installed entry package directory. * @returns number of distinct installed packages visited. */ -export function verifyInstalledProductIsolation(directory: string): number { +export function verifyInstalledProductIsolation(directory: string, optionalBundles: readonly string[] = OPTIONAL_BUNDLES): number { const visited = new Set() const queue = [{ directory, chain: [] as string[] }] for (const item of queue) { @@ -28,8 +29,14 @@ export function verifyInstalledProductIsolation(directory: string): number { const manifest = JSON.parse(readFileSync(join(canonical, 'package.json'), 'utf8')) as InstalledManifest const chain = [...item.chain, manifest.name] rejectExperimental(manifest.name, chain) + // The bundles the entry package ships switched off are installed beside the product, not required by it. + const offered = item.chain.length === 0 ? new Set(optionalBundles) : new Set() for (const section of ['dependencies', 'optionalDependencies', 'peerDependencies'] as const) { for (const [name, range] of Object.entries(manifest[section] ?? {})) { + if (section === 'dependencies' && offered.has(name)) { + if (installedPackage(canonical, name) === undefined) throw new Error(`optional bundle is missing: ${[...chain, name].join(' -> ')}`) + continue + } rejectExperimental(name, [...chain, name]) if (range.startsWith('npm:')) { rejectExperimental(range.slice(4), [...chain, `${name} (${range})`]) diff --git a/scripts/verify-default-product-isolation.spec.ts b/scripts/verify-default-product-isolation.spec.ts index 5c7a130040..cfe65c4bb1 100644 --- a/scripts/verify-default-product-isolation.spec.ts +++ b/scripts/verify-default-product-isolation.spec.ts @@ -69,6 +69,45 @@ describe('default product isolation', () => { expect(verifyDefaultProductIsolation(root).failures).toEqual([]) }) + it('ships an optional bundle switched off: its graph is outside the product, its name stays out of imports and defaults', () => { + const root = fixture() + const layer = '@deepseek-ai/dsh-experimental-layer' + write(root, 'packages/experimental/layer/package.json', { + name: layer, dependencies: { [experimental]: 'workspace:^' }, dsh: { bundle: { patch: './cordis.patch.yml' } }, + }) + write(root, 'packages/experimental/layer/cordis.patch.yml', [{ insert: [{ name: experimental }] }]) + manifest(root, 'apps/cli/package.json', { dependencies: { [core]: 'workspace:^', [layer]: 'workspace:^' } }) + write(root, profile, `export const PROFILE_TEMPLATES = { web: { bundles: ['${base}'] } }\n` + + `export const DEFAULT_PROFILE_BUNDLES = ['${base}']\n` + + `export const OPTIONAL_BUNDLES = ['${layer}']\n`) + expect(verifyDefaultProductIsolation(root)).toMatchObject({ failures: [], packageCount: 5 }) + + // The exception covers the dependency edge alone: a runtime import or a default template still names the product. + write(root, 'apps/cli/src/bin.ts', `import '${layer}'\n`) + expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(`apps/cli/src/bin.ts -> ${layer}`) + write(root, 'apps/cli/src/bin.ts', 'export {}\n') + write(root, profile, `export const PROFILE_TEMPLATES = { web: { bundles: ['${base}', '${layer}'] } }\n` + + `export const DEFAULT_PROFILE_BUNDLES = ['${base}']\n` + + `export const OPTIONAL_BUNDLES = ['${layer}']\n`) + expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(`optional bundle ${layer} must not be a default bundle`) + }) + + it('requires each optional bundle to be a runtime dependency that declares a bundle patch', () => { + const root = fixture() + write(root, profile, `export const PROFILE_TEMPLATES = { web: { bundles: ['${base}'] } }\n` + + `export const DEFAULT_PROFILE_BUNDLES = ['${base}']\n` + + `export const OPTIONAL_BUNDLES = ['${experimental}']\n`) + const failures = verifyDefaultProductIsolation(root).failures.join('\n') + expect(failures).toContain(`optional bundle ${experimental} must be a runtime dependency of apps/cli`) + expect(failures).toContain(`optional bundle ${experimental} must declare dsh.bundle.patch`) + + // An experimental runtime dependency the list does not name is still a product requirement. + write(root, profile, `export const PROFILE_TEMPLATES = { web: { bundles: ['${base}'] } }\n` + + `export const DEFAULT_PROFILE_BUNDLES = ['${base}']\n`) + manifest(root, 'apps/cli/package.json', { dependencies: { [core]: 'workspace:^', [experimental]: 'workspace:^' } }) + expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(`@deepseek-ai/dsh dependencies -> ${experimental}`) + }) + it.each(['dependencies', 'optionalDependencies', 'peerDependencies'])( 'rejects transitive experimental %s', (section) => { diff --git a/scripts/verify-default-product-isolation.ts b/scripts/verify-default-product-isolation.ts index b14a6454dc..45576d5e8e 100644 --- a/scripts/verify-default-product-isolation.ts +++ b/scripts/verify-default-product-isolation.ts @@ -1,4 +1,8 @@ -/** Keep experimental packages outside default installations, runtime imports, and shipped compositions. */ +/** + * Keep experimental packages outside default installations, runtime imports, and shipped compositions. + * The one declared exception is a bundle the launcher names in `OPTIONAL_BUNDLES`: shipped for the person to + * switch on, selected by no shipped template, its own dependency graph outside the default product's. + */ import { existsSync, globSync, readFileSync, statSync } from 'node:fs' import { basename, dirname, extname, relative, resolve } from 'node:path' @@ -68,9 +72,22 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes for (const path of ['apps/cli/package.json', 'apps/web/package.json', 'python/sdk-runtime/package.json']) { if (!existsSync(resolve(root, path))) failures.push(`missing default product root ${path}`) } - if (directories.get(resolve(root, 'apps/cli'))?.manifest.name !== '@deepseek-ai/dsh') { + const cli = directories.get(resolve(root, 'apps/cli')) + if (cli?.manifest.name !== '@deepseek-ai/dsh') { failures.push('apps/cli/package.json must identify @deepseek-ai/dsh') } + // The bundles the launcher ships switched off: each a runtime dependency of the installation that is a bundle, none a default. + const profilePath = resolve(root, PROFILE_SOURCE) + const selection = existsSync(profilePath) ? profilePackages(readFileSync(profilePath, 'utf8')) : undefined + const optionalBundles = new Set(selection?.optionalBundles ?? []) + for (const name of optionalBundles) { + if (cli?.manifest.dependencies?.[name] === undefined) { + failures.push(`${PROFILE_SOURCE}: optional bundle ${name} must be a runtime dependency of apps/cli`) + } + if (packages.get(name)?.manifest.dsh?.bundle?.patch === undefined) { + failures.push(`${PROFILE_SOURCE}: optional bundle ${name} must declare dsh.bundle.patch`) + } + } const queue: Package[] = [] const visited = new Set() @@ -208,14 +225,13 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes const path = display(pkg.directory) if (path.startsWith('apps/') || path === 'python/sdk-runtime') add(pkg, path) } - const profilePath = resolve(root, PROFILE_SOURCE) - if (existsSync(profilePath)) { - const selection = profilePackages(readFileSync(profilePath, 'utf8')) + if (selection !== undefined) { for (const name of selection.packages) { reference(name, PROFILE_SOURCE) if (packages.get(name)?.manifest.dsh?.bundle?.patch === undefined) { failures.push(`${PROFILE_SOURCE}: default bundle ${name} must declare dsh.bundle.patch`) } + if (optionalBundles.has(name)) failures.push(`${PROFILE_SOURCE}: optional bundle ${name} must not be a default bundle`) } const webLayers = selection.webBundles.flatMap((name) => { const pkg = packages.get(name) @@ -262,6 +278,7 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes const { manifest } = pkg for (const section of RUNTIME_SECTIONS) { for (const [name, range] of Object.entries(manifest[section] ?? {})) { + if (pkg === cli && section === 'dependencies' && optionalBundles.has(name)) continue dependency(name, range, pkg, `${manifest.name} ${section}`) } } @@ -322,16 +339,18 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -/** Read the literal package lists that define installation-owned profile defaults. */ -function profilePackages(source: string): { packages: string[]; webBundles: string[] } { +/** Read the literal package lists that define installation-owned profile defaults and the optional bundles it ships. */ +function profilePackages(source: string): { packages: string[]; webBundles: string[]; optionalBundles: string[] } { const file = ts.createSourceFile(PROFILE_SOURCE, source, ts.ScriptTarget.Latest, true) const required = new Set(['PROFILE_TEMPLATES', 'DEFAULT_PROFILE_BUNDLES']) const found = new Set() const packages: string[] = [] const webBundles: string[] = [] + const optionalBundles: string[] = [] const literals = (node: ts.Node, path: string[]): void => { if (ts.isStringLiteralLike(node)) { - if (node.text.startsWith('@')) packages.push(node.text) + if (path[0] === 'OPTIONAL_BUNDLES') optionalBundles.push(node.text) + else if (node.text.startsWith('@')) packages.push(node.text) if (path.join('.') === 'PROFILE_TEMPLATES.web.bundles') webBundles.push(node.text) } else if (ts.isArrayLiteralExpression(node)) node.elements.forEach((child) => { literals(child, path) }) else if (ts.isObjectLiteralExpression(node)) node.properties.forEach((child) => { literals(child, path) }) @@ -349,17 +368,20 @@ function profilePackages(source: string): { packages: string[]; webBundles: stri if (!ts.isVariableStatement(statement)) continue for (const declaration of statement.declarationList.declarations) { if (!ts.isIdentifier(declaration.name) - || !required.has(declaration.name.text) && declaration.name.text !== 'INSTALLATION_OWNED_PROFILE_TUPLES') continue + || !required.has(declaration.name.text) && declaration.name.text !== 'INSTALLATION_OWNED_PROFILE_TUPLES' + && declaration.name.text !== 'OPTIONAL_BUNDLES') continue if (declaration.initializer === undefined) continue if (required.has(declaration.name.text)) found.add(declaration.name.text) const before = packages.length literals(declaration.initializer, [declaration.name.text]) - if (packages.length === before) throw new Error(`${PROFILE_SOURCE}: ${declaration.name.text} has no default bundles`) + if (declaration.name.text !== 'OPTIONAL_BUNDLES' && packages.length === before) { + throw new Error(`${PROFILE_SOURCE}: ${declaration.name.text} has no default bundles`) + } } } if (found.size !== required.size) throw new Error(`${PROFILE_SOURCE}: missing default profile declarations`) if (webBundles.length === 0) throw new Error(`${PROFILE_SOURCE}: missing default Web bundle list`) - return { packages, webBundles } + return { packages, webBundles, optionalBundles } } if (import.meta.main) { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 9724817a41..465d8eb813 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -127,6 +127,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-settings-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-plugin-manager': { kind: 'none', reason: 'Browser-side management surface; registers nothing model-facing.' }, 'packages/client/ui-settings-plugin-inventory': { kind: 'none', reason: 'Browser-side inventory projection; registers nothing model-facing.' }, 'packages/client/ui-settings-unarchive-sessions': { kind: 'none', reason: 'Browser-side settings page; registers nothing model-facing.' }, 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md index 7c5a8b7f98..2a7bfef3e7 100644 --- a/snapshots/web/lifecycle-chrome/hero.expected.md +++ b/snapshots/web/lifecycle-chrome/hero.expected.md @@ -4,6 +4,8 @@ - button "New session": - img - text: New Session +- navigation "Global panels": + - button "Plugins" - text: Workspaces - button "Search sessions": - img diff --git a/snapshots/web/lifecycle-chrome/plan-active.expected.md b/snapshots/web/lifecycle-chrome/plan-active.expected.md index da4ae2482b..3c3cb7da3a 100644 --- a/snapshots/web/lifecycle-chrome/plan-active.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active.expected.md @@ -4,6 +4,8 @@ - button "New session": - img - text: New Session +- navigation "Global panels": + - button "Plugins" - text: Workspaces - button "Search sessions": - img diff --git a/tsconfig.base.json b/tsconfig.base.json index 4c2ae4e596..b55a3f3e6e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -249,6 +249,7 @@ "@deepseek-ai/dsh-client-ui-settings": ["./packages/client/ui-settings/src"], "@deepseek-ai/dsh-client-ui-settings-general": ["./packages/client/ui-settings-general/src"], "@deepseek-ai/dsh-client-ui-settings-models": ["./packages/client/ui-settings-models/src"], + "@deepseek-ai/dsh-client-ui-plugin-manager": ["./packages/client/ui-plugin-manager/src"], "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": ["./packages/client/ui-settings-plugin-inventory/src"], "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": ["./packages/client/ui-settings-unarchive-sessions/src"], "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 0ab7e0edcb..a3dd049b29 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -111,6 +111,7 @@ { "path": "./packages/client/ui-settings" }, { "path": "./packages/client/ui-settings-general" }, { "path": "./packages/client/ui-settings-models" }, + { "path": "./packages/client/ui-plugin-manager" }, { "path": "./packages/client/ui-settings-plugin-inventory" }, { "path": "./packages/client/ui-settings-unarchive-sessions" }, { "path": "./packages/client/locale" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 3ae8b85f12..cf53223326 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -38,6 +38,9 @@ "apps/web/tests/details-session-lifecycle.e2e.ts", "apps/web/tests/document-preview.e2e.ts", "apps/web/tests/plugin-config.e2e.ts", + "apps/web/tests/plugin-manager.e2e.ts", + "apps/web/tests/plugin-install-cancel.e2e.ts", + "apps/web/tests/plugin-install-approve.e2e.ts", "apps/web/tests/client-plugin-live.e2e.ts", "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/models-settings.e2e.ts",