diff --git a/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.i18n.yaml b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.i18n.yaml new file mode 100644 index 0000000000..50c86c11f6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-15-plugin-detail-documents.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.md +/agent-note-plugin-detail-documents: + en: 2cca350bb3d00987 + zh: 02689ad1893d30fe +/agent-note-plugin-detail-documents/problem: + en: 018296714f6bba40 + zh: 5926db9dca265dd7 +/agent-note-plugin-detail-documents/decision: + en: 638ccade77cb14db + zh: c70a83e2ed0e72f7 +/agent-note-plugin-detail-documents/alternatives-considered: + en: 791b7839125f9f99 + zh: f75e3abeb9777494 +/agent-note-plugin-detail-documents/consequences: + en: 0a37ee01fc43ff26 + zh: 156571e1d3181015 diff --git a/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.md b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.md new file mode 100644 index 0000000000..5ee84e08b9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.md @@ -0,0 +1,50 @@ +# Agent Note: Plugin detail documents + +Status: implemented + +English | [中文](2026-10-15-plugin-detail-documents.zh.md) + +## Problem + +Plugin cards and pages show a one-line description. A user choosing a plugin from a market, or checking what an installed bundle does, needs the longer description that package authors already write as a README, with screenshots, as extension marketplaces of code editors show it. The [market index](2026-10-02-plugin-market-index-format.md) carries list fields only, and an index document cannot carry a Markdown document and its images for every plugin. + +Package READMEs of DSH's own packages are maintainer references rather than user descriptions, so an author needs a way to name a separate user document per language. Packages come from local installations, public npm, other registries, public Git hosts, and private repositories that only the user's Git credentials can read. + +## Decision + +Detail documents are read in a fixed order. For every package the order begins with the Markdown file that `meta.detail` in its `locale/.json` names for the display language, falling back through shorter language prefixes to English, then the package README. A market entry page continues with the README of the repository directory the entry or its package declares, at the default branch. The [document order](../../../../packages/util/plugin-detail/README.md#document-order) of the stateless [`dsh-plugin-detail`](../../../../packages/util/plugin-detail/README.md) library is the single definition of the package steps; [`plugin-manager`](../../../../packages/boot/plugin-manager/README.md#bundle-detail-documents) and [`plugin-market`](../../../../packages/boot/plugin-market/README.md#plugin-detail-documents) document how they apply it. + +`meta.detail` lives in the `locale/.json` files that [plugin display metadata](../../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) already uses, without a package-field fallback, and is read only for detail documents, so a malformed value fails the detail read and leaves the plugin's title, description, and icon intact. The package README stands in for the README npm shows, because npm shows the README file of the published version. A source that exists but fails stops the read with a reason instead of falling through, so a missing `meta.detail` file or an oversized repository is reported rather than replaced by a different document. + +Bundle pages of **Plugins** are owned by `plugin-manager` and need no market: `readBundleDetail` reads an installed bundle from its directory, without network access or caching, and an official bundle offered before installation from the npm file CDNs at its offered version. Its image route resolves the bundle and document on every request, so the Host keeps no state. The bundle page shows the document first among its sections. + +The Client chooses which npm file CDNs a read asks first: it asks the existing registry comparison of the Plugins page and passes `npmFileSources`, such as `['npmmirror']` when npmmirror answered fastest. The Host orders its configured CDNs by that list and asks no CDN the configuration omits, so the Host services do not depend on the Client package that owns the comparison. + +Market entry pages are owned by `plugin-market`, which reads the entry version's source: the npm file CDNs for a public npm version, or the pinned Git commit. Repository files come from GitHub's raw host and the GitLab repository files API; an anonymous request to the repository's smart HTTP endpoint tells a missing file from a repository that needs credentials, and such a repository, or one on a host without an API, is read from a cached partial clone made with the user's Git configuration. Clones are depth-1 bare repositories without file contents until a file is read, limited per command and in total, and deleted when an operation on them fails or is cancelled. Any host of an http(s) repository URL may be cloned, over https, http, or ssh when `url..insteadOf` rewrites it: Git's credential helpers answer per host, so a clone sends a host only the credentials the user stored for it. The entry page shows the document first, then versions, then source. Neither page gives the document a heading of its own; the document's own headings name it. + +The Host reads only addresses it derives from installed packages, configured indexes, package manifests, and fixed CDN and API hosts; a client names a bundle or an index entry, never a URL. Images beside a document are served by authenticated Host routes that accept only image types inside the document's root, with a sandbox CSP so an SVG opened directly cannot run on the API origin. Markdown renders through the shared `DetailDocument` primitive, which omits raw HTML instead of showing it as literal text, because a README's HTML is layout for HTML renderers and reads as noise in text form; absolute HTTP(S) images load in the browser without a referrer, and relative links stay text. + +## Alternatives considered + +**Bundle details through the plugin market.** An earlier design read bundle documents in `plugin-market` and contributed them through `plugins.detail.section`, so bundle pages showed nothing with the market bundle off and every bundle read went through the market's cache. Installed packages are local files that need no cache, so `plugin-manager` reads them directly with the shared library. + +**A clone host allowlist and https-only clones.** Credential helpers, keychains, and `.netrc` already scope credentials to their host, so a market naming an unknown host receives no credentials for other hosts; an allowlist only blocked legitimate intranet repositories. + +**Detail fields in the index format.** A generator could publish document URLs, but the Host would then fetch addresses a market chooses, and the index would still not carry images or private documents. + +**npm tarball downloads.** A tarball covers every registry but downloads the whole package to read one file; the CDNs serve single files by exact version. Packages of other registries fall back to the entry's repository. + +**Proxying absolute images through the Host.** The Host would fetch URLs a document author chooses from the user's machine, including intranet and loopback addresses. Browser loads keep that risk out of the Host, at the cost of disclosing the user's IP address to image hosts. + +**Rendering raw HTML, and clickable relative links.** Many READMEs use HTML for layout, but rendering it requires sanitizing untrusted markup, so it is omitted; relative links would need a navigation target for every source. Both are deferred. + +**Full or blob-limited clones.** A full clone downloads every file of large repositories, and a blob size limit still downloads every small file. A depth-1 partial clone downloads commits and trees and fetches only the files a document reads. + +## Consequences + +- Authors write a user document per language under `meta.detail`; other packages show their README. +- An installed bundle without `meta.detail` or a README shows no document; its declared repository is read only on market entry pages. +- A repository README is read at the default branch and can differ from the listed version. +- The first missing file of a public GitHub or GitLab repository costs one anonymous smart HTTP request, cached for `cacheTtlMs`. +- Private repositories work when the user's Git credentials reach the host. +- Unit tests cover the document order, CDN fallback, API and clone reads, size limits, eviction, and image paths with real local repositories; real-composition tests read an installed bundle's localized document through `plugin-manager` and a market entry's document through `plugin-market`, and serve their images through the composed profiles. diff --git a/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.zh.md b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.zh.md new file mode 100644 index 0000000000..de6587fe49 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 插件详细介绍文档 + +Status: implemented + +[English](2026-10-15-plugin-detail-documents.md) | 中文 + +## 问题 + +插件卡片和页面只显示一行描述。用户从市场中选择插件,或查看某个已安装组合包(bundle)的用途时,需要包作者已经以 README 形式写好的、带截图的较长描述,与代码编辑器的扩展市场显示的内容相同。[市场索引](2026-10-02-plugin-market-index-format.zh.md)只携带列表字段,一份索引文档也无法为每个插件携带一份 Markdown 文档及其图片。 + +DSH 自身各包的 README 是面向维护者的参考,而不是面向用户的介绍,因此作者需要一种方式,为每种语言指定一份单独的用户文档。包可能来自本地安装、公共 npm、其他注册表、公共 Git 主机,以及只有用户的 Git 凭据才能读取的私有仓库。 + +## 决策 + +详细介绍文档按固定顺序读取。对每个包,顺序首先是其 `locale/.json` 中 `meta.detail` 为显示语言指定的 Markdown 文件,该语言没有时依次回退到更短的语言前缀,最后回退到英文;然后是包的 README。市场条目页面在此之后继续读取条目或其包所声明仓库目录中的 README,读取默认分支上的文件。[文档顺序](../../../../packages/util/plugin-detail/README.zh.md#document-order)属于无状态库 [`dsh-plugin-detail`](../../../../packages/util/plugin-detail/README.zh.md),是包内各步骤的唯一定义;[`plugin-manager`](../../../../packages/boot/plugin-manager/README.zh.md#bundle-detail-documents) 和 [`plugin-market`](../../../../packages/boot/plugin-market/README.zh.md#plugin-detail-documents) 说明它们如何应用该顺序。 + +`meta.detail` 位于[插件展示元信息](../../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)已经使用的 `locale/.json` 文件中,没有包字段回退,并且只在读取详细介绍文档时读取,因此格式错误的值只让详细介绍的读取失败,插件的标题、描述和图标不受影响。包的 README 代替 npm 显示的 README,因为 npm 显示的就是已发布版本中的 README 文件。存在但读取失败的来源会带着原因终止读取,而不是继续尝试下一个来源,因此缺失的 `meta.detail` 文件或过大的仓库会被报告,而不是被另一份文档替代。 + +**插件**页的组合包页面由 `plugin-manager` 负责,不需要市场:`readBundleDetail` 从已安装组合包的目录读取文档,不访问网络,也不缓存;当前安装提供、尚未安装的官方组合包按其提供的版本从 npm 文件 CDN 读取。其图片路由在每次请求时解析组合包和文档,因此 Host 不保留状态。组合包页面在其各部分中首先显示该文档。 + +由 Client 选择一次读取先请求哪些 npm 文件 CDN:它询问插件页现有的注册表比较,并传入 `npmFileSources`,例如 npmmirror 响应最快时传入 `['npmmirror']`。Host 按该列表排列其已配置的 CDN,且不请求配置未列出的 CDN,因此 Host 服务不依赖负责该比较的 Client 包。 + +市场条目页面由 `plugin-market` 负责,它读取条目版本的来源:公共 npm 版本从 npm 文件 CDN 读取,Git 版本从其固定的提交读取。仓库文件通过 GitHub 的原始文件主机和 GitLab 仓库文件 API 读取;一次不带任何凭据、发往该仓库 smart HTTP 端点的请求区分文件不存在与仓库需要凭据这两种情况,需要凭据的仓库以及所在主机没有 API 的仓库,从使用用户 Git 配置创建并缓存的部分克隆中读取。克隆是深度为 1 的裸仓库,在读取文件前不包含文件内容,按单条命令和总量限制大小,对其执行的操作失败或被取消时即被删除。http(s) 仓库 URL 的任何主机都可以克隆,可通过 https、http,或在 `url..insteadOf` 改写该 URL 时通过 ssh:Git 的凭据助手按主机给出凭据,因此克隆只向一个主机发送用户为该主机保存的凭据。条目页面先显示文档,然后是版本,然后是来源信息。两个页面都不为文档提供单独的标题;文档由其自身的标题命名。 + +Host 只读取它从已安装的包、已配置的索引、包 manifest 以及固定的 CDN 和 API 主机推导出的地址;客户端只指定一个组合包或一个索引条目,从不指定 URL。文档旁的图片由需要认证的 Host 路由提供,这些路由只接受文档根目录内的图片类型,并带有 sandbox CSP,使直接打开的 SVG 无法在 API origin 上运行。Markdown 通过共享的 `DetailDocument` 原语渲染,该原语省略原始 HTML,而不是将其显示为原样文本,因为 README 中的 HTML 是供 HTML 渲染器使用的排版,以文本形式显示时只会干扰阅读;绝对 HTTP(S) 地址的图片由浏览器不带 referrer 加载,相对链接保持为文本。 + +## 考虑过的替代方案 + +**通过插件市场提供组合包详细介绍。** 早先的设计在 `plugin-market` 中读取组合包文档,并通过 `plugins.detail.section` 提供它们,因此关闭市场组合包时组合包页面不显示任何内容,且每次组合包读取都经过市场的缓存。已安装的包是不需要缓存的本地文件,因此由 `plugin-manager` 使用共享库直接读取。 + +**克隆主机允许列表和只允许 https 克隆。** 凭据助手、钥匙串和 `.netrc` 已经按主机限定凭据,因此指定未知主机的市场不会得到其他主机的凭据;允许列表只会阻止合法的内网仓库。 + +**在索引格式中加入详细介绍字段。** 生成器可以发布文档 URL,但这样 Host 就会获取市场选择的地址,而且索引仍然无法携带图片或私有文档。 + +**下载 npm tarball。** tarball 适用于所有注册表,但为了读取一个文件要下载整个包;CDN 按精确版本提供单个文件。其他注册表的包改为从条目的仓库读取。 + +**通过 Host 代理绝对地址的图片。** Host 会从用户的机器获取文档作者选择的 URL,包括内网和回环地址。由浏览器加载可以让 Host 避开这一风险,代价是向图片主机暴露用户的 IP 地址。 + +**渲染原始 HTML,以及可点击的相对链接。** 许多 README 使用 HTML 排版,但渲染它需要清理不可信的标记,因此省略它;相对链接需要为每种来源提供跳转目标。两者都已延后。 + +**完整克隆或按 blob 大小限制的克隆。** 完整克隆会下载大型仓库的每个文件,按 blob 大小限制仍会下载每个小文件。深度为 1 的部分克隆只下载提交和树对象,并只获取文档读取的文件。 + +## 后果 + +- 作者在 `meta.detail` 下为每种语言编写一份用户文档;其他包显示其 README。 +- 没有 `meta.detail` 也没有 README 的已安装组合包不显示文档;其声明的仓库只在市场条目页面上读取。 +- 仓库 README 读取的是默认分支上的文件,可能与列出的版本不一致。 +- 公开 GitHub 或 GitLab 仓库中第一次遇到缺失的文件时,需要发送一次匿名的 smart HTTP 请求,其结果缓存 `cacheTtlMs`。 +- 用户的 Git 凭据能访问该主机时,即可读取私有仓库。 +- 单元测试使用真实的本地仓库,覆盖文档顺序、CDN 回退、API 与克隆读取、大小限制、淘汰和图片路径;真实组合测试通过 `plugin-manager` 读取已安装组合包的本地化文档,通过 `plugin-market` 读取市场条目的文档,并通过组合后的 profile 提供其图片。 diff --git a/apps/web/tests/cot-translation-paid.e2e.ts b/apps/web/tests/cot-translation-paid.e2e.ts index 11b0dcce85..0954aa180a 100644 --- a/apps/web/tests/cot-translation-paid.e2e.ts +++ b/apps/web/tests/cot-translation-paid.e2e.ts @@ -195,6 +195,8 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr expect(calls).toEqual([]) await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor() await panel.getByText(bundleVersion, { exact: true }).waitFor() + // The bundle's README is its detail document, shown between its components and its source. + await panel.locator('[data-detail-document] h1').waitFor() await compareOrRefreshGolden(join(EXPECTED, 'paid-choices.expected.md'), await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']], diff --git a/apps/web/tests/cot-translation.e2e.ts b/apps/web/tests/cot-translation.e2e.ts index 657c95fbdd..8419b55b79 100644 --- a/apps/web/tests/cot-translation.e2e.ts +++ b/apps/web/tests/cot-translation.e2e.ts @@ -212,6 +212,8 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen await panel.getByText('展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。', { exact: true }).waitFor() await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor() await panel.getByText(bundleVersion, { exact: true }).waitFor() + // The bundle's README is its detail document, shown between its components and its source. + await panel.locator('[data-detail-document] h1').waitFor() await compareOrRefreshGolden(join(EXPECTED, 'preferences.expected.md'), await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']], diff --git a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md index 4a5c9b38ad..3e4ab96099 100644 --- a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md +++ b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md @@ -6,6 +6,65 @@ - paragraph: - code: "@deepseek-ai/dsh-experimental-cot-translation-bundle" - paragraph: 机器翻译展开的思考内容;原文会发送给所选服务 +- region "详细介绍": + - heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1] + - paragraph: English | 中文 + - heading "Summary" [level=2] + - paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input. + - heading "Table of Contents" [level=2] + - list: + - listitem: Use this package + - listitem: Understand the implementation + - listitem: Further Exploration + - listitem: Model Experience + - listitem: Known Limitations and Deferred Work + - listitem: Dev Note + - separator + - paragraph + - heading "Use this package" [level=2] + - paragraph: + - text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language. + - code: auto + - text: follows the interface language; an explicit language code such as + - code: zh + - text: "," + - code: en + - text: ", or" + - code: ja + - text: overrides it. Save applies the preferences. + - paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts. + - paragraph: + - text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native + - code: deepseek-flash + - text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically. + - separator + - paragraph + - heading "Understand the implementation" [level=2] + - paragraph: + - text: The bundle patch inserts + - code: translator + - text: and + - code: cot-translation + - text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory." + - separator + - paragraph + - heading "Further Exploration" [level=2] + - paragraph: Text translation subsystem · Translator service · GUI consumer + - separator + - paragraph + - heading "Model Experience" [level=2] + - paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context. + - heading "KV Cache effect" [level=4] + - paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer. + - heading "Known Limitations and Deferred Work" [level=2] + - paragraph + - list: + - listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible. + - listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations. + - separator + - paragraph + - heading "Dev Note" [level=3] + - paragraph: None. - paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。 - paragraph: 每个未缓存片段都会通过所选账号或官方 API 凭据单独发送一次付费的 DeepSeek Flash 请求;重新展开会复用已保存的译文,更改服务、语言、原文或请求设置可能发送新的付费请求 - text: 翻译服务 已覆盖 diff --git a/apps/web/tests/expected/cot-translation/preferences.expected.md b/apps/web/tests/expected/cot-translation/preferences.expected.md index 29f85e915b..b2c7e3c3d7 100644 --- a/apps/web/tests/expected/cot-translation/preferences.expected.md +++ b/apps/web/tests/expected/cot-translation/preferences.expected.md @@ -6,6 +6,65 @@ - paragraph: - code: "@deepseek-ai/dsh-experimental-cot-translation-bundle" - paragraph: 机器翻译展开的思考内容;原文会发送给所选服务 +- region "详细介绍": + - heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1] + - paragraph: English | 中文 + - heading "Summary" [level=2] + - paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input. + - heading "Table of Contents" [level=2] + - list: + - listitem: Use this package + - listitem: Understand the implementation + - listitem: Further Exploration + - listitem: Model Experience + - listitem: Known Limitations and Deferred Work + - listitem: Dev Note + - separator + - paragraph + - heading "Use this package" [level=2] + - paragraph: + - text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language. + - code: auto + - text: follows the interface language; an explicit language code such as + - code: zh + - text: "," + - code: en + - text: ", or" + - code: ja + - text: overrides it. Save applies the preferences. + - paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts. + - paragraph: + - text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native + - code: deepseek-flash + - text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically. + - separator + - paragraph + - heading "Understand the implementation" [level=2] + - paragraph: + - text: The bundle patch inserts + - code: translator + - text: and + - code: cot-translation + - text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory." + - separator + - paragraph + - heading "Further Exploration" [level=2] + - paragraph: Text translation subsystem · Translator service · GUI consumer + - separator + - paragraph + - heading "Model Experience" [level=2] + - paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context. + - heading "KV Cache effect" [level=4] + - paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer. + - heading "Known Limitations and Deferred Work" [level=2] + - paragraph + - list: + - listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible. + - listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations. + - separator + - paragraph + - heading "Dev Note" [level=3] + - paragraph: None. - paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。 - paragraph: Bing 和 Google 翻译无需额外登录或 API key,但匿名接口可能拒绝或限制请求 - text: 翻译服务 diff --git a/apps/web/tests/expected/plugin-manager/preview.expected.md b/apps/web/tests/expected/plugin-manager/preview.expected.md index 4f78082f46..b72a046686 100644 --- a/apps/web/tests/expected/plugin-manager/preview.expected.md +++ b/apps/web/tests/expected/plugin-manager/preview.expected.md @@ -6,6 +6,53 @@ - code: "@deepseek-ai/dsh-experimental-badge-skill-bundle" - paragraph: 让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。 - img "DSH 徽章技能功能预览" +- region "详细介绍": + - heading "@deepseek-ai/dsh-experimental-badge-skill-bundle" [level=1] + - paragraph: English | 中文 + - heading "Summary" [level=2] + - paragraph: Let Agents whose presets load skills add a “powered by dsh” badge to documents, pull requests, and other outputs. The shared provider adds no model tool. It ships switched off in Web and Desktop. Select it in Plugins to enable it. + - heading "Table of Contents" [level=2] + - list: + - listitem: Use this package + - listitem: Understand the implementation + - listitem: Further Exploration + - listitem: Model Experience + - listitem: Known Limitations and Deferred Work + - listitem: Dev Note + - separator + - paragraph + - heading "Use this package" [level=2] + - paragraph: + - text: In Web or Desktop, open Plugins and enable + - strong: DSH badge skill + - text: in Official. Switch it off to remove its profile layer. + - paragraph: The switch adds or removes a Host-wide skill provider for existing and new Agents. Presets that expose skill loading can use the badge; minimal keeps its tool catalog unchanged. + - separator + - paragraph + - heading "Understand the implementation" [level=2] + - paragraph: + - code: cordis.patch.yml + - text: contributes the + - code: skill-badge + - text: provider row on the Host. Later root patches can disable that row; an override alone does not select the bundle. The profile composer owns patch ordering and errors. + - separator + - paragraph + - heading "Further Exploration" [level=2] + - paragraph: Capability implementation + - separator + - paragraph + - heading "Model Experience" [level=2] + - paragraph: Indirectly, through the capability implementation, which owns model context and results. + - heading "KV Cache effect" [level=4] + - paragraph: The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results. + - heading "Known Limitations and Deferred Work" [level=2] + - paragraph + - list: + - listitem: A preset must include skill discovery and loading to use the badge. The minimal preset does not gain skill tools from this bundle. + - separator + - paragraph + - heading "Dev Note" [level=3] + - paragraph: None. - heading "包含的组件" [level=4] - text: 共 1 个 · 1 已停用 - list: diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 05d3bbecda..2b1392cc39 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -709,6 +709,8 @@ describe('web e2e: plugin manager', () => { expect(fits).toBe(true) await detail.getByText(`v${badgeVersion}`, { exact: true }).waitFor() await detail.getByText(badgeVersion, { exact: true }).waitFor() + // The bundle's README is its detail document, shown between its components and its source. + await detail.locator('[data-detail-document] h1').waitFor() await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'), await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[badgeVersion, '{{version}}']], diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 05333653c2..1b7b3903b3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2482,7 +2482,8 @@ export interface PlanModeConfig { ## `@deepseek-ai/dsh-plugin-manager` - `inject`: `loader` · `profileContext` -- `source`: [`packages/boot/plugin-manager/src/index.ts:45`](../packages/boot/plugin-manager/src/index.ts) +- `refs`: [`NpmFileSource`](subsystems/boot.md) +- `source`: [`packages/boot/plugin-manager/src/index.ts:50`](../packages/boot/plugin-manager/src/index.ts) ```ts config-catalog /** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ @@ -2511,6 +2512,14 @@ export interface Config { versionListLimit?: number /** How many single-version registry lookups a version listing runs at once. */ versionLookupConcurrency?: number + /** Largest bundle detail document, and largest locale file read for one, in bytes. */ + detailMaxBytes?: number + /** Largest image served beside a bundle detail document, in bytes. */ + detailImageMaxBytes?: number + /** Deadline of one npm file CDN request for an offered bundle's detail document. */ + detailFetchTimeoutMs?: number + /** npm file CDNs that offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. */ + detailNpmFileSources?: NpmFileSource[] } ``` @@ -2521,8 +2530,8 @@ export interface Config { ## `@deepseek-ai/dsh-plugin-market` - `inject`: `pluginManager` -- `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/boot/plugin-market/src/index.ts:32`](../packages/boot/plugin-market/src/index.ts) +- `refs`: [`NpmFileSource`](subsystems/boot.md) · `Volatile` (`@deepseek-ai/cordis`) +- `source`: [`packages/boot/plugin-market/src/index.ts:43`](../packages/boot/plugin-market/src/index.ts) ```ts config-catalog /** Index sources and fetch limits. */ @@ -2533,8 +2542,24 @@ export interface Config { fetchTimeoutMs: number /** Largest accepted index body in bytes. */ maxBytes: number - /** Lifetime of a successfully read index document. */ + /** Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. */ cacheTtlMs: number + /** Largest detail document in bytes. */ + detailMaxBytes: number + /** Largest image beside a detail document in bytes. */ + detailImageMaxBytes: number + /** Largest amount in bytes one Git command adds to a partial clone; a repository whose fetch adds more is not read. */ + detailRepositoryMaxBytes: number + /** Largest total size of the partial clones; least recently used ones are deleted beyond it. */ + detailCacheMaxBytes: number + /** Directory of the partial clones, relative to the profile directory; absent means `.plugin-market/repositories`. */ + detailCacheDir?: string + /** Deadline of one Git command of the clone fallback. */ + detailGitTimeoutMs: number + /** npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first. */ + detailNpmFileSources: NpmFileSource[] + /** Hosts serving the GitLab repository files API. */ + detailGitlabHosts: string[] } ``` @@ -4850,6 +4875,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) | | `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) | | `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) | +| `@deepseek-ai/dsh-plugin-detail` | — | [`packages/util/plugin-detail/src/index.ts`](../packages/util/plugin-detail/src/index.ts) | | `@deepseek-ai/dsh-plugin-market-index` | — | [`packages/util/plugin-market-index/src/index.ts`](../packages/util/plugin-market-index/src/index.ts) | | `@deepseek-ai/dsh-remote-mock` | — | [`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts) | | `@deepseek-ai/dsh-sandbox-windows-acl` | — | [`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b767052d30..bc917305a4 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2484,7 +2484,8 @@ export interface PlanModeConfig { ## `@deepseek-ai/dsh-plugin-manager` - `inject`: `loader` · `profileContext` -- `source`: [`packages/boot/plugin-manager/src/index.ts:45`](../packages/boot/plugin-manager/src/index.ts) +- `refs`: [`NpmFileSource`](subsystems/boot.zh.md) +- `source`: [`packages/boot/plugin-manager/src/index.ts:50`](../packages/boot/plugin-manager/src/index.ts) ```ts config-catalog /** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ @@ -2513,6 +2514,14 @@ export interface Config { versionListLimit?: number /** How many single-version registry lookups a version listing runs at once. */ versionLookupConcurrency?: number + /** Largest bundle detail document, and largest locale file read for one, in bytes. */ + detailMaxBytes?: number + /** Largest image served beside a bundle detail document, in bytes. */ + detailImageMaxBytes?: number + /** Deadline of one npm file CDN request for an offered bundle's detail document. */ + detailFetchTimeoutMs?: number + /** npm file CDNs that offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. */ + detailNpmFileSources?: NpmFileSource[] } ``` @@ -2523,8 +2532,8 @@ export interface Config { ## `@deepseek-ai/dsh-plugin-market` - `inject`: `pluginManager` -- `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/boot/plugin-market/src/index.ts:32`](../packages/boot/plugin-market/src/index.ts) +- `refs`: [`NpmFileSource`](subsystems/boot.zh.md) · `Volatile` (`@deepseek-ai/cordis`) +- `source`: [`packages/boot/plugin-market/src/index.ts:43`](../packages/boot/plugin-market/src/index.ts) ```ts config-catalog /** Index sources and fetch limits. */ @@ -2535,8 +2544,24 @@ export interface Config { fetchTimeoutMs: number /** Largest accepted index body in bytes. */ maxBytes: number - /** Lifetime of a successfully read index document. */ + /** Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. */ cacheTtlMs: number + /** Largest detail document in bytes. */ + detailMaxBytes: number + /** Largest image beside a detail document in bytes. */ + detailImageMaxBytes: number + /** Largest amount in bytes one Git command adds to a partial clone; a repository whose fetch adds more is not read. */ + detailRepositoryMaxBytes: number + /** Largest total size of the partial clones; least recently used ones are deleted beyond it. */ + detailCacheMaxBytes: number + /** Directory of the partial clones, relative to the profile directory; absent means `.plugin-market/repositories`. */ + detailCacheDir?: string + /** Deadline of one Git command of the clone fallback. */ + detailGitTimeoutMs: number + /** npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first. */ + detailNpmFileSources: NpmFileSource[] + /** Hosts serving the GitLab repository files API. */ + detailGitlabHosts: string[] } ``` @@ -4852,6 +4877,7 @@ export interface Config { | `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) | | `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) | | `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) | +| `@deepseek-ai/dsh-plugin-detail` | — | [`packages/util/plugin-detail/src/index.ts`](../packages/util/plugin-detail/src/index.ts) | | `@deepseek-ai/dsh-plugin-market-index` | — | [`packages/util/plugin-market-index/src/index.ts`](../packages/util/plugin-market-index/src/index.ts) | | `@deepseek-ai/dsh-remote-mock` | — | [`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts) | | `@deepseek-ai/dsh-sandbox-windows-acl` | — | [`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts) | diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 013ecb4344..3776cb0096 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -21,8 +21,8 @@ en: 0894bd9f35430753 zh: 2d28090e71134edd /cookbook-adding-a-workspace-package/5-add-optional-plugin-display-metadata: - en: 2382017c7f84ace6 - zh: 03af99592baaab9f + en: 68b463cd3d2e5fb0 + zh: 80ea797c5770c573 /cookbook-adding-a-workspace-package/6-verify: en: 80c7fb90b1175aae zh: 2ba37962dbc57299 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index af3547ea1f..11cca9a60d 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -14,7 +14,7 @@ packages/// # ../../../vendor/cordis (+ ../../../vendor/schemastery if # you use Config, + ../..// for each dsh dep) src/index.ts # service default export or plugin (name/inject/apply/Config) - locale/en.json # optional display metadata: meta.title and meta.description + locale/en.json # optional display metadata: meta.title, meta.description, and meta.detail locale/zh.json # translations using the same fields README.md # service API, events, extension points, design notes, # + gated Model Experience context blocks or short form @@ -147,6 +147,8 @@ Export `/locale/en.json` for locale lookup; expose ` For an image on bundle cards, details, and component rows, a package-root plugin sets top-level `"icon": "./icon.svg"` in its exported manifest, or exports `./icon` when that field is omitted. A subpath plugin exports its own icon, such as `"./search/icon": "./search.svg"`. Include each image in `files`. A manifest `icon` is relative to the manifest's directory; absolute paths, URLs, paths outside that directory, and symlinks resolving outside it are rejected. An exported icon resolves through Node exports like locale resources and must remain inside its package. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Images must be self-contained; SVG is rendered as an image, not inline HTML. The Host returns a data URL without activating the plugin. Invalid declarations or unreadable files produce a metadata diagnostic while retaining valid text, without trying the export; missing or undecodable images use the panel's default artwork. +A bundle can also set `meta.detail` to a Markdown file path relative to the package root, such as `"docs/DETAIL.md"` in `locale/en.json` and `"docs/DETAIL.zh.md"` in `locale/zh.json`; it is read from these files at the package root. The path uses `/`, stays inside the package, ends in `.md` or `.markdown`, and has no package-field fallback; include the file and its images in `files`. A bundle's page in **Plugins**, and its market entry page when the [custom plugin markets bundle](../../packages/experimental/plugin-market-bundle/README.md) is on, show the file for the active UI language as the plugin's detail document. Without `meta.detail` they show the package README, and a market entry page then falls back to the declared repository's README; the [document order](../../packages/util/plugin-detail/README.md#document-order) defines the rules. Write it for users, and reference images by paths relative to the file. + Installed bundle cards and details, component lists and configuration details, and Settings' plugin inventory display this metadata, including disabled and preset plugins. Reads do not activate plugins. Only Settings shortens literal package-name and module-name fallbacks by removing npm scope and Cordis/DSH prefixes; Plugin Manager keeps complete names. Locale titles and descriptions remain unchanged. A row configuration page can use its registered summary when the plugin has no display description. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index c45632d3e7..b22976fb9d 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -14,7 +14,7 @@ packages/// # ../../../vendor/cordis (+ ../../../vendor/schemastery if # you use Config, + ../..// for each dsh dep) src/index.ts # service default export or plugin (name/inject/apply/Config) - locale/en.json # optional display metadata: meta.title and meta.description + locale/en.json # optional display metadata: meta.title, meta.description, and meta.detail locale/zh.json # translations using the same fields README.md # service API, events, extension points, design notes, # + gated Model Experience context blocks or short form @@ -147,6 +147,8 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex 要在组合包卡片、详情和组件行显示图片,包根插件在导出清单顶层设置 `"icon": "./icon.svg"`,或在省略该字段时导出 `./icon`。子路径插件导出自己的图标,例如 `"./search/icon": "./search.svg"`。将每张图片加入 `files`。清单 `icon` 相对于清单所在目录;绝对路径、URL、目录外路径,以及解析到目录外的符号链接均被拒绝。导出的图标像 locale 资源一样经 Node exports 解析,且必须位于所属包内。支持不超过 256 KiB 的 SVG、PNG、JPEG(`.jpg`/`.jpeg`)和 WebP 文件。图片必须自包含;SVG 作为图片渲染,不作为内联 HTML。Host 返回 data URL,不激活插件。声明无效或文件不可读时,显示元信息诊断并保留有效文本,不再尝试导出的图标;图片缺失或无法解码时使用面板的默认插画。 +组合包还可以把 `meta.detail` 设为相对于包根目录的 Markdown 文件路径,例如在 `locale/en.json` 中设为 `"docs/DETAIL.md"`,在 `locale/zh.json` 中设为 `"docs/DETAIL.zh.md"`;读取时使用包根目录下的这些文件。路径使用 `/`,必须位于包内,以 `.md` 或 `.markdown` 结尾,且没有包字段回退;将该文件及其图片加入 `files`。组合包在**插件**中的页面,以及启用[自定义插件市场组合包](../../packages/experimental/plugin-market-bundle/README.zh.md)时其市场条目页面,把当前界面语言对应的该文件作为插件的详细介绍文档显示。没有 `meta.detail` 时,它们显示包 README;市场条目页面在此之后还会回退到所声明仓库的 README;规则由[文档顺序](../../packages/util/plugin-detail/README.zh.md#document-order)定义。该文档面向用户编写,图片使用相对于该文件的路径引用。 + 已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。 仅设置页会移除字面包名和模块名回退值的 npm scope 与 Cordis/DSH 前缀;插件管理页保留完整名称。locale 标题和描述保持原样。插件没有展示描述时,行配置页可以使用注册组件的摘要。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index dd565cacdc..50b9819555 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -56,11 +56,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:359`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:365`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:372`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:379`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:136`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:392`](../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:398`](../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:405`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index d51831e5c6..582feb2230 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -58,11 +58,11 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:359`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:365`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:372`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:379`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:136`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:392`](../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:398`](../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:405`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 90537b1933..1eda478157 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -19,6 +19,7 @@ flowchart TD pkg_native_command["native-command"] pkg_output_retention["output-retention"] pkg_package_manifest["package-manifest"] + pkg_plugin_detail["plugin-detail"] pkg_plugin_market_index["plugin-market-index"] pkg_timeout["timeout"] pkg_util_code_language["util-code-language"] @@ -1303,6 +1304,7 @@ flowchart TD pkg_api_session_controller --> pkg_workspace pkg_plugin_market --> pkg_plugin_manager pkg_plugin_market --> pkg_settings + pkg_plugin_market --> pkg_subprocess pkg_plugin_market --> pkg_tools pkg_plugin_market --> pkg_typert_protocol pkg_headless --> pkg_agent @@ -1448,6 +1450,7 @@ flowchart TD | [`native-command`](../packages/util/native-command) | `util` | — | | [`output-retention`](../packages/util/output-retention) | `util` | — | | [`package-manifest`](../packages/util/package-manifest) | `util` | — | +| [`plugin-detail`](../packages/util/plugin-detail) | `util` | — | | [`plugin-market-index`](../packages/util/plugin-market-index) | `util` | — | | [`timeout`](../packages/util/timeout) | `util` | — | | [`util-code-language`](../packages/util/code-language) | `util` | — | @@ -1763,7 +1766,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`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), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | -| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`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), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`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), [`subagent`](../packages/subagent/subagent), [`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 148fb51478..928a955aef 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -21,6 +21,7 @@ flowchart TD pkg_native_command["native-command"] pkg_output_retention["output-retention"] pkg_package_manifest["package-manifest"] + pkg_plugin_detail["plugin-detail"] pkg_plugin_market_index["plugin-market-index"] pkg_timeout["timeout"] pkg_util_code_language["util-code-language"] @@ -1305,6 +1306,7 @@ flowchart TD pkg_api_session_controller --> pkg_workspace pkg_plugin_market --> pkg_plugin_manager pkg_plugin_market --> pkg_settings + pkg_plugin_market --> pkg_subprocess pkg_plugin_market --> pkg_tools pkg_plugin_market --> pkg_typert_protocol pkg_headless --> pkg_agent @@ -1450,6 +1452,7 @@ flowchart TD | [`native-command`](../packages/util/native-command) | `util` | — | | [`output-retention`](../packages/util/output-retention) | `util` | — | | [`package-manifest`](../packages/util/package-manifest) | `util` | — | +| [`plugin-detail`](../packages/util/plugin-detail) | `util` | — | | [`plugin-market-index`](../packages/util/plugin-market-index) | `util` | — | | [`timeout`](../packages/util/timeout) | `util` | — | | [`util-code-language`](../packages/util/code-language) | `util` | — | @@ -1765,7 +1768,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`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), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | -| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`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), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`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), [`subagent`](../packages/subagent/subagent), [`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 1e25bfcbed..0d52b7d7e5 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -6,11 +6,11 @@ en: 962b4398c9052959 zh: 14471c340a9f6e60 /profile-management/management-records: - en: 56c269c5f6dc107c - zh: 535fe725b924b1e6 + en: 2def418f04e9fc10 + zh: af0bd1610bdc2f2b /profile-management/market-records: - en: a28421bc6481b0ed - zh: 73142d6163a20f50 + en: 3dfc62d837d61b9b + zh: ec223be315f3dc62 /profile-management/profile-reconciliation: en: 42a093314af774d4 zh: 7c1784a3af02b31c diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 39b6dd3e4c..530af09e92 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -14,6 +14,8 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `InstallBundleOptions.saveExact` passes `--save-exact` to the shared package installer. `InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `approvedBuilds` grants persistent script permission to the supplied pending package names before installation. `registry` names the registry asked first; absent, the configured one. +`BundleDetail` is a profile bundle's long description: `{ status: 'found' }` with its `origin` (`locale` or `readme`), its `path` inside the package, and its `markdown`, or `{ status: 'none' }`; [`readBundleDetail`](../../packages/boot/plugin-manager/README.md#bundle-detail-documents) documents the order and the image route. `NpmFileSource` names a public npm file CDN, `jsdelivr`, `unpkg`, or `npmmirror`; a read's `npmFileSources` lists the ones to ask first. + `PluginRegistries` carries the configured first registry, `null` for the one pnpm's own configuration names, the fallbacks asked after it, and `resolved`, the URL pnpm's own configuration names or `null` while unread. `InspectOptions.registry` names the registry a lookup asks first. `ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. Optional `error` carries a localizable code and external diagnostic. `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path, plus `timedOut` when the manager terminated a run that stopped printing. A terminated run is classified `timeout` whatever exit status the signal left behind, so installation and removal report failure instead of success and no further registry is asked. `pendingBuilds` lists undecided packages across the profile; `approvedBuilds` records the names granted permission by this operation; `registries` lists the registries an installation asked, in order; `bundle` and `version` name the package a finished installation added and its manifest version; `failedAt` says whether the last failed run could not reach the registry it asked or the host a git or tarball spec is fetched from. @@ -26,6 +28,8 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `MarketQuery` selects one page of an index: fuzzy `text`, a `category`, `labels` that must all be present, a `MarketSort`, `offset`, `limit` up to 100, `refresh` to fetch again instead of using a cached document, and `fetchedAt` to continue the pages of one document. `MarketSearchResult` carries the index URL, `title`, optional `description`, market `metadata`, the document's `fetchedAt`, the page's `entries`, the `total` number of matches, and the `nextOffset`. Each `MarketListingEntry` adds `defaultVersion`, the version installed by default, and `installation`, the profile's status for the package name; each of its `versions` adds `install`, the exact spec and optional first registry for `installBundle`, and `compatibility`, the evaluation of its `engines.dsh` including a version exemption. +`PluginDetail` is a market entry's long description: `{ status: 'found' }` with its `origin` (`locale`, `readme`, or `repository`), its `markdown`, and a `document` id whose images `GET /api/plugin-market/detail-file` serves, or `{ status: 'none' }`. The [plugin market documents the order](../../packages/boot/plugin-market/README.md#plugin-detail-documents) its sources are read in. + ## Profile reconciliation @@ -151,6 +155,20 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts) Manage profile files and apply their declared reload lifecycle. ```ts cordis-catalog +/** + * Read a bundle's long description: the Markdown file its locale `meta.detail` names in `language`, otherwise its + * README. An installed bundle is read from its directory without network access; an official bundle the installation + * offers before it is installed is read from the npm file CDNs at its offered version. Nothing is cached. + * @param name - the bundle's package name, as `listBundles()` lists it. + * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English. + * @param options - `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order, such as the one + * a Client measured as fastest; configured CDNs it omits follow, and CDNs that are not configured are not asked. + * @returns the document with its origin and path, or `none`. + * @throws {RemoteError} `plugin-manager/unknown-bundle`, `plugin-manager/invalid-language`, or + * `plugin-manager/detail-unavailable`. + */ +@Remote async readBundleDetail(name: string, language: string, options?: { npmFileSources?: NpmFileSource[] }): Promise + /** Read exact plugin-version exemptions saved in this profile. * @returns Accepted package-name@version keys with the runtime versions they may run on, and any * record or file problem the reader rejected, which the caller reports instead of failing. @@ -312,6 +330,25 @@ Fetches, validates, and caches configured market indexes; annotates entries with * or `plugin-market/invalid-index`. */ async entry(url: string, name: string): Promise + +/** + * Read the long description of one listed version of a plugin market entry, from the version's source: the npm file + * CDNs for a public npm version, or its pinned Git commit. The Markdown file the package's locale `meta.detail` names + * in `language` comes first, then the package's README, then the README of the entry's `repository`, or for an npm + * version without one, of its `package.json` `repository`, at the default branch. An npm version from another + * registry has no readable package files, so only the entry's `repository` is read. + * @param url - a configured index URL. + * @param name - the plugin's package name. + * @param version - one listed version. + * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English. + * @param options - `refresh` reads again instead of reusing a result cached for `cacheTtlMs`; `npmFileSources` names + * npm file CDNs to ask first for an npm version, in order; configured CDNs it omits follow, and CDNs that are not + * configured are not asked. + * @returns the document with its origin and image document id, or `none`. + * @throws {RemoteError} the failures of `search`, `plugin-market/unknown-plugin` for an unlisted plugin or version, + * `plugin-market/invalid-query`, or `plugin-market/detail-unavailable`. + */ +@Remote async readEntryDetail( url: string, name: string, version: string, language: string, options?: { refresh?: boolean; npmFileSources?: NpmFileSource[] }, ): Promise ``` Source: [`packages/boot/plugin-market/src/index.ts`](../../packages/boot/plugin-market/src/index.ts) diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 5d563c041e..1cab38289f 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -14,6 +14,8 @@ `InstallBundleOptions.saveExact` 向共享包安装器传入 `--save-exact`。`InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。 +`BundleDetail` 是 profile 中组合包的详细介绍:`{ status: 'found' }` 携带其 `origin`(`locale` 或 `readme`)、在包内的 `path` 和 `markdown`;否则为 `{ status: 'none' }`。[`readBundleDetail`](../../packages/boot/plugin-manager/README.zh.md#bundle-detail-documents) 说明读取顺序和图片路由。`NpmFileSource` 指定一个公共 npm 文件 CDN:`jsdelivr`、`unpkg` 或 `npmmirror`;一次读取的 `npmFileSources` 列出先请求的 CDN。 + `PluginRegistries` 携带配置的第一个注册表(`null` 即 pnpm 自身配置指定的那个)、随后依次询问的备选注册表,以及 `resolved`——pnpm 自身配置指向的 URL,未读到时为 `null`。`InspectOptions.registry` 指定一次查询首先询问的注册表。 `ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录 pnpm 退出码、有界输出、截断标志及完整诊断日志路径;当管理器终止了一个停止打印的运行,还记录 `timedOut`。被终止的运行不论信号留下什么退出状态都归类为 `timeout`,因此安装与删除都报告失败而非成功,也不会再询问下一个注册表。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名;`registries` 按顺序列出一次安装问过的注册表;`bundle` 与 `version` 给出完成的安装新增的包及其清单版本;`failedAt` 说明最后一次失败的运行连不上的是所问的注册表,还是 git 或 tarball spec 自身拉取的主机。 @@ -26,6 +28,8 @@ `MarketQuery` 选择索引的一页:模糊匹配的 `text`、一个 `category`、必须全部具备的 `labels`、一个 `MarketSort`、`offset`、不超过 100 的 `limit`、表示重新获取而不使用缓存文档的 `refresh`,以及用于继续读取同一文档后续页的 `fetchedAt`。`MarketSearchResult` 携带索引 URL、`title`、可选的 `description`、市场 `metadata`、该文档的 `fetchedAt`、本页的 `entries`、匹配总数 `total` 和 `nextOffset`。每个 `MarketListingEntry` 额外携带 `defaultVersion`,即默认安装的版本,以及 `installation`,即该包名在当前 profile 中的状态;其 `versions` 中的每个版本额外携带 `install`,即交给 `installBundle` 的精确 spec 和可选的首选 registry,以及 `compatibility`,即包含版本豁免在内的 `engines.dsh` 评估结果。 +`PluginDetail` 是市场条目的详细介绍:`{ status: 'found' }` 携带其 `origin`(`locale`、`readme` 或 `repository`)、`markdown`,以及一个 `document` id,该文档中的图片由 `GET /api/plugin-market/detail-file` 提供;否则为 `{ status: 'none' }`。读取其来源的顺序见[插件市场的说明](../../packages/boot/plugin-market/README.zh.md#plugin-detail-documents)。 + ## Profile 重新组合 @@ -151,6 +155,20 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts) Manage profile files and apply their declared reload lifecycle. ```ts cordis-catalog +/** + * Read a bundle's long description: the Markdown file its locale `meta.detail` names in `language`, otherwise its + * README. An installed bundle is read from its directory without network access; an official bundle the installation + * offers before it is installed is read from the npm file CDNs at its offered version. Nothing is cached. + * @param name - the bundle's package name, as `listBundles()` lists it. + * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English. + * @param options - `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order, such as the one + * a Client measured as fastest; configured CDNs it omits follow, and CDNs that are not configured are not asked. + * @returns the document with its origin and path, or `none`. + * @throws {RemoteError} `plugin-manager/unknown-bundle`, `plugin-manager/invalid-language`, or + * `plugin-manager/detail-unavailable`. + */ +@Remote async readBundleDetail(name: string, language: string, options?: { npmFileSources?: NpmFileSource[] }): Promise + /** Read exact plugin-version exemptions saved in this profile. * @returns Accepted package-name@version keys with the runtime versions they may run on, and any * record or file problem the reader rejected, which the caller reports instead of failing. @@ -312,6 +330,25 @@ Fetches, validates, and caches configured market indexes; annotates entries with * or `plugin-market/invalid-index`. */ async entry(url: string, name: string): Promise + +/** + * Read the long description of one listed version of a plugin market entry, from the version's source: the npm file + * CDNs for a public npm version, or its pinned Git commit. The Markdown file the package's locale `meta.detail` names + * in `language` comes first, then the package's README, then the README of the entry's `repository`, or for an npm + * version without one, of its `package.json` `repository`, at the default branch. An npm version from another + * registry has no readable package files, so only the entry's `repository` is read. + * @param url - a configured index URL. + * @param name - the plugin's package name. + * @param version - one listed version. + * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English. + * @param options - `refresh` reads again instead of reusing a result cached for `cacheTtlMs`; `npmFileSources` names + * npm file CDNs to ask first for an npm version, in order; configured CDNs it omits follow, and CDNs that are not + * configured are not asked. + * @returns the document with its origin and image document id, or `none`. + * @throws {RemoteError} the failures of `search`, `plugin-market/unknown-plugin` for an unlisted plugin or version, + * `plugin-market/invalid-query`, or `plugin-market/detail-unavailable`. + */ +@Remote async readEntryDetail( url: string, name: string, version: string, language: string, options?: { refresh?: boolean; npmFileSources?: NpmFileSource[] }, ): Promise ``` Source: [`packages/boot/plugin-market/src/index.ts`](../../packages/boot/plugin-market/src/index.ts) diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 6881156ef5..da6f814458 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -32,8 +32,8 @@ import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { - BundleInfo, BundleRowInfo, ChangeResult, IncompatiblePlugin, InspectOptions, InstallBundleOptions, InstallSpecKind, ManagementError, - PackageResult, + BundleDetail, BundleInfo, BundleRowInfo, ChangeResult, IncompatiblePlugin, InspectOptions, InstallBundleOptions, InstallSpecKind, + ManagementError, PackageResult, PluginChange, PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallFailureKind, PluginInstallLogChunk, PluginInstallProgress, PluginInstallRequestId, PluginRegistries, PluginSpecInspection, PluginUpdateProgress, PluginUpdateResult, PluginUpdateStep, PluginVersion, PluginVersionList, RegistryAttempt, @@ -44,7 +44,7 @@ export type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/remote' export type {} from '@deepseek-ai/dsh-plugin-market/remote' export type { MarketCompatibility, MarketIndexSummary, MarketInstallation, MarketInstallationStatus, MarketListingEntry, MarketListingVersion, - MarketQuery, MarketSearchResult, MarketSort, + MarketQuery, MarketSearchResult, MarketSort, PluginDetail, PluginDetailDocumentId, PluginDetailOrigin, } from '@deepseek-ai/dsh-plugin-market/types' export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type {} from '@deepseek-ai/dsh-agent-preset-registry/remote' diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 0fdf4947a3..e16fd551f3 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -21,8 +21,11 @@ en: 483285658e267dd5 zh: 846bec2daac59a96 /deepseek-ai-dsh-plugin-manager/use-this-package/configuration: - en: 861333e423369690 - zh: 6d16604bd24766d3 + en: 64089ca8084f5e0f + zh: 8328a53cd6420a82 +/deepseek-ai-dsh-plugin-manager/use-this-package/bundle-detail-documents: + en: 99b084ff497011d2 + zh: 6c047796c1991784 /deepseek-ai-dsh-plugin-manager/understand-the-implementation: en: 495dadf79c48c43c zh: 9884dd04a5bb49dc diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index e1d36046e9..0f1c691e9f 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -92,6 +92,21 @@ The CLI exposes `dsh plugin --profile version-exemptions`, `allow-vers | `versionListLimit` | `20` | How many of the newest versions above the installed one a version listing reads and returns. | | `versionLookupConcurrency` | `4` | How many single-version registry lookups a version listing runs at once. | | `idleTimeoutMs` | `600000` | Maximum time in milliseconds a service package run may capture no output before the manager terminates it; a run with inherited descriptors (`dsh plugin`) is never bound. | +| `detailMaxBytes` | `1048576` | Largest bundle detail document, and largest locale file read for one, in bytes. | +| `detailImageMaxBytes` | `5242880` | Largest image served beside a bundle detail document, in bytes. | +| `detailFetchTimeoutMs` | `15000` | Deadline of one npm file CDN request for an offered bundle's detail document. | +| `detailNpmFileSources` | `['jsdelivr', 'unpkg', 'npmmirror']` | npm file CDNs offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. | + + +### Bundle detail documents + +`readBundleDetail(name, language, { npmFileSources? })` reads the long description that a bundle's page in **Plugins** shows, for a bundle `listBundles()` lists: the locale `meta.detail` file, otherwise the README, as steps 1 and 2 of the [document order](../../util/plugin-detail/README.md#document-order) define them. Without either, the result is `{ status: 'none' }`; the repository a bundle declares is not read. `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order; configured CDNs it omits follow, and CDNs that `detailNpmFileSources` does not list are not asked. A found document carries its `origin` (`locale` or `readme`), its `path` inside the package, and its Markdown without a leading YAML front matter block. + +An installed bundle is read from its directory, without network access. An official bundle the installation offers before it is installed is read from the npm file CDNs at its offered version; one offered as a development link has no document. Nothing is cached, so a reinstalled bundle shows its own document. + +`GET /api/plugin-manager/detail-file?bundle=&document=&path=`, with `&sources=,...` to ask those CDNs first, serves an image beside a bundle's document to authenticated owner connections. The destination is relative to the document, or to the package root when it starts with `/`. Only PNG, JPEG, GIF, WebP, AVIF, and SVG files inside the package are served, with `Content-Security-Policy: sandbox`, `X-Content-Type-Options: nosniff`, and `Cache-Control: no-store`; another path or an unlisted bundle answers 404. + +Failures: `plugin-manager/unknown-bundle` for a bundle `listBundles()` does not list or whose directory cannot be resolved, `plugin-manager/invalid-language`, and `plugin-manager/detail-unavailable` with `reason` `too-large`, `network`, or `invalid` for a malformed locale file or `meta.detail`, or a named file that does not exist. The image route answers them with 404, 413, 502, and 400. ----- diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 354b6bc89a..2f8a0cc3d0 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -92,6 +92,21 @@ CLI 提供 `dsh plugin --profile version-exemptions`、`allow-version | `versionListLimit` | `20` | 版本列表读取并返回的、比已安装版本新的最新版本个数。 | | `versionLookupConcurrency` | `4` | 版本列表同时运行的单版本注册表查询个数。 | | `idleTimeoutMs` | `600000` | service 包操作允许持续无捕获输出的最长毫秒数,达到即被管理器终止;继承描述符运行的 `dsh plugin` 不受此上界约束。 | +| `detailMaxBytes` | `1048576` | 组合包详细介绍文档的最大大小,以及为读取它而读取的 locale 文件的最大大小,单位字节。 | +| `detailImageMaxBytes` | `5242880` | 与组合包详细介绍文档一同提供的图片的最大大小,单位字节。 | +| `detailFetchTimeoutMs` | `15000` | 为读取当前安装提供、尚未安装的组合包的详细介绍文档而发出的单次 npm 文件 CDN 请求的时限。 | +| `detailNpmFileSources` | `['jsdelivr', 'unpkg', 'npmmirror']` | 读取当前安装提供、尚未安装的组合包时按顺序使用的 npm 文件 CDN;一次读取的 `npmFileSources` 把其中列出的 CDN 移到最前。 | + + +### 组合包详细介绍文档 + +`readBundleDetail(name, language, { npmFileSources? })` 为 `listBundles()` 列出的组合包读取**插件**页中该组合包页面显示的详细介绍:locale `meta.detail` 文件,否则为 README,即[文档顺序](../../util/plugin-detail/README.zh.md#document-order)第 1 步和第 2 步的定义。两者都没有时,结果为 `{ status: 'none' }`;不读取组合包声明的仓库。`npmFileSources` 按顺序列出读取当前安装提供、尚未安装的组合包时先请求的 npm 文件 CDN;它未列出的已配置 CDN 排在其后,`detailNpmFileSources` 未列出的 CDN 不会被请求。找到的文档携带其 `origin`(`locale` 或 `readme`)、在包内的 `path`,以及去掉开头 YAML front matter 块后的 Markdown 内容。 + +已安装的组合包从其目录读取,不访问网络。当前安装提供、尚未安装的官方组合包按其提供的版本从 npm 文件 CDN 读取;以开发链接形式提供的组合包没有文档。结果不被缓存,因此重新安装的组合包显示其自身的文档。 + +`GET /api/plugin-manager/detail-file?bundle=&document=&path=`(加上 `&sources=,...` 时先请求这些 CDN)向已通过认证的 owner 连接提供组合包文档旁的图片。目标路径相对于该文档;以 `/` 开头时相对于包根目录。只提供包内的 PNG、JPEG、GIF、WebP、AVIF 和 SVG 文件,响应带有 `Content-Security-Policy: sandbox`、`X-Content-Type-Options: nosniff` 和 `Cache-Control: no-store`;其他路径或未列出的组合包返回 404。 + +失败:`listBundles()` 未列出或无法解析其目录的组合包为 `plugin-manager/unknown-bundle`;无效语言为 `plugin-manager/invalid-language`;`plugin-manager/detail-unavailable` 携带 `reason`,取值为 `too-large`、`network`,或在 locale 文件或 `meta.detail` 格式错误、指定的文件不存在时为 `invalid`。图片路由对这些失败依次返回 404、413、502 和 400。 ----- diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index 08a101ef5f..333f88f939 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -67,7 +67,9 @@ "semver": "^7.8.5", "yaml": "^2.9.0", "zod": "4.4.3", - "@deepseek-ai/dsh-util-values": "workspace:*" + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-plugin-detail": "workspace:*", + "@deepseek-ai/dsh-timeout": "workspace:*" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -109,6 +111,7 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-sandbox": "workspace:*", - "@deepseek-ai/dsh-user-approval": "workspace:*" + "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*" } } diff --git a/packages/boot/plugin-manager/src/bundle-detail.ts b/packages/boot/plugin-manager/src/bundle-detail.ts new file mode 100644 index 0000000000..5cd16f4e2c --- /dev/null +++ b/packages/boot/plugin-manager/src/bundle-detail.ts @@ -0,0 +1,104 @@ +/** + * Detail documents of profile bundles: an installed bundle's `meta.detail` file or README from its directory, and an + * official bundle the installation offers, before it is installed, from the npm file CDNs at its offered version. Reads + * keep no state; an image request resolves its bundle and document again. + */ +import semver from 'semver' +import { resolveBundleDir } from '@deepseek-ai/dsh-app-boot' +import type { ProfileContext } from '@deepseek-ai/dsh-app-boot' +import { + DetailFailure, detailImage, languageChain, localFiles, npmFileSourceOrder, npmFiles, readPackageDocument, + type DetailFiles, type NpmFileSource, +} from '@deepseek-ai/dsh-plugin-detail' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { BundleDetail, BundleInfo } from './types.ts' + +/** Limits and sources of bundle detail reads. */ +export interface BundleDetailOptions { + readonly profile: ProfileContext + readonly maxDocumentBytes: number + readonly maxImageBytes: number + readonly npmFileSources: readonly NpmFileSource[] + /** Deadline of one CDN request. */ + readonly timeoutMs: number + /** The plugin manager's disposal. */ + readonly lifetime: AbortSignal +} + +/** + * The files of a listed bundle: its directory when its files can be read, otherwise its offered npm version. + * @param bundle - the bundle as `listBundles()` reports it. + * @param preferred - npm file CDNs to ask first for an offered version; see `npmFileSourceOrder`. + * @param options - profile, sources, and limits. + * @returns the files, or undefined for a bundle offered at no published version. + * @throws {RemoteError} `plugin-manager/unknown-bundle` when the bundle's directory cannot be resolved. + */ +function filesOf(bundle: BundleInfo, preferred: readonly string[], options: BundleDetailOptions): DetailFiles | undefined { + if (bundle.availability !== 'missing') { + let dir: string + try { + dir = resolveBundleDir('dsh', bundle.name, options.profile.installAnchor, options.profile.dir) + } catch (error) { + throw new RemoteError('plugin-manager/unknown-bundle', (error as Error).message, { name: bundle.name }, { cause: error }) + } + // `listBundles()` reports the version of every bundle whose manifest it read; one without is read as unversioned. + return localFiles(dir, String(bundle.version)) + } + const version = bundle.installTarget?.version + if (version === undefined || semver.valid(version) !== version) return undefined + const sources = npmFileSourceOrder(options.npmFileSources, preferred) + return npmFiles(bundle.name, version, sources, { timeoutMs: options.timeoutMs, lifetime: options.lifetime }) +} + +/** Rethrow a detail failure as the Remote error clients read. */ +function remoteFailure(error: unknown): never { + if (error instanceof DetailFailure) { + throw new RemoteError('plugin-manager/detail-unavailable', error.message, { reason: error.reason }, { cause: error }) + } + throw error +} + +/** + * Read a bundle's detail document in a display language. + * @param bundle - the bundle as `listBundles()` reports it. + * @param language - a BCP 47 language id. + * @param preferred - npm file CDNs to ask first for an offered version; see `npmFileSourceOrder`. + * @param options - profile, sources, and limits. + * @returns the document, or `none` when the bundle has neither a `meta.detail` file nor a README. + * @throws {RemoteError} `plugin-manager/invalid-language`, `plugin-manager/unknown-bundle`, or + * `plugin-manager/detail-unavailable`. + */ +export async function readBundleDocument( + bundle: BundleInfo, language: string, preferred: readonly string[], options: BundleDetailOptions, +): Promise { + let languages: string[] + try { + languages = languageChain(language) + } catch (error) { + throw new RemoteError('plugin-manager/invalid-language', (error as Error).message, { language }, { cause: error }) + } + const files = filesOf(bundle, preferred, options) + if (files === undefined) return { status: 'none' } + const document = await readPackageDocument(files, '', languages, options.maxDocumentBytes, options.lifetime).catch(remoteFailure) + return document === undefined ? { status: 'none' } : { status: 'found', ...document } +} + +/** + * Read an image beside a bundle's detail document. + * @param bundle - the bundle as `listBundles()` reports it. + * @param document - the document's path inside the bundle, as {@link readBundleDocument} returned it. + * @param destination - the image destination as the document writes it. + * @param preferred - npm file CDNs to ask first for an offered version; see `npmFileSourceOrder`. + * @param options - profile, sources, and limits. + * @returns the bytes and media type, or undefined for a path outside the bundle, an unsupported type, or a missing file. + * @throws {RemoteError} `plugin-manager/unknown-bundle` or `plugin-manager/detail-unavailable`. + */ +export async function readBundleImage( + bundle: BundleInfo, document: string, destination: string, preferred: readonly string[], options: BundleDetailOptions, +): Promise<{ bytes: Uint8Array; mediaType: string } | undefined> { + const image = detailImage(document, destination) + const files = image === undefined ? undefined : filesOf(bundle, preferred, options) + if (image === undefined || files === undefined) return undefined + const bytes = await files.read(image.path, options.maxImageBytes, options.lifetime).catch(remoteFailure) + return bytes === undefined ? undefined : { bytes, mediaType: image.mediaType } +} diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 1894e6b4da..16bc5ee932 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -10,7 +10,12 @@ import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import z from '@deepseek-ai/schemastery' import semver from 'semver' -import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' +import { TypertRemoteService, Remote, RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { detailImageResponse, NPM_FILE_SOURCES } from '@deepseek-ai/dsh-plugin-detail' +import type { NpmFileSource } from '@deepseek-ai/dsh-plugin-detail/types' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import type {} from '@deepseek-ai/dsh-client-connection' +import { readBundleDocument, readBundleImage, type BundleDetailOptions } from './bundle-detail.ts' import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory' import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, @@ -32,8 +37,8 @@ import { approveBuilds, recordPendingBuilds } from './build-approval.ts' import { checkGithubConnection } from './github-connection.ts' import { newerVersions, VERSION_FIELDS, VERSION_LIST_FIELDS, versionFacts } from './versions.ts' import type { - BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ListVersionsOptions, ManagementError, PackageResult, - PluginChange, PluginEntryId, PluginInfo, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId, + BundleDetail, BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ListVersionsOptions, ManagementError, + PackageResult, PluginChange, PluginEntryId, PluginInfo, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId, PluginRegistries, PluginSpecInspection, PluginUpdateResult, PluginUpdateStep, PluginVersion, PluginVersionList, Registry, RegistryAttempt, UpdateBundleOptions, } from './types.ts' @@ -67,8 +72,19 @@ export interface Config { versionListLimit?: number /** How many single-version registry lookups a version listing runs at once. */ versionLookupConcurrency?: number + /** Largest bundle detail document, and largest locale file read for one, in bytes. */ + detailMaxBytes?: number + /** Largest image served beside a bundle detail document, in bytes. */ + detailImageMaxBytes?: number + /** Deadline of one npm file CDN request for an offered bundle's detail document. */ + detailFetchTimeoutMs?: number + /** npm file CDNs that offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. */ + detailNpmFileSources?: NpmFileSource[] } +/** The Fetch route serving images beside bundle detail documents. */ +const DETAIL_FILE_PATH = '/api/plugin-manager/detail-file' + /** An http(s) URL, as pnpm's `--registry` takes it. */ const REGISTRY_URL = /^https?:\/\/\S+$/ @@ -288,6 +304,10 @@ export class PluginManager extends TypertRemoteService { fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]), versionListLimit: z.number().step(1).min(1).default(20), versionLookupConcurrency: z.number().step(1).min(1).default(4), + detailMaxBytes: z.natural().min(1).default(1024 * 1024), + detailImageMaxBytes: z.natural().min(1).default(5 * 1024 * 1024), + detailFetchTimeoutMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(15000), + detailNpmFileSources: z.array(z.union(NPM_FILE_SOURCES)).min(1).default([...NPM_FILE_SOURCES]), }) /** Management bundles remain protected if their files become unreadable. */ private readonly managementBundles = new Set() @@ -312,6 +332,7 @@ export class PluginManager extends TypertRemoteService { private readonly abort = new AbortController() /** Installations by request id, from their call until it settles. */ private readonly installs = new Map() + private readonly detail: BundleDetailOptions constructor(ctx: Context, config: Config) { super(ctx, 'pluginManager') @@ -331,10 +352,64 @@ export class PluginManager extends TypertRemoteService { registry: config.registry === undefined ? null : normalizeRegistry(config.registry), fallbackRegistries: (config as Required).fallbackRegistries.map(normalizeRegistry), } + const required = config as Required + this.detail = { + profile: this.profile, maxDocumentBytes: required.detailMaxBytes, maxImageBytes: required.detailImageMaxBytes, + npmFileSources: required.detailNpmFileSources, timeoutMs: required.detailFetchTimeoutMs, lifetime: this.abort.signal, + } ctx.effect(() => async () => { this.abort.abort() await Promise.allSettled([...this.packageOperations]) }, 'plugin-manager: package cancellation') + ctx.inject(['connection'], (scope) => { + scope.effect(() => scope.connection.fetch.register({ + path: DETAIL_FILE_PATH, methods: ['GET'], requestBody: 'buffered', fetch: request => this.serveDetailFile(request), + }), `plugin-manager: ${DETAIL_FILE_PATH}`) + }) + } + + /** + * Read a bundle's long description: the Markdown file its locale `meta.detail` names in `language`, otherwise its + * README. An installed bundle is read from its directory without network access; an official bundle the installation + * offers before it is installed is read from the npm file CDNs at its offered version. Nothing is cached. + * @param name - the bundle's package name, as `listBundles()` lists it. + * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English. + * @param options - `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order, such as the one + * a Client measured as fastest; configured CDNs it omits follow, and CDNs that are not configured are not asked. + * @returns the document with its origin and path, or `none`. + * @throws {RemoteError} `plugin-manager/unknown-bundle`, `plugin-manager/invalid-language`, or + * `plugin-manager/detail-unavailable`. + */ + @Remote + async readBundleDetail(name: string, language: string, options?: { npmFileSources?: NpmFileSource[] }): Promise { + return await readBundleDocument(await this.listedBundle(name), language, options?.npmFileSources ?? [], this.detail) + } + + private async listedBundle(name: string): Promise { + const bundle = (await this.listBundles()).find(candidate => candidate.name === name) + if (bundle === undefined) throw new RemoteError('plugin-manager/unknown-bundle', `No such bundle: ${name}`, { name }) + return bundle + } + + /** + * Answer `GET ${DETAIL_FILE_PATH}?bundle=&document=&path=[&sources=,...]` with one image + * beside a bundle document; `sources` names npm file CDNs to ask first for an offered bundle, as `readBundleDetail` does. + */ + private async serveDetailFile(request: Request): Promise { + const params = new URL(request.url).searchParams + const [bundle, document, path] = [params.get('bundle'), params.get('document'), params.get('path')] + // Images are read again on every request, so a reinstalled bundle shows its own. + const cache = 'no-store' + if (bundle === null || document === null || path === null) return detailImageResponse('invalid', cache) + try { + const image = await readBundleImage(await this.listedBundle(bundle), document, path, params.get('sources')?.split(',') ?? [], this.detail) + return detailImageResponse(image, cache) + } catch (error) { + const failure = remoteErrorOf(error) + if (failure?.code === 'plugin-manager/unknown-bundle') return detailImageResponse(undefined, cache) + if (failure?.code === 'plugin-manager/detail-unavailable') return detailImageResponse(failure.details.reason, cache) + throw error + } } /** Read exact plugin-version exemptions saved in this profile. diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index c401217d49..e4909d5555 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -4,6 +4,23 @@ import type { PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' 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' +import type {} from '@deepseek-ai/dsh-typert-protocol' +import type { DetailFailureReason } from '@deepseek-ai/dsh-plugin-detail/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** The profile lists no such bundle, or its directory cannot be resolved. */ + 'plugin-manager/unknown-bundle': { readonly name: string } + /** The display language is not a BCP 47 language id. */ + 'plugin-manager/invalid-language': { readonly language: string } + /** + * A bundle's detail document or image exists but cannot be read: `too-large` beyond a size limit, `network` when the + * npm file CDNs cannot be reached, `invalid` for a malformed locale file or `meta.detail`, or a named file that does + * not exist. Bundle files come from local directories and public CDNs, which never answer `denied`. + */ + 'plugin-manager/detail-unavailable': { readonly reason: DetailFailureReason } + } +} /** Reasons a profile control cannot modify its target. */ export type ReadOnlyReason = 'management-required' | 'unaddressable' @@ -341,6 +358,22 @@ export interface PluginInstallLogChunk { readonly exitCode?: number | null } +/** + * A bundle's long description, or `none` when it has neither a `meta.detail` file nor a README. Images resolve beside + * `path` through `GET /api/plugin-manager/detail-file?bundle=&document=&path=`. + */ +export type BundleDetail = + | { + readonly status: 'found' + /** `locale` for the file locale `meta.detail` names, `readme` for the package's README. */ + readonly origin: 'locale' | 'readme' + /** `/`-separated path of the document inside the package. */ + readonly path: string + /** Markdown text without a byte order mark or a leading YAML front matter block. */ + readonly markdown: string + } + | { readonly status: 'none' } + /** What changed in the profile, for consumers that show it. */ export interface PluginChange { /** The operation that changed it. */ diff --git a/packages/boot/plugin-manager/tests/bundle-detail.spec.ts b/packages/boot/plugin-manager/tests/bundle-detail.spec.ts new file mode 100644 index 0000000000..8f1de9641c --- /dev/null +++ b/packages/boot/plugin-manager/tests/bundle-detail.spec.ts @@ -0,0 +1,177 @@ +/** Bundle detail documents and their images through a real profile Include and Loader. */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { realpath } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { Service, type Context } from '@deepseek-ai/cordis' +import { boot, getDshRuntimeVersion, initProfile, loadProfileDirectory, OFFICIAL_ON_DEMAND_CATALOG, readProfileManifest, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import PluginManager, { type Config } from '../src/index.ts' +import * as operations from '../src/operations.ts' + +afterEach(() => { vi.unstubAllGlobals() }) + +/** The `connection` Fetch route registry, recording the routes the manager registers. */ +class RecordingConnection extends Service { + /** The instance the latest fixture mounted. */ + static current: RecordingConnection | undefined + readonly routes = new Map Promise>() + readonly fetch = { + register: (route: { path: string; fetch: (request: Request) => Promise }) => { + this.routes.set(route.path, route.fetch) + return () => Promise.resolve(void this.routes.delete(route.path)) + }, + } + + constructor(ctx: Context) { + super(ctx, 'connection') + RecordingConnection.current = this + } +} + +async function fixture(config: Config = {}) { + const home = await realpath(mkdtempSync(join(tmpdir(), 'plugin-manager-detail-'))) + let owner: Context | undefined + onTestFinished(async () => { await owner?.fiber.dispose(); rmSync(home, { recursive: true, force: true }) }) + const dir = join(home, 'profiles', 'test') + writeFileSync(join(home, 'package.json'), JSON.stringify({ name: 'installation', dependencies: {} })) + initProfile(dir, ['core', 'documented']) + const bundle = (name: string, files: Record) => { + const path = join(dir, 'node_modules', name) + for (const [file, contents] of Object.entries({ 'cordis.patch.yml': '[]', ...files })) { + mkdirSync(join(path, file, '..'), { recursive: true }) + writeFileSync(join(path, file), contents) + } + writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })) + } + bundle('core', { 'cordis.patch.yml': JSON.stringify([{ insert: [{ id: 'manager', name: 'cordis:manager', config }, { id: 'connection', name: 'cordis:connection' }] }]) }) + bundle('documented', { + 'locale/en.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }), + 'locale/zh.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.zh.md' } }), + 'guide/DETAIL.md': '---\ntitle: x\n---\n# Documented\n\n![shot](img/shot.svg)\n', + 'guide/DETAIL.zh.md': '# 有说明\n', + 'guide/img/shot.svg': '', + }) + bundle('plain', { 'readme.md': '# Plain' }) + bundle('bare', {}) + bundle('broken', { 'locale/en.json': JSON.stringify({ meta: { detail: '../outside.md' } }) }) + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { documented: '1.0.0', plain: '1.0.0', bare: '1.0.0', broken: '1.0.0' } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + const profile: ProfileContext = { + name: 'test', startedBundles: loadProfileDirectory('test', dir, join(home, 'package.json')).layers.map(layer => layer.packageName), + dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor: join(home, 'package.json'), cwd: home, home, overlays: [], telemetryDisabledEnv: undefined, + } + const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (root) => { + owner = root + root.provide('profileContext', profile) + Object.assign(root.loader.builtins, { manager: PluginManager, connection: RecordingConnection }) + }) + const registry = vi.spyOn(operations, 'readProfileRegistry').mockResolvedValue('https://registry.npmjs.org/') + onTestFinished(() => { registry.mockRestore() }) + const connection = RecordingConnection.current as RecordingConnection + const image = (query: string) => (connection.routes.get('/api/plugin-manager/detail-file') as (request: Request) => Promise)( + new Request(`http://dsh.local/api/plugin-manager/detail-file?${query}`)) + return { manager: ctx.pluginManager, image, connection } +} + +const failureOf = async (promise: Promise) => remoteErrorOf(await promise.catch((error: unknown) => error)) + +it('reads an installed bundle\'s localized meta.detail file or README from its directory', async () => { + const fetch = vi.fn(() => Promise.reject(new Error('no network'))) + vi.stubGlobal('fetch', fetch) + const { manager } = await fixture() + expect(await manager.readBundleDetail('documented', 'en-US')).toEqual({ + status: 'found', origin: 'locale', path: 'guide/DETAIL.md', markdown: '# Documented\n\n![shot](img/shot.svg)\n', + }) + expect(await manager.readBundleDetail('documented', 'zh-CN')).toMatchObject({ origin: 'locale', markdown: '# 有说明\n' }) + expect(await manager.readBundleDetail('plain', 'en')).toEqual({ status: 'found', origin: 'readme', path: 'readme.md', markdown: '# Plain' }) + expect(await manager.readBundleDetail('bare', 'en')).toEqual({ status: 'none' }) + expect(fetch).not.toHaveBeenCalled() + expect(await failureOf(manager.readBundleDetail('broken', 'en'))).toMatchObject({ code: 'plugin-manager/detail-unavailable', details: { reason: 'invalid' } }) + expect(await failureOf(manager.readBundleDetail('absent', 'en'))).toMatchObject({ code: 'plugin-manager/unknown-bundle', details: { name: 'absent' } }) + expect(await failureOf(manager.readBundleDetail('plain', 'x y'))).toMatchObject({ code: 'plugin-manager/invalid-language', details: { language: 'x y' } }) +}) + +it('serves images beside a bundle document without caching and refuses other paths', async () => { + const { image, manager, connection } = await fixture({ detailImageMaxBytes: 20 }) + const ok = await image('bundle=documented&document=guide%2FDETAIL.md&path=img%2Fshot.svg') + expect([ok.status, ok.headers.get('content-type'), ok.headers.get('cache-control'), ok.headers.get('content-security-policy')?.startsWith('sandbox')]) + .toEqual([413, 'text/plain; charset=utf-8', 'no-store', true]) + expect(await ok.text()).toBe('too-large') + expect((await image('bundle=documented&document=guide%2FDETAIL.md&path=..%2F..%2Fpackage.json')).status).toBe(404) + expect((await image('bundle=documented&document=guide%2FDETAIL.md&path=img%2Fmissing.png')).status).toBe(404) + expect((await image('bundle=absent&document=README.md&path=a.png')).status).toBe(404) + expect((await image('bundle=documented&document=README.md')).status).toBe(400) + const read = vi.spyOn(manager, 'listBundles').mockRejectedValueOnce(new TypeError('bug')) + await expect(image('bundle=documented&document=README.md&path=a.png')).rejects.toThrow('bug') + read.mockRestore() + const { image: roomy } = await fixture() + const served = await roomy('bundle=documented&document=guide%2FDETAIL.md&path=img%2Fshot.svg') + expect([served.status, served.headers.get('content-type')]).toEqual([200, 'image/svg+xml']) + expect(await served.text()).toContain(' { + const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName + const version = getDshRuntimeVersion() + const fetch = vi.fn((url: string) => Promise.resolve(url === `https://registry.npmmirror.com/${name}/${version}/files/README.md` + ? new Response('# Official') + : url.includes('/files/') ? new Response('missing', { status: 404 }) : Promise.reject(new Error(`unexpected ${url}`)))) + vi.stubGlobal('fetch', fetch) + const { manager, image } = await fixture() + expect(await manager.readBundleDetail(name, 'en', { npmFileSources: ['npmmirror'] })).toEqual({ status: 'found', origin: 'readme', path: 'README.md', markdown: '# Official' }) + expect(fetch.mock.calls[0]?.[0]).toBe(`https://registry.npmmirror.com/${name}/${version}/files/locale/en.json`) + fetch.mockClear() + expect((await image(`bundle=${encodeURIComponent(name)}&document=README.md&path=a.png&sources=npmmirror`)).status).toBe(404) + expect(fetch.mock.calls[0]?.[0]).toBe(`https://registry.npmmirror.com/${name}/${version}/files/a.png`) + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline')))) + expect(await failureOf(manager.readBundleDetail(name, 'en'))).toMatchObject({ code: 'plugin-manager/detail-unavailable', details: { reason: 'network' } }) + expect((await image(`bundle=${encodeURIComponent(name)}&document=README.md&path=a.png`)).status).toBe(502) +}) + +it.each([undefined, { npmFileSources: [] }])('asks the CDNs in configured order unless npmmirror is asked first (%o)', async (options) => { + const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName + const version = getDshRuntimeVersion() + const fetch = vi.fn(() => Promise.resolve(new Response('missing', { status: 404 }))) + vi.stubGlobal('fetch', fetch) + const { manager } = await fixture({ detailNpmFileSources: ['unpkg', 'npmmirror'] }) + expect(await manager.readBundleDetail(name, 'en', options)).toEqual({ status: 'none' }) + expect(fetch.mock.calls[0]).toEqual([`https://unpkg.com/${name}@${version}/locale/en.json`, expect.anything()]) +}) + +it('shows no document for an offered bundle without a published version', async () => { + const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName + const { manager } = await fixture() + const list = manager.listBundles.bind(manager) + vi.spyOn(manager, 'listBundles').mockImplementation(async () => (await list()).map(bundle => bundle.name === name + ? { ...bundle, installTarget: { spec: 'link:../x', version: 'link:../x' } } + : bundle)) + expect(await manager.readBundleDetail(name, 'en')).toEqual({ status: 'none' }) + expect((await failureOf(manager.readBundleDetail('documented', 'en')))).toBeUndefined() +}) + +it('reports a listed bundle whose directory is gone, and keeps failures other than detail failures', async () => { + const { manager, image } = await fixture() + const list = manager.listBundles.bind(manager) + const listing = vi.spyOn(manager, 'listBundles').mockImplementation(async () => [...await list(), { + name: 'vanished', official: false, availability: 'profile', enabled: false, installed: true, optional: false, removable: true, updatable: false, rows: [], overrides: [], + }]) + expect(await failureOf(manager.readBundleDetail('vanished', 'en'))).toMatchObject({ code: 'plugin-manager/unknown-bundle', details: { name: 'vanished' } }) + listing.mockRestore() + // A failure outside the detail rules, here decoding the document, reaches the caller unchanged. + const decoder = new TextDecoder() + const decoding = vi.spyOn(TextDecoder.prototype, 'decode').mockImplementation((input) => { + if (decoding.mock.calls.length > 1) throw new TypeError('bug') + decoding.mockRestore() + const text = decoder.decode(input) + vi.spyOn(TextDecoder.prototype, 'decode').mockImplementation(() => { throw new TypeError('bug') }) + return text + }) + await expect(manager.readBundleDetail('documented', 'en')).rejects.toThrow('bug') + vi.restoreAllMocks() + expect((await image('bundle=documented&document=guide%2FDETAIL.md&path=img%2Fshot.svg')).status).toBe(200) +}) diff --git a/packages/boot/plugin-manager/tsconfig.json b/packages/boot/plugin-manager/tsconfig.json index ba181795a1..dac19ccf9e 100644 --- a/packages/boot/plugin-manager/tsconfig.json +++ b/packages/boot/plugin-manager/tsconfig.json @@ -64,6 +64,15 @@ }, { "path": "../../interaction/user-approval" + }, + { + "path": "../../util/plugin-detail" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../../client/connection/tsconfig.host.json" } ] } diff --git a/packages/boot/plugin-market/README.i18n.yaml b/packages/boot/plugin-market/README.i18n.yaml index 8e54eca06a..a9b46ad8cd 100644 --- a/packages/boot/plugin-market/README.i18n.yaml +++ b/packages/boot/plugin-market/README.i18n.yaml @@ -9,17 +9,20 @@ en: ee5478f5717295df zh: 75a03a6db791fc6b /deepseek-ai-dsh-plugin-market/summary: - en: 05b6a148b03fa0a4 - zh: e5568747e1cf1d16 + en: 5fc3f5439344cb9a + zh: 73e549c0b21b3843 /deepseek-ai-dsh-plugin-market/table-of-contents: en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-plugin-market/use-this-package: - en: 880da6f2249be5dc - zh: af79801b129d774c + en: 0553ab42ee4d83ff + zh: 4cc8163884357247 +/deepseek-ai-dsh-plugin-market/use-this-package/plugin-detail-documents: + en: 0e15de273c9acaae + zh: 4fd93c92b71d4398 /deepseek-ai-dsh-plugin-market/understand-the-implementation: - en: fe70456b69339c7a - zh: 46e53647850af839 + en: 678705f57d986377 + zh: 68d090edfe04acde /deepseek-ai-dsh-plugin-market/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 @@ -36,8 +39,8 @@ en: 02cf6033092eb236 zh: cb928bfa6d39a7f7 /deepseek-ai-dsh-plugin-market/known-limitations-and-deferred-work: - en: 2097c5644d5b6d85 - zh: 1deb4c97e7efbfc6 + en: 427d9c58ee09bea4 + zh: ec1c2bade3b56014 /deepseek-ai-dsh-plugin-market/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/boot/plugin-market/README.md b/packages/boot/plugin-market/README.md index 4e191bf8c0..e805dcb2e4 100644 --- a/packages/boot/plugin-market/README.md +++ b/packages/boot/plugin-market/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`pluginMarket` reads the plugin market indexes the user configures, validates them with [`dsh-plugin-market-index`](../../util/plugin-market-index/README.md), and annotates every entry with the profile's installation status, DSH compatibility, and the exact arguments that install it. The Web [Discover tab](../../client/ui-plugin-market/README.md) and the `plugin_market` agent tool read through it; installation itself goes through [`pluginManager.installBundle`](../plugin-manager/README.md). The format and its rationale live in the [index format Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.md); the Host and Web design lives in the [Discover Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.md). +`pluginMarket` reads the plugin market indexes the user configures, validates them with [`dsh-plugin-market-index`](../../util/plugin-market-index/README.md), and annotates every entry with the profile's installation status, DSH compatibility, and the exact arguments that install it. It also reads each market entry's long description, a Markdown document with images, for Discover entry pages. The Web [Discover tab](../../client/ui-plugin-market/README.md) and the `plugin_market` agent tool read through it; installation itself goes through [`pluginManager.installBundle`](../plugin-manager/README.md). The format and its rationale live in the [index format Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.md); the Host and Web design lives in the [Discover Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.md). ## Table of Contents @@ -31,7 +31,15 @@ The optional [custom plugin markets bundle](../../experimental/plugin-market-bun | `indexes` | `[]` | Index URLs in display order. A usable URL is in canonical form and uses `http:` or `https:`, without credentials or fragment. The field is volatile: Discover, Settings, and `setIndexes` change it without remounting this service or `plugin-manager`. | | `fetchTimeoutMs` | `15000` | Deadline for one index fetch, including its body. | | `maxBytes` | `134217728` | Largest accepted index body. | -| `cacheTtlMs` | `600000` | Lifetime of a successfully read document. | +| `cacheTtlMs` | `600000` | Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. | +| `detailMaxBytes` | `1048576` | Largest detail document, and largest locale file read for one. | +| `detailImageMaxBytes` | `5242880` | Largest image served beside a detail document. | +| `detailRepositoryMaxBytes` | `52428800` | Largest amount one Git command adds to a partial clone; a repository whose fetch adds more is not read. | +| `detailCacheMaxBytes` | `524288000` | Largest total size of the partial clones; least recently used ones are deleted beyond it after each fetch. | +| `detailCacheDir` | `.plugin-market/repositories` | Directory of the partial clones, relative to the profile directory; required, as an absolute path, when the service runs outside a dsh profile. | +| `detailGitTimeoutMs` | `60000` | Deadline of one Git command of the clone fallback. | +| `detailNpmFileSources` | `['jsdelivr', 'unpkg', 'npmmirror']` | npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first, and CDNs this list omits are not asked. | +| `detailGitlabHosts` | `['gitlab.com']` | Hosts whose GitLab repository files API serves public repositories, compared without letter case. | Settings and profile files can store any http(s) string, so the service validates each URL when it lists or reads it. `listIndexes` reports an unusable URL with its refusal, `search` and `entry` refuse it, and no request is sent to it; a repeated URL is listed once. @@ -40,6 +48,7 @@ The Remote methods are: - `listIndexes()` returns each configured URL with the title and description of its last successful read and, when the last read failed, its error. It never fetches. - `setIndexes(urls)` normalizes and validates each URL not already configured, keeps configured ones as written, refuses a repeated URL, and writes it through `settings.update` to this service's profile entry. It fails with `plugin-market/settings-unavailable` without Settings or a profile entry. The committed list is announced by `plugin-market/changed`. - `search(url, query)` accepts only a configured URL, so the service never fetches an arbitrary address. It returns one page of the plugins that match `query`, with the index title, description, and declarations, the document's `fetchedAt`, the `total` number of matches, and the `nextOffset`; each plugin carries its `defaultVersion` and `installation`, and each version its `install` and `compatibility`. +- `readEntryDetail(url, name, version, language, { refresh?, npmFileSources? })` reads the long description of one listed version of a configured index's entry from that version's source: the npm file CDNs for a version without `registry` or with the npm or npmmirror registry, or its pinned Git commit and `path`. An npm version from another registry has no readable package files. The service method `entry(url, name)`, which is not a Remote method, returns one plugin of a configured index in the same form, or `undefined` when the index does not list it. @@ -57,6 +66,18 @@ A query selects plugins with every one of its conditions: Text matches the package name, author, keywords, and every language of the title, description, and category and label titles. It is split into runs of letters, marks, and digits; a run of Chinese, Japanese, or Korean characters becomes its overlapping character pairs and its last character, so text written without spaces matches without a dictionary. Words are compared after NFKC normalization and without case. Every query word must match an indexed word as a prefix or, from four characters, within an edit distance of one fifth of its length rounded to the nearest integer, at most 2. Name and title matches weigh three times, and keyword matches twice, a match elsewhere. +### Plugin detail documents + +`readEntryDetail` reads all three steps of the [document order](../../util/plugin-detail/README.md#document-order): the locale `meta.detail` file and the README from the entry version's source, then the declared repository's README. For an npm version the README is the one npm shows for it. A Git entry whose `repository` is its own source directory skips the repository step. A malformed locale file or `meta.detail`, or a named file that does not exist, fails the read instead of moving to the next step. + +Without any, the result is `{ status: 'none' }`. A found document carries its `origin` (`locale`, `readme`, or `repository`), its Markdown without a leading YAML front matter block, and a `document` id. Results, and the `too-large`, `denied`, and `invalid` failures that reading again would repeat, are cached for `cacheTtlMs`; `refresh` reads again instead, and concurrent reads share one read. A `document` id stays valid while a read returns it or an image request names it at least once every `cacheTtlMs`; an unused id is forgotten, and its images answer 404 until the document is read again. + +Repository files are read through GitHub's raw file host for `github.com` and the GitLab repository files API for `detailGitlabHosts`. API requests follow redirects within their origin. When an API answers 401, 403, or 404, a request to the repository's smart HTTP endpoint without any credentials decides whether the repository is public; a public repository's miss means the file does not exist. A repository that needs credentials, and a repository on a host without such an API, is read from a partial clone over https, http, or ssh. Git uses the user's configuration, credential helpers, and `url..insteadOf` rules without prompting, so an https URL can be routed to ssh; ssh runs in batch mode unless `GIT_SSH` or `GIT_SSH_COMMAND` is set. + +`GET /api/plugin-market/detail-file?document=&path=` serves an image beside a read document to authenticated owner connections. The destination is relative to the document, or to the package or repository root when it starts with `/`; its query and fragment are ignored. Only PNG, JPEG, GIF, WebP, AVIF, and SVG files inside that root are served, with `Content-Security-Policy: sandbox`, `X-Content-Type-Options: nosniff`, and a private cache for `cacheTtlMs`. A missing file, unknown document, or other path answers 404; the failures below answer 413, 502, 403, 503, and 400; failure responses are not cached. + +A source that exists but cannot be read fails with `plugin-market/detail-unavailable` and one `reason`: `too-large` for a document, image, or repository over its limit; `network` when a source cannot be reached or times out; `denied` when Git refuses the repository with the user's credentials; `git-unavailable` when a repository must be cloned and Git cannot run; `invalid` for a malformed locale file or `meta.detail`, or a named file that does not exist. An unlisted entry or version fails with `plugin-market/unknown-plugin`; an invalid language fails with `plugin-market/invalid-query`. A Git that cannot start and a local Git command that fails, such as on a full disk, fail the read without a detail reason and are not cached. + Failures use `plugin-market/invalid-query`, `plugin-market/invalid-index-url`, `plugin-market/unknown-index`, `plugin-market/fetch-failed`, and `plugin-market/invalid-index`; the last carries the JSON path of the offending value. The `plugin_market` tool lists the configured indexes, pages the plugins of one index in index order or, with a `query`, by best match, or pages one plugin's versions. It requires no approval because it reads only the sources the user configured and changes nothing; each version it returns carries the `target` and `registry` to pass to `plugin_manager`'s `install_bundle`, which keeps its own approval. The tool cannot change index sources. Creator mode keeps its row beside `plugin_manager`, and the row registers the tool only while `pluginMarket` is mounted; other presets keep it disabled. @@ -74,6 +95,8 @@ Filtering and ordering read only the document; installation status and compatibi Compatibility is computed per version with `evaluatePluginCompatibility`, the version's `engines.dsh`, and the profile's version exemptions, the same check installation applies. A plugin's default version is its highest compatible version without a prerelease tag, or its highest compatible prerelease when it lists no version without one; a plugin with no compatible version has none. Installation status comes from `pluginManager.listBundles()` by package name, measured against the default version, or the highest version without one: `not-installed`, also for an on-demand catalog entry the profile has not installed; `installed` at that version; `update-available` when that version is greater; `different-version` when the installed version is unknown, not valid SemVer, or greater; and `provided` for a bundle the DSH installation ships. +Entry detail reads share one reader that the service owns; document selection and the npm file CDNs come from [`dsh-plugin-detail`](../../util/plugin-detail/README.md). Each partial clone is a bare repository under `detailCacheDir`, named by a hash of its URL, initialized in a staging directory and renamed into place, and fetched with `--depth 1 --filter=blob:none` so only commits and trees arrive; Git downloads a file's contents when it is read. Commands run without inherited `GIT_*` variables other than the ssh program, with hooks, the file-system monitor, submodule recursion, and every transport but https, http, and ssh disabled, and no working tree is created. The directory size is checked every 200 ms while Git downloads; growth past `detailRepositoryMaxBytes` within one command stops Git, and output keeps a file's final newline byte. A file read stops once its contents pass the read's limit, and an image read completes even when the browser abandons the request, so leaving a page never interrupts a clone. A ref under `refs/dsh/` records each fetched commit, so a commit is never fetched lazily without the size check. Operations on one repository run one at a time, also across processes that share `detailCacheDir`, such as Web and Desktop on one profile, through a lock file beside the repository; eviction deletes a repository only after them, enforces `detailCacheMaxBytes` after every fetch and every file read, and deletes the repository in use last, after its operation, when it alone exceeds the total; one that fails or is cancelled, for any reason but a file over its read's limit, deletes the repository, so the next read starts from a fresh clone. Eviction also removes staging directories older than `detailGitTimeoutMs`. Disposal stops Git and waits for it. + `marketInstallTarget` builds install arguments: `name@version` with the version's registry for npm, and `git+.git#` with `&path:/` for a package directory in Git. ----- @@ -104,6 +127,11 @@ Append-only: results enter the conversation as ordinary tool results and do not - **Last write wins** — `setIndexes` replaces the whole list, so two windows editing sources at the same time can overwrite each other's change. - **Redirects refused** — an index served only behind a redirect must be configured at its final URL. - **Whole-document reads** — the Host downloads and keeps each index up to `maxBytes` and builds its text index in memory; a synthetic index of 20,000 plugins took about 0.8 s of build time and 130 MiB of heap. Parsing runs in a Worker, but receiving the validated index still blocks the Host event loop while the clone is deserialized: about 35 ms for a synthetic 7.6 MiB index of 20,000 plugins and about 190 ms for 38 MiB and 100,000 plugins. Larger markets need a server-side query protocol. +- **Default-branch READMEs** — a repository README is read at the default branch, which can differ from the listed version; npm records no commit the CDNs expose. +- **Private npm registries** — the public CDNs do not serve packages of other registries, so only the entry's repository can provide such a version's document. +- **Fetching an unadvertised commit** — a pinned Git entry is fetched by commit id, which the server must allow (`uploadpack.allowReachableSHA1InWant` or `allowAnySHA1InWant`); GitHub and GitLab allow it, and a server that refuses reports `denied`. +- **Exact README names through APIs** — the single-file APIs of public repositories are asked for `README.md`, `readme.md`, `Readme.md`, `README.markdown`, and `README` only; another letter case, such as `README.MD`, is found only in a clone. +- **No credentials for APIs** — GitHub and GitLab APIs are read anonymously; private repositories always use the clone fallback, which needs the user's Git credentials for the host. - **Pages can change** — `fetchedAt` pins pages only while the document is cached; after another read replaces it, a reader must start again from the first page. diff --git a/packages/boot/plugin-market/README.zh.md b/packages/boot/plugin-market/README.zh.md index 6d590412b0..31dae179db 100644 --- a/packages/boot/plugin-market/README.zh.md +++ b/packages/boot/plugin-market/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`pluginMarket` 读取用户配置的插件市场索引,用 [`dsh-plugin-market-index`](../../util/plugin-market-index/README.zh.md) 校验,并为每个条目标注 profile 中的安装状态、DSH 兼容性以及安装它所需的精确参数。Web 的[发现标签页](../../client/ui-plugin-market/README.zh.md)和 `plugin_market` agent 工具通过它读取;安装本身经由 [`pluginManager.installBundle`](../plugin-manager/README.zh.md) 进行。格式及其理由见[索引格式 Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.zh.md);Host 与 Web 的设计见[发现 Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.zh.md)。 +`pluginMarket` 读取用户配置的插件市场索引,用 [`dsh-plugin-market-index`](../../util/plugin-market-index/README.zh.md) 校验,并为每个条目标注 profile 中的安装状态、DSH 兼容性以及安装它所需的精确参数。它还为发现区的条目页面读取每个市场条目的详细介绍,即一份带图片的 Markdown 文档。Web 的[发现标签页](../../client/ui-plugin-market/README.zh.md)和 `plugin_market` agent 工具通过它读取;安装本身经由 [`pluginManager.installBundle`](../plugin-manager/README.zh.md) 进行。格式及其理由见[索引格式 Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-index-format.zh.md);Host 与 Web 的设计见[发现 Agent Note](../../../.agents/notes/implemented/feature/2026-10-02-plugin-market-discover.zh.md)。 ## 目录 @@ -31,7 +31,15 @@ kind: "package-reference" | `indexes` | `[]` | 按显示顺序排列的索引 URL。可用的 URL 采用规范形式,使用 `http:` 或 `https:`,不含凭据或片段。该字段是 volatile 字段:发现区、设置和 `setIndexes` 修改它时,不会重新挂载本服务或 `plugin-manager`。 | | `fetchTimeoutMs` | `15000` | 单次索引获取(包括响应体)的时限。 | | `maxBytes` | `134217728` | 接受的最大索引响应体。 | -| `cacheTtlMs` | `600000` | 成功读取的文档的缓存时长。 | +| `cacheTtlMs` | `600000` | 成功读取的索引文档、详细介绍的读取结果以及仓库默认分支提交的缓存时长。 | +| `detailMaxBytes` | `1048576` | 详细介绍文档的最大大小,以及为读取它而读取的 locale 文件的最大大小。 | +| `detailImageMaxBytes` | `5242880` | 与详细介绍文档一同提供的图片的最大大小。 | +| `detailRepositoryMaxBytes` | `52428800` | 单条 Git 命令向一个部分克隆增加的最大数据量;获取时增加的数据量超过该值的仓库不被读取。 | +| `detailCacheMaxBytes` | `524288000` | 所有部分克隆的最大总大小;每次获取后,超出时删除最近最少使用的部分克隆。 | +| `detailCacheDir` | `.plugin-market/repositories` | 部分克隆所在的目录,相对于 profile 目录;服务在 dsh profile 之外运行时必须以绝对路径配置。 | +| `detailGitTimeoutMs` | `60000` | 克隆回退方式中单条 Git 命令的时限。 | +| `detailNpmFileSources` | `['jsdelivr', 'unpkg', 'npmmirror']` | 按顺序读取的 npm 文件 CDN;一次读取的 `npmFileSources` 把其中列出的 CDN 移到最前,本列表未列出的 CDN 不会被请求。 | +| `detailGitlabHosts` | `['gitlab.com']` | 通过 GitLab 仓库文件 API 提供公开仓库的主机,比较时不区分大小写。 | 设置和 profile 文件可以存储任意 http(s) 字符串,因此服务在列出或读取每个 URL 时校验它。`listIndexes` 报告不可用的 URL 及其拒绝原因,`search` 和 `entry` 拒绝该 URL,且不会向它发送请求;重复的 URL 只列出一次。 @@ -40,6 +48,7 @@ Remote 方法如下: - `listIndexes()` 返回每个已配置 URL,以及其最近一次成功读取得到的标题和描述;最近一次读取失败时,还返回其错误。它从不发起获取。 - `setIndexes(urls)` 规范化并校验每个尚未配置的 URL,已配置的 URL 保持原样,拒绝重复的 URL,并通过 `settings.update` 把列表写入本服务的 profile 条目。没有 Settings 或 profile 条目时,以 `plugin-market/settings-unavailable` 失败。提交后的列表通过 `plugin-market/changed` 通告。 - `search(url, query)` 只接受已配置的 URL,因此服务从不获取任意地址。它返回匹配 `query` 的一页插件,并附带索引的标题、描述和声明、该文档的 `fetchedAt`、匹配总数 `total` 和 `nextOffset`;每个插件携带 `defaultVersion` 和 `installation`,每个版本携带 `install` 和 `compatibility`。 +- `readEntryDetail(url, name, version, language, { refresh?, npmFileSources? })` 从已配置索引中某个条目的一个已列出版本的来源读取该版本的详细介绍:没有 `registry` 或使用 npm 或 npmmirror 注册表的版本从 npm 文件 CDN 读取,Git 版本从其固定的 Git 提交和 `path` 读取。来自其他注册表的 npm 版本没有可读取的包文件。 服务方法 `entry(url, name)` 不是 Remote 方法,它以相同形式返回已配置索引中的一个插件;索引未列出该插件时返回 `undefined`。 @@ -57,6 +66,19 @@ Remote 方法如下: 文本匹配包名、作者、关键词,以及标题、描述、分类标题和标签标题的所有语言。文本被拆分为由字母、组合符号和数字构成的连续片段;由中文、日文或韩文字符构成的片段变为相邻的两字组合及其最后一个字符,因此不带空格书写的文本无需词典也能匹配。词在 NFKC 规范化后不区分大小写地比较。查询中的每个词都必须匹配某个已索引的词,可以是前缀,或者在词长至少四个字符时,编辑距离不超过其长度的五分之一(四舍五入),且最多为 2。名称和标题匹配的权重是其他位置的三倍,关键词匹配是两倍。 + +### 插件详细介绍文档 + +`readEntryDetail` 读取[文档顺序](../../util/plugin-detail/README.zh.md#document-order)的全部三步:先从条目版本的来源读取 locale `meta.detail` 文件和 README,再读取所声明仓库的 README。对 npm 版本,README 即 npm 为该版本显示的 README。`repository` 就是自身来源目录的 Git 条目跳过仓库这一步。locale 文件或 `meta.detail` 格式错误,或指定的文件不存在时,读取失败,而不是进入下一步。 + +三者都没有时,结果为 `{ status: 'none' }`。找到的文档携带其 `origin`(`locale`、`readme` 或 `repository`)、去掉开头 YAML front matter 块后的 Markdown 内容和 `document` id。结果,以及再次读取也会重复出现的 `too-large`、`denied` 和 `invalid` 失败,缓存 `cacheTtlMs`;传入 `refresh` 时改为重新读取;并发读取共享一次读取。只要每个 `cacheTtlMs` 内至少有一次读取返回某个 `document` id 或有一次图片请求引用它,该 id 就保持有效;未使用的 id 会被遗忘,在再次读取该文档之前,其图片请求返回 404。 + +`github.com` 上的仓库文件通过 GitHub 的原始文件主机读取,`detailGitlabHosts` 中主机上的仓库文件通过 GitLab 仓库文件 API 读取。API 请求跟随同一 origin 内的重定向。API 返回 401、403 或 404 时,由一次不带任何凭据、发往该仓库 smart HTTP 端点的请求判断仓库是否公开;公开仓库中未找到文件即表示该文件不存在。需要凭据的仓库,以及所在主机没有此类 API 的仓库,通过 https、http 或 ssh 从部分克隆中读取。Git 使用用户的配置、凭据助手和 `url..insteadOf` 规则,且不提示输入,因此 https URL 可能被改为通过 ssh 访问;除非设置了 `GIT_SSH` 或 `GIT_SSH_COMMAND`,ssh 以批处理模式运行。 + +`GET /api/plugin-market/detail-file?document=&path=` 向已通过认证的 owner 连接提供已读取文档旁的图片。目标路径相对于该文档;以 `/` 开头时相对于包或仓库的根目录;路径中的查询和片段被忽略。只提供该根目录内的 PNG、JPEG、GIF、WebP、AVIF 和 SVG 文件,响应带有 `Content-Security-Policy: sandbox`、`X-Content-Type-Options: nosniff` 和时长为 `cacheTtlMs` 的私有缓存。文件不存在、文档未知或其他路径返回 404;下文所列的失败依次返回 413、502、403、503 和 400;失败响应不被缓存。 + +存在但无法读取的来源以 `plugin-market/detail-unavailable` 失败,并携带一个 `reason`:文档、图片或仓库超过其大小限制时为 `too-large`;来源无法访问或超时时为 `network`;Git 使用用户的凭据仍被仓库拒绝时为 `denied`;仓库必须克隆而 Git 无法运行时为 `git-unavailable`;locale 文件或 `meta.detail` 格式错误,或指定的文件不存在时为 `invalid`。未列出的条目或版本以 `plugin-market/unknown-plugin` 失败;无效的语言以 `plugin-market/invalid-query` 失败。Git 无法启动,以及本地 Git 命令失败(例如磁盘已满)时,读取失败且不带详细介绍的失败原因,也不被缓存。 + 失败使用 `plugin-market/invalid-query`、`plugin-market/invalid-index-url`、`plugin-market/unknown-index`、`plugin-market/fetch-failed` 和 `plugin-market/invalid-index`;最后一种携带违规值的 JSON 路径。 `plugin_market` 工具列出已配置的索引,按索引顺序分页列出某个索引的插件,或在给出 `query` 时按最佳匹配分页列出,或分页列出某个插件的版本。它不需要批准,因为它只读取用户配置的源且不做任何改动;它返回的每个版本都携带要传给 `plugin_manager` 的 `install_bundle` 的 `target` 和 `registry`,`install_bundle` 仍保留自己的批准要求。该工具不能修改索引源。Creator 模式在 `plugin_manager` 旁边保留它的行,该行只在 `pluginMarket` 已挂载时注册工具;其他预设保持禁用。 @@ -74,6 +96,8 @@ Host 使用进程的 `fetch` 获取,因此配置的出站代理生效,并拒 兼容性按版本计算:使用 `evaluatePluginCompatibility`,传入该版本的 `engines.dsh` 和 profile 的版本豁免,与安装时执行的检查相同。插件的默认版本是其不带先行版本号的最高兼容版本;插件完全没有此类版本时,使用其最高兼容的先行版本;没有兼容版本的插件没有默认版本。安装状态按包名根据 `pluginManager.listBundles()` 计算,以默认版本为准,没有默认版本时以最高版本为准:`not-installed`(包括当前 profile 尚未安装的按需目录条目);版本相同时为 `installed`;该版本更高时为 `update-available`;已安装版本未知、不是有效的 SemVer 或更高时为 `different-version`;DSH 安装随附的组合包(bundle)为 `provided`。 +条目详细介绍的读取共用服务拥有的一个读取器;文档选择和 npm 文件 CDN 来自 [`dsh-plugin-detail`](../../util/plugin-detail/README.zh.md)。每个部分克隆是 `detailCacheDir` 下的一个裸仓库,以其 URL 的哈希命名,先在暂存目录中初始化,再重命名到目标位置,并使用 `--depth 1 --filter=blob:none` 获取,因此只下载提交和树对象;读取文件时 Git 才下载其内容。命令运行时不继承除 ssh 程序之外的 `GIT_*` 变量,禁用 hook、文件系统监视器、子模块递归,以及 https、http 和 ssh 之外的所有传输协议,且不创建工作树。Git 下载期间每 200 毫秒检查一次目录大小;单条命令执行期间增长超过 `detailRepositoryMaxBytes` 时停止 Git,且输出保留文件末尾的换行字节。文件内容超过本次读取的限制时,读取即停止;浏览器放弃请求时,图片读取仍会完成,因此离开页面不会中断克隆。`refs/dsh/` 下的 ref 记录每个已获取的提交,因此不会在没有大小检查的情况下延迟获取提交。对同一仓库的操作逐个执行,共享 `detailCacheDir` 的多个进程(例如同一 profile 上的 Web 和 Desktop)之间也是如此,由仓库旁的一个锁文件实现;淘汰只在这些操作结束后删除该仓库,并在每次获取和每次文件读取后执行 `detailCacheMaxBytes` 总量限制;正在使用的仓库单独超过该总量时,淘汰最后删除它,并等到其操作结束后再删除;除文件超过本次读取的限制外,任何原因导致操作失败或被取消时,都删除该仓库,使下一次读取从新的克隆开始。淘汰时还会删除存在时间超过 `detailGitTimeoutMs` 的暂存目录。销毁时停止 Git 并等待其退出。 + `marketInstallTarget` 构建安装参数:npm 为 `name@version` 加该版本的 registry,Git 为 `git+.git#`,包位于子目录时追加 `&path:/`。 ----- @@ -104,6 +128,11 @@ Host 使用进程的 `fetch` 获取,因此配置的出站代理生效,并拒 - **后写入者覆盖**——`setIndexes` 会替换整个列表,因此两个窗口同时编辑索引源时,可能互相覆盖对方的修改。 - **拒绝重定向**——只能经重定向访问的索引必须以其最终 URL 配置。 - **读取整份文档**——Host 下载并保留每个不超过 `maxBytes` 的索引,并在内存中构建其文本索引;一个包含 20,000 个插件的合成索引构建用时约 0.8 秒,占用约 130 MiB 堆内存。解析在 Worker 中运行,但接收校验后的索引时,反序列化 clone 仍会阻塞 Host 事件循环:包含 20,000 个插件的 7.6 MiB 合成索引约 35 毫秒,包含 100,000 个插件的 38 MiB 索引约 190 毫秒。更大的市场需要服务端查询协议。 +- **默认分支的 README**——仓库 README 读取的是默认分支上的文件,可能与列出的版本不一致;CDN 公开的 npm 数据不记录提交。 +- **私有 npm 注册表**——公共 CDN 不提供其他注册表的包,因此此类版本的文档只能来自条目的仓库。 +- **获取未公布的提交**——固定版本的 Git 条目按提交 id 获取,这需要服务器允许(`uploadpack.allowReachableSHA1InWant` 或 `allowAnySHA1InWant`);GitHub 和 GitLab 允许这样做,拒绝的服务器报告 `denied`。 +- **API 只查询精确的 README 文件名**——公开仓库的单文件 API 只查询 `README.md`、`readme.md`、`Readme.md`、`README.markdown` 和 `README`;其他大小写形式(例如 `README.MD`)只能在克隆中找到。 +- **API 不使用凭据**——GitHub 和 GitLab API 以匿名方式读取;私有仓库总是使用克隆回退方式,这要求用户拥有该主机的 Git 凭据。 - **分页可能变化**——`fetchedAt` 只在文档仍被缓存时固定分页;另一次读取替换文档后,读取方必须从第一页重新开始。 diff --git a/packages/boot/plugin-market/package.json b/packages/boot/plugin-market/package.json index 644ed4be73..b10dc50c84 100644 --- a/packages/boot/plugin-market/package.json +++ b/packages/boot/plugin-market/package.json @@ -50,11 +50,16 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-atomic-write": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-package-manifest": "workspace:*", + "@deepseek-ai/dsh-plugin-detail": "workspace:*", "@deepseek-ai/dsh-plugin-market-index": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", + "execa": "^10.0.1", + "hosted-git-info": "^9.0.3", "minisearch": "^7.2.0", "semver": "^7.8.5", "zod": "^4.4.3" @@ -64,6 +69,7 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/dsh-plugin-manager": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, @@ -77,13 +83,16 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/cosmokit": "workspace:~", "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-config-editor": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-plugin-manager": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@types/hosted-git-info": "^3.0.5", "@types/semver": "^7.8.0" } } diff --git a/packages/boot/plugin-market/src/detail-files.ts b/packages/boot/plugin-market/src/detail-files.ts new file mode 100644 index 0000000000..c9c5faa9a7 --- /dev/null +++ b/packages/boot/plugin-market/src/detail-files.ts @@ -0,0 +1,79 @@ +/** + * Git repository files for plugin detail documents, through GitHub or GitLab single-file APIs with a cached partial + * clone as the fallback for repositories those APIs cannot serve anonymously. + */ +import { + DetailFailure, encodePath, fetchBody, README_NAMES, readmeByName, type DetailFiles, type ReadFile, type RemoteFileOptions, +} from '@deepseek-ai/dsh-plugin-detail' +import type { RepositoryCache } from './repository-cache.ts' + +/** A repository as single-file APIs and Git address it. */ +export interface RepositoryRef { + /** Lower-case host name, without port. */ + readonly host: string + /** The project path below the host, such as `owner/repo` or `group/subgroup/project`, without `.git`. */ + readonly project: string + /** URL Git fetches from: `https:///.git`, or the configured http(s) URL as written for other forms. */ + readonly url: string +} + +/** Which single-file API a host offers anonymously. */ +type FileApi = 'github' | 'gitlab' | undefined + +/** Single-file API settings and the clone fallback of repository sources. */ +export interface RepositoryFileOptions extends RemoteFileOptions { + /** Lower-case hosts serving the GitLab repository files API; `github.com` always uses GitHub's raw file host. */ + readonly gitlabHosts: readonly string[] + /** The clone cache's reads. */ + readonly cache: Pick +} + +function apiUrl(api: Exclude, repository: RepositoryRef, ref: string, path: string): string { + return api === 'github' + ? `https://raw.githubusercontent.com/${repository.project}/${encodeURIComponent(ref)}/${encodePath(path)}` + // The repository URL's origin keeps a self-hosted GitLab's scheme and port. + : `${new URL(repository.url).origin}/api/v4/projects/${encodeURIComponent(repository.project)}/repository/files/${encodeURIComponent(path)}/raw?ref=${encodeURIComponent(ref)}` +} + +/** + * Files of one repository at a commit, or at its default branch. Hosts with a single-file API are read through it; a + * missing answer is confirmed with an anonymous request to the repository's smart HTTP endpoint, and a repository that + * needs credentials, or a host without such an API, is read from the cached partial clone. + * @param repository - the repository. + * @param commit - a full commit id, or undefined for the default branch. + * @param options - API hosts, deadline, lifetime, and the clone cache. + * @returns the files. + */ +export function repositoryFiles(repository: RepositoryRef, commit: string | undefined, options: RepositoryFileOptions): DetailFiles { + const api: FileApi = repository.host === 'github.com' ? 'github' : options.gitlabHosts.includes(repository.host) ? 'gitlab' : undefined + const ref = commit ?? 'HEAD' + /** An API read: the bytes, or undefined for an answer that does not distinguish a missing file from a private repository. */ + const fromApi = async (path: string, maxBytes: number, signal: AbortSignal, via: Exclude) => { + const body = await fetchBody(apiUrl(via, repository, ref, path), maxBytes, signal, options) + if (body.ok) return body.bytes + if (body.status !== 404 && body.status !== 401 && body.status !== 403) { + throw new DetailFailure('network', `${repository.url}: ${path} answered HTTP ${String(body.status)}`) + } + return undefined + } + return { + key: `git:${repository.url}#${ref}`, + async read(path, maxBytes, signal) { + if (api !== undefined) { + const bytes = await fromApi(path, maxBytes, signal, api) + if (bytes !== undefined || await options.cache.isPublic(repository.url)) return bytes + } + return await options.cache.read(repository.url, commit, path, maxBytes, signal) + }, + async readme(dir, maxBytes, signal) { + if (api !== undefined) { + const read: ReadFile = (path, max, readSignal) => fromApi(path, max, readSignal, api) + // The first name settles most lookups; the others are worth asking only where a miss is authoritative. + const found = await readmeByName(read, dir, maxBytes, signal, README_NAMES.slice(0, 1)) + if (found !== undefined) return found + if (await options.cache.isPublic(repository.url)) return await readmeByName(read, dir, maxBytes, signal, README_NAMES.slice(1)) + } + return await options.cache.readme(repository.url, commit, dir, maxBytes, signal) + }, + } +} diff --git a/packages/boot/plugin-market/src/detail.ts b/packages/boot/plugin-market/src/detail.ts new file mode 100644 index 0000000000..127dc2896d --- /dev/null +++ b/packages/boot/plugin-market/src/detail.ts @@ -0,0 +1,221 @@ +/** + * Plugin market entry detail documents: the package document {@link readPackageDocument} selects from the entry + * version's source, otherwise the README of the repository directory the entry or its package declares; cached reads + * and the images beside a read document. + */ +import { createHash } from 'node:crypto' +import { posix } from 'node:path' +import { brandString } from '@deepseek-ai/dsh-brand' +import { DetailFailure, detailImage, joinPath, markdownOf, readPackageDocument, type DetailFiles } from '@deepseek-ai/dsh-plugin-detail' +import hostedGitInfo from 'hosted-git-info' +import { repositoryFiles, type RepositoryFileOptions, type RepositoryRef } from './detail-files.ts' +import type { PluginDetail, PluginDetailDocumentId, PluginDetailOrigin } from './types.ts' + +/** A repository directory whose README is the last detail source. */ +export interface RepositoryLocation extends RepositoryRef { + /** Package directory inside the repository, `''` for its root. */ + readonly directory: string +} + +/** Where one package's detail sources are. */ +export interface DetailLocation { + /** The package's files, when they can be read; `locale/.json` and the README are read from them. */ + readonly files?: DetailFiles + /** Package directory inside `files`, `''` for its root. */ + readonly dir: string + /** + * The repository read last, at its default branch; `manifest` reads `repository` from the package's `package.json` + * in `files`. + */ + readonly repository: RepositoryLocation | 'manifest' | undefined +} + +/** Limits of detail documents and their images. */ +export interface DetailLimits { + readonly maxDocumentBytes: number + readonly maxImageBytes: number + /** Lifetime of a read result, including a failure that a retry would repeat. */ + readonly ttlMs: number +} + +/** Failures that reading again within `ttlMs` would repeat. */ +const LASTING_FAILURES = new Set(['too-large', 'denied', 'invalid']) + +/** + * The repository a URL or shorthand names: hosts npm knows by their owner and project, so a GitHub page such as + * `/o/r/tree/main/pkg` names `o/r`; other http(s) URLs by their path, without a GitLab `/-/` page suffix. + * @param url - a manifest `repository` URL or shorthand, or a market index's `repository.url`. + * @returns the repository, or undefined for a gist, another protocol, or fewer than two path segments. + */ +export function repositoryOf(url: string): RepositoryRef | undefined { + // A committish such as `#semver:^1` names a revision, not the repository. + const declared = url.replace(/#.*$/u, '') + const hosted = hostedGitInfo.fromUrl(declared) + if (hosted !== undefined) { + if (hosted.type === 'gist') return undefined + const project = `${hosted.user}/${hosted.project}` + return { host: hosted.domain.toLowerCase(), project, url: `https://${hosted.domain}/${project}.git` } + } + const address = declared.replace(/^git\+/u, '') + if (!URL.canParse(address)) return undefined + const parsed = new URL(address) + const project = parsed.pathname.replace(/\/-\/.*$/u, '').replace(/^\/+|\/+$/gu, '').replace(/\.git$/u, '') + if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || parsed.username !== '' || parsed.password !== '' || project.split('/').length < 2) { + return undefined + } + return { host: parsed.hostname.toLowerCase(), project, url: `${parsed.protocol}//${parsed.host}/${project}.git` } +} + +/** + * Parse a manifest `repository` into the repository and directory its README is read from. + * @param value - a manifest `repository` string or object. + * @returns the location, or undefined when it names no repository {@link repositoryOf} accepts; a `directory` that + * leaves the repository is dropped. + */ +export function manifestRepository(value: unknown): RepositoryLocation | undefined { + const object = typeof value === 'object' && value !== null ? value as { url?: unknown; directory?: unknown } : undefined + const declared = object === undefined ? value : object.url + if (typeof declared !== 'string') return undefined + const repository = repositoryOf(declared) + if (repository === undefined) return undefined + return { ...repository, directory: typeof object?.directory === 'string' ? normalizedDirectory(object.directory) ?? '' : '' } +} + +/** A `/`-separated directory without empty, `.`, or `..` segments; undefined for one that leaves its root. */ +function normalizedDirectory(directory: string): string | undefined { + const normalized = posix.normalize(directory.replace(/\\/gu, '/')).replace(/\/+$/u, '') + if (normalized === '.' || normalized === '') return '' + return normalized === '..' || normalized.startsWith('../') || posix.isAbsolute(normalized) ? undefined : normalized +} + +/** A read document and the files its images resolve against. */ +interface DetailDocument { + readonly files: DetailFiles + /** Path of the document inside `files`. */ + readonly path: string + /** When a read returned the document or an image beside it was served last. */ + usedAt: number +} + +/** A cached read: the document, or a failure that reading again would repeat. */ +type CachedRead = + | { readonly value: PluginDetail; readonly expiresAt: number } + | { readonly failure: DetailFailure; readonly expiresAt: number } + +/** Reads and caches detail documents for one service. */ +export class DetailReader { + private readonly results = new Map() + private readonly pending = new Map>() + private readonly documents = new Map() + + /** + * @param limits - document and image sizes and the result lifetime. + * @param repositories - single-file API, clone, and request settings of repository reads. + */ + constructor(private readonly limits: DetailLimits, private readonly repositories: RepositoryFileOptions) {} + + /** + * Read a package's detail document, sharing concurrent reads and reusing a result, or a failure that reading again + * would repeat, for `ttlMs`. + * @param location - the package's sources. + * @param languages - the chain from `languageChain` of `dsh-plugin-detail`. + * @param refresh - read again instead of reusing a cached result. + * @returns the first document the sources provide, or `none`. + * @throws {DetailFailure} when a source that should provide a document cannot be read. + */ + read(location: DetailLocation, languages: readonly string[], refresh = false): Promise { + const { repository } = location + const repositoryKey = typeof repository === 'string' ? repository : repository === undefined ? '' : `${repository.url}:${repository.directory}` + const key = JSON.stringify([location.files?.key ?? '', location.dir, repositoryKey, languages]) + const now = Date.now() + const cached = this.results.get(key) + if (!refresh && cached !== undefined && cached.expiresAt > now) { + if ('failure' in cached) return Promise.reject(cached.failure) + if (cached.value.status === 'found') this.use(cached.value.document) + return Promise.resolve(cached.value) + } + let pending = this.pending.get(key) + if (pending === undefined) { + pending = this.resolve(location, languages, this.repositories.lifetime).then((value) => { + this.remember(key, { value, expiresAt: Date.now() + this.limits.ttlMs }) + return value + }, (error: unknown) => { + if (error instanceof DetailFailure && LASTING_FAILURES.has(error.reason)) { + this.remember(key, { failure: error, expiresAt: Date.now() + this.limits.ttlMs }) + } + throw error + }).finally(() => { this.pending.delete(key) }) + this.pending.set(key, pending) + } + return pending + } + + /** + * Read an image beside a document. + * @param id - the document id a detail read returned. + * @param authored - the image destination as the document writes it: relative to the document, or to the package or + * repository root when it starts with `/`; a query or fragment is ignored. + * @returns the bytes and media type, or undefined for an unknown document, a path outside its root, an unsupported + * image type, or a missing file. + * @throws {DetailFailure} when the source fails or the image exceeds `maxImageBytes`. + */ + async image(id: string, authored: string): Promise<{ bytes: Uint8Array; mediaType: string } | undefined> { + const document = this.use(brandString(id)) + const image = document === undefined ? undefined : detailImage(document.path, authored) + if (document === undefined || image === undefined) return undefined + // A request the browser abandons still completes, so a cancelled image never interrupts a shared clone. + const bytes = await document.files.read(image.path, this.limits.maxImageBytes, this.repositories.lifetime) + return bytes === undefined ? undefined : { bytes, mediaType: image.mediaType } + } + + /** + * Mark a document used and forget documents unused for `ttlMs`, so ids live as long as a read or an image request + * returns them. A cached read is younger than `ttlMs` and marks its document, so no cached read names a forgotten id. + */ + private use(id: PluginDetailDocumentId): DetailDocument | undefined { + const now = Date.now() + for (const [known, document] of this.documents) if (now - document.usedAt > this.limits.ttlMs) this.documents.delete(known) + const document = this.documents.get(id) + if (document !== undefined) document.usedAt = now + return document + } + + /** Cache a read and drop expired ones, so the cache holds only reads younger than `ttlMs`. */ + private remember(key: string, read: CachedRead): void { + const now = Date.now() + for (const [cachedKey, cached] of this.results) if (cached.expiresAt <= now) this.results.delete(cachedKey) + this.results.set(key, read) + } + + private async resolve(location: DetailLocation, languages: readonly string[], signal: AbortSignal): Promise { + const { files, dir } = location + let repository = location.repository === 'manifest' ? undefined : location.repository + if (files !== undefined) { + const document = await readPackageDocument(files, dir, languages, this.limits.maxDocumentBytes, signal) + if (document !== undefined) return this.found(document.origin, files, document.path, document.markdown) + if (location.repository === 'manifest') repository = await this.manifestRepositoryOf(files, dir, signal) + } + if (repository === undefined) return { status: 'none' } + const source = repositoryFiles(repository, undefined, this.repositories) + const readme = await source.readme(repository.directory, this.limits.maxDocumentBytes, signal) + return readme === undefined ? { status: 'none' } : this.found('repository', source, readme.path, markdownOf(readme.bytes)) + } + + private async manifestRepositoryOf(files: DetailFiles, dir: string, signal: AbortSignal): Promise { + const bytes = await files.read(joinPath(dir, 'package.json'), this.limits.maxDocumentBytes, signal) + if (bytes === undefined) return undefined + try { + return manifestRepository((JSON.parse(new TextDecoder().decode(bytes)) as { repository?: unknown } | null)?.repository) + } catch (_malformed) { + // A manifest the package manager accepted parses; an unreadable one names no repository to read. + return undefined + } + } + + private found(origin: PluginDetailOrigin, files: DetailFiles, path: string, markdown: string): PluginDetail { + const document = brandString(createHash('sha256').update(`${files.key}\0${path}`).digest('hex').slice(0, 32)) + this.documents.set(document, { files, path, usedAt: Date.now() }) + this.use(document) + return { status: 'found', origin, markdown, document } + } +} diff --git a/packages/boot/plugin-market/src/index.ts b/packages/boot/plugin-market/src/index.ts index dbbce34c4c..f4f2a26c95 100644 --- a/packages/boot/plugin-market/src/index.ts +++ b/packages/boot/plugin-market/src/index.ts @@ -1,5 +1,6 @@ /** Host reader of configured plugin market indexes for the Discover page and the `plugin_market` tool. */ +import { isAbsolute, join, resolve } from 'node:path' import type { Context, Volatile } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import semver from 'semver' @@ -8,15 +9,25 @@ import { marketInstallTarget, type MarketIndex, type MarketPlugin, type MarketVe import type { BundleInfo } from '@deepseek-ai/dsh-plugin-manager/types' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { + DetailFailure, detailImageResponse, languageChain, NPM_FILE_SOURCES, npmFileSourceOrder, npmFiles, +} from '@deepseek-ai/dsh-plugin-detail' +import type { NpmFileSource } from '@deepseek-ai/dsh-plugin-detail/types' +import { DetailReader, repositoryOf, type DetailLocation, type RepositoryLocation } from './detail.ts' +import { repositoryFiles, type RepositoryFileOptions } from './detail-files.ts' import { normalizeIndexUrl } from './index-url.ts' +import { RepositoryCache } from './repository-cache.ts' import { parseIndexInWorker } from './parse-thread.ts' import { MarketSearch } from './search.ts' -// Type-only: the `pluginManager` service, `settings.update`, and the Loader's `loader/volatile-update` event. +// Type-only: the `pluginManager` service, `settings.update`, the Loader's `loader/volatile-update` event, and the +// `connection` Fetch routes. import type {} from '@deepseek-ai/dsh-plugin-manager' import type {} from '@deepseek-ai/dsh-settings' import type {} from '@deepseek-ai/cordis-plugin-loader' +import type {} from '@deepseek-ai/dsh-client-connection' import type { MarketCompatibility, MarketIndexSummary, MarketInstallation, MarketListingEntry, MarketListingVersion, MarketQuery, MarketSearchResult, + PluginDetail, } from './types.ts' export type * from './types.ts' @@ -36,8 +47,24 @@ export interface Config { fetchTimeoutMs: number /** Largest accepted index body in bytes. */ maxBytes: number - /** Lifetime of a successfully read index document. */ + /** Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. */ cacheTtlMs: number + /** Largest detail document in bytes. */ + detailMaxBytes: number + /** Largest image beside a detail document in bytes. */ + detailImageMaxBytes: number + /** Largest amount in bytes one Git command adds to a partial clone; a repository whose fetch adds more is not read. */ + detailRepositoryMaxBytes: number + /** Largest total size of the partial clones; least recently used ones are deleted beyond it. */ + detailCacheMaxBytes: number + /** Directory of the partial clones, relative to the profile directory; absent means `.plugin-market/repositories`. */ + detailCacheDir?: string + /** Deadline of one Git command of the clone fallback. */ + detailGitTimeoutMs: number + /** npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first. */ + detailNpmFileSources: NpmFileSource[] + /** Hosts serving the GitLab repository files API. */ + detailGitlabHosts: string[] } /** The exact diagnostic of a failure, including a non-Error rejection. */ @@ -55,6 +82,12 @@ function causeChainOf(error: unknown, depth = 0): string { : messageOf(error) } +/** The Fetch route serving images beside detail documents. */ +const DETAIL_FILE_PATH = '/api/plugin-market/detail-file' + +/** Registries whose packages the public npm file CDNs serve. */ +const PUBLIC_REGISTRIES = new Set(['registry.npmjs.org', 'registry.npmmirror.com']) + /** Largest page `search` returns. */ const MAX_LIMIT = 100 @@ -80,6 +113,14 @@ export default class PluginMarket extends TypertRemoteService { fetchTimeoutMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(15000), maxBytes: z.natural().min(1).default(128 * 1024 * 1024), cacheTtlMs: z.natural().default(600000), + detailMaxBytes: z.natural().min(1).default(1024 * 1024), + detailImageMaxBytes: z.natural().min(1).default(5 * 1024 * 1024), + detailRepositoryMaxBytes: z.natural().min(1).default(50 * 1024 * 1024), + detailCacheMaxBytes: z.natural().min(1).default(500 * 1024 * 1024), + detailCacheDir: z.string(), + detailGitTimeoutMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(60000), + detailNpmFileSources: z.array(z.union(NPM_FILE_SOURCES)).min(1).default([...NPM_FILE_SOURCES]), + detailGitlabHosts: z.array(z.string()).default(['gitlab.com']), }) private readonly lifetime = new AbortController() @@ -87,10 +128,34 @@ export default class PluginMarket extends TypertRemoteService { private readonly pending = new Map>() private readonly errors = new Map() private readonly entryId: string | undefined + private readonly repositories: RepositoryCache + private readonly details: DetailReader constructor(ctx: Context, private readonly config: Config) { super(ctx, 'pluginMarket') this.entryId = ctx.fiber.entry?.options.id + const profile = ctx.get('profileContext') + const cacheDir = profile === undefined + ? config.detailCacheDir + : resolve(profile.dir, config.detailCacheDir ?? join('.plugin-market', 'repositories')) + if (cacheDir === undefined || !isAbsolute(cacheDir)) throw new Error('plugin-market: detailCacheDir must be an absolute path when the service runs without a dsh profile') + const lifetime = this.lifetime.signal + this.repositories = new RepositoryCache({ + dir: cacheDir, maxRepositoryBytes: config.detailRepositoryMaxBytes, maxCacheBytes: config.detailCacheMaxBytes, + // The URL forms repositories are read from, and ssh, which `url..insteadOf` may route them to. + timeoutMs: config.detailGitTimeoutMs, ttlMs: config.cacheTtlMs, transports: ['https', 'http', 'ssh'], lifetime, + // Bounds how far a fetch overshoots `detailRepositoryMaxBytes`; directory walks cost little at this rate. + sizePollMs: 200, + }) + this.details = new DetailReader( + { maxDocumentBytes: config.detailMaxBytes, maxImageBytes: config.detailImageMaxBytes, ttlMs: config.cacheTtlMs }, + this.detailRepositoryOptions(), + ) + ctx.inject(['connection'], (scope) => { + scope.effect(() => scope.connection.fetch.register({ + path: DETAIL_FILE_PATH, methods: ['GET'], requestBody: 'buffered', fetch: request => this.serveDetailFile(request), + }), `plugin-market: ${DETAIL_FILE_PATH}`) + }) ctx.on('loader/volatile-update', () => { const configured = new Set(this.sources().flatMap(source => source.error === undefined ? [source.url] : [])) for (const url of [...this.cache.keys(), ...this.errors.keys()]) { @@ -103,7 +168,7 @@ export default class PluginMarket extends TypertRemoteService { }) ctx.effect(() => async () => { this.lifetime.abort(new Error('Plugin market disposed')) - await Promise.allSettled(this.pending.values()) + await Promise.allSettled([...this.pending.values(), this.repositories.settled()]) }) } @@ -200,6 +265,101 @@ export default class PluginMarket extends TypertRemoteService { return plugin === undefined ? undefined : (await this.annotator())(plugin) } + /** + * Read the long description of one listed version of a plugin market entry, from the version's source: the npm file + * CDNs for a public npm version, or its pinned Git commit. The Markdown file the package's locale `meta.detail` names + * in `language` comes first, then the package's README, then the README of the entry's `repository`, or for an npm + * version without one, of its `package.json` `repository`, at the default branch. An npm version from another + * registry has no readable package files, so only the entry's `repository` is read. + * @param url - a configured index URL. + * @param name - the plugin's package name. + * @param version - one listed version. + * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English. + * @param options - `refresh` reads again instead of reusing a result cached for `cacheTtlMs`; `npmFileSources` names + * npm file CDNs to ask first for an npm version, in order; configured CDNs it omits follow, and CDNs that are not + * configured are not asked. + * @returns the document with its origin and image document id, or `none`. + * @throws {RemoteError} the failures of `search`, `plugin-market/unknown-plugin` for an unlisted plugin or version, + * `plugin-market/invalid-query`, or `plugin-market/detail-unavailable`. + */ + @Remote + async readEntryDetail( + url: string, name: string, version: string, language: string, options?: { refresh?: boolean; npmFileSources?: NpmFileSource[] }, + ): Promise { + const languages = this.languages(language) + const plugin = (await this.document(url, false)).index.plugins.find(candidate => candidate.name === name) + const listed = plugin?.versions.find(candidate => candidate.version === version) + if (plugin === undefined || listed === undefined) { + throw new RemoteError('plugin-market/unknown-plugin', `${url} lists no ${name}@${version}`, { name, version }) + } + const declared = plugin.repository + const found = declared === undefined ? undefined : repositoryOf(declared.url) + let repository: RepositoryLocation | undefined = found === undefined ? undefined : { ...found, directory: declared?.directory ?? '' } + const { source } = listed + let location: DetailLocation + if (source.type === 'git') { + const ref = repositoryOf(source.url) + /* v8 ignore next -- parseMarketIndex admits only https Git URLs with an owner and a project */ + if (ref === undefined) return { status: 'none' } + const dir = source.path ?? '' + // A repository that names the source directory itself adds nothing to the source. + if (repository?.url === ref.url && repository.directory === dir) repository = undefined + location = { files: repositoryFiles(ref, source.commit, this.detailRepositoryOptions()), dir, repository } + } else if (source.registry === undefined || PUBLIC_REGISTRIES.has(new URL(source.registry).host)) { + location = this.npmLocation(name, version, repository ?? 'manifest', options?.npmFileSources ?? []) + } else { + location = { dir: '', repository } + } + return await this.readDetail(location, languages, options?.refresh === true) + } + + private languages(language: string): string[] { + try { + return languageChain(language) + } catch (error) { + throw new RemoteError('plugin-market/invalid-query', messageOf(error), { field: 'language', reason: 'format' }, { cause: error }) + } + } + + private detailRepositoryOptions(): RepositoryFileOptions { + const { config } = this + return { + timeoutMs: config.fetchTimeoutMs, lifetime: this.lifetime.signal, + gitlabHosts: config.detailGitlabHosts.map(host => host.toLowerCase()), cache: this.repositories, + } + } + + /** The detail location of a published npm version on the configured CDNs, the preferred ones first. */ + private npmLocation(name: string, version: string, repository: RepositoryLocation | 'manifest', preferred: readonly string[]): DetailLocation { + const sources = npmFileSourceOrder(this.config.detailNpmFileSources, preferred) + return { dir: '', repository, files: npmFiles(name, version, sources, { timeoutMs: this.config.fetchTimeoutMs, lifetime: this.lifetime.signal }) } + } + + private async readDetail(location: DetailLocation, languages: readonly string[], refresh: boolean): Promise { + try { + return await this.details.read(location, languages, refresh) + } catch (error) { + if (!(error instanceof DetailFailure)) throw error + throw new RemoteError('plugin-market/detail-unavailable', error.message, { reason: error.reason }, { cause: error }) + } + } + + /** Answer `GET ${DETAIL_FILE_PATH}?document=&path=` with one image beside a read detail document. */ + private async serveDetailFile(request: Request): Promise { + const params = new URL(request.url).searchParams + const document = params.get('document') + const path = params.get('path') + // Images stay cached as long as their document's result. + const cache = `private, max-age=${String(Math.floor(this.config.cacheTtlMs / 1000))}` + if (document === null || path === null) return detailImageResponse('invalid', cache) + try { + return detailImageResponse(await this.details.image(document, path), cache) + } catch (error) { + if (!(error instanceof DetailFailure)) throw error + return detailImageResponse(error.reason, cache) + } + } + private async document(url: string, refresh: boolean, fetchedAt?: number): Promise { const source = this.sources().find(candidate => candidate.url === url) if (source === undefined) throw new RemoteError('plugin-market/unknown-index', `Not a configured index source: ${url}`, { url }) diff --git a/packages/boot/plugin-market/src/repository-cache.ts b/packages/boot/plugin-market/src/repository-cache.ts new file mode 100644 index 0000000000..87a9b51b15 --- /dev/null +++ b/packages/boot/plugin-market/src/repository-cache.ts @@ -0,0 +1,402 @@ +/** + * Cached partial clones for plugin detail documents of repositories that need the user's Git credentials or have no + * anonymous single-file API. Each repository is a bare repository fetched with depth 1 and without file contents; Git + * downloads a file's contents only when it is read. No working tree is created, so no checkout filter, hook, LFS + * download, or symbolic link takes effect. A repository whose operation fails or is cancelled is deleted, so an + * interrupted command never leaves a damaged clone behind. + */ +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, rename, rm, stat, utimes } from 'node:fs/promises' +import { join } from 'node:path' +import { execa } from 'execa' +import { withFileLock } from '@deepseek-ai/dsh-atomic-write' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { DetailFailure, readmeName, type FoundFile } from '@deepseek-ai/dsh-plugin-detail' + +/** + * Git deadlines one operation's lock waits for: an operation runs at most a fetch and a file read that contact the + * remote, each bounded by the deadline, plus short local commands. + */ +const OPERATION_COMMANDS = 4 + +/** Limits, location, and transports of the clone cache. */ +export interface RepositoryCacheOptions { + /** Directory holding one bare repository per repository URL. */ + readonly dir: string + /** Largest amount one command adds to a repository; a command that adds more is stopped and the repository deleted. */ + readonly maxRepositoryBytes: number + /** Largest total size; least recently used repositories are deleted after a fetch until the cache fits. */ + readonly maxCacheBytes: number + /** Deadline of one Git command and of one anonymous accessibility check. */ + readonly timeoutMs: number + /** How long a repository's default-branch commit and its anonymous accessibility are reused. */ + readonly ttlMs: number + /** Git transports Git may use, such as `https` and `ssh`; `url..insteadOf` in the user's configuration still applies. */ + readonly transports: readonly string[] + /** Disposal of the owning service. */ + readonly lifetime: AbortSignal + /** Interval between size checks of a repository while Git fetches into it. */ + readonly sizePollMs: number +} + +/** Output that names a connection failure or a timeout rather than a refusal by the server. */ +const NETWORK_FAILURE = new RegExp([ + 'Could not resolve host', 'Failed to connect', 'Connection (?:refused|reset)', 'timed out', 'Network is unreachable', 'SSL', 'TLS', +].join('|'), 'iu') + +/** One Git command's outcome. */ +interface GitResult { + readonly stdout: Buffer + readonly failed: boolean + /** The output exceeded the command's buffer. */ + readonly overflowed: boolean + /** How the command ended, such as its exit code or the deadline it passed, followed by its error output. */ + readonly diagnostic: string +} + +/** A file over the size its read accepts; the clone itself stays usable. */ +class FileTooLarge extends DetailFailure { + /** @param path - the file. @param maxBytes - the accepted size. */ + constructor(path: string, maxBytes: number) { + super('too-large', `${path} exceeds ${String(maxBytes)} bytes`) + } +} + +/** The failure of a Git command that contacts the remote: `network` for a timeout or a connection failure, otherwise `denied`. */ +function remoteFailureOf(result: GitResult, label: string): DetailFailure { + return new DetailFailure(NETWORK_FAILURE.test(result.diagnostic) ? 'network' : 'denied', `${label}: ${result.diagnostic}`) +} + +/** A value reused until it expires. */ +interface Expiring { + readonly value: T + readonly expiresAt: number +} + +async function sizeOf(path: string): Promise { + let total = 0 + for (const entry of await readdir(path, { withFileTypes: true, recursive: true })) { + if (entry.isFile()) total += (await stat(join(entry.parentPath, entry.name))).size + } + return total +} + +/** Staging directories that an initialization in progress owns; an older one was left by a stopped process. */ +const STAGING_PREFIX = '.init-' + +/** + * The environment of every Git command: the scrubbed parent environment without Git overrides such as `GIT_DIR`, which a + * hook that launched DSH can leave, and without prompts or LFS downloads. + */ +function gitEnv(): Record { + const env = Object.fromEntries(Object.entries(scrubbedParentEnv()) + .filter(([name]) => !name.startsWith('GIT_') || name === 'GIT_SSH' || name === 'GIT_SSH_COMMAND')) + // A user's own ssh program keeps precedence; the default only keeps ssh from waiting for a prompt. + const ssh = env.GIT_SSH === undefined && env.GIT_SSH_COMMAND === undefined ? { GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' } : {} + return { + ...env, ...ssh, LC_ALL: 'C', GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never', + GCM_INTERACTIVE: 'never', GIT_LFS_SKIP_SMUDGE: '1', GIT_LITERAL_PATHSPECS: '1', + } +} + +/** Partial clones of detail repositories under one directory, owned by one service. */ +export class RepositoryCache { + private readonly locks = new Map>() + private readonly heads = new Map>() + /** + * Whether `git --version` has succeeded. A missing Git is checked this way before any operation, because Windows + * reports a missing command as an ordinary exit code that would otherwise read as a refused fetch. + */ + private gitStarts = false + private readonly visibility = new Map>() + private readonly checks = new Map>() + private readonly running = new Set>() + + /** @param options - location, limits, deadline, transports, and lifetime. */ + constructor(private readonly options: RepositoryCacheOptions) {} + + /** + * Whether a repository's smart HTTP endpoint answers without credentials, which makes a missing single-file API answer + * authoritative. The request carries no user configuration, credential helper, or `.netrc` entry; concurrent checks of + * one repository share it, and its answer is reused for `ttlMs`. + * @param url - the repository's https URL, ending in `.git`. + * @returns true for a 2xx answer; false for 401, 403, 404, or a redirect, which hosts send for a private repository. + * @throws {DetailFailure} `network` when the host cannot be reached or answers another status. + */ + isPublic(url: string): Promise { + const cached = this.visibility.get(url) + if (cached !== undefined && cached.expiresAt > Date.now()) return Promise.resolve(cached.value) + let pending = this.checks.get(url) + if (pending === undefined) { + pending = this.check(url).finally(() => { this.checks.delete(url) }) + this.checks.set(url, pending) + } + return pending + } + + private async check(url: string): Promise { + const endpoint = `${url}/info/refs?service=git-upload-pack` + let response: Response + try { + response = await fetch(endpoint, { redirect: 'manual', signal: AbortSignal.any([this.options.lifetime, AbortSignal.timeout(this.options.timeoutMs)]) }) + } catch (error) { + throw new DetailFailure('network', `Could not reach ${endpoint}: ${(error as Error).message}`, { cause: error }) + } + await response.body?.cancel() + const { status } = response + if (!response.ok && status !== 401 && status !== 403 && status !== 404 && (status < 300 || status > 399)) { + throw new DetailFailure('network', `${endpoint} answered HTTP ${String(status)}`) + } + this.visibility.set(url, { value: response.ok, expiresAt: Date.now() + this.options.ttlMs }) + return response.ok + } + + /** + * Read one regular file of a repository. + * @param url - the repository URL. + * @param commit - a full commit id, or undefined for the default branch. + * @param path - `/`-separated path relative to the repository root. + * @param maxBytes - largest accepted size. + * @param signal - cancellation of the read. + * @returns the bytes, or undefined when the commit has no regular file at `path`. + * @throws {DetailFailure} when Git cannot fetch the commit, the repository grows past its limit, or the file exceeds `maxBytes`. + */ + read(url: string, commit: string | undefined, path: string, maxBytes: number, signal: AbortSignal): Promise { + return this.locked(url, async (repository) => { + const resolved = await this.commit(repository, url, commit, signal) + const blob = (await this.files(repository, resolved, path, signal)).get(path) + return blob === undefined ? undefined : await this.blob(repository, url, blob, path, maxBytes, signal) + }) + } + + /** + * Read the README of a repository directory: `README.md`, `README.markdown`, or `README` in any letter case. + * @param url - the repository URL. + * @param commit - a full commit id, or undefined for the default branch. + * @param dir - directory relative to the repository root; `''` for the root. + * @param maxBytes - largest accepted size. + * @param signal - cancellation of the read. + * @returns the file, or undefined when the directory has none. + * @throws {DetailFailure} as {@link read} does. + */ + readme(url: string, commit: string | undefined, dir: string, maxBytes: number, signal: AbortSignal): Promise { + return this.locked(url, async (repository) => { + const resolved = await this.commit(repository, url, commit, signal) + const files = await this.files(repository, resolved, dir === '' ? undefined : `${dir}/`, signal) + const prefix = dir === '' ? '' : `${dir}/` + const name = readmeName([...files.keys()].map(path => path.slice(prefix.length))) + if (name === undefined) return undefined + const path = `${prefix}${name}` + return { path, bytes: await this.blob(repository, url, files.get(path) as string, path, maxBytes, signal) } + }) + } + + /** Wait until every running Git command has ended; the lifetime signal stops them. */ + async settled(): Promise { + await Promise.allSettled([...this.running]) + } + + /** + * Serialize operations on one repository, so a fetch and the reads after it never interleave, and delete the + * repository when an operation fails or is cancelled for any reason but a file over its read's size. + */ + private locked(url: string, operation: (repository: string) => Promise): Promise { + const repository = join(this.options.dir, createHash('sha256').update(url).digest('hex').slice(0, 24)) + return this.serialized(repository, () => operation(repository).catch(async (error: unknown) => { + if (!(error instanceof FileTooLarge)) await rm(repository, { recursive: true, force: true }) + throw error + })) + } + + /** + * Run an operation after every earlier operation on the same repository directory has ended, in this service and in + * other processes that share the cache directory, such as Web and Desktop on one profile. + */ + private serialized(repository: string, operation: () => Promise): Promise { + const previous = this.locks.get(repository) ?? Promise.resolve() + const next = previous.then(async () => { + await mkdir(this.options.dir, { recursive: true }) + // An operation runs at most this many Git commands, each bounded by the Git deadline. + const waitMs = OPERATION_COMMANDS * this.options.timeoutMs + return await withFileLock(repository, operation, { waitMs, signal: this.options.lifetime }) + }) + // The caller receives the outcome; the lock and the settlement wait only for completion. + const tracked: Promise = next.then(() => undefined, () => undefined).finally(() => { + if (this.locks.get(repository) === tracked) this.locks.delete(repository) + this.running.delete(tracked) + }) + this.locks.set(repository, tracked) + this.running.add(tracked) + return next + } + + /** The commit to read: a fetched commit, fetching it or the default branch when necessary. */ + private async commit(repository: string, url: string, commit: string | undefined, signal: AbortSignal): Promise { + if (!this.gitStarts) { + // `git version ` is one short line. + await this.run(this.options.dir, ['--version'], signal, 1024).catch((error: unknown) => { + throw new DetailFailure('git-unavailable', (error as Error).message, { cause: error }) + }) + this.gitStarts = true + } + if (!existsSync(repository)) { + // A repository appears complete or not at all: it is initialized beside its final name, then renamed. + const staging = await mkdtemp(join(this.options.dir, STAGING_PREFIX)) + try { + await this.run(staging, ['init', '--quiet', '--bare', '--template='], signal) + for (const [key, value] of [['remote.origin.url', url], ['remote.origin.promisor', 'true'], ['remote.origin.partialclonefilter', 'blob:none']]) { + await this.run(staging, ['config', key as string, value as string], signal) + } + await rename(staging, repository) + } finally { + await rm(staging, { recursive: true, force: true }) + } + } + await utimes(repository, new Date(), new Date()) + // An object lookup would fetch a missing commit lazily from the promisor remote, without the size watch; a ref + // written after a completed fetch records the commit instead, and eviction removes it with the repository. + const head = this.heads.get(url) + const known = commit ?? (head !== undefined && head.expiresAt > Date.now() ? head.value : undefined) + if (known !== undefined && !(await this.git(['show-ref', '--verify', '--quiet', `refs/dsh/${known}`], repository, signal)).failed) return known + await this.fetch(repository, url, commit ?? 'HEAD', signal) + const fetched = commit ?? (await this.run(repository, ['rev-parse', 'FETCH_HEAD'], signal)).toString('utf8').trim() + await this.run(repository, ['update-ref', `refs/dsh/${fetched}`, fetched], signal) + if (commit === undefined) this.heads.set(url, { value: fetched, expiresAt: Date.now() + this.options.ttlMs }) + return fetched + } + + /** Fetch one commit without file contents. */ + private async fetch(repository: string, url: string, target: string, signal: AbortSignal): Promise { + const args = ['fetch', '--quiet', '--depth', '1', '--filter=blob:none', '--no-tags', 'origin', target] + await this.watched(repository, url, args, signal, `${url}: git fetch`) + await this.evict(repository) + } + + /** + * Run a Git command that may download objects, stopping it when the repository grows past its limit during or by the + * command; the caller's lock then deletes the repository. + */ + private async watched( + repository: string, url: string, args: readonly string[], signal: AbortSignal, label: string, + maxBuffer = this.options.maxRepositoryBytes, + ): Promise { + const limit = this.options.maxRepositoryBytes + // The limit applies to what one command adds, so a clone that earlier reads filled stays usable. + const before = await sizeOf(repository) + const oversized = new AbortController() + const timer = setInterval(() => { + void sizeOf(repository).then((size) => { if (size - before > limit) oversized.abort() }, /* v8 ignore next -- Git renamed or + deleted a file during the walk, which no test can time; the next tick measures again */ (_racing: unknown) => {}) + }, this.options.sizePollMs) + let result: GitResult + try { + result = await this.git(args, repository, signal, oversized.signal, maxBuffer) + } finally { + clearInterval(timer) + } + if (oversized.signal.aborted || (!result.failed && await sizeOf(repository) - before > limit)) { + throw new DetailFailure('too-large', `${url}: one fetch adds more than ${String(limit)} bytes`) + } + if (result.failed && !result.overflowed) throw remoteFailureOf(result, label) + return result + } + + /** + * Delete least recently used repositories until the cache fits, `kept` last, and staging directories older than the + * Git deadline. Each deletion is queued behind the operations on its repository and not awaited, because the caller + * holds the lock of `kept` and another repository's queued operation may be waiting to evict `kept`. + */ + private async evict(kept: string): Promise { + const now = Date.now() + const entries: { path: string; size: number; used: number }[] = [] + for (const entry of await readdir(this.options.dir, { withFileTypes: true })) { + const path = join(this.options.dir, entry.name) + try { + const used = (await stat(path)).mtimeMs + if (entry.name.startsWith(STAGING_PREFIX)) { + if (now - used > this.options.timeoutMs) await rm(path, { recursive: true, force: true }) + } else if (entry.isDirectory()) { + entries.push({ path, size: await sizeOf(path), used }) + } + } catch (_removed) { + // Another operation deleted the directory while it was measured; it no longer counts. + } + } + let total = entries.reduce((sum, entry) => sum + entry.size, 0) + // The repository in use goes last: it is deleted only when it alone exceeds the cache, after its operation ends. + for (const entry of entries.sort((a, b) => Number(a.path === kept) - Number(b.path === kept) || a.used - b.used)) { + if (total <= this.options.maxCacheBytes) return + void this.serialized(entry.path, () => rm(entry.path, { recursive: true, force: true })).catch(/* v8 ignore next -- a + forced recursive delete fails only on file permissions no test sets; the repository stays and is measured again + after the next fetch */ (_unremovable: unknown) => {}) + total -= entry.size + } + } + + /** + * The regular files directly below a tree path, or the file at a path, with their blob ids; listing reads only trees, + * so it fetches no file contents. + */ + private async files(repository: string, commit: string, path: string | undefined, signal: AbortSignal): Promise> { + const listing = (await this.run(repository, ['ls-tree', '-z', commit, ...path === undefined ? [] : ['--', path]], signal)).toString('utf8') + const files = new Map() + for (const line of listing.split('\0')) { + // Symbolic links (120000), submodules (160000), and trees are not files. + const fields = /^100(?:644|755) blob ([0-9a-f]+)\t(.+)$/su.exec(line) + if (fields !== null) files.set(fields[2] as string, fields[1] as string) + } + return files + } + + /** Read a blob, fetching its contents under the repository size limit when the clone does not hold them yet. */ + private async blob( + repository: string, url: string, blob: string, path: string, maxBytes: number, signal: AbortSignal, + ): Promise { + // The buffer stops Git once the contents pass `maxBytes`, so an oversized file never enters memory whole. + const result = await this.watched(repository, url, ['cat-file', 'blob', blob], signal, `git cat-file ${path}`, maxBytes) + if (result.overflowed) throw new FileTooLarge(path, maxBytes) + // Reading a blob can download it, so the cache total is enforced after every read as after every fetch. + await this.evict(repository) + return result.stdout + } + + /** + * Run a Git command on local objects and return its output. A failure, such as a missing Git, a full disk, or a timeout, + * is a plain error: it says nothing about the repository, so no read caches it. + */ + private async run(repository: string, args: readonly string[], signal: AbortSignal, maxBuffer?: number): Promise { + const result = await this.git(args, repository, signal, undefined, maxBuffer) + if (result.failed) throw new Error(`git ${args[0] as string}: ${result.diagnostic}`) + return result.stdout + } + + /** + * Run Git with options that keep it from running repository-supplied programs and allow only the configured transports. + * Cancellation by `signal` or the lifetime rejects with its reason; `stop` ends Git with a failed result. + */ + private async git( + args: readonly string[], cwd: string, signal: AbortSignal, stop?: AbortSignal, maxBuffer = this.options.maxRepositoryBytes, + ): Promise { + const safety = [ + // An empty `core.askPass` ends Git's askpass lookup where Windows drops the empty `GIT_ASKPASS`, so no dialog opens. + 'core.askPass=', 'core.hooksPath=/dev/null', 'core.fsmonitor=false', 'submodule.recurse=false', 'gc.auto=0', 'maintenance.auto=false', + 'protocol.allow=never', ...this.options.transports.map(transport => `protocol.${transport}.allow=always`), + ].flatMap(setting => ['-c', setting]) + const result = await execa('git', [...safety, ...args], { + // A blob's last byte may be a newline, so the output is kept exactly as Git wrote it. + cwd, env: gitEnv(), extendEnv: false, stdin: 'ignore', encoding: 'buffer', stripFinalNewline: false, reject: false, + maxBuffer: { stdout: maxBuffer, stderr: this.options.maxRepositoryBytes }, + timeout: this.options.timeoutMs, cancelSignal: AbortSignal.any([signal, this.options.lifetime, ...stop === undefined ? [] : [stop]]), + killDescendants: true, killSignal: 'SIGKILL', + }) + signal.throwIfAborted() + this.options.lifetime.throwIfAborted() + // Git that is missing or cannot run says nothing about the repository, so no read caches the failure. + return { + stdout: Buffer.from(result.stdout), failed: result.failed, + overflowed: result.isMaxBuffer && result.stdout.byteLength >= maxBuffer, + diagnostic: `${String(result.shortMessage)}\n${Buffer.from(result.stderr).toString('utf8').trim()}`, + } + } +} diff --git a/packages/boot/plugin-market/src/types.ts b/packages/boot/plugin-market/src/types.ts index 78ff20aedb..4a4663ad77 100644 --- a/packages/boot/plugin-market/src/types.ts +++ b/packages/boot/plugin-market/src/types.ts @@ -1,7 +1,9 @@ /** JSON-safe records and events of the plugin market Remote namespace. */ import type { MarketIndexMetadata, MarketInstallTarget, MarketPlugin, MarketVersion } from '@deepseek-ai/dsh-plugin-market-index' import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest' +import type { Branded } from '@deepseek-ai/dsh-brand' import type {} from '@deepseek-ai/dsh-typert-protocol' +import type { DetailFailureReason } from '@deepseek-ai/dsh-plugin-detail/types' declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { @@ -17,6 +19,15 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'plugin-market/invalid-query': { readonly field: string; readonly reason: string } /** Index sources cannot be saved without the settings service and this service's profile entry. */ 'plugin-market/settings-unavailable': Record + /** The index lists no such plugin or version. */ + 'plugin-market/unknown-plugin': { readonly name: string; readonly version: string } + /** + * A detail document exists but cannot be read: `too-large` beyond a size limit, `network` when a source cannot be + * reached, `denied` when Git refuses the repository with the user's credentials, `git-unavailable` when the + * repository must be cloned and Git cannot run, `invalid` for a malformed locale file or `meta.detail`, or a named + * file that does not exist. + */ + 'plugin-market/detail-unavailable': { readonly reason: DetailFailureReason } } } @@ -127,6 +138,27 @@ export interface MarketSearchResult { readonly nextOffset: number | null } +/** Opaque id of a read detail document; image requests name it to read files beside the document. */ +export type PluginDetailDocumentId = Branded<'PluginDetailDocumentId'> + +/** + * Where a detail document comes from: `locale` for the file `meta.detail` names, `readme` for the package's README, and + * `repository` for the README of the directory the package's `repository` names. + */ +export type PluginDetailOrigin = 'locale' | 'readme' | 'repository' + +/** A plugin's long description, or `none` when no source provides one. */ +export type PluginDetail = + | { + readonly status: 'found' + readonly origin: PluginDetailOrigin + /** Markdown text, decoded as UTF-8. */ + readonly markdown: string + /** Relative image paths of the document resolve beside it through `GET /api/plugin-market/detail-file`. */ + readonly document: PluginDetailDocumentId + } + | { readonly status: 'none' } + declare module '@deepseek-ai/cordis' { interface Events { /** diff --git a/packages/boot/plugin-market/tests/built-worker.e2e.ts b/packages/boot/plugin-market/tests/built-worker.e2e.ts index d5a52fe34d..da851ca37a 100644 --- a/packages/boot/plugin-market/tests/built-worker.e2e.ts +++ b/packages/boot/plugin-market/tests/built-worker.e2e.ts @@ -40,7 +40,7 @@ describe.skipIf(!built)('built index parsing (plain node)', () => { const ctx = new Context() try { await ctx.plugin(Manager) - await ctx.plugin(PluginMarket, { indexes: Object.keys(bodies) }) + await ctx.plugin(PluginMarket, { indexes: Object.keys(bodies), detailCacheDir: '/nonexistent/plugin-market-detail-cache' }) const query = { sort: { kind: 'index' }, offset: 0, limit: 10 } const valid = await ctx.pluginMarket.search('https://m.example/valid.json', query) const invalid = await ctx.pluginMarket.search('https://m.example/invalid.json', query).catch(error => error) diff --git a/packages/boot/plugin-market/tests/configuration.spec.ts b/packages/boot/plugin-market/tests/configuration.spec.ts index 5a6e21bb2a..3594b1993f 100644 --- a/packages/boot/plugin-market/tests/configuration.spec.ts +++ b/packages/boot/plugin-market/tests/configuration.spec.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSy import { tmpdir } from 'node:os' import { join } from 'node:path' import { Service, type Context } from '@deepseek-ai/cordis' -import { expect, it } from 'vitest' +import { expect, it, vi } from 'vitest' import { boot, initProfile, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot' import ConfigEditor from '@deepseek-ai/dsh-config-editor' import Settings from '@deepseek-ai/dsh-settings' @@ -55,3 +55,74 @@ it('saves index sources without remounting, announces them, and restores them at try { await ctx?.fiber.dispose() } finally { rmSync(home, { recursive: true, force: true }) } } }) + +/** The `connection` Fetch route registry, recording the routes the market registers. */ +class RecordingConnection extends Service { + static readonly routes = new Map Promise>() + readonly fetch = { + register: (route: { path: string; fetch: (request: Request) => Promise }) => { + RecordingConnection.routes.set(route.path, route.fetch) + return () => Promise.resolve(void RecordingConnection.routes.delete(route.path)) + }, + } + + constructor(ctx: Context) { super(ctx, 'connection') } +} + +it('reads a market entry detail document and serves its images through the composed profile', async () => { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-plugin-market-detail-'))) + const commit = 'c'.repeat(40) + const raw = `https://raw.githubusercontent.com/o/r/${commit}/pkg` + const files: Record = { + 'https://first.example/index.json': JSON.stringify({ format: 1, title: 'Market', plugins: [{ + name: 'from-git', versions: [{ version: '1.0.0', publishTimestamp: 1, source: { type: 'git', url: 'https://github.com/o/r', commit, path: 'pkg' }, engines: { dsh: '*' } }], + }] }), + [`${raw}/locale/en.json`]: JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }), + [`${raw}/locale/zh.json`]: JSON.stringify({ meta: { detail: 'guide/DETAIL.zh.md' } }), + [`${raw}/guide/DETAIL.md`]: '# Test entry\n\n![diagram](img/diagram.svg)\n', + [`${raw}/guide/DETAIL.zh.md`]: '# 测试条目\n', + [`${raw}/guide/img/diagram.svg`]: '', + // The repository is public, so a file missing from the raw host is missing from the repository. + 'https://github.com/o/r.git/info/refs?service=git-upload-pack': 'refs', + } + vi.stubGlobal('fetch', vi.fn((url: string) => Promise.resolve(url in files ? new Response(files[url]) : new Response('missing', { status: 404 })))) + let ctx: Awaited> | undefined + try { + const dir = join(home, 'profiles', 'test') + initProfile(dir, ['test-bundle']) + const bundle = join(dir, 'node_modules', 'test-bundle') + mkdirSync(bundle, { recursive: true }) + writeFileSync(join(home, 'package.json'), '{"name":"test-installation"}\n') + writeFileSync(join(bundle, 'package.json'), JSON.stringify({ name: 'test-bundle', version: '1.0.0', dsh: { bundle: { patch: 'cordis.patch.yml' } } })) + writeFileSync(join(bundle, 'cordis.patch.yml'), readFileSync(new URL('./fixtures/market.patch.yml', import.meta.url))) + writeFileSync(join(dir, 'cordis.yml'), '- name: cordis:connection\n') + const profile: ProfileContext = { + name: 'test', startedBundles: ['test-bundle'], dir, patchPath: join(dir, 'cordis.patch.yml'), + installAnchor: join(home, 'package.json'), cwd: home, home, overlays: [], telemetryDisabledEnv: undefined, + } + ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (root) => { + root.provide('profileContext', profile) + Object.assign(root.loader.builtins, { + editor: ConfigEditor, settings: Settings, manager: StubPluginManager, market: PluginMarket, connection: RecordingConnection, + }) + }) + const market = ctx.get('pluginMarket')! + const detail = await market.readEntryDetail('https://first.example/index.json', 'from-git', '1.0.0', 'en-US') + expect(detail).toMatchObject({ status: 'found', origin: 'locale', markdown: '# Test entry\n\n![diagram](img/diagram.svg)\n' }) + expect(await market.readEntryDetail('https://first.example/index.json', 'from-git', '1.0.0', 'zh-CN')).toMatchObject({ origin: 'locale', markdown: '# 测试条目\n' }) + if (detail.status !== 'found') throw new Error('expected a document') + const serve = RecordingConnection.routes.get('/api/plugin-market/detail-file')! + const image = await serve(new Request(`http://dsh.local/api/plugin-market/detail-file?document=${detail.document}&path=img%2Fdiagram.svg`)) + expect(image.status).toBe(200) + expect(image.headers.get('content-type')).toBe('image/svg+xml') + expect(image.headers.get('content-security-policy')).toContain('sandbox') + expect(await image.text()).toContain(' bytes === undefined ? undefined : new TextDecoder().decode(bytes) + +afterEach(() => { vi.unstubAllGlobals() }) + +/** Answer requests by URL with a fresh response each time; any other URL fails the test. */ +function serve(routes: Record Response)>) { + const fetch = vi.fn((url: string) => { + const route = routes[url] + if (route === undefined) throw new Error(`unexpected fetch ${url}`) + if (route instanceof Error) return Promise.reject(route) + return Promise.resolve(route()) + }) + vi.stubGlobal('fetch', fetch) + return fetch +} + +/** A clone cache double recording its calls. */ +function clones(answers: { public?: boolean | Error; read?: string; readme?: string } = {}) { + const calls: string[] = [] + const cache = { + isPublic: vi.fn((url: string) => { + calls.push(`public ${url}`) + return answers.public instanceof Error ? Promise.reject(answers.public) : Promise.resolve(answers.public ?? false) + }), + read: vi.fn((url: string, commit: string | undefined, path: string) => { + calls.push(`read ${url} ${String(commit)} ${path}`) + return Promise.resolve(answers.read === undefined ? undefined : Buffer.from(answers.read)) + }), + readme: vi.fn((url: string, commit: string | undefined, dir: string) => { + calls.push(`readme ${url} ${String(commit)} ${dir}`) + return Promise.resolve(answers.readme === undefined ? undefined : { path: `${dir}/README.md`, bytes: Buffer.from(answers.readme) }) + }), + } + const settings: RepositoryFileOptions = { + ...options, gitlabHosts: ['gitlab.example'], cache, + } + return { calls, settings } +} + +const GITHUB = { host: 'github.com', project: 'o/r', url: 'https://github.com/o/r.git' } +const GITLAB = { host: 'gitlab.example', project: 'g/s/p', url: 'https://gitlab.example/g/s/p.git' } +const OTHER = { host: 'git.example', project: 'o/r', url: 'https://git.example/o/r.git' } +const COMMIT = 'a'.repeat(40) + +it('reads public repositories through the GitHub and GitLab single-file APIs', async () => { + serve({ + [`https://raw.githubusercontent.com/o/r/${COMMIT}/p/README.md`]: () => new Response('# gh'), + 'https://raw.githubusercontent.com/o/r/HEAD/README.md': () => new Response('no', { status: 404 }), + 'https://raw.githubusercontent.com/o/r/HEAD/readme.md': () => new Response('no', { status: 404 }), + 'https://raw.githubusercontent.com/o/r/HEAD/Readme.md': () => new Response('# root'), + 'https://raw.githubusercontent.com/o/r/HEAD/missing.png': () => new Response('no', { status: 404 }), + 'https://gitlab.example/api/v4/projects/g%2Fs%2Fp/repository/files/guide%2Fa.md/raw?ref=HEAD': () => new Response('# gl'), + 'https://raw.githubusercontent.com/o/r/HEAD/down.md': () => new Response('down', { status: 500 }), + }) + const { calls, settings } = clones({ public: true }) + const pinned = repositoryFiles(GITHUB, COMMIT, settings) + expect(pinned.key).toBe(`git:https://github.com/o/r.git#${COMMIT}`) + expect(await pinned.readme('p', 100, signal)).toMatchObject({ path: 'p/README.md' }) + const head = repositoryFiles(GITHUB, undefined, settings) + expect(await head.readme('', 100, signal)).toMatchObject({ path: 'Readme.md' }) + expect(await head.read('missing.png', 100, signal)).toBeUndefined() + await expect(head.read('down.md', 100, signal)).rejects.toMatchObject({ reason: 'network' }) + expect(text(await repositoryFiles(GITLAB, undefined, settings).read('guide/a.md', 100, signal))).toBe('# gl') + // Each ambiguous answer asks whether the repository is public; the clone cache shares and caches that check. + expect(calls).toEqual(['public https://github.com/o/r.git', 'public https://github.com/o/r.git']) +}) + +it('reads repositories that need credentials, and repositories on hosts without a single-file API, from the clone', async () => { + serve({ + 'https://raw.githubusercontent.com/o/r/HEAD/README.md': () => new Response('no', { status: 404 }), + 'https://raw.githubusercontent.com/o/r/HEAD/a.png': () => new Response('no', { status: 403 }), + }) + const { calls, settings } = clones({ public: false, read: 'png', readme: '# private' }) + const files = repositoryFiles(GITHUB, undefined, settings) + expect(await files.readme('', 100, signal)).toMatchObject({ path: '/README.md' }) + expect(text(await files.read('a.png', 100, signal))).toBe('png') + const other = repositoryFiles(OTHER, COMMIT, settings) + expect(text(await other.read('b.png', 100, signal))).toBe('png') + expect((await other.readme('d', 100, signal))?.path).toBe('d/README.md') + expect(calls).toEqual([ + 'public https://github.com/o/r.git', + 'readme https://github.com/o/r.git undefined ', + 'public https://github.com/o/r.git', + 'read https://github.com/o/r.git undefined a.png', + `read https://git.example/o/r.git ${COMMIT} b.png`, + `readme https://git.example/o/r.git ${COMMIT} d`, + ]) + const cleartext = repositoryFiles({ host: 'git.example', project: 'o/r', url: 'http://git.example/o/r.git' }, undefined, settings) + expect(text(await cleartext.read('c.png', 100, signal))).toBe('png') +}) + +it('reports a failed anonymous check as the read\'s failure', async () => { + serve({ 'https://raw.githubusercontent.com/o/r/HEAD/a.md': () => new Response('no', { status: 404 }) }) + const { calls, settings } = clones({ public: new DetailFailure('network', 'offline') }) + const files = repositoryFiles(GITHUB, undefined, settings) + await expect(files.read('a.md', 100, signal)).rejects.toMatchObject({ reason: 'network' }) + await expect(files.read('a.md', 100, signal)).rejects.toMatchObject({ reason: 'network' }) + expect(calls).toEqual(['public https://github.com/o/r.git', 'public https://github.com/o/r.git']) +}) diff --git a/packages/boot/plugin-market/tests/detail-reader.spec.ts b/packages/boot/plugin-market/tests/detail-reader.spec.ts new file mode 100644 index 0000000000..46edcfa709 --- /dev/null +++ b/packages/boot/plugin-market/tests/detail-reader.spec.ts @@ -0,0 +1,182 @@ +/** Market entry detail documents: package document, repository README, caching, and images beside a read document. */ +import { afterEach, expect, it, vi } from 'vitest' +import { DetailFailure, readmeName, type DetailFiles } from '@deepseek-ai/dsh-plugin-detail' +import { DetailReader, manifestRepository, repositoryOf, type DetailLocation } from '../src/detail.ts' +import type { RepositoryFileOptions } from '../src/detail-files.ts' + +/** Await a rejection that matches `fields` and whose message contains `message`. */ +async function expectRejection(promise: Promise, fields: object, message: string): Promise { + await expect(promise).rejects.toMatchObject(fields) + await expect(promise).rejects.toThrow(message) +} + +/** In-memory files under one key. */ +function memory(key: string, contents: Record): DetailFiles & { reads: string[] } { + const reads: string[] = [] + const read = (path: string, maxBytes: number) => { + reads.push(path) + const value = contents[path] + if (value === undefined) return Promise.resolve(undefined) + if (value.length > maxBytes) return Promise.reject(new DetailFailure('too-large', path)) + return Promise.resolve(new TextEncoder().encode(value)) + } + return { + key, reads, read, + async readme(dir, maxBytes) { + const prefix = dir === '' ? '' : `${dir}/` + const name = readmeName(Object.keys(contents).filter(path => path.startsWith(prefix)).map(path => path.slice(prefix.length))) + if (name === undefined) return undefined + const path = `${prefix}${name}` + return { path, bytes: (await read(path, maxBytes)) as Uint8Array } + }, + } +} + +const lifetime = new AbortController().signal + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +function reader(repositoryContents: Record = {}, limits: { ttlMs?: number } = {}) { + const raw = vi.fn((url: string) => Promise.resolve(new Response(repositoryContents[url] ?? 'missing', { status: url in repositoryContents ? 200 : 404 }))) + vi.stubGlobal('fetch', raw) + const repositories: RepositoryFileOptions = { + timeoutMs: 1000, lifetime, gitlabHosts: [], + cache: { isPublic: () => Promise.resolve(true), read: () => Promise.resolve(undefined), readme: () => Promise.resolve(undefined) }, + } + return new DetailReader({ maxDocumentBytes: 100, maxImageBytes: 10, ttlMs: limits.ttlMs ?? 60_000 }, repositories) +} + +const EN_ZH = ['zh', 'en'] + +it('parses repositories from manifests, shorthands, and web pages', () => { + expect(manifestRepository('github:o/r')).toEqual({ host: 'github.com', project: 'o/r', url: 'https://github.com/o/r.git', directory: '' }) + expect(manifestRepository({ type: 'git', url: 'git+https://gitlab.com/g/s/p.git#main', directory: './packages/a/' })) + .toEqual({ host: 'gitlab.com', project: 'g/s/p', url: 'https://gitlab.com/g/s/p.git', directory: 'packages/a' }) + expect(manifestRepository({ url: 'git+https://git.example:8443/team/repo.git', directory: '../x' })) + .toEqual({ host: 'git.example', project: 'team/repo', url: 'https://git.example:8443/team/repo.git', directory: '' }) + expect(manifestRepository({ url: 'github:o/r', directory: '/abs' })).toMatchObject({ directory: '' }) + expect(manifestRepository({ url: 'https://git.example/team/repo', directory: '.' })).toMatchObject({ directory: '' }) + expect(manifestRepository({ url: 'https://git.example/team/repo', directory: 7 })).toMatchObject({ directory: '' }) + for (const value of [undefined, null, 7, { url: 7 }, 'not a repository', 'ftp://git.example/a/b', 'https://git.example/one', 'gist:abc']) { + expect(manifestRepository(value)).toBeUndefined() + } + expect(repositoryOf('https://github.com/o/r/tree/main/pkg')).toEqual({ host: 'github.com', project: 'o/r', url: 'https://github.com/o/r.git' }) + expect(repositoryOf('https://git.corp.example/g/p/-/tree/main')).toEqual({ host: 'git.corp.example', project: 'g/p', url: 'https://git.corp.example/g/p.git' }) + expect(repositoryOf('https://user:pw@git.example/a/b')).toBeUndefined() + expect(repositoryOf('http://intranet/a/b/')).toEqual({ host: 'intranet', project: 'a/b', url: 'http://intranet/a/b.git' }) +}) + +it('reads the package document first, and reports an invalid one', async () => { + const files = memory('npm:a@1', { + 'locale/en.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }), + 'guide/DETAIL.md': '# Detail', + 'README.md': '# Readme', + }) + const details = reader() + expect(await details.read({ files, dir: '', repository: 'manifest' }, EN_ZH)).toMatchObject({ status: 'found', origin: 'locale', markdown: '# Detail' }) + expect(await details.read({ files: memory('npm:r@1', { 'README.md': '# Readme' }), dir: '', repository: undefined }, ['en'])) + .toMatchObject({ origin: 'readme', markdown: '# Readme' }) + const missing = memory('npm:b@1', { 'locale/en.json': JSON.stringify({ meta: { detail: 'GONE.md' } }) }) + await expectRejection(details.read({ files: missing, dir: '', repository: undefined }, ['en']), { reason: 'invalid' }, 'GONE.md') +}) + +it('reads the repository README last, from package.json or the given repository, at the default branch', async () => { + const details = reader({ + 'https://raw.githubusercontent.com/o/r/HEAD/packages/a/README.md': '# Repo A', + 'https://raw.githubusercontent.com/o/r/HEAD/README.md': '# Repo', + }) + const files = memory('npm:a@1', { 'package.json': JSON.stringify({ repository: { url: 'github:o/r', directory: 'packages/a' } }) }) + expect(await details.read({ files, dir: '', repository: 'manifest' }, ['en'])).toMatchObject({ origin: 'repository', markdown: '# Repo A' }) + expect(await details.read({ files: memory('npm:b@1', {}), dir: '', repository: 'manifest' }, ['en'])).toEqual({ status: 'none' }) + expect(await details.read({ files: memory('npm:c@1', { 'package.json': '{' }), dir: '', repository: 'manifest' }, ['en'])).toEqual({ status: 'none' }) + const repository = { host: 'github.com', project: 'o/r', url: 'https://github.com/o/r.git', directory: '' } + expect(await details.read({ dir: '', repository }, ['en'])).toMatchObject({ origin: 'repository', markdown: '# Repo' }) + expect(await details.read({ dir: '', repository: { ...repository, directory: 'missing' } }, ['en'])).toEqual({ status: 'none' }) + expect(await details.read({ dir: '', repository: undefined }, ['en'])).toEqual({ status: 'none' }) +}) + +it('reports a failure of the declared repository', async () => { + const details = reader() + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline')))) + const github = { host: 'github.com', project: 'o/r', url: 'https://github.com/o/r.git', directory: '' } + await expectRejection(details.read({ dir: '', repository: github }, ['en']), { reason: 'network' }, 'offline') +}) + +it('shares concurrent reads, reuses results and lasting failures until they expire, and asks again after a network failure', async () => { + vi.useFakeTimers() + const files = memory('npm:a@1', { 'README.md': '# A' }) + const details = reader({}, { ttlMs: 1000 }) + const location: DetailLocation = { files, dir: '', repository: undefined } + const [first, second] = await Promise.all([details.read(location, ['en']), details.read(location, ['en'])]) + expect(second).toBe(first) + expect(await details.read(location, ['en'])).toBe(first) + expect(files.reads.filter(path => path === 'README.md')).toHaveLength(1) + vi.advanceTimersByTime(1001) + await details.read(location, ['en']) + expect(files.reads.filter(path => path === 'README.md')).toHaveLength(2) + + const big = memory('npm:big@1', { 'README.md': 'x'.repeat(200) }) + await expect(details.read({ files: big, dir: '', repository: undefined }, ['en'])).rejects.toMatchObject({ reason: 'too-large' }) + await expect(details.read({ files: big, dir: '', repository: undefined }, ['en'])).rejects.toMatchObject({ reason: 'too-large' }) + expect(big.reads.filter(path => path === 'README.md')).toHaveLength(1) + + let calls = 0 + const flaky: DetailFiles = { + key: 'flaky', read: () => Promise.resolve(undefined), + readme: () => (++calls === 1 ? Promise.reject(new DetailFailure('network', 'offline')) : Promise.resolve(undefined)), + } + await expect(details.read({ files: flaky, dir: '', repository: undefined }, ['en'])).rejects.toMatchObject({ reason: 'network' }) + expect(await details.read({ files: flaky, dir: '', repository: undefined }, ['en'])).toEqual({ status: 'none' }) + const broken: DetailFiles = { key: 'broken', read: () => Promise.reject(new TypeError('bug')), readme: () => Promise.resolve(undefined) } + await expect(details.read({ files: broken, dir: '', repository: undefined }, ['en'])).rejects.toThrow('bug') +}) + +it('forgets a document id unused for the result lifetime, and keeps one that reads or images keep using', async () => { + vi.useFakeTimers() + const details = reader({}, { ttlMs: 1000 }) + const read = (key: string) => details.read({ files: memory(key, { 'README.md': '# A', 'a.png': 'png' }), dir: '', repository: undefined }, ['en']) + const kept = await read('npm:kept@1') + const idle = await read('npm:idle@1') + if (kept.status !== 'found' || idle.status !== 'found') throw new Error('expected documents') + vi.advanceTimersByTime(600) + expect(await details.image(kept.document, 'a.png')).toMatchObject({ mediaType: 'image/png' }) + vi.advanceTimersByTime(600) + // The cached read of `kept` expired, but its image request keeps the id; `idle` went unused for longer than the lifetime. + expect(await details.image(kept.document, 'a.png')).toMatchObject({ mediaType: 'image/png' }) + expect(await details.image(idle.document, 'a.png')).toBeUndefined() + expect(await read('npm:idle@1')).toEqual(idle) + expect(await details.image(idle.document, 'a.png')).toMatchObject({ mediaType: 'image/png' }) + vi.advanceTimersByTime(500) + // A cached read marks its document used. + expect(await read('npm:idle@1')).toEqual(idle) + vi.advanceTimersByTime(700) + expect(await details.image(idle.document, 'a.png')).toMatchObject({ mediaType: 'image/png' }) // A cached read without a document marks nothing. + const empty = { files: memory('npm:empty@1', {}), dir: '', repository: undefined } as const + expect(await details.read(empty, ['en'])).toEqual({ status: 'none' }) + expect(await details.read(empty, ['en'])).toEqual({ status: 'none' }) +}) + +it('reads images beside a document and refuses other paths', async () => { + const files = memory('npm:a@1', { + 'locale/en.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }), + 'guide/DETAIL.md': '# Detail', 'guide/img/a.png': 'png', 'logo.SVG': '', 'guide/notes.txt': 'text', 'guide/big.gif': 'x'.repeat(20), + 'guide/a b.webp': 'webp', + }) + const details = reader() + const detail = await details.read({ files, dir: '', repository: undefined }, ['en']) + if (detail.status !== 'found') throw new Error('expected a document') + expect(await details.image(detail.document, 'img/a.png?raw=1#x')).toEqual({ bytes: new TextEncoder().encode('png'), mediaType: 'image/png' }) + expect(await details.image(detail.document, '/logo.SVG')).toMatchObject({ mediaType: 'image/svg+xml' }) + expect(await details.image(detail.document, './a%20b.webp')).toMatchObject({ mediaType: 'image/webp' }) + for (const path of ['../../etc/passwd.png', 'notes.txt', 'img/missing.png']) { + expect(await details.image(detail.document, path)).toBeUndefined() + } + await expect(details.image(detail.document, 'big.gif')).rejects.toMatchObject({ reason: 'too-large' }) + expect(await details.image('unknown', 'img/a.png')).toBeUndefined() + const root = await details.read({ files: memory('npm:b@1', { 'README.md': '# B', 'b.png': 'b' }), dir: '', repository: undefined }, ['en']) + if (root.status !== 'found') throw new Error('expected a document') + expect(await details.image(root.document, 'b.png')).toMatchObject({ mediaType: 'image/png' }) +}) diff --git a/packages/boot/plugin-market/tests/detail-remote.spec.ts b/packages/boot/plugin-market/tests/detail-remote.spec.ts new file mode 100644 index 0000000000..ddcf9d1333 --- /dev/null +++ b/packages/boot/plugin-market/tests/detail-remote.spec.ts @@ -0,0 +1,184 @@ +/** The detail Remote methods and the image route of the plugin market service. */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context, Service } from '@deepseek-ai/cordis' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest' +import { DetailReader } from '../src/detail.ts' +import PluginMarket from '../src/index.ts' +import { RepositoryCache } from '../src/repository-cache.ts' +import { parseIndexBody } from '../src/parse.ts' + +vi.mock('../src/parse-thread.ts', () => ({ + parseIndexInWorker: async (body: Uint8Array, url: string) => await Promise.resolve(parseIndexBody(body, url)), +})) + +const INDEX = 'https://market.example/index.json' +const COMMIT = 'b'.repeat(40) +let home: string + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'dsh-plugin-market-detail-remote-')) + // Every repository of these tests is public; no test runs Git against a network host. + vi.spyOn(RepositoryCache.prototype, 'isPublic').mockResolvedValue(true) +}) +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + rmSync(home, { recursive: true, force: true }) +}) + +/** Serve fixed bodies by URL; any other URL answers 404. */ +function serve(routes: Record) { + const fetch = vi.fn((url: string) => { + const body = routes[url] + if (body instanceof Error) return Promise.reject(body) + if (body === undefined) return Promise.resolve(new Response('missing', { status: 404 })) + return Promise.resolve(new Response(typeof body === 'string' ? body : JSON.stringify(body))) + }) + vi.stubGlobal('fetch', fetch) + return fetch +} + +const index = { + format: 1, title: 'Market', plugins: [ + { + name: 'from-git', repository: { url: 'https://github.com/o/mono', directory: 'other' }, + versions: [{ version: '1.0.0', publishTimestamp: 1, source: { type: 'git', url: 'https://github.com/o/r', commit: COMMIT, path: 'pkg' }, engines: { dsh: '*' } }], + }, + { + name: 'from-git-root', repository: { url: 'https://example.com/only' }, + versions: [{ version: '1.0.0', publishTimestamp: 1, source: { type: 'git', url: 'https://github.com/o/root', commit: COMMIT }, engines: { dsh: '*' } }], + }, + { + name: 'from-git-self', repository: { url: 'https://github.com/o/self', directory: 'pkg' }, + versions: [{ version: '1.0.0', publishTimestamp: 1, source: { type: 'git', url: 'https://github.com/o/self', commit: COMMIT, path: 'pkg' }, engines: { dsh: '*' } }], + }, + { name: 'bare', versions: [{ version: '1.0.0', publishTimestamp: 1, source: { type: 'npm' }, engines: { dsh: '*' } }] }, + { + name: 'from-npm', versions: [ + { version: '2.0.0', publishTimestamp: 1, source: { type: 'npm' }, engines: { dsh: '*' } }, + { version: '1.0.0', publishTimestamp: 1, source: { type: 'npm', registry: 'https://npm.corp.example/' }, engines: { dsh: '*' } }, + { version: '0.9.0', publishTimestamp: 1, source: { type: 'npm', registry: 'https://registry.npmmirror.com/' }, engines: { dsh: '*' } }, + ], + repository: { url: 'https://github.com/o/npm' }, + }, + ], +} + +async function mount(options: { profile?: boolean; connection?: boolean } = {}) { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + class Manager extends Service { + constructor(inner: Context) { super(inner, 'pluginManager') } + listBundles() { return Promise.resolve([]) } + listVersionExemptions() { return { exemptions: {}, warnings: [] } } + } + await ctx.plugin(Manager) + if (options.profile === true) { + ctx.provide('profileContext', { + name: 'test', startedBundles: [], dir: join(home, 'profile'), patchPath: join(home, 'profile', 'cordis.patch.yml'), + installAnchor: join(home, 'package.json'), cwd: home, home, overlays: [], telemetryDisabledEnv: undefined, + }) + } + const routes = new Map Promise>() + if (options.connection === true) { + class Connection extends Service { + readonly fetch = { + register: (route: { path: string; fetch: (request: Request) => Promise }) => { + routes.set(route.path, route.fetch) + return () => Promise.resolve(void routes.delete(route.path)) + }, + } + + constructor(inner: Context) { super(inner, 'connection') } + } + await ctx.plugin(Connection) + } + await ctx.plugin(PluginMarket, { indexes: [INDEX], ...options.profile === true ? {} : { detailCacheDir: join(home, 'cache') } }) + return { market: ctx.pluginMarket, routes } +} + +const failureOf = async (promise: Promise) => remoteErrorOf(await promise.catch((error: unknown) => error)) + +it('requires a clone cache directory outside a profile', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + class Manager extends Service { + constructor(inner: Context) { super(inner, 'pluginManager') } + } + await ctx.plugin(Manager) + await expect(ctx.plugin(PluginMarket, { indexes: [] })).rejects.toThrow('detailCacheDir must be an absolute path') +}) + +it('reads market entries from their version source and the entry repository', async () => { + const requests = serve({ + [INDEX]: index, + [`https://raw.githubusercontent.com/o/r/${COMMIT}/pkg/README.md`]: '# Git', + [`https://raw.githubusercontent.com/o/root/${COMMIT}/README.md`]: '# Root', + 'https://cdn.jsdelivr.net/npm/from-npm@2.0.0/README.md': '# Npm', + 'https://raw.githubusercontent.com/o/npm/HEAD/README.md': '# Npm repository', + }) + const { market } = await mount({ profile: true }) + expect(await market.readEntryDetail(INDEX, 'from-git', '1.0.0', 'en')).toMatchObject({ origin: 'readme', markdown: '# Git' }) + expect(await market.readEntryDetail(INDEX, 'from-git-root', '1.0.0', 'en')).toMatchObject({ origin: 'readme', markdown: '# Root' }) + expect(await market.readEntryDetail(INDEX, 'bare', '1.0.0', 'en')).toEqual({ status: 'none' }) + // The entry's repository is the source directory just read, so its default branch is not read again. + expect(await market.readEntryDetail(INDEX, 'from-git-self', '1.0.0', 'en')).toEqual({ status: 'none' }) + expect(requests.mock.calls.map(([url]) => url).filter(url => url.includes('/o/self/HEAD/'))).toEqual([]) + expect(await market.readEntryDetail(INDEX, 'from-npm', '2.0.0', 'en')).toMatchObject({ origin: 'readme', markdown: '# Npm' }) + // A private registry's package files are not on the public CDNs; only the entry's repository is read. + expect(await market.readEntryDetail(INDEX, 'from-npm', '1.0.0', 'en')).toMatchObject({ origin: 'repository', markdown: '# Npm repository' }) + expect(await market.readEntryDetail(INDEX, 'from-npm', '0.9.0', 'en')).toMatchObject({ origin: 'repository' }) + expect(await failureOf(market.readEntryDetail(INDEX, 'from-npm', '3.0.0', 'en'))) + .toMatchObject({ code: 'plugin-market/unknown-plugin', details: { name: 'from-npm', version: '3.0.0' } }) + expect(await failureOf(market.readEntryDetail(INDEX, 'absent', '1.0.0', 'en'))).toMatchObject({ code: 'plugin-market/unknown-plugin' }) + expect(await failureOf(market.readEntryDetail(INDEX, 'absent', '1.0.0', '!'))).toMatchObject({ code: 'plugin-market/invalid-query' }) + serve({ [INDEX]: index, 'https://registry.npmmirror.com/from-npm/2.0.0/files/README.md': '# Mirror' }) + const { market: mirrored } = await mount() + expect(await mirrored.readEntryDetail(INDEX, 'from-npm', '2.0.0', 'en', { npmFileSources: ['npmmirror'] })).toMatchObject({ markdown: '# Mirror' }) +}) + +it('reports unreadable sources as detail-unavailable and keeps other failures', async () => { + serve({ [INDEX]: index, 'https://cdn.jsdelivr.net/npm/from-npm@2.0.0/locale/en.json': new Error('offline') }) + const { market } = await mount() + await market.search(INDEX, { sort: { kind: 'index' }, offset: 0, limit: 1 }) + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline')))) + expect(await failureOf(market.readEntryDetail(INDEX, 'from-npm', '2.0.0', 'en'))) + .toMatchObject({ code: 'plugin-market/detail-unavailable', details: { reason: 'network' } }) + const read = vi.spyOn(DetailReader.prototype, 'read').mockRejectedValueOnce(new TypeError('bug')) + try { + await expect(market.readEntryDetail(INDEX, 'from-npm', '2.0.0', 'en')).rejects.toThrow('bug') + } finally { + read.mockRestore() + } +}) + +it('serves images beside a read document and maps failures to statuses', async () => { + serve({ + [INDEX]: index, + [`https://raw.githubusercontent.com/o/r/${COMMIT}/pkg/README.md`]: '# Git ![a](a.png)', + [`https://raw.githubusercontent.com/o/r/${COMMIT}/pkg/a.png`]: 'png', + }) + const { market, routes } = await mount({ connection: true }) + const detail = await market.readEntryDetail(INDEX, 'from-git', '1.0.0', 'en') + if (detail.status !== 'found') throw new Error('expected a document') + const route = routes.get('/api/plugin-market/detail-file')! + const get = (query: string) => route(new Request(`http://dsh.local/api/plugin-market/detail-file${query}`)) + const ok = await get(`?document=${detail.document}&path=a.png`) + expect([ok.status, ok.headers.get('content-type'), ok.headers.get('x-content-type-options'), await ok.text()]) + .toEqual([200, 'image/png', 'nosniff', 'png']) + expect((await get(`?document=${detail.document}`)).status).toBe(400) + expect((await get(`?document=${detail.document}&path=missing.png`)).status).toBe(404) + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('x'.repeat(10), { headers: { 'content-length': String(6 * 1024 * 1024) } })))) + expect((await get(`?document=${detail.document}&path=large.png`)).status).toBe(413) + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline')))) + expect((await get(`?document=${detail.document}&path=b.png`)).status).toBe(502) + const image = vi.spyOn(DetailReader.prototype, 'image').mockRejectedValueOnce(new TypeError('bug')) + try { + await expect(get(`?document=${detail.document}&path=c.png`)).rejects.toThrow('bug') + } finally { + image.mockRestore() + } +}) diff --git a/packages/boot/plugin-market/tests/market-parse-failure.spec.ts b/packages/boot/plugin-market/tests/market-parse-failure.spec.ts index d17c85905a..f52fa177ca 100644 --- a/packages/boot/plugin-market/tests/market-parse-failure.spec.ts +++ b/packages/boot/plugin-market/tests/market-parse-failure.spec.ts @@ -3,6 +3,9 @@ import { Context, Service } from '@deepseek-ai/cordis' import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import { expect, it, onTestFinished, vi } from 'vitest' +/** Clone cache directory of services whose tests read no repository; nothing creates it. */ +const UNUSED_CACHE = '/nonexistent/plugin-market-detail-cache' + vi.mock('../src/parse-thread.ts', () => ({ parseIndexInWorker: () => Promise.reject(new Error('parser crashed')) })) const { default: PluginMarket } = await import('../src/index.ts') @@ -18,7 +21,7 @@ it('reports a parse Worker failure as plugin-market/fetch-failed', async () => { listVersionExemptions() { return { exemptions: {}, warnings: [] } } } await ctx.plugin(Manager) - await ctx.plugin(PluginMarket, { indexes: ['https://market.example/index.json'] }) + await ctx.plugin(PluginMarket, { indexes: ['https://market.example/index.json'], detailCacheDir: UNUSED_CACHE }) const failure = await ctx.pluginMarket.search('https://market.example/index.json', { sort: { kind: 'index' }, offset: 0, limit: 1 }) .then(() => { throw new Error('resolved') }, (error: unknown) => remoteErrorOf(error)) expect(failure).toMatchObject({ code: 'plugin-market/fetch-failed', details: { reason: 'parser crashed' } }) diff --git a/packages/boot/plugin-market/tests/market.spec.ts b/packages/boot/plugin-market/tests/market.spec.ts index f0112f07b8..2bd2e998b5 100644 --- a/packages/boot/plugin-market/tests/market.spec.ts +++ b/packages/boot/plugin-market/tests/market.spec.ts @@ -7,6 +7,9 @@ import { afterEach, expect, it, onTestFinished, vi } from 'vitest' import PluginMarket, { type Config, type MarketQuery } from '../src/index.ts' import { parseIndexBody } from '../src/parse.ts' +/** Clone cache directory of services whose tests read no repository; nothing creates it. */ +const UNUSED_CACHE = '/nonexistent/plugin-market-detail-cache' + // Parse in-process; parse-worker.spec.ts runs the real Worker. vi.mock('../src/parse-thread.ts', () => ({ parseIndexInWorker: async (body: Uint8Array, url: string, signal: AbortSignal) => { @@ -66,7 +69,7 @@ async function mount(config: Partial<{ indexes: string[]; fetchTimeoutMs: number captured = resolved } } - await ctx.plugin(CapturingMarket, { indexes: [URL_A], ...config }) + await ctx.plugin(CapturingMarket, { indexes: [URL_A], detailCacheDir: UNUSED_CACHE, ...config }) if (captured === undefined) throw new Error('market did not mount') return { ctx, market: ctx.pluginMarket, config: captured } } diff --git a/packages/boot/plugin-market/tests/repository-cache.spec.ts b/packages/boot/plugin-market/tests/repository-cache.spec.ts new file mode 100644 index 0000000000..bb57767bc3 --- /dev/null +++ b/packages/boot/plugin-market/tests/repository-cache.spec.ts @@ -0,0 +1,295 @@ +/** Partial clones of real local repositories: reads, README lookup, limits, eviction, and Git failures. */ +import { execFileSync } from 'node:child_process' +import { createHash, randomBytes } from 'node:crypto' +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { DetailFailure } from '@deepseek-ai/dsh-plugin-detail' +import { RepositoryCache, type RepositoryCacheOptions } from '../src/repository-cache.ts' + +/** Await a rejection that matches `fields` and whose message contains `message`. */ +async function expectRejection(promise: Promise, fields: object, message: string): Promise { + await expect(promise).rejects.toMatchObject(fields) + await expect(promise).rejects.toThrow(message) +} + + +let root: string +const lifetime = new AbortController() +const signal = new AbortController().signal +const text = (bytes: Uint8Array | undefined): string => new TextDecoder().decode(bytes) + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'dsh-plugin-market-repositories-')) + // Git reads no configuration of the developer running the tests. + mkdirSync(join(root, 'home')) + vi.stubEnv('HOME', join(root, 'home')) + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'home')) +}) +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + rmSync(root, { recursive: true, force: true }) +}) + +function git(cwd: string, ...args: string[]): string { + // Fixture repositories ignore inherited Git overrides, such as a hook's `GIT_DIR`, and every user or system configuration. + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_'))) + return execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', '-c', 'commit.gpgsign=false', ...args], { + cwd, encoding: 'utf8', + env: { ...env, HOME: join(root, 'home'), XDG_CONFIG_HOME: join(root, 'home'), GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, + }).trim() +} + +/** The cache directory name of a repository URL. */ +function cloneName(url: string): string { + return createHash('sha256').update(url).digest('hex').slice(0, 24) +} + +/** Bytes of the regular files below a directory. */ +function dirSize(dir: string): number { + return readdirSync(dir, { recursive: true, encoding: 'utf8' }) + .map(name => lstatSync(join(dir, name))) + .reduce((sum, entry) => sum + (entry.isFile() ? entry.size : 0), 0) +} + +/** A repository with `files` committed on its default branch, served as a `file:` URL. */ +function repository(name: string, files: Record): { url: string; commit: string; dir: string } { + const dir = join(root, 'sources', name) + mkdirSync(dir, { recursive: true }) + git(dir, 'init', '--quiet', '--initial-branch=main') + // A partial fetch from a local repository needs the server side to accept object filters. + git(dir, 'config', 'uploadpack.allowFilter', 'true') + git(dir, 'config', 'uploadpack.allowAnySHA1InWant', 'true') + for (const [path, contents] of Object.entries(files)) { + mkdirSync(dirname(join(dir, path)), { recursive: true }) + writeFileSync(join(dir, path), contents) + } + git(dir, 'add', '-A') + git(dir, 'commit', '--quiet', '-m', 'init') + return { url: pathToFileURL(dir).href, commit: git(dir, 'rev-parse', 'HEAD'), dir } +} + +function cache(options: Partial = {}): RepositoryCache { + return new RepositoryCache({ + dir: join(root, 'cache'), maxRepositoryBytes: 10 * 1024 * 1024, maxCacheBytes: 100 * 1024 * 1024, + timeoutMs: 30_000, ttlMs: 60_000, transports: ['file', 'ssh'], lifetime: lifetime.signal, sizePollMs: 1, ...options, + }) +} + +it('reads files at a pinned commit and the default branch, fetching contents only when read', async () => { + const source = repository('a', { 'packages/p/README.md': '# P', 'packages/p/img/a.png': 'png', 'big.bin': 'x'.repeat(4096) }) + const repositories = cache() + expect(new TextDecoder().decode(await repositories.read(source.url, source.commit, 'packages/p/img/a.png', 100, signal))).toBe('png') + expect(await repositories.read(source.url, source.commit, 'packages/p/missing.png', 100, signal)).toBeUndefined() + expect(await repositories.read(source.url, source.commit, 'packages/p', 100, signal)).toBeUndefined() + await expect(repositories.read(source.url, source.commit, 'big.bin', 100, signal)).rejects.toMatchObject({ reason: 'too-large' }) + // A file over its read's size leaves the clone usable. + expect(readdirSync(join(root, 'cache'))).toHaveLength(1) + writeFileSync(join(source.dir, 'packages/p/README.md'), '# P2') + git(source.dir, 'commit', '--quiet', '-am', 'second') + expect(new TextDecoder().decode((await repositories.readme(source.url, undefined, 'packages/p', 100, signal))?.bytes)).toBe('# P2') + writeFileSync(join(source.dir, 'packages/p/README.md'), '# P3') + git(source.dir, 'commit', '--quiet', '-am', 'third') + // The default branch commit stays cached for the TTL. + expect(new TextDecoder().decode((await repositories.readme(source.url, undefined, 'packages/p', 100, signal))?.bytes)).toBe('# P2') + expect(await repositories.readme(source.url, source.commit, 'packages/p/img', 100, signal)).toBeUndefined() + expect(new TextDecoder().decode((await repositories.readme(source.url, source.commit, 'packages/p', 100, signal))?.bytes)).toBe('# P') + await repositories.settled() +}) + +it('picks the README of a root directory by name order, in any letter case', async () => { + const source = repository('b', { 'readme.markdown': 'second', 'Readme.md': 'first', 'docs/readme': 'plain' }) + const repositories = cache() + expect(await repositories.readme(source.url, source.commit, '', 100, signal)).toMatchObject({ path: 'Readme.md' }) + expect(await repositories.readme(source.url, source.commit, 'docs', 100, signal)).toMatchObject({ path: 'docs/readme' }) +}) + +it('asks the smart HTTP endpoint without credentials whether a repository is public, and caches the answer', async () => { + const answers: Record = { + 'https://git.example/public.git/info/refs?service=git-upload-pack': new Response('refs'), + 'https://git.example/private.git/info/refs?service=git-upload-pack': new Response('no', { status: 401 }), + 'https://git.example/moved.git/info/refs?service=git-upload-pack': new Response(null, { status: 302, headers: { location: '/login' } }), + 'https://git.example/broken.git/info/refs?service=git-upload-pack': new Response('no', { status: 500 }), + 'https://git.example/down.git/info/refs?service=git-upload-pack': new Error('offline'), + } + const fetch = vi.fn((url: string, init: RequestInit) => { + expect(init.redirect).toBe('manual') + const answer = answers[url] + return answer instanceof Error ? Promise.reject(answer) : Promise.resolve(answer as Response) + }) + vi.stubGlobal('fetch', fetch) + const repositories = cache() + const [first, second] = await Promise.all([repositories.isPublic('https://git.example/public.git'), repositories.isPublic('https://git.example/public.git')]) + expect([first, second]).toEqual([true, true]) + expect(await repositories.isPublic('https://git.example/public.git')).toBe(true) + expect(await repositories.isPublic('https://git.example/private.git')).toBe(false) + expect(await repositories.isPublic('https://git.example/moved.git')).toBe(false) + await expectRejection(repositories.isPublic('https://git.example/broken.git'), { reason: 'network' }, 'HTTP 500') + await expectRejection(repositories.isPublic('https://git.example/down.git'), { reason: 'network' }, 'offline') + expect(fetch).toHaveBeenCalledTimes(5) +}) + +it('reports an unreachable host as a network failure, a failed local command as a plain error, and a Git that cannot run', async () => { + vi.stubEnv('GIT_SSH_COMMAND', 'ssh -F /dev/null -o BatchMode=yes -o ConnectTimeout=5') + await expectRejection(cache().read('ssh://127.0.0.1:1/owner/repo.git', undefined, 'README.md', 100, signal), { reason: 'network' }, 'git fetch') + const source = repository('t', { 'README.md': 'x' }) + const repositories = cache() + await repositories.read(source.url, source.commit, 'README.md', 100, signal) + // A clone whose commit refs cannot be written fails a local command after a successful fetch; that says nothing about + // the repository, and the clone is deleted. + const [clone] = readdirSync(join(root, 'cache')) + rmSync(join(root, 'cache', clone as string, 'refs', 'dsh'), { recursive: true }) + writeFileSync(join(root, 'cache', clone as string, 'refs', 'dsh'), 'not a directory') + writeFileSync(join(source.dir, 'README.md'), 'y') + git(source.dir, 'commit', '--quiet', '-am', 'next') + const broken = repositories.read(source.url, git(source.dir, 'rev-parse', 'HEAD'), 'README.md', 100, signal) + await expect(broken).rejects.toThrow('git update-ref') + await expect(broken).rejects.not.toBeInstanceOf(DetailFailure) + expect(readdirSync(join(root, 'cache'))).toEqual([]) + // Git that cannot answer `git --version` within the deadline cannot run. + const other = repository('t2', { 'README.md': 'x' }) + await expectRejection(cache({ timeoutMs: 1 }).read(other.url, other.commit, 'README.md', 100, signal), { reason: 'git-unavailable' }, 'timed out') + vi.stubEnv('PATH', join(root, 'no-git')) + const missing = cache().read(other.url, other.commit, 'README.md', 100, signal) + await expectRejection(missing, { reason: 'git-unavailable' }, 'git --version') + expect(readdirSync(join(root, 'cache'))).toEqual([]) +}) + +it('reports a repository Git cannot fetch as denied', async () => { + const repositories = cache() + const missing = pathToFileURL(join(root, 'missing')).href + const failure = await repositories.read(missing, undefined, 'README.md', 100, signal).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(DetailFailure) + expect(failure).toMatchObject({ reason: 'denied' }) + expect(readdirSync(join(root, 'cache'))).toEqual([]) + const source = repository('d', { 'README.md': 'x' }) + await expect(repositories.read(source.url, 'f'.repeat(40), 'README.md', 100, signal)).rejects.toMatchObject({ reason: 'denied' }) + await expect(repositories.read(source.url, source.commit, 'README.md', 100, signal)).resolves.toBeDefined() +}) + +it('stops a fetch once the repository grows past its limit, and deletes the repository', async () => { + const source = repository('e', { 'README.md': 'x' }) + // The empty repository already exceeds one byte, so the first size check stops Git. + const repositories = cache({ maxRepositoryBytes: 1 }) + await expect(repositories.read(source.url, source.commit, 'README.md', 100, signal)).rejects.toMatchObject({ reason: 'too-large' }) + expect(readdirSync(join(root, 'cache'))).toEqual([]) +}) + +it('refuses a repository that a completed fetch left past its limit', async () => { + // Distinct files make distinct trees, so the fetch adds one tree object per directory. + const source = repository('e2', Object.fromEntries(Array.from({ length: 1000 }, (_value, index) => [`dir${String(index)}/f.txt`, String(index)]))) + const repositories = cache({ maxRepositoryBytes: 4096, sizePollMs: 3_600_000 }) + await expect(repositories.read(source.url, source.commit, 'dir0/f.txt', 100, signal)).rejects.toMatchObject({ reason: 'too-large' }) + expect(readdirSync(join(root, 'cache'))).toEqual([]) +}) + +it('deletes least recently used repositories beyond the cache size and stale staging directories', async () => { + const first = repository('f', { 'README.md': 'first' }) + const second = repository('g', { 'README.md': 'second' }) + const third = repository('h', { 'README.md': 'third' }) + // The total holds one of these clones, not two. + const probe = cache({ dir: join(root, 'probe') }) + await probe.read(first.url, first.commit, 'README.md', 100, signal) + const one = dirSize(join(root, 'probe')) + const repositories = cache({ maxCacheBytes: Math.floor(one * 2.5) }) + mkdirSync(join(root, 'cache', '.init-stale'), { recursive: true }) + utimesSync(join(root, 'cache', '.init-stale'), new Date(0), new Date(0)) + mkdirSync(join(root, 'cache', '.init-running')) + writeFileSync(join(root, 'cache', 'stray.txt'), 'not a repository') + // An entry that disappears while it is measured no longer counts. + symlinkSync(join(root, 'gone'), join(root, 'cache', 'vanished')) + await repositories.read(first.url, first.commit, 'README.md', 100, signal) + expect(readdirSync(join(root, 'cache')).filter(name => name.startsWith('.init-'))).toEqual(['.init-running']) + rmSync(join(root, 'cache', '.init-running'), { recursive: true }) + rmSync(join(root, 'cache', 'vanished')) + rmSync(join(root, 'cache', 'stray.txt')) + await repositories.read(second.url, second.commit, 'README.md', 100, signal) + await repositories.settled() + expect(readdirSync(join(root, 'cache'))).toHaveLength(2) + await repositories.read(third.url, third.commit, 'README.md', 100, signal) + await repositories.settled() + // The least recently used clone goes first; the two newer ones fit. + expect(readdirSync(join(root, 'cache')).sort()).toEqual([second.url, third.url].map(cloneName).sort()) + // A cached commit is read again without a fetch. + await repositories.read(third.url, third.commit, 'README.md', 100, signal) + expect(existsSync(join(root, 'cache'))).toBe(true) +}) + +it('completes concurrent fetches of different repositories that each evict the other', async () => { + const a = repository('ca', { 'README.md': 'a' }) + const b = repository('cb', { 'README.md': 'b' }) + const repositories = cache({ maxCacheBytes: 1 }) + const reads = await Promise.all([a, b].map(source => repositories.readme(source.url, undefined, '', 100, signal))) + expect(reads.map(read => text(read?.bytes))).toEqual(['a', 'b']) + await repositories.settled() + expect(readdirSync(join(root, 'cache')).length).toBeLessThanOrEqual(1) +}) + +it('returns a blob whose last byte is a newline intact', async () => { + const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]) + const source = repository('nl', { 'a.png': bytes }) + expect(await cache().read(source.url, source.commit, 'a.png', 100, signal)).toEqual(Buffer.from(bytes)) +}) + +it('enforces the cache total after a blob read, deleting the repository in use after its read when it alone is too large', async () => { + const source = repository('blob', { 'README.md': 'r', 'big.bin': randomBytes(200_000) }) + const repositories = cache() + await repositories.read(source.url, source.commit, 'README.md', 100, signal) + await repositories.settled() + const kept = readdirSync(join(root, 'cache')).filter(name => !name.endsWith('.lock')) + expect(kept).toHaveLength(1) + // The clone without contents fits; reading the large blob downloads it and grows the repository past the total. + const small = cache({ maxCacheBytes: 150_000 }) + expect((await small.read(source.url, source.commit, 'big.bin', 300_000, signal))?.byteLength).toBe(200_000) + // The deletion waits for the read that holds the repository, then runs. + await new Promise((resolve) => { setTimeout(resolve, 50) }) + await small.settled() + expect(readdirSync(join(root, 'cache')).filter(name => !name.endsWith('.lock'))).toEqual([]) +}) + +it('serializes one repository across caches that share the directory, as two processes on one profile do', async () => { + const source = repository('shared', { 'README.md': 'shared' }) + const [first, second] = [cache(), cache()] + const reads = await Promise.all([first, second].map(each => each.read(source.url, source.commit, 'README.md', 100, signal))) + expect(reads.map(read => text(read))).toEqual(['shared', 'shared']) + await Promise.all([first.settled(), second.settled()]) + expect(readdirSync(join(root, 'cache')).filter(name => !name.endsWith('.lock'))).toHaveLength(1) +}) + +it('stops Git when the caller cancels or the service is disposed', async () => { + const source = repository('i', { 'README.md': 'x' }) + const cancelled = new AbortController() + cancelled.abort(new Error('cancelled')) + await expect(cache().read(source.url, source.commit, 'README.md', 100, cancelled.signal)).rejects.toThrow('cancelled') + const disposed = new AbortController() + disposed.abort(new Error('disposed')) + await expect(cache({ lifetime: disposed.signal }).read(source.url, source.commit, 'README.md', 100, signal)).rejects.toThrow('disposed') + // Neither a repository nor its staging directory remains. + expect(readdirSync(join(root, 'cache'))).toEqual([]) +}) + +it('reports file contents Git can no longer fetch and deletes the clone, ignoring an inherited GIT_DIR', async () => { + vi.stubEnv('GIT_DIR', join(root, 'elsewhere')) + const source = repository('j', { 'README.md': 'x', 'b.png': 'b' }) + const repositories = cache() + await repositories.read(source.url, source.commit, 'README.md', 100, signal) + rmSync(source.dir, { recursive: true, force: true }) + await expectRejection(repositories.read(source.url, source.commit, 'b.png', 100, signal), { reason: 'denied' }, 'git cat-file b.png') + expect(readdirSync(join(root, 'cache'))).toEqual([]) +}) + +it('fetches a cached default-branch commit again after eviction, and reports a clone that can no longer fetch it', async () => { + const first = repository('k', { 'README.md': 'first' }) + const second = repository('l', { 'README.md': 'second' }) + const repositories = cache({ maxCacheBytes: 1 }) + await repositories.read(first.url, undefined, 'README.md', 100, signal) + await repositories.read(second.url, undefined, 'README.md', 100, signal) + expect(text(await repositories.read(first.url, undefined, 'README.md', 100, signal))).toBe('first') + const [clone] = readdirSync(join(root, 'cache')) + rmSync(join(root, 'cache', clone as string, 'objects', 'pack'), { recursive: true, force: true }) + rmSync(first.dir, { recursive: true, force: true }) + await expectRejection(repositories.read(first.url, undefined, 'README.md', 100, signal), { reason: 'denied' }, 'git fetch') +}) diff --git a/packages/boot/plugin-market/tests/tools.spec.ts b/packages/boot/plugin-market/tests/tools.spec.ts index ae1b929b15..14c5c7a479 100644 --- a/packages/boot/plugin-market/tests/tools.spec.ts +++ b/packages/boot/plugin-market/tests/tools.spec.ts @@ -7,6 +7,9 @@ import { afterEach, expect, it, onTestFinished, vi } from 'vitest' import PluginMarket from '../src/index.ts' import * as tool from '../src/tools.ts' +/** Clone cache directory of services whose tests read no repository; nothing creates it. */ +const UNUSED_CACHE = '/nonexistent/plugin-market-detail-cache' + const URL_A = 'https://market.example/index.json' afterEach(() => { vi.unstubAllGlobals() }) @@ -26,7 +29,7 @@ async function fixture() { listVersionExemptions() { return { exemptions: {}, warnings: [] } } } await ctx.plugin(Manager) - await ctx.plugin(PluginMarket, { indexes: [URL_A] }) + await ctx.plugin(PluginMarket, { indexes: [URL_A], detailCacheDir: UNUSED_CACHE }) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) const fiber = await ctx.plugin(tool) @@ -167,7 +170,7 @@ it('registers the tool only while the pluginMarket service is mounted', async () await ctx.plugin(ToolRuntime) await ctx.plugin(tool) expect(ctx.tools.get('plugin_market')).toBeUndefined() - const market = await ctx.plugin(PluginMarket, { indexes: [] }) + const market = await ctx.plugin(PluginMarket, { indexes: [], detailCacheDir: UNUSED_CACHE }) await vi.waitFor(() => { expect(ctx.tools.get('plugin_market')).toBeDefined() }) await market.dispose() await vi.waitFor(() => { expect(ctx.tools.get('plugin_market')).toBeUndefined() }) diff --git a/packages/boot/plugin-market/tsconfig.json b/packages/boot/plugin-market/tsconfig.json index 19804b2708..9a7ab10fda 100644 --- a/packages/boot/plugin-market/tsconfig.json +++ b/packages/boot/plugin-market/tsconfig.json @@ -19,6 +19,11 @@ { "path": "../../util/values" }, { "path": "../../settings/settings" }, { "path": "../../typert/protocol" }, - { "path": "../../core/tools" } + { "path": "../../core/tools" }, + { "path": "../../util/brand" }, + { "path": "../../subprocess/subprocess" }, + { "path": "../../client/connection/tsconfig.host.json" }, + { "path": "../../util/plugin-detail" }, + { "path": "../../util/atomic-write" } ] } diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 7e1a2d3f38..6800eabe84 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: ebda556fc4602b34 - zh: 3097ad4a0eda6828 + en: 0d126503459846d5 + zh: c380b8e975a76913 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: en: fcb2e69c8b028c0a zh: d505a075679974eb @@ -33,8 +33,8 @@ en: e9da90f03691101b zh: 902cda935a4ce3c8 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/detail-page-extension-points: - en: 5266d86638cc5cd7 - zh: 398d710df9806cea + en: 54d7666c630da471 + zh: d7ded257d0fd5559 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs: en: 76e0908903ab8fde zh: ce6ae1cdd6aea971 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index ce2ed7e92f..a88ae41a2d 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -33,6 +33,8 @@ The heading moves its complete action group to another line when space is insuff Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` of a package-root plugin; subpath plugins never read one; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the package root's manifest `icon` or exported `./icon`, or a subpath plugin's exported `/icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information. +A bundle's page shows the bundle's detail document first among its sections, without a heading of its own: the bundle's long description in the current UI language, as Markdown with images. It is the Markdown file that `meta.detail` in the bundle's `locale/.json` names, otherwise the bundle's README, read by [`pluginManager.readBundleDetail`](../../boot/plugin-manager/README.md#bundle-detail-documents) in the [document order of `dsh-plugin-detail`](../../util/plugin-detail/README.md#document-order); the repository a bundle declares is not read. An installed bundle's document comes from its own files without network access, and an on-demand Official bundle's from the npm file CDNs at its offered version. While the document loads the section shows three skeleton lines; a bundle without a document shows no section; a failed read names its reason with **Retry**. Raw HTML is omitted, relative images load from the Host beside the document, relative links stay text, and a document taller than the section's height limit scrolls inside it. A bundle whose files are not installed, such as an on-demand Official bundle, is read from the npm file CDNs: its read first asks the Host's registry comparison and, when npmmirror answered fastest, reads it and its images from npmmirror first. An installed bundle's read does not wait for the comparison. Any profile change and a connection reset read shown documents again. + On-demand Official cards are visible offline before installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. An update removes the installed package and installs the offered one with the same selection, reporting whether a restart is required; when the installation fails after the removal, the package stays uninstalled. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable. The first list read shows a short group-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states. @@ -92,7 +94,7 @@ The bundle's patch must declare the row under that id, and the registration exis ### Detail page extension points -A plugin with something to say about a bundle, a row, or an official plugin it does not own contributes to that object's page through three list slots the page declares: `plugins.detail.actions` for a control at the head of the page, before the page's own switch and uninstall; `plugins.detail.badge` for a tag beside the title, after the version, experimental, and problem tags; and `plugins.detail.section` for a section under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Every entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`, where `pkg` and `row` carry the name, version, installed and enabled facts, and the row list a contribution decides on. An entry renders null for a subject it has nothing for and draws its own section chrome; the page orders entries by `order`. +A plugin with something to say about a bundle, a row, or an official plugin it does not own contributes to that object's page through three list slots the page declares: `plugins.detail.actions` for a control at the head of the page, before the page's own switch and uninstall; `plugins.detail.badge` for a tag beside the title, after the version, experimental, and problem tags; and `plugins.detail.section` for a section under the page's own content — after the source on a bundle's page, after the configuration on a row's or an official plugin's page. Every entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`, where `pkg` and `row` carry the name, version, installed and enabled facts, and the row list a contribution decides on. An entry renders null for a subject it has nothing for and draws its own section chrome; the page orders entries by `order`. ```tsx ignore-check ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 2afa9a55fa..6017a51223 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" 已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到包根插件可访问的 `package.json`,子路径插件从不读取;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示包根的清单 `icon` 或导出的 `./icon`,或子路径插件导出的 `<子路径>/icon`;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。 +组合包页面在其各部分中首先显示该组合包的详细介绍文档,不带单独的标题:以当前界面语言、带图片的 Markdown 显示该组合包的详细介绍。该文档是组合包 `locale/.json` 中 `meta.detail` 指定的 Markdown 文件,没有时为组合包的 README,由 [`pluginManager.readBundleDetail`](../../boot/plugin-manager/README.zh.md#bundle-detail-documents) 按 [`dsh-plugin-detail` 的文档顺序](../../util/plugin-detail/README.zh.md#document-order)读取;不读取组合包声明的仓库。已安装组合包的文档来自其自身文件,不访问网络;按需官方组合包的文档按其提供的版本从 npm 文件 CDN 读取。文档加载期间,该部分显示三行骨架屏;没有文档的组合包不显示该部分;读取失败时说明原因,并提供**重试**。原始 HTML 会被省略,相对路径的图片从 Host 上该文档旁的位置加载,相对链接保持为文本,文档高度超过该部分的高度上限时在该部分内滚动。文件未安装的组合包(例如按需官方组合包)从 npm 文件 CDN 读取:读取前先询问 Host 的注册表比较,npmmirror 响应最快时,先从 npmmirror 读取该文档及其图片。已安装组合包的读取不等待该比较。任何 profile 变化和连接重置都会重新读取已显示的文档。 + 官方按需卡片在安装前即可离线显示。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新先删除已安装的包,再以相同的选择状态安装所提供的包,并报告是否需要重启;删除之后安装失败时,该包保持未安装。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。 首次读取列表时,页面显示一条较短的分组标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。 @@ -92,7 +94,7 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ ### 详情页扩展点 -对某个不属于自己的组合包、行或官方插件有话要说的插件,通过本页声明的三个 list slot 向该对象的页面贡献内容:`plugins.detail.actions` 在页头放一个控件,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁放一个标签,位于版本、实验性和异常标签之后;`plugins.detail.section` 在页面自身内容之下放一个区块——组合包页在组件列表之后,行页和官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`,其中 `pkg` 与 `row` 携带包名、版本、是否已安装、是否启用以及行列表这些供贡献者判断的事实。条目对无话可说的 subject 返回 null,自绘区块外观;页面按 `order` 排列条目。 +对某个不属于自己的组合包、行或官方插件有话要说的插件,通过本页声明的三个 list slot 向该对象的页面贡献内容:`plugins.detail.actions` 在页头放一个控件,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁放一个标签,位于版本、实验性和异常标签之后;`plugins.detail.section` 在页面自身内容之下放一个区块——组合包页在来源信息之后,行页和官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`,其中 `pkg` 与 `row` 携带包名、版本、是否已安装、是否启用以及行列表这些供贡献者判断的事实。条目对无话可说的 subject 返回 null,自绘区块外观;页面按 `order` 排列条目。 ```tsx ignore-check ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ diff --git a/packages/client/ui-plugin-manager/src/client/BundleDetailSection.tsx b/packages/client/ui-plugin-manager/src/client/BundleDetailSection.tsx new file mode 100644 index 0000000000..90a2f07045 --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/BundleDetailSection.tsx @@ -0,0 +1,58 @@ +/** A bundle's long description on its page: the Markdown document the Host reads for it. */ +import { useEffect, useMemo, type ReactNode } from 'react' +import { DetailDocument, detailDocumentState, useDetailDocumentLabels, type MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { bundleDetailKey, type BundleDetailFailure, type BundleDetailsState } from './detail-store.ts' + +type Translate = PropsLocale<'pluginManager'>['t'] + +const FAILURE_KEYS = { + 'too-large': 'detailTooLarge', + 'network': 'detailNetwork', + 'denied': 'detailFailed', + 'git-unavailable': 'detailFailed', + 'invalid': 'detailInvalid', + 'other': 'detailFailed', +} as const satisfies Record + +/** What the section reads and calls; the page passes them from its injected face. */ +export interface BundleDetailSectionProps { + readonly name: string + readonly version: string | undefined + /** Whether the bundle's files are unavailable, so the Host reads its offered version from the npm file CDNs. */ + readonly fromNpm: boolean + readonly language: string + readonly details: BundleDetailsState + readonly t: Translate + readonly load: (name: string, version: string | undefined, language: string, fromNpm: boolean) => void + readonly retry: (name: string, version: string | undefined, language: string, fromNpm: boolean) => void + readonly image: (name: string, document: string, destination: string, fromNpm: boolean) => string | undefined +} + +/** + * Read and render a bundle's detail document in the active language; relative images load from the Host beside it. + * @param props - the bundle, the language, the reads, and the page's callbacks. + * @returns the section, or null when the bundle has no document. + */ +export function BundleDetailSection(props: BundleDetailSectionProps): ReactNode { + const { name, version, fromNpm, language, details, t, load, retry, image } = props + const key = bundleDetailKey(name, version, language) + const state = details[key] + // The key names the read completely; a read the controller forgot, after a profile change, starts again. + const missing = state === undefined + useEffect(() => { if (missing) load(name, version, language, fromNpm) }, [key, missing]) + const labels = useDetailDocumentLabels({ + title: t('detailTitle'), loading: t('detailLoading'), retry: t('retry'), copy: t('copy'), copied: t('copied'), + codeBlock: t('codeBlock.title'), wrap: t('codeBlock.wrap'), unwrap: t('codeBlock.unwrap'), footnotes: t('detailFootnotes'), + }) + const detail = state?.status === 'ready' ? state.detail : undefined + const document = detail?.status === 'found' ? detail.path : undefined + const images = useMemo( + () => document === undefined ? undefined : { resolve: value => image(name, document, value, fromNpm) }, + [name, document, fromNpm, image], + ) + const shown = detailDocumentState(state?.status === 'error' + ? { status: 'error', message: t(FAILURE_KEYS[state.failure]) } + : state === undefined || state.status === 'loading' ? state : { status: 'ready', markdown: detail?.status === 'found' ? detail.markdown : undefined }) + return { retry(name, version, language, fromNpm) }} /> +} diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 7280e0bd96..8144f4c9b5 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -33,6 +33,7 @@ import { managementText, noticeText, packageText, registryText, restartText, row import type { PluginPackageRef, PluginRowRef, PluginsSubject } from './slot-contract.ts' import type { ConfigPageForm } from './slot-contract.ts' import { UpdateDialog, VersionSection } from './versions.tsx' +import { BundleDetailSection } from './BundleDetailSection.tsx' import css from './PluginManagerPage.module.css' import badgeSkillPreview from './assets/badge-skill-preview.svg' import badgeSkillPreviewZh from './assets/badge-skill-preview.zh.svg' @@ -716,12 +717,14 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot, form }: { * and configure controls; and where it comes from. */ function PackageDetail({ - pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot, inMore, versions, + pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot, inMore, versions, detail, onBack, onSetEnabled, onUninstall, onUpdate, onSetRowEnabled, onEnsureVersions, onCheckVersions, onUpdateVersion, }: { readonly pkg: PackageView /** The package's version check, when one started. */ readonly versions: VersionCheck | undefined + /** The bundle's detail document section, shown first among the page's sections. */ + readonly detail: ReactNode readonly t: Translate readonly resolveText: ResolveText readonly busy: boolean @@ -808,6 +811,7 @@ function PackageDetail({ {pkg.readOnlyReason === undefined ? null :

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

} {preview === undefined ? null : }
+ {detail} {configured ? (
@@ -1547,6 +1551,8 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { // What is open; a package that leaves the list (uninstalled) drops back to the cards. const view = props.useStore(state => state.view), { setView, setTab } = props.actions const tabs = props.usePluginTabs(snapshot => snapshot) + const bundleDetails = props.useBundleDetails(snapshot => snapshot) + const language = props.useLocale(snapshot => snapshot.active) const selectedTab = props.useStore(state => state.tab) // A contributed tab that left the ledger falls back to Installed, and stays there when it returns. const tab = tabs.some(item => item.id === selectedTab) ? selectedTab : INSTALLED_TAB @@ -1769,6 +1775,11 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { configure={configure(openPkg)} renderSlot={renderSlot} inMore={openPkgInMore} + detail={( + + )} onBack={() => { setView({ kind: openPkgInMore ? 'more' : 'list' }) }} onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }} onUninstall={() => { props.uninstall(openPkg.name) }} diff --git a/packages/client/ui-plugin-manager/src/client/detail-store.ts b/packages/client/ui-plugin-manager/src/client/detail-store.ts new file mode 100644 index 0000000000..531ef3d29d --- /dev/null +++ b/packages/client/ui-plugin-manager/src/client/detail-store.ts @@ -0,0 +1,124 @@ +/** Bundle detail reads: the long description of each bundle the page opens, read from the Host by name and language. */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { BundleDetail, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' +import { NPMMIRROR_REGISTRY } from '@deepseek-ai/dsh-plugin-manager/registry' + +/** Why a bundle detail read failed: the Host's `plugin-manager/detail-unavailable` reason, or `other`. */ +export type BundleDetailFailure = 'too-large' | 'network' | 'denied' | 'git-unavailable' | 'invalid' | 'other' + +/** One bundle detail read. */ +export type BundleDetailState = + | { readonly status: 'loading' } + | { readonly status: 'ready'; readonly detail: BundleDetail } + | { readonly status: 'error'; readonly failure: BundleDetailFailure } + +/** Bundle detail reads by {@link bundleDetailKey}. */ +export type BundleDetailsState = Readonly> + +/** + * The identity of a bundle detail read: the bundle, its installed version, and the display language. + * @param name - the bundle's package name. + * @param version - the installed or offered version, so an updated bundle reads again. + * @param language - the display language. + * @returns the key of the read in {@link BundleDetailsState}. + */ +export function bundleDetailKey(name: string, version: string | undefined, language: string): string { + return JSON.stringify([name, version ?? null, language]) +} + +/** The Host route of images beside bundle detail documents, relative to the application base. */ +const DETAIL_FILE_ROUTE = 'api/plugin-manager/detail-file' + +function failureOf(failure: RemoteFailure): BundleDetailFailure { + return failure.code === 'plugin-manager/detail-unavailable' ? failure.details.reason : 'other' +} + +/** Reads bundle detail documents and publishes them to the page. */ +export class BundleDetailController { + /** Detail reads published to the page. */ + readonly store: SnapshotStore = createSnapshotStore({}) + /** The latest read of each key, numbered across the controller's lifetime; an older read's answer is dropped. */ + private readonly reads = new Map() + private sequence = 0 + /** npm file CDNs to ask first, from the Host's latest registry comparison: npmmirror when its registry answered fastest. */ + private npmFileSources: 'npmmirror'[] = [] + private disposed = false + + /** + * @param ctx - the page plugin's context, whose `remote.pluginManager` namespace answers. + * @param base - the application base URL that Host routes resolve against. + */ + constructor(private readonly ctx: ClientContext, private readonly base: () => string) {} + + /** + * Read a bundle's document unless the read already ran or runs. + * @param name - the bundle's package name. + * @param version - its installed or offered version. + * @param language - the display language. + * @param fromNpm - whether the bundle's files are unavailable, so the Host reads its offered version from the npm file CDNs. + */ + load(name: string, version: string | undefined, language: string, fromNpm: boolean): void { + if (this.store.getSnapshot()[bundleDetailKey(name, version, language)] === undefined) void this.read(name, version, language, fromNpm) + } + + /** + * Read a bundle's document again. + * @param name - the bundle's package name. + * @param version - its installed or offered version. + * @param language - the display language. + * @param fromNpm - whether the Host reads the bundle from the npm file CDNs. + */ + retry(name: string, version: string | undefined, language: string, fromNpm: boolean): void { + void this.read(name, version, language, fromNpm) + } + + /** + * The URL of an image beside a bundle's document. + * @param name - the bundle's package name. + * @param document - the document's path inside the bundle. + * @param destination - the image destination as the document writes it. + * @param fromNpm - whether the Host reads the bundle from the npm file CDNs. + * @returns the authenticated Host URL, or undefined for an absolute, protocol-relative, or fragment destination. + */ + image(name: string, document: string, destination: string, fromNpm: boolean): string | undefined { + if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/iu.test(destination)) return undefined + const sources = fromNpm ? this.npmFileSources : [] + const query = new URLSearchParams({ bundle: name, document, path: destination, ...sources.length > 0 ? { sources: sources.join(',') } : {} }) + return new URL(`${DETAIL_FILE_ROUTE}?${query.toString()}`, this.base()).href + } + + /** Forget every read, so each shown bundle reads its document again; bundle files can change at any profile change. */ + reset(): void { + this.reads.clear() + this.store.set({}) + } + + /** Stop publishing late settlements. */ + dispose(): void { + this.disposed = true + } + + private async read(name: string, version: string | undefined, language: string, fromNpm: boolean): Promise { + const key = bundleDetailKey(name, version, language) + const read = ++this.sequence + this.reads.set(key, read) + this.publish(key, { status: 'loading' }) + // The remote is captured before any wait: the plugin's context may be disposed while the comparison runs. + const { remote } = this.ctx + const current = (): boolean => !this.disposed && this.reads.get(key) === read + // Only a bundle read from the CDNs waits for the registry comparison. + if (fromNpm) { + const fastest = await remote.pluginRegistryProbe.fastest() + // A retry or reset started while the comparison ran settles the key instead. + if (!current()) return + this.npmFileSources = fastest.ok && fastest.value === NPMMIRROR_REGISTRY ? ['npmmirror'] : [] + } + const answer = await remote.pluginManager.readBundleDetail(name, language, { npmFileSources: fromNpm ? this.npmFileSources : [] }) + if (current()) this.publish(key, answer.ok ? { status: 'ready', detail: answer.value } : { status: 'error', failure: failureOf(answer.error) }) + } + + private publish(key: string, state: BundleDetailState): void { + this.store.set({ ...this.store.getSnapshot(), [key]: state }) + } +} diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index bbf624c901..870ccba12f 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -84,6 +84,8 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => { // A page never rendered holds no snapshot to refresh. const refresh = (): void => { + // A profile change can replace a bundle's files, and a restarted Host is read afresh. + controller.details.reset() if (controller.getSnapshot().status !== 'idle') void controller.load() } const disposers = [ diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 6ed9b2e5db..e22d8d1d07 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -286,6 +286,13 @@ export const zh = { reasonIncompatibleInstall: '请安装与当前 DSH 兼容的插件版本。', reasonIncompatibleInstalled: '请卸载后重新安装与当前 DSH 兼容的版本。', reasonOperationError: 'Host 报告了一个错误', + detailTitle: '详细介绍', + detailLoading: '正在读取详细介绍', + detailTooLarge: '详细介绍超过大小限制,未加载', + detailNetwork: '无法连接到详细介绍的来源', + detailInvalid: '插件声明的详细介绍文件无效', + detailFailed: '无法读取详细介绍', + detailFootnotes: '脚注', } satisfies Record /** Plugin manager locale key union. */ @@ -572,4 +579,11 @@ export const en = { reasonIncompatibleInstall: 'Install a plugin version compatible with this DSH.', reasonIncompatibleInstalled: 'Uninstall it and install a version compatible with this DSH.', reasonOperationError: 'The Host reported an error.', + detailTitle: 'Details', + detailLoading: 'Reading details', + detailTooLarge: 'The details exceed the size limit and were not loaded.', + detailNetwork: 'Could not reach the source of the details.', + detailInvalid: 'The plugin declares an invalid details file.', + detailFailed: 'Could not read the details.', + detailFootnotes: 'Footnotes', } 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 index 3fcfa5b39a..775cbf0212 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -41,6 +41,8 @@ import type { ConfigLedger } from './config-ledger.ts' import type { PluginTabInstall } from './slot-contract.ts' import type { PluginTab } from './tab-ledger.ts' import { shortName } from './presentation.ts' +import { BundleDetailController, type BundleDetailsState } from './detail-store.ts' +import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' /** The action a failed notice names. */ export type FailedAction = 'enable' | 'disable' | 'uninstall' | 'rowEnable' | 'rowDisable' @@ -389,7 +391,17 @@ export interface PluginManagerFace { configLedger: HostObservable /** Tabs other plugins contribute, bound by the renderer as usePluginTabs. */ pluginTabs: HostObservable + /** Bundle detail reads, bound by the renderer as useBundleDetails. */ + bundleDetails: SnapshotStore + /** The active locale, bound as useLocale, for the language detail documents are read in. */ + locale: HostObservable } + /** Read a bundle's detail document unless the read already ran or runs. */ + loadDetail: BundleDetailController['load'] + /** Read a bundle's detail document again. */ + retryDetail: BundleDetailController['retry'] + /** The URL of an image beside a bundle's detail document, or undefined for a destination the page loads as written. */ + detailImage: BundleDetailController['image'] /** Read the Host once the tab first renders. */ ensure: () => void /** Read the Host again, then check every updatable package for newer versions. */ @@ -631,6 +643,8 @@ export class PluginManagerController { private registryRead: RegistryRead | undefined /** A Discover entry chose the dialog's registry; that choice applies to this dialog only and is never remembered. */ private transientRegistry = false + /** Bundle detail documents the page shows. */ + readonly details: BundleDetailController /** The registry last used from this browser, kept across dialogs and page loads; null until one was used. */ private readonly registryMemory: SnapshotStore = createSnapshotStore(null, { persist: { name: 'dsh.plugin-manager.install-registry' }, @@ -642,6 +656,7 @@ export class PluginManagerController { constructor( private readonly ctx: ClientContext, ) { + this.details = new BundleDetailController(ctx, () => document.baseURI) this.store = createSnapshotStore({ status: 'idle', refreshStatus: 'idle', packages: [], busy: [], notice: null, install: IDLE_INSTALL, confirm: null, highlight: null, versions: {}, update: null, @@ -659,6 +674,7 @@ export class PluginManagerController { /** Stop publishing and drop every late settlement. */ dispose(): void { this.disposed = true + this.details.dispose() this.registryRead = undefined this.request = undefined this.generation += 1 @@ -674,9 +690,16 @@ export class PluginManagerController { inject( configLedger: HostObservable, pluginTabs: HostObservable, resolveText: PluginManagerFace['resolveText'], ): PluginManagerFace { + const details = this.details return { resolveText, - hooks: { pluginManager: this.store, configLedger, pluginTabs, configurations: this.ctx.configForms.describe() }, + hooks: { + pluginManager: this.store, configLedger, pluginTabs, configurations: this.ctx.configForms.describe(), + bundleDetails: details.store, locale: this.ctx.locale, + }, + loadDetail: (name, version, language, fromNpm) => { details.load(name, version, language, fromNpm) }, + retryDetail: (name, version, language, fromNpm) => { details.retry(name, version, language, fromNpm) }, + detailImage: (name, document, destination, fromNpm) => details.image(name, document, destination, fromNpm), configForm: id => this.ctx.configForms.get(id), ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() }, refresh: () => { void this.refresh() }, diff --git a/packages/client/ui-plugin-manager/src/client/slot-contract.ts b/packages/client/ui-plugin-manager/src/client/slot-contract.ts index ff37603568..b7a2c3fea2 100644 --- a/packages/client/ui-plugin-manager/src/client/slot-contract.ts +++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts @@ -160,7 +160,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'plugins.detail.badge': { kind: 'list'; scope: 'root'; owner: PluginDetailProps } /** - * Sections under a detail page's own content: after the rows on a bundle's + * Sections under a detail page's own content: after the source on a bundle's * page, after the configuration on a row's or an official plugin's page. * An entry draws its own section chrome and renders null for a subject it * has nothing for. 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 index 34e7d6bd37..6653080225 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -41,6 +41,7 @@ async function bench() { listBundles: vi.fn(() => Promise.resolve({ ok: true as const, value: [] })), listPlugins: vi.fn(() => Promise.resolve({ ok: true as const, value: [] })), registries: vi.fn(() => Promise.resolve({ ok: true as const, value: { registry: null, fallbackRegistries: [], resolved: null } })), + readBundleDetail: vi.fn(() => Promise.resolve({ ok: true as const, value: { status: 'none' as const } })), }, }) const panelInfo = createSnapshotStore({ activePanelId: null }) @@ -186,8 +187,15 @@ describe('ui-plugin-manager browser plugin', () => { face.ensure() await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') }) expect(b.list).toHaveBeenCalledTimes(1) + // Bundle detail reads go to the Host by name; a profile change forgets them. + face.loadDetail('dsh-a', '1.0.0', 'en', false) + await vi.waitFor(() => { expect(face.hooks.bundleDetails.getSnapshot()).not.toEqual({}) }) + face.retryDetail('dsh-a', '1.0.0', 'en', false) + expect(face.detailImage('dsh-a', 'README.md', 'a.png', false)).toContain('api/plugin-manager/detail-file?bundle=dsh-a') + expect(face.hooks.locale.getSnapshot().active).toBe('zh') b.remote.emit('plugin-manager/changed', [{ reason: 'bundle' }]) await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) }) + expect(face.hooks.bundleDetails.getSnapshot()).toEqual({}) b.ctx.emit('connection/reset') await vi.waitFor(() => { 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 index 469e565656..754b0a9c99 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -10,6 +10,8 @@ import type { ConfigForm, ConfigFormSnapshot, SettingsMirrorSnapshot } from '@de import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' import { StrictMode, type ReactNode } from 'react' +import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' +import { bundleDetailKey, type BundleDetailsState } from '../src/client/detail-store.ts' import { createNavigationStore } from '../src/client/navigation-store.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' import { PluginRefreshToast } from '../src/client/PluginRefreshToast.tsx' @@ -111,6 +113,7 @@ function renderTab( const store = createSnapshotStore({ ...READY, ...state }) const ledger = createSnapshotStore({ ...NO_CONFIG, ...config }) const tabLedger = createSnapshotStore(tabs) + const bundleDetails = createSnapshotStore({}) const actions = { ensure: vi.fn(), refresh: vi.fn(), @@ -142,6 +145,10 @@ function renderTab( runUpdate: vi.fn(), closeUpdate: vi.fn(), toggleUpdateOutput: vi.fn(), + loadDetail: vi.fn(), + retryDetail: vi.fn(), + detailImage: vi.fn((name: string, document: string, destination: string, _fromNpm: boolean) => + `https://dsh.example/api/plugin-manager/detail-file?bundle=${name}&document=${document}&path=${destination}`), } const unusedStandardHook = (): never => { throw new Error('Plugin manager fixture does not provide global state') } const standard = { @@ -163,6 +170,8 @@ function renderTab( usePluginManager: bindSnapshotSelector(store), useConfigLedger: bindSnapshotSelector(ledger), usePluginTabs: bindSnapshotSelector(tabLedger), + useBundleDetails: bindSnapshotSelector(bundleDetails), + useLocale: bindSnapshotSelector(createSnapshotStore({ active: 'en', locales: [], revision: 0 })), useConfigurations: bindSnapshotSelector(createSnapshotStore({ status: 'ready', error: null, view: { writable: true, hasDocument: true, namespaces: Object.keys(forms).map(ns => ({ @@ -206,6 +215,7 @@ function renderTab( store, actions, set: (next: Partial) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) }, + setDetails: (next: BundleDetailsState) => { act(() => { bundleDetails.set(next) }) }, setLanguage: (dict: typeof en) => { locale.setLocale(dict === zh ? 'zh' : 'en') currentT = translate(dict) @@ -427,6 +437,34 @@ describe('PluginManagerPage', () => { expect(document.querySelector('[data-plugin-package="dsh-better-sidebar"]')).not.toBeNull() }) + it('shows a bundle\'s detail document first among its sections, and its failures with a retry', () => { + const b = renderTab({ packages: [pkg()] }) + act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) }) + expect(b.actions.loadDetail).toHaveBeenCalledWith('dsh-better-sidebar', '0.16.0', 'en', false) + expect(screen.getByRole('status', { name: en.detailLoading })).toBeTruthy() + const key = bundleDetailKey('dsh-better-sidebar', '0.16.0', 'en') + b.setDetails({ [key]: { status: 'ready', detail: { status: 'found', origin: 'locale', path: 'guide/DETAIL.md', markdown: '# Sidebar\n\n![shot](img/a.png)' } } }) + const sections = [...document.querySelectorAll('[data-plugin-detail="dsh-better-sidebar"] section')] + const order = sections.map(section => section.hasAttribute('data-detail-document') ? 'detail' : section.hasAttribute('data-plugin-source') ? 'source' : 'other') + expect(order[0]).toBe('detail') + expect(order).toContain('source') + expect(screen.getByRole('heading', { name: 'Sidebar' })).toBeTruthy() + expect(screen.getByRole('img', { name: 'shot' }).getAttribute('src')) + .toBe('https://dsh.example/api/plugin-manager/detail-file?bundle=dsh-better-sidebar&document=guide/DETAIL.md&path=img/a.png') + const failures = [['too-large', en.detailTooLarge], ['network', en.detailNetwork], ['denied', en.detailFailed], ['invalid', en.detailInvalid], ['other', en.detailFailed]] as const + for (const [failure, text] of failures) { + b.setDetails({ [key]: { status: 'error', failure } }) + expect(screen.getByRole('alert').textContent).toBe(text) + } + fireEvent.click(screen.getByRole('button', { name: en.retry })) + expect(b.actions.retryDetail).toHaveBeenCalledWith('dsh-better-sidebar', '0.16.0', 'en', false) + b.setDetails({ [key]: { status: 'ready', detail: { status: 'none' } } }) + expect(document.querySelector('[data-detail-document]')).toBeNull() + // A read the controller forgot, after a profile change, starts again. + b.setDetails({}) + expect(b.actions.loadDetail).toHaveBeenCalledTimes(2) + }) + it('says where a bundle comes from: the spec that installs it, or built in, with its version', () => { const b = renderTab({ packages: [ pkg({ source: 'github:someone/dsh-better-sidebar' }), @@ -445,6 +483,8 @@ describe('PluginManagerPage', () => { expect(facts()).toEqual([en.sourceSpec, en.sourceBuiltIn]) act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-missing' }) }) expect(facts()).toEqual([en.sourceSpec, en.sourceNotInstalled]) + // A bundle whose files are not installed is read from the npm file CDNs. + expect(b.actions.loadDetail).toHaveBeenLastCalledWith('dsh-missing', undefined, 'en', true) }) it('preserves the requested bundle through StrictMode effect replay and page remounts', () => { @@ -805,12 +845,12 @@ describe('PluginManagerPage', () => { expect(chinese.getAttribute('src')).toMatch(/badge-skill-preview\.zh\.svg$/) fireEvent.error(chinese) expect(screen.queryByRole('img')).toBeNull() - expect(screen.getByRole('status').textContent).toBe(zh.previewUnavailable) + expect(screen.getByText(zh.previewUnavailable).getAttribute('role')).toBe('status') fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', name) })) expect(actions.setEnabled).toHaveBeenCalledWith(name, true) setLanguage(en) expect(screen.getByRole('img').getAttribute('src')).toBe(english) - expect(screen.queryByRole('status')).toBeNull() + expect(screen.queryByText(en.previewUnavailable)).toBeNull() }) it('shows no preview for bundles without one', () => { diff --git a/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts new file mode 100644 index 0000000000..b57cee89f6 --- /dev/null +++ b/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts @@ -0,0 +1,125 @@ +import { Context } from '@deepseek-ai/cordis' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import type { BundleDetail } from '@deepseek-ai/dsh-api-remotes/client' +import { expect, it, onTestFinished, vi } from 'vitest' +import { BundleDetailController, bundleDetailKey } from '../src/client/detail-store.ts' + +type Answer = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: RemoteError } +const ok = (value: T): Promise> => Promise.resolve({ ok: true, value }) +const FOUND: BundleDetail = { status: 'found', origin: 'readme', path: 'README.md', markdown: '# A' } +const flush = () => new Promise((resolve) => { setTimeout(resolve, 0) }) + +function setup() { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const manager = { + readBundleDetail: vi.fn((_name: string, _language: string, _options: object): Promise> => ok(FOUND)), + } + const probe = { fastest: vi.fn((): Promise> => ok('https://registry.npmmirror.com/')) } + new TestRemote(ctx, { pluginManager: manager, pluginRegistryProbe: probe }) + const controller = new BundleDetailController(ctx, () => 'https://dsh.example/prefix/') + const state = (name: string, version: string | undefined, language: string) => + controller.store.getSnapshot()[bundleDetailKey(name, version, language)] + return { controller, manager, probe, state } +} + +it('reads each bundle version once per language and publishes the result', async () => { + const { controller, manager, state } = setup() + controller.load('a', '1.0.0', 'en', false) + controller.load('a', '1.0.0', 'en', false) + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'loading' }) + await vi.waitFor(() => { expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) }) + controller.load('a', undefined, 'zh', false) + await vi.waitFor(() => { expect(state('a', undefined, 'zh')?.status).toBe('ready') }) + expect(manager.readBundleDetail.mock.calls).toEqual([['a', 'en', { npmFileSources: [] }], ['a', 'zh', { npmFileSources: [] }]]) +}) + +it('reports failures by reason, reads again on retry, and settles a retried key with the latest answer', async () => { + const { controller, manager, state } = setup() + manager.readBundleDetail.mockImplementationOnce(() => Promise.resolve({ ok: false, error: new RemoteError('plugin-manager/detail-unavailable' as never, 'big', { reason: 'too-large' } as never) })) + manager.readBundleDetail.mockImplementationOnce(() => Promise.resolve({ ok: false, error: new RemoteError('plugin-manager/unknown-bundle' as never, 'gone', { name: 'b' } as never) })) + controller.load('a', '1.0.0', 'en', false) + controller.load('b', '1.0.0', 'en', false) + await vi.waitFor(() => { + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'error', failure: 'too-large' }) + expect(state('b', '1.0.0', 'en')).toEqual({ status: 'error', failure: 'other' }) + }) + let first: (value: Answer) => void = () => {} + manager.readBundleDetail.mockImplementationOnce(() => new Promise((resolve) => { first = resolve })) + controller.retry('a', '1.0.0', 'en', false) + controller.retry('a', '1.0.0', 'en', false) + await vi.waitFor(() => { expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) }) + first({ ok: true, value: { status: 'none' } }) + await flush() + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) +}) + +it('forgets every read on reset, drops answers of forgotten reads, and stops publishing after disposal', async () => { + const { controller, manager, state } = setup() + let answer: (value: Answer) => void = () => {} + manager.readBundleDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve })) + controller.load('a', '1.0.0', 'en', false) + controller.reset() + answer({ ok: true, value: FOUND }) + await flush() + expect(controller.store.getSnapshot()).toEqual({}) + // A read from before a reset that settles after the read that replaced it does not overwrite it. + let stale: (value: Answer) => void = () => {} + manager.readBundleDetail.mockImplementationOnce(() => new Promise((resolve) => { stale = resolve })) + controller.load('a', '1.0.0', 'en', false) + controller.reset() + manager.readBundleDetail.mockImplementationOnce(() => ok({ status: 'none' })) + controller.load('a', '1.0.0', 'en', false) + await vi.waitFor(() => { expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: { status: 'none' } }) }) + stale({ ok: true, value: FOUND }) + await flush() + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: { status: 'none' } }) + controller.reset() + manager.readBundleDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve })) + controller.load('a', '1.0.0', 'en', false) + controller.dispose() + answer({ ok: true, value: FOUND }) + await flush() + expect(state('a', '1.0.0', 'en')).toEqual({ status: 'loading' }) +}) + +it('asks the registry comparison only for bundles read from npm, and reads them and their images from npmmirror first where it won', async () => { + const { controller, manager, probe, state } = setup() + controller.load('installed', '1.0.0', 'en', false) + await vi.waitFor(() => { expect(state('installed', '1.0.0', 'en')?.status).toBe('ready') }) + expect(probe.fastest).not.toHaveBeenCalled() + expect(controller.image('x', 'README.md', 'a.png', true)).not.toContain('sources=') + controller.load('offered', '1.0.0', 'en', true) + await vi.waitFor(() => { expect(state('offered', '1.0.0', 'en')?.status).toBe('ready') }) + expect(manager.readBundleDetail.mock.calls.at(-1)).toEqual(['offered', 'en', { npmFileSources: ['npmmirror'] }]) + expect(controller.image('offered', 'README.md', 'a.png', true)).toContain('&sources=npmmirror') + expect(controller.image('installed', 'README.md', 'a.png', false)).not.toContain('sources=') + probe.fastest.mockImplementationOnce(() => Promise.resolve({ ok: false, error: new RemoteError('remote/unavailable' as never, 'gone', {} as never) })) + controller.retry('offered', '1.0.0', 'en', true) + await vi.waitFor(() => { expect(manager.readBundleDetail.mock.calls.at(-1)).toEqual(['offered', 'en', { npmFileSources: [] }]) }) +}) + +it('drops a read whose registry comparison settles after a reset or disposal, without asking the Host', async () => { + const { controller, manager, probe, state } = setup() + let settle: (value: Answer) => void = () => {} + probe.fastest.mockImplementation(() => new Promise((resolve) => { settle = resolve })) + controller.load('offered', '1.0.0', 'en', true) + controller.reset() + settle({ ok: true, value: 'https://registry.npmmirror.com/' }) + await flush() + controller.load('offered', '1.0.0', 'en', true) + controller.dispose() + settle({ ok: true, value: 'https://registry.npmmirror.com/' }) + await flush() + expect(manager.readBundleDetail).not.toHaveBeenCalled() + expect(state('offered', '1.0.0', 'en')).toEqual({ status: 'loading' }) +}) + +it('addresses relative images through the Host route and leaves other destinations alone', () => { + const { controller } = setup() + expect(controller.image('@acme/x', 'guide/D.md', 'img/a b.png', false)) + .toBe('https://dsh.example/prefix/api/plugin-manager/detail-file?bundle=%40acme%2Fx&document=guide%2FD.md&path=img%2Fa+b.png') + for (const destination of ['https://example.com/a.png', 'data:image/png;base64,AA', '//cdn.example/a.png', '#top']) { + expect(controller.image('x', 'README.md', destination, false)).toBeUndefined() + } +}) diff --git a/packages/client/ui-plugin-market/README.i18n.yaml b/packages/client/ui-plugin-market/README.i18n.yaml index 4223a3e430..4ca8e13747 100644 --- a/packages/client/ui-plugin-market/README.i18n.yaml +++ b/packages/client/ui-plugin-market/README.i18n.yaml @@ -9,17 +9,17 @@ en: 62972eb0dff8c145 zh: 682cc03b492cd730 /deepseek-ai-dsh-client-ui-plugin-market/summary: - en: f95719fc57b4e921 - zh: bf8a63b54127c147 + en: 55a3d1b12cd7be61 + zh: a961cf0212b3fda8 /deepseek-ai-dsh-client-ui-plugin-market/table-of-contents: en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-client-ui-plugin-market/use-this-package: - en: 2690b95e156cc473 - zh: 0d9e897ad628f9dd + en: f873bf36195df821 + zh: e13c78ba3f3e29f1 /deepseek-ai-dsh-client-ui-plugin-market/understand-the-implementation: - en: c56790cb58083f85 - zh: a3d4ad4bad08e483 + en: 4107a6967934f6b6 + zh: 46acec8bedb86329 /deepseek-ai-dsh-client-ui-plugin-market/model-experience: en: 08d25ab9cbdd7567 zh: 26e6e75c123951ee @@ -27,8 +27,8 @@ en: 4f0eadf94828e66e zh: 4f8a6ec170ebbfb6 /deepseek-ai-dsh-client-ui-plugin-market/known-limitations-and-deferred-work: - en: 5e98bb00293c12e3 - zh: 279b09a42c515d17 + en: 493d1326b1645f3f + zh: 878a53ed6cb7878d /deepseek-ai-dsh-client-ui-plugin-market/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-plugin-market/README.md b/packages/client/ui-plugin-market/README.md index b89f158b64..7a2bf9ffa8 100644 --- a/packages/client/ui-plugin-market/README.md +++ b/packages/client/ui-plugin-market/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Discover is the second tab of the **Plugins** page, after **Installed**. It lists the bundles that plugin market indexes offer; users manage index sources, switch between markets, search, filter by category and label, sort, open an entry's page, and install an entry after confirming it. The Host [`pluginMarket`](../../boot/plugin-market/README.md) service fetches and validates indexes; this package only presents them. +Discover is the second tab of the **Plugins** page, after **Installed**. It lists the bundles that plugin market indexes offer; users manage index sources, switch between markets, search, filter by category and label, sort, open an entry's page, and install an entry after confirming it. Entry pages show each entry's long description as Markdown with images. The Host [`pluginMarket`](../../boot/plugin-market/README.md) service fetches and validates indexes; this package only presents them. ## Table of Contents @@ -28,12 +28,14 @@ Open **Plugins** and select **Discover**. With no index source, the tab shows an The toolbar holds the market menu, which switches markets and ends with **Manage index sources…**; the search field; a **Labels** menu with multi-select checkmarks when the market declares labels; the **Sort** menu; and a refresh button. Category pills, with **All** first, follow when the market declares categories. -Each card shows the icon, localized title, label tags, a one-line description, and the default version, author, and publish date, with one action: **Install** for an installable entry; a disabled **Install** whose tooltip says no version is compatible with the running DSH when the plugin has no default version and its highest version is neither compatible nor exempted; or **Installed**, **Update available**, or **Provided by DSH**, which switches to the Installed tab and opens that bundle's page when that tab lists it. Selecting a card opens the entry's page with its full description, compatibility notice, and the default version's facts: author; homepage and source code repository, as links that open in a new tab, with the package directory beside the repository; the npm package and registry, or the Git repository, full commit, and directory; publish date; category; labels; and market. A **Versions** list follows in descending SemVer order; each row tags the recommended, prerelease, installed, incompatible, and exempted versions and offers **Install** for any other version installation accepts, except for a bundle DSH provides. The page's back link returns to the list with its search, filters, and sort intact. +Each card shows the icon, localized title, label tags, a one-line description, and the default version, author, and publish date, with one action: **Install** for an installable entry; a disabled **Install** whose tooltip says no version is compatible with the running DSH when the plugin has no default version and its highest version is neither compatible nor exempted; or **Installed**, **Update available**, or **Provided by DSH**, which switches to the Installed tab and opens that bundle's page when that tab lists it. Selecting a card opens the entry's page with its full description, compatibility notice, and, under the source heading, the default version's facts: author; homepage and source code repository, as links that open in a new tab, with the package directory beside the repository; the npm package and registry, or the Git repository, full commit, and directory; publish date; category; labels; and market. The page shows the long description first, without a heading of its own, then **Versions**, then the source facts. The **Versions** list is in descending SemVer order; each row tags the recommended, prerelease, installed, incompatible, and exempted versions and offers **Install** for any other version installation accepts, except for a bundle DSH provides. The page's back link returns to the list with its search, filters, and sort intact. **Install** chooses the default version and opens a confirmation dialog with the entry's icon, title, that version, the author, its source facts, an exemption notice when one applies, and a trust notice. Confirming opens the Plugins install dialog at its check screen with the entry's exact spec and registry; there is no spec form, and a refusal offers **Retry**. The registry an entry names applies to that install only. The Host [`pluginMarket.search`](../../boot/plugin-market/README.md#use-this-package) matches, filters, orders, and pages; the tab sends the search text 250 ms after typing stops. Search matches names, authors, keywords, and every language of titles, descriptions, categories, and labels, fuzzily. Label filters must all match. **Sort** offers **Best match** while there is search text, **Default order**, **Publish date**, and each declared sort key, which starts in its declared direction; a directed sort adds a row that reverses it. Typing switches **Default order** to **Best match**, and clearing the text switches back. Categories, labels, and sort keys are listed by localized title. The list loads 50 entries and adds 50 with each **Show more**. +The description section shows the default version's long description in the active UI language: the version's locale `meta.detail` file, otherwise its package README, otherwise its repository README, in the [order `pluginMarket` documents](../../boot/plugin-market/README.md#plugin-detail-documents). While the document loads, the section shows three skeleton lines; an entry without a document shows no section; a failed read names its reason, such as a size limit, an unreachable source, or a refused repository, with **Retry**. Raw HTML is omitted, and a document taller than the section's height limit scrolls inside it. Relative image paths load from the Host beside the document, absolute HTTP(S) images load directly without a referrer, and relative links stay text. The section is the shared [`DetailDocument`](../ui-primitives/README.md#component-catalog) primitive. + Reading a market shows card skeletons. A failed read shows a short sentence with **Retry**, the Host's reason appears under the source in **Index sources**, and keeps the market's last listing; a failed read of the source list shows **Retry** in place of the tab. Discover re-reads sources on `plugin-market/changed` and connection reset, and recomputes installation status on `plugin-manager/changed`. ----- @@ -41,6 +43,8 @@ Reading a market shows card skeletons. A failed read shows a short sentence with ## Understand the implementation +Detail reads are kept in a second snapshot store keyed by index URL, entry, version, and language; a section starts its read when it renders, an npm version's read first asks the Host's registry comparison and passes `npmFileSources: ['npmmirror']` when npmmirror answered fastest, only the latest read of a key settles it, **Retry** asks the Host to read again instead of returning its cached result, and a connection reset forgets every read, because a restarted Host no longer knows the document ids its images use, so each shown section reads its document again. Image URLs resolve `api/plugin-market/detail-file` against `document.baseURI`, so they keep a deployment prefix and use the Desktop `dsh-app://app/` origin, which [`MarkdownText`](../ui-primitives/README.md) accepts for that route. + The plugin registers the `discover` entry, labelled **Discover**, into `plugins.tab`, the tab slot that [`ui-plugin-manager`](../ui-plugin-manager/README.md) declares; the owner props provide `install({ spec, registry?, title, version })` and `openPackage(name)`; Discover passes the entry's localized title and the chosen version. A React-free controller owns source reads, source edits, and the selected index's query and loaded pages in a snapshot store; selecting another market starts from the initial query. Later pages send the first page's `fetchedAt`, and a page from a replaced document reloads the list once; a second replacement fails the read. The listing keeps the query its results answer, so the empty-state wording follows the shown results while a new query loads or after it fails, and **Retry** repeats the read that failed: the first pages of a query or the next page. A change to the profile's bundles reloads every loaded page to refresh installation status, and a filter or sort key the index no longer declares leaves the query. The unsent search text, the open entry, and the confirmation are component state. Pure helpers in `presentation.ts` derive the query order from the text, drop undeclared ids, order declarations with `Intl.Collator` in the active UI language, and list source facts. Icons load lazily with `referrerpolicy="no-referrer"` and fall back to the default artwork. ----- @@ -59,6 +63,7 @@ None. - **No upgrade action** — an available update is shown as a status only. +- **Plain Markdown only** — detail documents omit raw HTML, including HTML images and layout, and relative links, including in-page anchors, are not clickable. - **Remote icons** — URL icons are requested by the browser, which discloses the user's IP address to the image host. When the Web UI is served over HTTPS, the browser blocks or upgrades `http:` icons from an `http:` index, and a failed icon shows the default artwork. diff --git a/packages/client/ui-plugin-market/README.zh.md b/packages/client/ui-plugin-market/README.zh.md index c5d906e802..40b98ec314 100644 --- a/packages/client/ui-plugin-market/README.zh.md +++ b/packages/client/ui-plugin-market/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -「发现」是**插件**页的第二个标签页,位于**已安装**之后。它列出插件市场索引提供的组合包(bundle);用户可以管理索引源、在插件市场之间切换、搜索、按分类和标签筛选、排序、打开条目页面,并在确认后安装条目。Host 的 [`pluginMarket`](../../boot/plugin-market/README.zh.md) 服务负责获取和校验索引;本包只负责展示。 +「发现」是**插件**页的第二个标签页,位于**已安装**之后。它列出插件市场索引提供的组合包(bundle);用户可以管理索引源、在插件市场之间切换、搜索、按分类和标签筛选、排序、打开条目页面,并在确认后安装条目。条目页面以带图片的 Markdown 显示每个条目的详细介绍。Host 的 [`pluginMarket`](../../boot/plugin-market/README.zh.md) 服务负责获取和校验索引;本包只负责展示。 ## 目录 @@ -28,12 +28,14 @@ kind: "package-reference" 工具栏包含:插件市场菜单,用于切换插件市场,最后一项为**管理索引源…**;搜索框;插件市场声明了标签时显示的**标签**菜单,以勾选标记支持多选;**排序**菜单;以及刷新按钮。插件市场声明了分类时,其后显示分类按钮,**全部**排在第一位。 -每张卡片显示图标、本地化标题、标签、一行描述,以及默认版本、作者和发布日期,并只有一个操作:可安装的条目显示**安装**;没有默认版本、且最高版本既不兼容也未豁免的条目显示禁用的**安装**,其提示框说明没有与当前运行的 DSH 兼容的版本;其余条目显示**已安装**、**有新版本**或**随 DSH 提供**,点击后切换到「已安装」标签页,并在该标签页列出该组合包时打开其页面。选择卡片会打开条目页面,显示其完整描述、兼容性提示和默认版本的信息:作者;主页和源代码仓库(在新标签页中打开的链接,仓库旁显示包所在目录);npm 包和注册表,或 Git 仓库、完整提交和目录;发布日期;分类;标签;以及插件市场。其后是按 SemVer 降序排列的**版本**列表;每行标出推荐、预发布、已安装、不兼容和已豁免的版本,并为安装检查接受的其他版本提供**安装**,DSH 随附的组合包除外。页面的返回链接回到列表,搜索、筛选和排序保持不变。 +每张卡片显示图标、本地化标题、标签、一行描述,以及默认版本、作者和发布日期,并只有一个操作:可安装的条目显示**安装**;没有默认版本、且最高版本既不兼容也未豁免的条目显示禁用的**安装**,其提示框说明没有与当前运行的 DSH 兼容的版本;其余条目显示**已安装**、**有新版本**或**随 DSH 提供**,点击后切换到「已安装」标签页,并在该标签页列出该组合包时打开其页面。选择卡片会打开条目页面,显示其完整描述、兼容性提示,并在来源标题下显示默认版本的信息:作者;主页和源代码仓库(在新标签页中打开的链接,仓库旁显示包所在目录);npm 包和注册表,或 Git 仓库、完整提交和目录;发布日期;分类;标签;以及插件市场。页面先显示详细介绍,不带单独的标题,然后是**版本**,然后是来源信息。**版本**列表按 SemVer 降序排列;每行标出推荐、预发布、已安装、不兼容和已豁免的版本,并为安装检查接受的其他版本提供**安装**,DSH 随附的组合包除外。页面的返回链接回到列表,搜索、筛选和排序保持不变。 **安装**选择默认版本并打开确认对话框,显示条目的图标、标题、该版本、作者、来源信息、适用时的豁免提示,以及信任提示。确认后,插件页的安装对话框以该条目的精确 spec 和注册表在检查界面打开;不显示 spec 表单,被拒绝时提供**重试**。条目指定的注册表只用于这一次安装。 Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this-package) 负责匹配、过滤、排序和分页;标签页在停止输入 250 毫秒后发送搜索文本。搜索模糊匹配名称、作者、关键词,以及标题、描述、分类和标签的所有语言。所选标签必须全部匹配。有搜索文本时,**排序**提供**最佳匹配**,此外还提供**默认顺序**、**发布时间**以及每个已声明的排序键,排序键以其声明的方向开始;有方向的排序会额外显示一行,用于反转方向。输入文本会把**默认顺序**切换为**最佳匹配**,清空文本时切换回来。分类、标签和排序键按本地化标题列出。列表加载 50 个条目,每次点击**显示更多**再增加 50 个。 +详细介绍部分以当前界面语言显示默认版本的详细介绍:按 [`pluginMarket` 说明的顺序](../../boot/plugin-market/README.zh.md#plugin-detail-documents),依次为该版本 locale 中 `meta.detail` 指定的文件、其包 README、其仓库 README。文档加载期间,该部分显示三行骨架屏;没有文档的条目不显示该部分;读取失败时说明原因,例如超过大小限制、来源无法访问或仓库拒绝访问,并提供**重试**。原始 HTML 会被省略;文档高度超过该部分的高度上限时,在该部分内滚动。相对路径的图片从 Host 上该文档旁的位置加载,绝对 HTTP(S) 地址的图片不带 referrer 直接加载,相对链接保持为文本。该部分使用共享的 [`DetailDocument`](../ui-primitives/README.zh.md#component-catalog) 原语。 + 读取插件市场时显示卡片骨架屏。读取失败时显示一句简短说明和**重试**,Host 给出的原因显示在**索引源**中对应索引源的下方,并保留该插件市场上一次的列表;读取索引源列表失败时,在该标签页的位置显示**重试**。「发现」在收到 `plugin-market/changed` 和连接重置时重新读取索引源,在收到 `plugin-manager/changed` 时重新计算安装状态。 ----- @@ -41,6 +43,8 @@ Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this- ## 理解实现 +详细介绍的读取结果保存在第二个快照存储中,以索引 URL、条目、版本和语言为键;该部分在渲染时开始读取;npm 版本的读取先询问 Host 的注册表比较,npmmirror 响应最快时传入 `npmFileSources: ['npmmirror']`;只有某个键最新一次的读取才会让该部分得到结果;**重试**要求 Host 重新读取,而不是返回其缓存的结果;连接重置时丢弃所有读取结果,因为重启后的 Host 不再知道其图片使用的文档 id,所以每个已显示的部分会重新读取其文档。图片 URL 基于 `document.baseURI` 解析 `api/plugin-market/detail-file`,因此保留部署前缀,并在 Desktop 中使用 `dsh-app://app/` origin;[`MarkdownText`](../ui-primitives/README.zh.md) 对该路由接受此 origin。 + 本插件把标签为**发现**的 `discover` 条目注册到 `plugins.tab`,即 [`ui-plugin-manager`](../ui-plugin-manager/README.zh.md) 声明的标签页 slot;owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`;发现页传入条目的本地化标题和所选版本。一个不依赖 React 的控制器在快照存储中拥有索引源读取、索引源编辑,以及所选索引的查询和已加载的页;选择另一个插件市场时从初始查询开始。之后的页发送第一页的 `fetchedAt`,来自已替换文档的页会让列表重新加载一次;再次被替换时读取失败。列表保留其结果所回答的查询,因此新查询加载期间或失败后,空状态文案仍与显示的结果一致;**重试**重新执行失败的读取:某个查询的前几页,或下一页。profile 的组合包变化时,重新加载所有已加载的页以刷新安装状态;索引不再声明的过滤条件或排序键会从查询中移除。尚未发送的搜索文本、打开的条目和确认对话框是组件状态。`presentation.ts` 中的纯函数根据文本得出查询顺序、移除未声明的 id、使用当前界面语言的 `Intl.Collator` 对声明排序,以及列出来源信息。图标以 `referrerpolicy="no-referrer"` 延迟加载,失败时回退到默认插画。 ----- @@ -59,6 +63,7 @@ Host 的 [`pluginMarket.search`](../../boot/plugin-market/README.zh.md#use-this- - **没有升级操作**——可用更新只显示为状态。 +- **只支持纯 Markdown**——详细介绍文档省略原始 HTML(包括 HTML 图片和排版),相对链接(包括页内锚点)不可点击。 - **远程图标**——URL 图标由浏览器请求,会向图片主机暴露用户的 IP 地址。Web UI 通过 HTTPS 提供时,浏览器会拦截或升级来自 `http:` 索引的 `http:` 图标,加载失败的图标显示默认插画。 diff --git a/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css b/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css index 6ad459de5d..c60637db49 100644 --- a/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css +++ b/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css @@ -291,6 +291,14 @@ margin-top: 16px; } +.detailDocument { + margin-top: 16px; +} + +.detailDocument:empty { + display: none; +} + .sectionTitle { margin: 0; font-size: 14px; diff --git a/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx b/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx index 0941d101ba..26fe9bfa9a 100644 --- a/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx +++ b/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx @@ -11,6 +11,7 @@ import type { DiscoverFace, DiscoverState, ListingFailure, ListingState, SourceF import { displayDeclarations, indexLabel, presentedVersion, sourceFacts, withText, type DiscoverQuery, type DisplayDeclaration, type SourceFact, } from './presentation.ts' +import { PluginDetailSection } from './PluginDetailSection.tsx' import css from './DiscoverSection.module.css' /** Props of the Discover tab. */ @@ -183,11 +184,13 @@ function VersionRow({ entry, version, title, t, language, onInstall }: { ) } -function EntryDetail({ entry, listing, t, face, language, onBack, onInstall, onOpenInstalled }: { +function EntryDetail({ entry, listing, t, face, hooks, language, onBack, onInstall, onOpenInstalled }: { readonly entry: MarketListingEntry readonly listing: MarketSearchResult readonly t: Translate readonly face: DiscoverActions + /** The detail section's bound hooks. */ + readonly hooks: Pick readonly language: string readonly onBack: () => void readonly onInstall: (version: MarketListingVersion) => void @@ -229,6 +232,19 @@ function EntryDetail({ entry, listing, t, face, language, onBack, onInstall, onO

)} +
+ +
+
+

{t('versionsTitle')}

+
    + {entry.versions.map(item => ( + + ))} +
+

{t('factSource')}

@@ -259,14 +275,6 @@ function EntryDetail({ entry, listing, t, face, language, onBack, onInstall, onO
{t('factMarket')}
{face.resolveText(listing.title)}
-
-

{t('versionsTitle')}

-
    - {entry.versions.map(item => ( - - ))} -
-
) } @@ -386,11 +394,12 @@ function unfiltered(query: DiscoverQuery): boolean { return query.text.trim() === '' && query.category === null && query.labels.length === 0 } -function Browser({ state, listing, t, face, language, onInstall, openPackage }: { +function Browser({ state, listing, t, face, hooks, language, onInstall, openPackage }: { readonly state: DiscoverState readonly listing: ListingState readonly t: Translate readonly face: DiscoverActions + readonly hooks: Pick readonly language: string readonly onInstall: (choice: InstallChoice) => void readonly openPackage: (name: string) => void @@ -413,7 +422,7 @@ function Browser({ state, listing, t, face, language, onInstall, openPackage }: const openEntry = opened === null ? undefined : value?.entries.find(entry => entry.name === opened.name) ?? opened if (openEntry !== undefined && value !== null) { return ( - { setOpened(null) }} + { setOpened(null) }} onInstall={(version) => { onInstall({ entry: openEntry, version }) }} onOpenInstalled={() => { openPackage(openEntry.name) }} /> ) } @@ -580,7 +589,8 @@ export function DiscoverSection(props: DiscoverSectionProps): ReactNode { ) } else { body = ( - { setConfirming(choice) }} openPackage={openPackage} /> ) } diff --git a/packages/client/ui-plugin-market/src/client/PluginDetailSection.tsx b/packages/client/ui-plugin-market/src/client/PluginDetailSection.tsx new file mode 100644 index 0000000000..a560f1674a --- /dev/null +++ b/packages/client/ui-plugin-market/src/client/PluginDetailSection.tsx @@ -0,0 +1,47 @@ +/** A market entry's long description: the Markdown document the Host reads for one listed version. */ +import { useEffect, useMemo, type ReactNode } from 'react' +import { DetailDocument, detailDocumentState, useDetailDocumentLabels, type MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { detailKey, type DetailFace, type DetailFailureKind, type DetailRequest } from './market-store.ts' + +const FAILURE_KEYS = { + 'too-large': 'detailTooLarge', + 'network': 'detailNetwork', + 'denied': 'detailDenied', + 'git-unavailable': 'detailGitUnavailable', + 'invalid': 'detailInvalid', + 'other': 'detailFailed', +} as const satisfies Record + +/** Props of the detail section: the entry version, the dictionary, and the injected detail reads. */ +export type PluginDetailSectionProps = { readonly request: DetailRequest } & PropsLocale<'pluginMarket'> & InjectFace + +/** + * Read and render an entry version's detail document in the active language. Relative images load from the Host beside + * the document. + * @param props - the entry version, the dictionary, and the injected detail reads. + * @returns the section, or null when the version has no document. + */ +export function PluginDetailSection(props: PluginDetailSectionProps): ReactNode { + const { request, t, loadDetail, retryDetail, detailImage } = props + const language = props.useLocale(snapshot => snapshot.active) + const key = detailKey(request, language) + const state = props.useDetails(details => details[key]) + // The key names the request completely; a read the controller forgot, after a connection reset, starts again. + const missing = state === undefined + useEffect(() => { if (missing) loadDetail(request, language) }, [key, missing]) + const labels = useDetailDocumentLabels({ + title: t('detailTitle'), loading: t('detailLoading'), retry: t('retry'), copy: t('copy'), copied: t('copied'), + codeBlock: t('codeBlock.title'), wrap: t('codeBlock.wrap'), unwrap: t('codeBlock.unwrap'), footnotes: t('footnotes'), + }) + const detail = state?.status === 'ready' ? state.detail : undefined + const document = detail?.status === 'found' ? detail.document : undefined + const images = useMemo( + () => document === undefined ? undefined : { resolve: value => detailImage(document, value) }, + [document, detailImage], + ) + const shown = detailDocumentState(state?.status === 'error' + ? { status: 'error', message: t(FAILURE_KEYS[state.failure]) } + : state === undefined || state.status === 'loading' ? state : { status: 'ready', markdown: detail?.status === 'found' ? detail.markdown : undefined }) + return { retryDetail(request, language) }} /> +} diff --git a/packages/client/ui-plugin-market/src/client/index.ts b/packages/client/ui-plugin-market/src/client/index.ts index 754267f2a5..9d92d5da7a 100644 --- a/packages/client/ui-plugin-market/src/client/index.ts +++ b/packages/client/ui-plugin-market/src/client/index.ts @@ -1,6 +1,7 @@ /** * Discover, browser half: the Plugins page tab that browses the plugin market indexes the user configures through the - * `pluginMarket` Remote and installs an entry through the page's install dialog. + * `pluginMarket` Remote and installs an entry through the page's install dialog; entry pages show each version's + * detail document. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -28,7 +29,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { const NS = 'pluginMarket' /** Services the section reads. */ -export const inject = ['slots', 'locale', 'remote', 'remote.pluginMarket'] +export const inject = ['slots', 'locale', 'remote', 'remote.pluginMarket', 'remote.pluginRegistryProbe'] /** * Contribute Discover to the Plugins page and keep it current on Host changes. @@ -36,13 +37,16 @@ export const inject = ['slots', 'locale', 'remote', 'remote.pluginMarket'] */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugin-market: dictionaries') - const controller = new DiscoverController(ctx) + const controller = new DiscoverController(ctx, () => document.baseURI) ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-market: controller') ctx.effect(() => { const disposers = [ ctx.remote.$on('plugin-market/changed', () => { controller.sourcesChanged() }), ctx.remote.$on('plugin-manager/changed', () => { controller.bundlesChanged() }), - ctx.on('connection/reset', () => { controller.sourcesChanged() }), + ctx.on('connection/reset', () => { + controller.sourcesChanged() + controller.detailsReset() + }), ] return () => { for (const dispose of disposers) dispose() } }, 'ui-plugin-market: host invalidations') diff --git a/packages/client/ui-plugin-market/src/client/locales.ts b/packages/client/ui-plugin-market/src/client/locales.ts index 63bc504cea..924ff10b43 100644 --- a/packages/client/ui-plugin-market/src/client/locales.ts +++ b/packages/client/ui-plugin-market/src/client/locales.ts @@ -80,6 +80,15 @@ export const zh = { versionPrerelease: '预发布', confirmTitle: '安装「{name}」?', installTrust: '插件在本机以你的权限运行。请确认你信任它的来源', + detailTitle: '详细介绍', + detailLoading: '正在读取详细介绍', + detailTooLarge: '详细介绍或它所在的仓库超过大小限制,未加载', + detailNetwork: '无法连接到详细介绍的来源', + detailDenied: '无法访问详细介绍所在的仓库,请检查 Git 凭据', + detailGitUnavailable: '详细介绍所在的仓库需要用 Git 读取,但无法运行 Git。请安装 Git 后重试', + detailInvalid: '插件声明的详细介绍文件无效', + detailFailed: '无法读取详细介绍', + footnotes: '脚注', } as const /** Dictionary keys owned by this plugin. */ @@ -165,4 +174,13 @@ export const en = { versionPrerelease: 'Prerelease', confirmTitle: 'Install "{name}"?', installTrust: 'Plugins run on this machine with your permissions. Make sure you trust where this one comes from.', + detailTitle: 'Details', + detailLoading: 'Reading details', + detailTooLarge: 'The details or their repository exceed the size limit and were not loaded.', + detailNetwork: 'Could not reach the source of the details.', + detailDenied: 'Could not access the repository of the details. Check your Git credentials.', + detailGitUnavailable: 'The details are in a repository that must be read with Git, and Git could not run. Install Git, then retry.', + detailInvalid: 'The plugin declares an invalid details file.', + detailFailed: 'Could not read the details.', + footnotes: 'Footnotes', } satisfies Record diff --git a/packages/client/ui-plugin-market/src/client/market-store.ts b/packages/client/ui-plugin-market/src/client/market-store.ts index 110acc4773..43d5d2b721 100644 --- a/packages/client/ui-plugin-market/src/client/market-store.ts +++ b/packages/client/ui-plugin-market/src/client/market-store.ts @@ -1,9 +1,10 @@ /** Discover state: configured index sources, the selected index's query and loaded results, and source editing. */ +import { NPMMIRROR_REGISTRY } from '@deepseek-ai/dsh-plugin-manager/registry' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' -import type { MarketIndexSummary, MarketSearchResult, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' +import type { MarketIndexSummary, MarketSearchResult, PluginDetail, PluginDetailDocumentId, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' import { declaredQuery, INITIAL_QUERY, type DiscoverQuery } from './presentation.ts' @@ -48,13 +49,61 @@ export interface DiscoverState { readonly sourceFailure: SourceFailure | null } -/** What the Discover tab receives through injection. */ -export interface DiscoverFace { +/** The market entry version a detail read is about. */ +export interface DetailRequest { + readonly url: string + readonly name: string + readonly version: string + /** Whether the version is an npm package; the Host reads those of the public registries from the npm file CDNs. */ + readonly npm: boolean +} + +/** Why a detail read failed: the Host's `plugin-market/detail-unavailable` reason, or `other` for any other failure. */ +export type DetailFailureKind = 'too-large' | 'network' | 'denied' | 'git-unavailable' | 'invalid' | 'other' + +/** One detail read. */ +export type DetailState = + | { readonly status: 'loading' } + | { readonly status: 'ready'; readonly detail: PluginDetail } + | { readonly status: 'error'; readonly failure: DetailFailureKind } + +/** Detail reads by {@link detailKey}. */ +export type DetailsState = Readonly> + +/** + * The identity of a detail read. + * @param request - the plugin. + * @param language - the display language. + * @returns the key of the read in {@link DetailsState}. + */ +export function detailKey(request: DetailRequest, language: string): string { + return JSON.stringify([language, request]) +} + +/** What the entry detail section receives through injection. */ +export interface DetailFace { readonly hooks: { + /** Detail reads, bound as `useDetails`. */ + readonly details: SnapshotStore + /** The active locale, bound as `useLocale`. */ + readonly locale: HostObservable + } + /** Read a plugin's detail document unless the read already ran or runs. */ + readonly loadDetail: (request: DetailRequest, language: string) => void + /** Read a failed detail document again, bypassing the Host's cached result. */ + readonly retryDetail: (request: DetailRequest, language: string) => void + /** + * The URL of an image beside a detail document. + * @returns the authenticated Host URL, or undefined for an absolute, protocol-relative, or fragment destination. + */ + readonly detailImage: (document: PluginDetailDocumentId, destination: string) => string | undefined +} + +/** What the Discover tab receives through injection. */ +export interface DiscoverFace extends DetailFace { + readonly hooks: DetailFace['hooks'] & { /** Discover state, bound as `useDiscover`. */ readonly discover: SnapshotStore - /** The active locale, bound as `useLocale`, for collation. */ - readonly locale: HostObservable } /** Resolve market text in the current language. */ readonly resolveText: (text: LocalizedText) => string @@ -90,6 +139,13 @@ function sourceFailure(failure: RemoteFailure): SourceFailure { return { kind: 'other', reason: failure.message } } +function detailFailure(failure: RemoteFailure): DetailFailureKind { + return failure.code === 'plugin-market/detail-unavailable' ? failure.details.reason : 'other' +} + +/** The Host route of images beside detail documents, relative to the application base. */ +const DETAIL_FILE_ROUTE = 'api/plugin-market/detail-file' + function listingFailure(failure: RemoteFailure): ListingFailure { return failure.code === 'plugin-market/invalid-index' ? 'invalid' : 'fetch' } @@ -98,7 +154,13 @@ function listingFailure(failure: RemoteFailure): ListingFailure { export class DiscoverController { /** Shared Discover state published to the tab. */ readonly store: SnapshotStore = createSnapshotStore(INITIAL) + /** Detail reads published to detail sections. */ + readonly details: SnapshotStore = createSnapshotStore({}) private disposed = false + /** The latest detail read of each key; an older read's answer is dropped. */ + private readonly detailReads = new Map() + /** Numbers detail reads across the controller's lifetime, so a read started before a reset never matches one after it. */ + private detailSequence = 0 private indexRead = 0 private listingRead = 0 /** Select the last source once the list it was added to arrives. */ @@ -106,8 +168,11 @@ export class DiscoverController { /** Runs the listing read that failed last. */ private failed: () => void = () => {} - /** @param ctx - the plugin context whose `remote.pluginMarket` namespace answers. */ - constructor(private readonly ctx: ClientContext) {} + /** + * @param ctx - the plugin context whose `remote.pluginMarket` namespace answers. + * @param base - the application base URL that Host routes resolve against. + */ + constructor(private readonly ctx: ClientContext, private readonly base: () => string) {} /** Stop publishing late settlements. */ dispose(): void { @@ -120,8 +185,10 @@ export class DiscoverController { * @returns the tab's injected state sources and actions. */ face(resolveText: DiscoverFace['resolveText']): DiscoverFace { + const detail = this.detailFace() return { - hooks: { discover: this.store, locale: this.ctx.locale }, + ...detail, + hooks: { ...detail.hooks, discover: this.store }, resolveText, ensure: () => { if (this.state().status === 'idle') void this.loadIndexes() }, reload: () => { void this.loadIndexes() }, @@ -152,6 +219,30 @@ export class DiscoverController { } } + /** Detail reads and image addressing of the entry detail section. */ + private detailFace(): DetailFace { + return { + hooks: { details: this.details, locale: this.ctx.locale }, + loadDetail: (request, language) => { + if (this.details.getSnapshot()[detailKey(request, language)] === undefined) void this.readDetail(request, language) + }, + retryDetail: (request, language) => { void this.readDetail(request, language, true) }, + detailImage: (document, destination) => { + if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/iu.test(destination)) return undefined + return new URL(`${DETAIL_FILE_ROUTE}?document=${encodeURIComponent(document)}&path=${encodeURIComponent(destination)}`, this.base()).href + }, + } + } + + /** + * Forget every detail read after the connection reset: a restarted Host no longer knows the document ids that address + * their images, and each shown section reads its document again. + */ + detailsReset(): void { + this.detailReads.clear() + this.details.set({}) + } + /** Re-read the sources after the Host announced a change or the connection reset; an unrendered tab stays idle. */ sourcesChanged(): void { if (this.state().status !== 'idle') void this.loadIndexes() @@ -164,6 +255,32 @@ export class DiscoverController { if (listing?.status === 'ready') void this.load(listing.url, listing.query, Math.max((listing.value as MarketSearchResult).entries.length, PAGE_SIZE), false) } + private async readDetail(request: DetailRequest, language: string, refresh = false): Promise { + const key = detailKey(request, language) + const read = ++this.detailSequence + this.detailReads.set(key, read) + this.publishDetail(key, { status: 'loading' }) + // The remote is captured before any wait: the plugin's context may be disposed while the comparison runs. + const { remote } = this.ctx + const current = (): boolean => !this.disposed && this.detailReads.get(key) === read + // Only an npm version waits for the registry comparison; a Git version is read at once. + // npmmirror is asked first where its registry answered the Host's comparison fastest. + let npmFileSources: 'npmmirror'[] = [] + if (request.npm) { + const fastest = await remote.pluginRegistryProbe.fastest() + // A retry or reset started while the comparison ran settles the key instead. + if (!current()) return + if (fastest.ok && fastest.value === NPMMIRROR_REGISTRY) npmFileSources = ['npmmirror'] + } + const { url, name, version } = request + const answer = await remote.pluginMarket.readEntryDetail(url, name, version, language, { refresh, npmFileSources }) + if (current()) this.publishDetail(key, answer.ok ? { status: 'ready', detail: answer.value } : { status: 'error', failure: detailFailure(answer.error) }) + } + + private publishDetail(key: string, state: DetailState): void { + this.details.set({ ...this.details.getSnapshot(), [key]: state }) + } + private state(): DiscoverState { return this.store.getSnapshot() } diff --git a/packages/client/ui-plugin-market/tests/browser-plugin.client.spec.ts b/packages/client/ui-plugin-market/tests/browser-plugin.client.spec.ts index 760009b0d9..f17e52fabe 100644 --- a/packages/client/ui-plugin-market/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plugin-market/tests/browser-plugin.client.spec.ts @@ -6,6 +6,8 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { expect, it, onTestFinished, vi } from 'vitest' import { apply, inject } from '../src/client/index.ts' +import type { PluginDetailDocumentId } from '@deepseek-ai/dsh-api-remotes/client' +import { brandString } from '@deepseek-ai/dsh-brand' import type { DiscoverFace } from '../src/client/market-store.ts' it('contributes Discover to the Plugins page, follows Host changes, and withdraws with its fiber', async () => { @@ -18,7 +20,7 @@ it('contributes Discover to the Plugins page, follows Host changes, and withdraw read: vi.fn(), setIndexes: vi.fn(), } - const remote = new TestRemote(ctx, { pluginMarket: market }) + const remote = new TestRemote(ctx, { pluginMarket: market, pluginRegistryProbe: { fastest: vi.fn() } }) const slots = ctx.get('slots') as SlotRegistry slots.register({ name: 'root', children: { 'plugins.tab': { kind: 'list', scope: 'root' } } } as never, () => null) const fiber = await ctx.plugin({ inject: [...inject], apply }).await() @@ -32,6 +34,8 @@ it('contributes Discover to the Plugins page, follows Host changes, and withdraw const injected: object = entry!.inject!() const face = injected as DiscoverFace expect(face.resolveText({ en: 'English' })).toBe('English') + expect(face.detailImage(brandString('d'), 'img/a b.png')) + .toBe(new URL('api/plugin-market/detail-file?document=d&path=img%2Fa%20b.png', document.baseURI).href) face.ensure() await vi.waitFor(() => { expect(face.hooks.discover.getSnapshot().status).toBe('ready') }) remote.emit('plugin-market/changed', []) diff --git a/packages/client/ui-plugin-market/tests/discover.client.spec.tsx b/packages/client/ui-plugin-market/tests/discover.client.spec.tsx index f0e4796ea0..88ee3e6cd3 100644 --- a/packages/client/ui-plugin-market/tests/discover.client.spec.tsx +++ b/packages/client/ui-plugin-market/tests/discover.client.spec.tsx @@ -6,7 +6,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' import { DiscoverSection, type DiscoverSectionProps } from '../src/client/DiscoverSection.tsx' -import type { DiscoverState, ListingState } from '../src/client/market-store.ts' +import type { DetailsState, DiscoverState, ListingState } from '../src/client/market-store.ts' import { INITIAL_QUERY, type DiscoverQuery } from '../src/client/presentation.ts' import { en, zh, type PluginMarketLocaleKey } from '../src/client/locales.ts' @@ -86,6 +86,7 @@ function renderTab(state: Partial = {}, dictionary: Record undefined), } const install = vi.fn() const openPackage = vi.fn() @@ -94,6 +95,7 @@ function renderTab(state: Partial = {}, dictionary: Record) => typeof text === 'string' ? text : text[language] ?? text.en, useDiscover: bindSnapshotSelector(store), useLocale: bindSnapshotSelector(locale), + useDetails: bindSnapshotSelector(createSnapshotStore({})), } as DiscoverSectionProps render() const set = (next: Partial): void => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) } @@ -278,9 +280,11 @@ describe('DiscoverSection', () => { }) it('opens an entry page with its source facts and actions, and returns to the list', () => { - const { install, openPackage } = renderTab() + const { install, openPackage, actions } = renderTab() fireEvent.click(card('dsh-a').getByRole('button', { name: 'View Alpha' })) const detail = within(document.querySelector('[data-market-detail="dsh-a"]') as HTMLElement) + expect(actions.loadDetail).toHaveBeenCalledWith({ url: A, name: 'dsh-a', version: '1.0.0', npm: true }, 'en') + expect(detail.getByRole('status', { name: en.detailLoading })).toBeTruthy() expect(detail.getByRole('heading', { name: 'Alpha' })).toBeTruthy() expect(detail.getByText('dsh-a')).toBeTruthy() for (const value of ['Jane', 'dsh-a@1.0.0', 'https://r.example/', 'Tools', 'Official', 'Beta', 'Alpha market']) expect(detail.getByText(value)).toBeTruthy() @@ -297,6 +301,7 @@ describe('DiscoverSection', () => { fireEvent.click(detail.getByRole('button', { name: en.backToList })) fireEvent.click(card('dsh-b').getByRole('button', { name: 'View dsh-b' })) const git = within(document.querySelector('[data-market-detail="dsh-b"]') as HTMLElement) + expect(actions.loadDetail).toHaveBeenLastCalledWith({ url: A, name: 'dsh-b', version: '1.0.0', npm: false }, 'en') expect(git.queryByRole('link')).toBeNull() for (const value of ['https://github.com/o/r', COMMIT, 'packages/b', 'Themes']) expect(git.getByText(value)).toBeTruthy() fireEvent.click(git.getByRole('button', { name: 'View dsh-b in Installed' })) diff --git a/packages/client/ui-plugin-market/tests/market-store.client.spec.ts b/packages/client/ui-plugin-market/tests/market-store.client.spec.ts index 203fca4c37..4bb0646530 100644 --- a/packages/client/ui-plugin-market/tests/market-store.client.spec.ts +++ b/packages/client/ui-plugin-market/tests/market-store.client.spec.ts @@ -1,9 +1,12 @@ import { Context } from '@deepseek-ai/cordis' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import type { MarketIndexSummary, MarketListingEntry, MarketQuery, MarketSearchResult } from '@deepseek-ai/dsh-api-remotes/client' +import type { + MarketIndexSummary, MarketListingEntry, MarketQuery, MarketSearchResult, PluginDetail, PluginDetailDocumentId, +} from '@deepseek-ai/dsh-api-remotes/client' +import { brandString } from '@deepseek-ai/dsh-brand' import { describe, expect, it, onTestFinished, vi } from 'vitest' -import { DiscoverController, PAGE_SIZE } from '../src/client/market-store.ts' +import { detailKey, DiscoverController, PAGE_SIZE } from '../src/client/market-store.ts' import { INITIAL_QUERY } from '../src/client/presentation.ts' const A = 'https://a.example/index.json' @@ -45,11 +48,15 @@ function setup(indexes: MarketIndexSummary[] = [{ url: A }, { url: B }]) { listIndexes: vi.fn(() => ok(state.indexes)), search: vi.fn((url: string, _query: MarketQuery) => ok(listing(url, url === A ? 'A' : 'B', url === A ? 'Desc' : undefined))), setIndexes: vi.fn((urls: string[]) => { state.indexes = urls.map(url => ({ url: url.trim() })); return ok(undefined) }), + readEntryDetail: vi.fn( + (_url: string, _name: string, _version: string, _language: string, _options: object): Promise> => ok({ status: 'none' }), + ), } - new TestRemote(ctx, { pluginMarket: market }) - const controller = new DiscoverController(ctx) + const probe = { fastest: vi.fn((): Promise> => ok('https://registry.npmmirror.com/')) } + new TestRemote(ctx, { pluginMarket: market, pluginRegistryProbe: probe }) + const controller = new DiscoverController(ctx, () => 'https://dsh.example/prefix/') const face = controller.face(text => typeof text === 'string' ? text : text.en) - return { controller, face, market, state, snapshot: () => controller.store.getSnapshot() } + return { controller, face, market, probe, state, snapshot: () => controller.store.getSnapshot() } } describe('DiscoverController', () => { @@ -343,3 +350,127 @@ describe('DiscoverController', () => { expect(snapshot().listing?.status).toBe('more') }) }) + +describe('detail reads', () => { + const BUNDLE = { url: A, name: 'b', version: '1.0.0', npm: true } as const + const ENTRY = { url: B, name: 'e', version: '2.0.0', npm: false } as const + const FOUND: PluginDetail = { status: 'found', origin: 'readme', markdown: '# B', document: brandString('doc') } + + it('reads each plugin once per language and publishes the result', async () => { + const { controller, face, market } = setup() + market.readEntryDetail.mockImplementationOnce(() => ok(FOUND)) + face.loadDetail(BUNDLE, 'en') + face.loadDetail(BUNDLE, 'en') + expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'loading' }) + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: FOUND }) }) + face.loadDetail(ENTRY, 'zh') + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(ENTRY, 'zh')]).toEqual({ status: 'ready', detail: { status: 'none' } }) }) + // An npm version asks the registry comparison and reads npmmirror first where it won; a Git version does not ask. + expect(market.readEntryDetail.mock.calls).toEqual([ + [A, 'b', '1.0.0', 'en', { refresh: false, npmFileSources: ['npmmirror'] }], [B, 'e', '2.0.0', 'zh', { refresh: false, npmFileSources: [] }], + ]) + }) + + it('reports failures by reason and reads again on retry', async () => { + const { controller, face, market } = setup() + // The Git entry's read starts at once; the npm one waits for the registry comparison, so answers go by name. + market.readEntryDetail.mockImplementationOnce(() => fail('plugin-market/unknown-plugin', 'gone', { name: 'e', version: '2.0.0' })) + market.readEntryDetail.mockImplementationOnce(() => fail('plugin-market/detail-unavailable', 'big', { reason: 'too-large' })) + face.loadDetail(BUNDLE, 'en') + face.loadDetail(ENTRY, 'en') + await vi.waitFor(() => { + expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'error', failure: 'too-large' }) + expect(controller.details.getSnapshot()[detailKey(ENTRY, 'en')]).toEqual({ status: 'error', failure: 'other' }) + }) + face.retryDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: { status: 'none' } }) }) + // A retry bypasses the Host's cached result. + expect(market.readEntryDetail.mock.calls.at(-1)).toEqual([A, 'b', '1.0.0', 'en', { refresh: true, npmFileSources: ['npmmirror'] }]) + }) + + it('settles a retried read with the retry\'s answer', async () => { + const { controller, face, market } = setup() + let first: (value: Answer) => void = () => {} + market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { first = resolve })) + market.readEntryDetail.mockImplementationOnce(() => ok(FOUND)) + face.loadDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(market.readEntryDetail).toHaveBeenCalledOnce() }) + face.retryDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: FOUND }) }) + first({ ok: true, value: { status: 'none' } }) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: FOUND }) + }) + + it('forgets every read after the connection reset and drops the answers of forgotten reads', async () => { + const { controller, face, market } = setup() + let answer: (value: Answer) => void = () => {} + face.loadDetail(ENTRY, 'zh') + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(ENTRY, 'zh')]?.status).toBe('ready') }) + market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve })) + face.loadDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(market.readEntryDetail).toHaveBeenCalledTimes(2) }) + controller.detailsReset() + expect(controller.details.getSnapshot()).toEqual({}) + answer({ ok: true, value: FOUND }) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + expect(controller.details.getSnapshot()).toEqual({}) + face.loadDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]?.status).toBe('ready') }) + // A read from before a reset that settles after the read that replaced it does not overwrite it. + let stale: (value: Answer) => void = () => {} + market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { stale = resolve })) + controller.detailsReset() + face.loadDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(market.readEntryDetail).toHaveBeenCalledTimes(3) }) + controller.detailsReset() + face.loadDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: { status: 'none' } }) }) + stale({ ok: true, value: FOUND }) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: { status: 'none' } }) + }) + + it('keeps the configured CDN order where npmmirror did not answer fastest', async () => { + const { controller, face, market, probe } = setup() + probe.fastest.mockImplementationOnce(() => ok('https://registry.npmjs.org/')) + face.loadDetail(BUNDLE, 'en') + await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]?.status).toBe('ready') }) + expect(market.readEntryDetail.mock.calls).toEqual([[A, 'b', '1.0.0', 'en', { refresh: false, npmFileSources: [] }]]) + }) + + it('drops a read whose registry comparison settles after a reset or disposal, without asking the Host', async () => { + const { controller, face, market, probe } = setup() + let settle: (value: Answer) => void = () => {} + probe.fastest.mockImplementation(() => new Promise((resolve) => { settle = resolve })) + face.loadDetail(BUNDLE, 'en') + controller.detailsReset() + settle({ ok: true, value: null }) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + face.loadDetail(BUNDLE, 'en') + controller.dispose() + settle({ ok: true, value: null }) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + expect(market.readEntryDetail).not.toHaveBeenCalled() + }) + + it('stops publishing detail reads after disposal', async () => { + const { controller, face, market } = setup() + let answer: (value: Answer) => void = () => {} + market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve })) + face.loadDetail(BUNDLE, 'en') + controller.dispose() + answer({ ok: true, value: FOUND }) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'loading' }) + }) + + it('addresses relative images through the Host route and leaves other destinations alone', () => { + const { face } = setup() + const doc = brandString('d 1') + expect(face.detailImage(doc, 'img/a.png')).toBe('https://dsh.example/prefix/api/plugin-market/detail-file?document=d%201&path=img%2Fa.png') + for (const destination of ['https://example.com/a.png', 'data:image/png;base64,AA', '//cdn.example/a.png', '#top', 'mailto:a@b']) { + expect(face.detailImage(doc, destination)).toBeUndefined() + } + }) +}) diff --git a/packages/client/ui-plugin-market/tests/plugin-detail.client.spec.tsx b/packages/client/ui-plugin-market/tests/plugin-detail.client.spec.tsx new file mode 100644 index 0000000000..5380c7b7c9 --- /dev/null +++ b/packages/client/ui-plugin-market/tests/plugin-detail.client.spec.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { PluginDetailDocumentId } from '@deepseek-ai/dsh-api-remotes/client' +import { brandString } from '@deepseek-ai/dsh-brand' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' +import { PluginDetailSection, type PluginDetailSectionProps } from '../src/client/PluginDetailSection.tsx' +import { detailKey, type DetailRequest, type DetailsState } from '../src/client/market-store.ts' +import { en, zh, type PluginMarketLocaleKey } from '../src/client/locales.ts' + +afterEach(cleanup) + +const COMMON: Record = { 'copy': 'Copy', 'copied': 'Copied', 'codeBlock.title': 'Code block', 'codeBlock.wrap': 'Wrap', 'codeBlock.unwrap': 'Unwrap', 'retry': 'Retry' } + +function translator(dictionary: Record): PluginDetailSectionProps['t'] { + const strings: Record = dictionary + return (key: string) => strings[key] ?? COMMON[key] ?? key +} + +const REQUEST: DetailRequest = { url: 'https://a.example/index.json', name: 'a', version: '1.0.0', npm: true } +const DOCUMENT = brandString('doc') + +function setup(initial: DetailsState = {}, language = 'en') { + const details = createSnapshotStore(initial) + const locale = createSnapshotStore({ active: language, locales: [], revision: 0 }) + const face = { + loadDetail: vi.fn(), retryDetail: vi.fn(), + detailImage: vi.fn((document: PluginDetailDocumentId, destination: string) => `https://dsh.example/api/plugin-market/detail-file?document=${document}&path=${destination}`), + useDetails: bindSnapshotSelector(details), useLocale: bindSnapshotSelector(locale), + } + const set = (key: string, state: DetailsState[string]): void => { act(() => { details.set({ ...details.getSnapshot(), [key]: state }) }) } + const forget = (): void => { details.set({}) } + return { face, set, locale, forget } +} + +it('reads the document of the request in the active language and renders its Markdown with images beside it', () => { + const { face, set, locale, forget } = setup() + const view = render() + expect(face.loadDetail).toHaveBeenCalledWith(REQUEST, 'en') + expect(screen.getByRole('status', { name: en.detailLoading })).toBeTruthy() + set(detailKey(REQUEST, 'en'), { status: 'ready', detail: { status: 'found', origin: 'readme', markdown: '# Alpha\n\n![shot](img/shot.png) [guide](docs/guide.md)', document: DOCUMENT } }) + expect(screen.queryByRole('heading', { name: en.detailTitle })).toBeNull() + expect(screen.getByRole('heading', { name: 'Alpha' })).toBeTruthy() + expect(screen.getByRole('img', { name: 'shot' }).getAttribute('src')).toBe('https://dsh.example/api/plugin-market/detail-file?document=doc&path=img/shot.png') + // Relative links stay text. + expect(screen.queryByRole('link', { name: 'guide' })).toBeNull() + expect(screen.getByText('guide')).toBeTruthy() + act(() => { locale.set({ active: 'zh', locales: [], revision: 1 }) }) + expect(face.loadDetail).toHaveBeenLastCalledWith(REQUEST, 'zh') + view.rerender() + expect(face.loadDetail).toHaveBeenCalledTimes(2) + // A read the controller forgot starts again; a settled one does not. + set(detailKey(REQUEST, 'zh'), { status: 'ready', detail: { status: 'none' } }) + act(() => { forget() }) + expect(face.loadDetail).toHaveBeenCalledTimes(3) +}) + +it('renders nothing without a document, and a reason with a retry when the read failed', () => { + const key = detailKey(REQUEST, 'en') + const { face, set } = setup({ [key]: { status: 'ready', detail: { status: 'none' } } }) + const view = render() + expect(view.container.innerHTML).toBe('') + const reasons = [ + ['too-large', en.detailTooLarge], ['network', en.detailNetwork], ['denied', en.detailDenied], + ['invalid', en.detailInvalid], ['other', en.detailFailed], + ] as const + for (const [failure, text] of reasons) { + set(key, { status: 'error', failure }) + expect(screen.getByRole('alert').textContent).toBe(text) + } + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(face.retryDetail).toHaveBeenCalledWith(REQUEST, 'en') +}) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 2e59aaf715..92dcd1dcd0 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,14 +18,14 @@ en: 7bae72c97d0449f5 zh: 4589ecc2b216edc5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 6f5457b84fc9db09 - zh: 85993f1c44bfee2c + en: 03388f40dc593990 + zh: 5f1541a6e20c8cfe /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: 28ac8f0b1edbcf81 zh: 5c4c324bf84a282f /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: - en: 12fe9d9b1410edef - zh: f2dfdc01170d7cce + en: 4b6e7c1dd8d8c2a7 + zh: 227747b459518e37 /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: en: 4aef65dea11de083 zh: abc22cd7c65cdf38 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 5b29187be1..803cce60c0 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -66,6 +66,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `SettingsFormModel`, `settingsNumberField`, `settingsTextField` | The staged-edit model behind such a page over a settings scope: drafts are staged and written on save, a field is overridden by its presence in the user layer, and a save that did not land keeps its drafts. | | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | | `MarkdownText`, `MarkdownDelegateProvider`, `CodeBlock` | Untrusted GFM with TeX math, owner-delegated HTTP(S) navigation, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. | +| `DetailDocument`, `detailDocumentState`, `useDetailDocumentLabels` | Section without a heading of its own for a long Markdown description, such as a plugin's detail document: three skeleton lines while loading, the document, the owner's failure message with a retry, or nothing when there is no document. A document taller than `min(560px, 60vh)` scrolls inside a keyboard-focusable region that the owner's title names. Raw HTML in the document is omitted. Relative links stay text and only images the owner's `images` resolver accepts, or absolute HTTP(S) images, load. `detailDocumentState` maps an owner's read to the shown state, and `useDetailDocumentLabels` builds reference-stable labels from translated strings. | | `UserTextRun`, `ReferenceChip` | Uninterpreted inline text and business-resolved reference labels or activation buttons. | | `CommandText` | Selectable command or argument text; only horizontal overflow adds a named keyboard focus target. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | @@ -99,7 +100,7 @@ The catalog above lists what each export is for; this section covers the behavio The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. -`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file` route; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). +`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). `MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index c1593e273b..a87598a306 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -66,6 +66,7 @@ kind: "package-library" | `SettingsFormModel`、`settingsNumberField`、`settingsTextField` | 这类页面背后基于设置 scope 的暂存编辑模型:草稿先暂存、保存时写入,字段是否被覆盖看用户层是否含有它,未落地的保存保留草稿。 | | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | | `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 | +| `DetailDocument`、`detailDocumentState`、`useDetailDocumentLabels` | 用于较长 Markdown 描述(例如插件的详细介绍文档)的区块,本身不带标题:加载期间显示三行骨架屏,随后显示文档、owner 提供的失败消息和重试,或在没有文档时不显示任何内容。文档高度超过 `min(560px, 60vh)` 时,在可通过键盘聚焦、由 owner 的标题命名的区域内滚动。文档中的原始 HTML 会被省略。相对链接保持为文本,只加载 owner 的 `images` 解析器接受的图片或绝对 HTTP(S) 地址的图片。`detailDocumentState` 把 owner 的读取结果映射为显示状态,`useDetailDocumentLabels` 根据翻译后的字符串构建引用稳定的标签。 | | `UserTextRun`、`ReferenceChip` | 不做解析的行内文本,以及业务已解析的引用标签或激活按钮。 | | `CommandText` | 可选中的命令或参数文本;仅横向溢出时添加具名的键盘聚焦目标。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | @@ -99,7 +100,7 @@ kind: "package-library" 最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 -`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 +`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 `MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 diff --git a/packages/client/ui-primitives/src/DetailDocument.module.css b/packages/client/ui-primitives/src/DetailDocument.module.css new file mode 100644 index 0000000000..6e75b56606 --- /dev/null +++ b/packages/client/ui-primitives/src/DetailDocument.module.css @@ -0,0 +1,65 @@ +.section { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.document { + min-width: 0; + /* A long README scrolls inside the section instead of pushing the page's later sections out of reach. */ + max-height: min(560px, 60vh); + overflow-y: auto; + padding: 4px 16px; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: var(--dsw-radius-lg); + overflow-wrap: anywhere; +} + +.document:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 3px; +} + +.failure { + display: flex; + align-items: center; + gap: 10px; +} + +.error { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-state-error-primary); +} + +.skeleton { + display: flex; + flex-direction: column; + gap: 10px; +} + +.bar { + display: block; + height: 12px; + border-radius: var(--dsw-radius-xs); + background: var(--dsw-alias-bg-skeleton); + animation: dsh-plugin-detail-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite; +} + +.long { width: min(480px, 90%); } +.short { width: min(240px, 50%); } + +@keyframes dsh-plugin-detail-skeleton { + 0% { opacity: 1; } + 40% { opacity: 0.6; } + 80%, 100% { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .bar { animation: none; } +} diff --git a/packages/client/ui-primitives/src/DetailDocument.tsx b/packages/client/ui-primitives/src/DetailDocument.tsx new file mode 100644 index 0000000000..d965fce410 --- /dev/null +++ b/packages/client/ui-primitives/src/DetailDocument.tsx @@ -0,0 +1,120 @@ +import { useMemo, type ReactNode } from 'react' +import { Button } from './Button.tsx' +import { MarkdownText, type MarkdownLabels, type MarkdownPathImages } from './markdown/MarkdownText.tsx' +import { StateDot } from './StateDot.tsx' +import css from './DetailDocument.module.css' + +/** One read of a long Markdown description, such as a plugin's detail document. */ +export type DetailDocumentState = + | { readonly status: 'loading' } + | { readonly status: 'found'; readonly markdown: string } + | { readonly status: 'none' } + | { readonly status: 'error'; readonly message: string } + +/** A read as owners track it: in flight, settled with a document or none, or failed with a localized message. */ +export type DetailDocumentRead = + | undefined + | { readonly status: 'loading' } + | { readonly status: 'ready'; readonly markdown: string | undefined } + | { readonly status: 'error'; readonly message: string } + +/** + * The state {@link DetailDocument} shows for an owner's read: loading until it settles, then the document, none, or the + * failure. + * @param read - the owner's read; undefined before it starts. + * @returns the state to show. + */ +export function detailDocumentState(read: DetailDocumentRead): DetailDocumentState { + if (read?.status === 'ready') return read.markdown === undefined ? { status: 'none' } : { status: 'found', markdown: read.markdown } + return read?.status === 'error' ? read : { status: 'loading' } +} + +/** Translated strings of a detail document's chrome, as an owner's dictionary supplies them. */ +export interface DetailDocumentCopy { + readonly title: string + readonly loading: string + readonly retry: string + readonly copy: string + readonly copied: string + readonly codeBlock: string + readonly wrap: string + readonly unwrap: string + readonly footnotes: string +} + +/** + * Assemble reference-stable labels for {@link DetailDocument} from translated strings. + * @param copy - the translated strings. + * @returns labels that keep their identity while the strings stay equal. + */ +export function useDetailDocumentLabels(copy: DetailDocumentCopy): DetailDocumentLabels { + const { title, loading, retry, footnotes } = copy + const { copy: copyLabel, copied: copiedLabel, codeBlock: codeLabel, wrap: wrapLabel, unwrap: unwrapLabel } = copy + const markdown = useMemo(() => ({ code: { copyLabel, copiedLabel, toolbarLabels: { codeLabel, wrapLabel, unwrapLabel } }, footnotes }), + [copyLabel, copiedLabel, codeLabel, wrapLabel, unwrapLabel, footnotes]) + return useMemo(() => ({ title, loading, retry, markdown }), [title, loading, retry, markdown]) +} + +/** Localized chrome of a detail document. */ +export interface DetailDocumentLabels { + /** Accessible name of the scrollable document region; the section shows no heading of its own. */ + readonly title: string + /** Accessible name of the loading placeholder. */ + readonly loading: string + /** Retry button text. */ + readonly retry: string + readonly markdown: MarkdownLabels +} + +/** + * A section showing a long Markdown description, without a heading of its own: three skeleton lines while it loads, the + * document once found, or the owner's failure message with a retry. Nothing renders for `none`. A document taller than + * the section's height limit scrolls inside it, and the scroll region, named by `labels.title`, takes keyboard focus. + * Raw HTML is omitted, relative links stay text, and only images the owner's `images` resolver accepts, or absolute + * HTTP(S) images, load. + * @param props.state - the read to show. + * @param props.labels - localized region name, placeholder name, retry text, and Markdown chrome; keep `markdown` reference-stable. + * @param props.images - resolves the document's relative image destinations to displayable URLs. + * @param props.onRetry - reads the document again after a failure. + * @returns the section, or null. + */ +export function DetailDocument({ state, labels, images, onRetry }: { + readonly state: DetailDocumentState + readonly labels: DetailDocumentLabels + readonly images?: MarkdownPathImages | undefined + readonly onRetry: () => void +}): ReactNode { + if (state.status === 'none') return null + let body: ReactNode + switch (state.status) { + case 'found': + body = ( +
+ +
+ ) + break + case 'error': + body = ( +
+

{state.message}

+ +
+ ) + break + case 'loading': + body = ( +
+ + + +
+ ) + break + } + return ( +
+ {body} +
+ ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 18f9544405..1083cf6827 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -106,6 +106,8 @@ export type { CodeBlockProps } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownDelegateProvider } from './markdown/MarkdownDelegate.tsx' export type { MarkdownDelegate, MarkdownDelegateProviderProps, MarkdownExternalLinkHandler } from './markdown/MarkdownDelegate.tsx' +export { DetailDocument, detailDocumentState, useDetailDocumentLabels } from './DetailDocument.tsx' +export type { DetailDocumentCopy, DetailDocumentLabels, DetailDocumentRead, DetailDocumentState } from './DetailDocument.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages } from './markdown/MarkdownText.tsx' export { extractMarkdownPlainText } from './markdown/plain-text.ts' diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index ab2c0b380a..c76ed4171f 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -32,6 +32,7 @@ function renderSettled( labels: MarkdownLabels, fileMentions: MarkdownFileMentions | undefined, pathImages: MarkdownPathImages | undefined, + omitHtml: boolean, ): ReactNode[] { const root = parseGfmWithMath(text) const targets = createReferenceTargets() @@ -41,6 +42,7 @@ function renderSettled( labels, fileMentions, pathImages, + omitHtml, targets, footnoteOrder: [], footnoteCounts: new Map(), @@ -74,8 +76,11 @@ class StreamingRenderer { private lastText: string | null = null private lastRendered: ReactNode[] = [] - /** @param labels - Localized Markdown chrome baked into cached elements; the owner replaces the renderer when it changes. */ - constructor(private readonly labels: MarkdownLabels) {} + /** + * @param labels - Localized Markdown chrome baked into cached elements; the owner replaces the renderer when it changes. + * @param omitHtml - Whether raw HTML renders nothing; the owner replaces the renderer when it changes. + */ + constructor(private readonly labels: MarkdownLabels, readonly omitHtml: boolean) {} /** * Render the current accumulated text. Idempotent per text value, so React @@ -110,6 +115,7 @@ class StreamingRenderer { labels: this.labels, fileMentions: undefined, pathImages: undefined, + omitHtml: this.omitHtml, targets: frameTargets, footnoteOrder: this.frozenFootnoteOrder, footnoteCounts: this.frozenFootnoteCounts, @@ -129,6 +135,7 @@ class StreamingRenderer { labels: this.labels, fileMentions: undefined, pathImages: undefined, + omitHtml: this.omitHtml, targets: frameTargets, footnoteOrder: [...this.frozenFootnoteOrder], footnoteCounts: new Map(this.frozenFootnoteCounts), @@ -164,15 +171,17 @@ class StreamingRenderer { * `MarkdownDelegateProvider` can delegate ordinary HTTP(S) activation while * modified clicks retain native behavior. `variant="compact"` uses secondary * text sizing, uniform bold headings, and tight block spacing; the default - * `body` variant uses the full document typography. + * `body` variant uses the full document typography. `omitHtml` renders raw + * HTML as nothing instead of literal text, for documents written for HTML + * renderers, such as package READMEs. * The provider's `openFile` enables local Markdown links in settled messages, * including `#L24` and `#L24-L30` destinations (ranges open at their first line). - * @returns A GFM document with TeX math rendered through KaTeX; raw HTML and - * unsafe protocols are disabled. Local links without an opener remain text; + * @returns A GFM document with TeX math rendered through KaTeX; raw HTML never + * renders as markup, and unsafe protocols are disabled. Local links without an opener remain text; * absolute HTTP(S) images render directly. */ export const MarkdownText = memo(function MarkdownText({ - text, streaming = false, labels, fileMentions, pathImages, variant = 'body', + text, streaming = false, labels, fileMentions, pathImages, variant = 'body', omitHtml = false, }: { text: string streaming?: boolean @@ -180,20 +189,21 @@ export const MarkdownText = memo(function MarkdownText({ fileMentions?: MarkdownFileMentions | undefined pathImages?: MarkdownPathImages | undefined variant?: 'body' | 'compact' + omitHtml?: boolean }) { const streamRef = useRef(null) const streamLabelsRef = useRef(labels) const children = useMemo(() => { if (!streaming) { streamRef.current = null - return renderSettled(text, labels, fileMentions, pathImages) + return renderSettled(text, labels, fileMentions, pathImages, omitHtml) } - if (streamRef.current === null || streamLabelsRef.current !== labels) { - streamRef.current = new StreamingRenderer(labels) + if (streamRef.current === null || streamLabelsRef.current !== labels || streamRef.current.omitHtml !== omitHtml) { + streamRef.current = new StreamingRenderer(labels, omitHtml) streamLabelsRef.current = labels } return streamRef.current.render(text) - }, [text, streaming, labels, fileMentions, pathImages]) + }, [text, streaming, labels, fileMentions, pathImages, omitHtml]) return
{children}
}) diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index cdb9afcbda..c4ff4a2400 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -77,12 +77,17 @@ function remoteImageUrl(url: string): string | undefined { } } -/** Rewritten images may use Web media protocols or the Desktop application's file route. */ +/** Authenticated Host media routes as the Desktop application addresses them. */ +const DESKTOP_MEDIA_ROUTES = [ + 'dsh-app://app/api/file?', 'dsh-app://app/api/plugin-market/detail-file?', 'dsh-app://app/api/plugin-manager/detail-file?', +] + +/** Rewritten images may use Web media protocols or the Desktop application's media routes. */ function vocabularyImageUrl(url: string): string | undefined { try { const protocol = new URL(url).protocol return protocol === 'http:' || protocol === 'https:' || protocol === 'blob:' || protocol === 'data:' - || url.startsWith('dsh-app://app/api/file?') + || DESKTOP_MEDIA_ROUTES.some(route => url.startsWith(route)) ? url : undefined } catch { @@ -193,6 +198,8 @@ export interface MarkdownRenderContext { readonly fileMentions: MarkdownFileMentions | undefined /** Local-path image vocabulary; absent wherever no rewriting owner exists. */ readonly pathImages: MarkdownPathImages | undefined + /** Raw HTML renders nothing instead of literal text. */ + readonly omitHtml: boolean /** Inside an anchor's children: interactive mentions must not nest there. */ readonly inLink?: boolean /** Reference targets visible to this pass. */ @@ -331,8 +338,8 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte return {value} } case 'html': - // No HTML parser enters the pipeline: raw HTML stays literal text. - return node.value + // No HTML parser enters the pipeline: raw HTML is literal text, or nothing where the owner omits it. + return context.omitHtml ? null : node.value case 'code': return renderCode(node, key, context) case 'math': diff --git a/packages/client/ui-primitives/tests/detail-document.client.spec.tsx b/packages/client/ui-primitives/tests/detail-document.client.spec.tsx new file mode 100644 index 0000000000..0e2ae34095 --- /dev/null +++ b/packages/client/ui-primitives/tests/detail-document.client.spec.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { DetailDocument, detailDocumentState, useDetailDocumentLabels, type DetailDocumentCopy, type DetailDocumentLabels } from '../src/DetailDocument.tsx' + +afterEach(cleanup) + +const LABELS: DetailDocumentLabels = { + title: 'Details', loading: 'Reading details', retry: 'Retry', + markdown: { code: { copyLabel: 'Copy', copiedLabel: 'Copied' }, footnotes: 'Footnotes' }, +} + +it('shows a placeholder, the document with resolved images, or a failure with a retry, and nothing without a document', () => { + const onRetry = vi.fn() + const view = render() + // The section has no heading of its own; its title names the document region. + expect(screen.queryByRole('heading', { name: 'Details' })).toBeNull() + expect(screen.getByRole('status', { name: 'Reading details' })).toBeTruthy() + view.rerender(bold kept\n\n
' }} labels={LABELS} + images={{ resolve: value => `https://host.example/${value}` }} onRetry={onRetry} />) + expect(screen.getByRole('heading', { name: 'A' })).toBeTruthy() + // The document scrolls inside a keyboard-focusable region named by the section heading. + const region = screen.getByRole('region', { name: 'Details' }) + expect(region.tabIndex).toBe(0) + expect(region.contains(screen.getByRole('heading', { name: 'A' }))).toBe(true) + expect(screen.getByRole('img', { name: 'i' }).getAttribute('src')).toBe('https://host.example/a.png') + expect(screen.queryByRole('link', { name: 'doc' })).toBeNull() + // Raw HTML renders nothing: neither markup nor its literal text. + expect(view.container.textContent).not.toContain('<') + expect(view.container.textContent).toContain('bold kept') + expect(view.container.querySelector('div[align]')).toBeNull() + view.rerender() + expect(screen.getByRole('alert').textContent).toBe('Could not read') + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(onRetry).toHaveBeenCalledOnce() + view.rerender() + expect(view.container.innerHTML).toBe('') +}) + +it('maps owner reads to shown states', () => { + expect(detailDocumentState(undefined)).toEqual({ status: 'loading' }) + expect(detailDocumentState({ status: 'loading' })).toEqual({ status: 'loading' }) + expect(detailDocumentState({ status: 'ready', markdown: '# A' })).toEqual({ status: 'found', markdown: '# A' }) + expect(detailDocumentState({ status: 'ready', markdown: undefined })).toEqual({ status: 'none' }) + expect(detailDocumentState({ status: 'error', message: 'm' })).toEqual({ status: 'error', message: 'm' }) +}) + +it('keeps label identity while the translated strings stay equal', () => { + const copy: DetailDocumentCopy = { + title: 'Details', loading: 'Reading', retry: 'Retry', copy: 'Copy', copied: 'Copied', codeBlock: 'Code', wrap: 'Wrap', unwrap: 'Unwrap', footnotes: 'Notes', + } + const { result, rerender } = renderHook((props: DetailDocumentCopy) => useDetailDocumentLabels(props), { initialProps: copy }) + const first = result.current + expect(first).toEqual({ + title: 'Details', loading: 'Reading', retry: 'Retry', + markdown: { code: { copyLabel: 'Copy', copiedLabel: 'Copied', toolbarLabels: { codeLabel: 'Code', wrapLabel: 'Wrap', unwrapLabel: 'Unwrap' } }, footnotes: 'Notes' }, + }) + rerender({ ...copy }) + expect(result.current).toBe(first) + rerender({ ...copy, title: 'Detail' }) + expect(result.current.markdown).toBe(first.markdown) +}) diff --git a/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx index 85516cfd0a..11aa179572 100644 --- a/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx @@ -74,14 +74,21 @@ describe('MarkdownText local-path images', () => { } }) - it('accepts only the Desktop file route from a local image resolver', () => { + it('accepts only the Desktop media routes from a local image resolver', () => { const src = 'dsh-app://app/api/file?path=%2Ftmp%2Fgraph.png' const view = render( src }} />) expect(view.container.querySelector('img')).toBeNull() view.rerender( src }} />) expect(view.getByRole('img').getAttribute('src')).toBe(src) + const detail = 'dsh-app://app/api/plugin-market/detail-file?document=d&path=a.png' + const bundle = 'dsh-app://app/api/plugin-manager/detail-file?bundle=b&document=README.md&path=a.png' + for (const route of [detail, bundle]) { + view.rerender( route }} />) + expect(view.getByRole('img').getAttribute('src')).toBe(route) + } for (const result of ['dsh-app://shell/api/file?path=x', 'dsh-app://app/assets/image.png', - 'dsh-app://app/api/file-other?path=x', 'dsh-app://app.example/api/file?path=x']) { + 'dsh-app://app/api/file-other?path=x', 'dsh-app://app.example/api/file?path=x', 'dsh-app://app/api/plugin-market/other?path=x', + 'dsh-app://app/api/plugin-manager/other?path=x']) { view.rerender( result }} />) expect(view.container.querySelector('img')).toBeNull() } diff --git a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx index bf1877c348..3c4536b7e7 100644 --- a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx @@ -23,6 +23,7 @@ function makeContext(): MarkdownRenderContext { labels: markdownLabels, fileMentions: undefined, pathImages: undefined, + omitHtml: false, targets: createReferenceTargets(), footnoteOrder: [], footnoteCounts: new Map(), diff --git a/packages/client/ui-primitives/tests/markdown.client.spec.tsx b/packages/client/ui-primitives/tests/markdown.client.spec.tsx index c655a007d2..553f60233a 100644 --- a/packages/client/ui-primitives/tests/markdown.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.client.spec.tsx @@ -332,6 +332,17 @@ describe('MarkdownText', () => { } }) + it('omits raw HTML when asked, in settled and streaming renders, and rebuilds the stream when the choice changes', () => { + const text = 'Before bold after\n\n
block
\n\nTail' + const view = render() + expect(view.container.textContent).toBe('Before bold after\nTail') + expect(view.container.querySelector('div[align]')).toBeNull() + view.rerender() + expect(view.container.textContent).toBe('Before bold after\nTail') + view.rerender() + expect(view.container.textContent).toContain('
block
') + }) + it('neutralizes raw HTML, unsafe or relative links, and unsupported images', () => { const markdown = [ '', 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 50bcbe529a..aa1f24bc8d 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2443,8 +2443,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ key: 'plugins.detail.section', kind: 'list', scope: 'root', - summary: 'Sections under a detail page\'s own content: after the rows on a bundle\'s page, after the configuration on a row\'s or an official plugin\'s page.', - doc: 'Sections under a detail page\'s own content: after the rows on a bundle\'s\npage, after the configuration on a row\'s or an official plugin\'s page.\nAn entry draws its own section chrome and renders null for a subject it\nhas nothing for.', + summary: 'Sections under a detail page\'s own content: after the source on a bundle\'s page, after the configuration on a row\'s or an official plugin\'s page.', + doc: 'Sections under a detail page\'s own content: after the source on a bundle\'s\npage, after the configuration on a row\'s or an official plugin\'s page.\nAn entry draws its own section chrome and renders null for a subject it\nhas nothing for.', registerOptions: [ { name: 'id', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6dee8f4661..6aa344b3b8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1751,6 +1751,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Manage profile files and apply their declared reload lifecycle.', description: 'Manage profile files and apply their declared reload lifecycle.', methods: [ + { + signature: '@Remote async readBundleDetail(name: string, language: string, options?: { npmFileSources?: NpmFileSource[] }): Promise', + description: 'Read a bundle\'s long description: the Markdown file its locale `meta.detail` names in `language`, otherwise its README. An installed bundle is read from its directory without network access; an official bundle the installation offers before it is installed is read from the npm file CDNs at its offered version. Nothing is cached.', + parameters: [{ name: 'name', description: 'the bundle\'s package name, as `listBundles()` lists it.' }, { name: 'language', description: 'the display language, such as `zh-CN`; documents fall back through its prefixes to English.' }, { name: 'options', description: '`npmFileSources` names npm file CDNs to ask first for an offered bundle, in order, such as the one a Client measured as fastest; configured CDNs it omits follow, and CDNs that are not configured are not asked.' }], + returns: 'the document with its origin and path, or `none`.', + throws: ['{RemoteError} `plugin-manager/unknown-bundle`, `plugin-manager/invalid-language`, or `plugin-manager/detail-unavailable`.'], + }, { signature: '@Remote listVersionExemptions(): { exemptions: Record; warnings: string[] }', description: 'Read exact plugin-version exemptions saved in this profile.', @@ -1868,6 +1875,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the plugin with its default version, installation status, and annotated versions; undefined when the index does not list it.', throws: ['{RemoteError} `plugin-market/unknown-index`, `plugin-market/invalid-index-url`, `plugin-market/fetch-failed`, or `plugin-market/invalid-index`.'], }, + { + signature: '@Remote async readEntryDetail( url: string, name: string, version: string, language: string, options?: { refresh?: boolean; npmFileSources?: NpmFileSource[] }, ): Promise', + description: 'Read the long description of one listed version of a plugin market entry, from the version\'s source: the npm file CDNs for a public npm version, or its pinned Git commit. The Markdown file the package\'s locale `meta.detail` names in `language` comes first, then the package\'s README, then the README of the entry\'s `repository`, or for an npm version without one, of its `package.json` `repository`, at the default branch. An npm version from another registry has no readable package files, so only the entry\'s `repository` is read.', + parameters: [{ name: 'url', description: 'a configured index URL.' }, { name: 'name', description: 'the plugin\'s package name.' }, { name: 'version', description: 'one listed version.' }, { name: 'language', description: 'the display language, such as `zh-CN`; documents fall back through its prefixes to English.' }, { name: 'options', description: '`refresh` reads again instead of reusing a result cached for `cacheTtlMs`; `npmFileSources` names npm file CDNs to ask first for an npm version, in order; configured CDNs it omits follow, and CDNs that are not configured are not asked.' }], + returns: 'the document with its origin and image document id, or `none`.', + throws: ['{RemoteError} the failures of `search`, `plugin-market/unknown-plugin` for an unlisted plugin or version, `plugin-market/invalid-query`, or `plugin-market/detail-unavailable`.'], + }, ], }, { @@ -5050,6 +5064,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'BrowserUseProviderName', declaration: 'export type BrowserUseProviderName = Branded<\'BrowserUseProviderName\'>;', }, + { + name: 'BundleDetail', + declaration: 'export type BundleDetail = {\n readonly status: \'found\';\n readonly origin: \'locale\' | \'readme\';\n readonly path: string;\n readonly markdown: string;\n} | {\n readonly status: \'none\';\n};', + }, { name: 'BundleInfo', declaration: 'export interface BundleInfo {\n name: string;\n official: boolean;\n availability: \'installation\' | \'profile\' | \'missing\';\n installTarget?: {\n spec: string;\n version: string;\n };\n version?: string;\n meta?: PluginLocalizedMeta;\n description?: string;\n enabled: boolean;\n installed: boolean;\n source?: string;\n optional: boolean;\n removable: boolean;\n updatable: boolean;\n readOnlyReason?: ReadOnlyReason;\n error?: ManagementError;\n rows: BundleRowInfo[];\n overrides: string[];\n}', @@ -6366,6 +6384,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'NotFutureError', declaration: 'export interface NotFutureError {\n readonly code: \'not_future\';\n readonly message: string;\n}', }, + { + name: 'NpmFileSource', + declaration: 'export type NpmFileSource = \'jsdelivr\' | \'unpkg\' | \'npmmirror\';', + }, { name: 'ObjectJsonSchema', declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', @@ -6466,6 +6488,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PluginChange', declaration: 'export interface PluginChange {\n readonly reason: \'plugin\' | \'bundle\' | \'install\' | \'remove\';\n}', }, + { + name: 'PluginDetail', + declaration: 'export type PluginDetail = {\n readonly status: \'found\';\n readonly origin: PluginDetailOrigin;\n readonly markdown: string;\n readonly document: PluginDetailDocumentId;\n} | {\n readonly status: \'none\';\n};', + }, + { + name: 'PluginDetailDocumentId', + declaration: 'export type PluginDetailDocumentId = Branded<\'PluginDetailDocumentId\'>;', + }, + { + name: 'PluginDetailOrigin', + declaration: 'export type PluginDetailOrigin = \'locale\' | \'readme\' | \'repository\';', + }, { name: 'PluginEntryId', declaration: 'export type PluginEntryId = Branded<\'PluginEntryId\'>;', diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index a0c881f1b6..fcd63ec242 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /util-shared-utilities/packages: - en: 2143dc15a12c830c - zh: c94fb851bac20a74 + en: 6f788656548ee4e0 + zh: 227695dafaa9476d /util-shared-utilities/related-documentation: en: 432ae81110914571 zh: c5bf4efc007311f3 diff --git a/packages/util/README.md b/packages/util/README.md index 41a1988bb8..c305e1fb69 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -28,6 +28,7 @@ Each package provides one primitive; open a package page for how to use it. |---|---| | [`brand/`](brand/README.md) | Nominal string types and their stateless constructor | | [`package-manifest/`](package-manifest/README.md) | Shared TypeScript declarations for package manifests | +| [`plugin-detail/`](plugin-detail/README.md) | Selects plugin detail documents and reads them from local packages and npm file CDNs | | [`plugin-market-index/`](plugin-market-index/README.md) | Validates plugin market index format 1 and builds pinned install arguments | | [`crypto/`](crypto/README.md) | Mints RFC 9562 v4 UUIDs from the cross-runtime `crypto.getRandomValues` primitive | | [`deque/`](deque/README.md) | Provides amortized constant-time queue operations with bounded vacant storage | diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index cd8cdcf3e8..5dc7a201a6 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -28,6 +28,7 @@ kind: "package-group" |---|---| | [`brand/`](brand/README.zh.md) | 提供名义字符串类型及其无状态构造函数 | | [`package-manifest/`](package-manifest/README.zh.md) | 包 manifest(元数据清单)的共享 TypeScript 声明 | +| [`plugin-detail/`](plugin-detail/README.zh.md) | 选择插件详细介绍文档,并从本地包和 npm 文件 CDN 读取它们 | | [`plugin-market-index/`](plugin-market-index/README.zh.md) | 校验插件市场索引格式 1,并生成锁定版本的安装参数 | | [`crypto/`](crypto/README.zh.md) | 基于跨运行时 `crypto.getRandomValues` 原语生成 RFC 9562 v4 UUID | | [`deque/`](deque/README.zh.md) | 提供摊销常数时间的队列操作和有界空闲存储 | diff --git a/packages/util/plugin-detail/README.i18n.yaml b/packages/util/plugin-detail/README.i18n.yaml new file mode 100644 index 0000000000..cb12152f41 --- /dev/null +++ b/packages/util/plugin-detail/README.i18n.yaml @@ -0,0 +1,46 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/util/plugin-detail/README.md +/: + en: 505fd12717a89948 + zh: 2ad120714356f61b +/dsh-plugin-detail: + en: 9ef51ea35aa9ea60 + zh: 341593614dac3945 +/dsh-plugin-detail/summary: + en: 9a5bdafd69ed0208 + zh: d436f289724bab19 +/dsh-plugin-detail/table-of-contents: + en: cd2a60a905c978d6 + zh: cc71d387d0cbd442 +/dsh-plugin-detail/use-this-package: + en: ccdf3fdf80317f76 + zh: 023b893b30d1dd5b +/dsh-plugin-detail/use-this-package/document-order: + en: fe28217d182b9174 + zh: 0a0b4a61ee4009eb +/dsh-plugin-detail/use-this-package/images: + en: abeb7bb1f7695e5e + zh: 59b87c9abea17dfb +/dsh-plugin-detail/use-this-package/file-sources: + en: 9542401a7d104851 + zh: 99f80d2960b77bc8 +/dsh-plugin-detail/use-this-package/image-responses: + en: d6e7da207ee7f2fb + zh: c7b6acaa4d2839a2 +/dsh-plugin-detail/api: + en: ed9c0c624558d944 + zh: 9373180d9edb1012 +/dsh-plugin-detail/model-experience: + en: 60eeeeff73bf48b4 + zh: cf3e6e1885719d6a +/dsh-plugin-detail/model-experience/kv-cache-effect: + en: 4f0eadf94828e66e + zh: 4f8a6ec170ebbfb6 +/dsh-plugin-detail/known-limitations-and-deferred-work: + en: 5d8abfbb06761f7f + zh: 8515079f96e513bf +/dsh-plugin-detail/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 3bb0c20c93f16f98 diff --git a/packages/util/plugin-detail/README.md b/packages/util/plugin-detail/README.md new file mode 100644 index 0000000000..b80404055c --- /dev/null +++ b/packages/util/plugin-detail/README.md @@ -0,0 +1,101 @@ +--- +description: "Plugin detail document selection without state: the meta.detail or README order, language fallback, image paths, and local and npm CDN file sources." +kind: "package-library" +--- + +# dsh-plugin-detail + +English | [中文](README.zh.md) + +## Summary + +Library without state for plugin detail documents, the long Markdown description a plugin's page shows. It owns the order in which a package's document is chosen, the language fallback, image destinations beside a document, and two read-only file sources: a local package directory and a published npm version on public file CDNs. [`plugin-manager`](../../boot/plugin-manager/README.md#bundle-detail-documents) reads profile bundles with it, and [`plugin-market`](../../boot/plugin-market/README.md#plugin-detail-documents) reads market entries with it and adds repository sources. + +## Table of Contents + +- [Use this package](#use-this-package) +- [API](#api) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +It is a library, not a service or plugin: no `ctx`, no registrations, no state. Reads go through the file source the caller passes. + + +### Document order + +A plugin's detail document is the first that exists, in this order: + +1. **Locale `meta.detail`.** For each language of the chain, the package's `locale/.json` is read, or, when that file does not exist, the same name with an upper-case two-letter region such as `locale/zh-CN.json`. When the file read declares `meta.detail`, that Markdown file is the document; otherwise the next language is tried. +2. **Package README.** `README.md`, `readme.md`, `Readme.md`, `README.markdown`, or `README`. A source that lists directories takes any letter case of `README`, `README.md`, and `README.markdown`. +3. **Repository README, market entries only.** The README of the repository directory that the market entry's `repository` names, otherwise the one that the package's `package.json` `repository` names, at the default branch. [`plugin-market`](../../boot/plugin-market/README.md#plugin-detail-documents) reads it; bundle pages in **Plugins** never read a repository. + +`readPackageDocument(files, dir, languages, maxBytes, signal)` implements steps 1 and 2 and returns `undefined` when neither applies. `meta.detail` must be a `/`-separated path relative to the package root that ends in `.md` or `.markdown` and has no empty, `.`, or `..` segment, backslash, or URL scheme. A malformed locale file, a malformed `meta.detail`, and a named file that does not exist fail with `DetailFailure` `invalid`; the README is not read in their place. + +`languageChain(language)` builds the chain: the language in lower case, each shorter prefix, then English, so `zh-Hans-CN` reads `zh-hans-cn`, `zh-hans`, `zh`, and `en`. + +The returned Markdown has no byte order mark and no leading YAML front matter block. + +### Images + +`detailImage(document, destination)` resolves an image a document writes: relative to the document, or to the root of its files when it starts with `/`; a query or fragment is ignored. It returns the image's path and media type, or `undefined` for a destination that leaves the root, is malformed, or names a type other than PNG, JPEG, GIF, WebP, AVIF, and SVG. + +### File sources + +- `localFiles(root, revision)` reads a package directory. A path whose real location leaves the directory reads as absent. `revision`, such as the installed version, becomes part of the source's `key`. +- `npmFiles(name, version, sources, options)` reads a published version from jsDelivr, unpkg, and npmmirror in the given order. A 404 means the package has no such file; a network failure or another status asks the next CDN. `npmFileSourceOrder(configured, preferred)` puts the preferred CDNs that are configured first, in the preferred order, then the other configured ones. +- `fetchBody(url, maxBytes, signal, options)` reads one HTTP body up to `maxBytes`, following redirects within the URL's origin. + +Every read is bounded by the caller's `maxBytes` and fails with `DetailFailure` `too-large` beyond it, or `network` when a source cannot be reached. + +### Image responses + +`detailImageResponse(outcome, cacheControl)` answers an image route: the image with `cacheControl`, 404 when there is none, or the failure's status (`too-large` 413, `network` 502, `denied` 403, `git-unavailable` 503, `invalid` 400) with the reason as its body and `Cache-Control: no-store`. Every response carries `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`, because an SVG opened directly would otherwise run scripts on the authenticated API origin. + +----- + + +## API + +```ts +import { + DetailFailure, detailImage, detailImageResponse, languageChain, localFiles, NPM_FILE_SOURCES, npmFiles, npmFileSourceOrder, + readPackageDocument, +} from '@deepseek-ai/dsh-plugin-detail' +``` + +`DetailFailureReason` and `NpmFileSource`, which Remote methods of other packages use, are also exported type-only from `@deepseek-ai/dsh-plugin-detail/types`. + +`DetailFiles` is the source interface: `read(path, maxBytes, signal)` returns a file's bytes or `undefined`, and `readme(dir, maxBytes, signal)` returns a directory's README. Repository sources implement it in `plugin-market`. + +----- + + +## Model Experience + +None, as this library selects documents for plugin pages and adds nothing to model requests. + +#### KV Cache effect + +None. + +## Known Limitations and Deferred Work + + + +- **Public npm only** — the file CDNs serve packages of the public npm registry; packages of other registries have no npm file source. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/util/plugin-detail/README.zh.md b/packages/util/plugin-detail/README.zh.md new file mode 100644 index 0000000000..2faa23c4e8 --- /dev/null +++ b/packages/util/plugin-detail/README.zh.md @@ -0,0 +1,101 @@ +--- +description: "无状态的插件详细介绍文档选择:meta.detail 或 README 的顺序、语言回退、图片路径,以及本地和 npm CDN 文件来源。" +kind: "package-library" +--- + +# dsh-plugin-detail + +[English](README.md) | 中文 + +## 概述 + +用于插件详细介绍文档的无状态库。详细介绍是插件页面显示的较长 Markdown 描述。本包负责选择包文档的顺序、语言回退、文档旁图片的目标路径,以及两种只读文件来源:本地包目录,以及公共文件 CDN 上已发布的 npm 版本。[`plugin-manager`](../../boot/plugin-manager/README.zh.md#bundle-detail-documents) 用它读取 profile 中的组合包,[`plugin-market`](../../boot/plugin-market/README.zh.md#plugin-detail-documents) 用它读取市场条目,并额外提供仓库来源。 + +## 目录 + +- [使用本包](#use-this-package) +- [API](#api) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +本包是库,不是服务或插件:没有 `ctx`、没有注册,也没有状态。所有读取都经由调用方传入的文件来源进行。 + + +### 文档顺序 + +插件的详细介绍文档是按以下顺序第一个存在的文档: + +1. **locale 中的 `meta.detail`。** 对语言链中的每种语言,读取包的 `locale/.json`;该文件不存在时,读取地区部分为大写两字母的同名文件,例如 `locale/zh-CN.json`。读取到的文件声明 `meta.detail` 时,该 Markdown 文件即为文档;否则尝试下一种语言。 +2. **包 README。** `README.md`、`readme.md`、`Readme.md`、`README.markdown` 或 `README`。能列出目录内容的来源接受任意大小写的 `README`、`README.md` 和 `README.markdown`。 +3. **仓库 README,仅限市场条目。** 市场条目的 `repository` 所指仓库目录中的 README;条目没有 `repository` 时,使用包 `package.json` 的 `repository` 所指仓库目录中的 README;均读取默认分支上的文件。[`plugin-market`](../../boot/plugin-market/README.zh.md#plugin-detail-documents) 读取此步;**插件**中的组合包页面从不读取仓库。 + +`readPackageDocument(files, dir, languages, maxBytes, signal)` 实现第 1 步和第 2 步,两者都不适用时返回 `undefined`。`meta.detail` 必须是相对于包根目录、以 `/` 分隔、以 `.md` 或 `.markdown` 结尾的路径,且不含空段、`.` 或 `..` 段、反斜杠或 URL scheme。locale 文件格式错误、`meta.detail` 格式错误,或指定的文件不存在时,以 `DetailFailure` `invalid` 失败;不会改为读取 README。 + +`languageChain(language)` 构建语言链:先是小写形式的该语言,然后是每个更短的前缀,最后是英文,因此 `zh-Hans-CN` 依次读取 `zh-hans-cn`、`zh-hans`、`zh` 和 `en`。 + +返回的 Markdown 不含字节顺序标记(BOM),也不含开头的 YAML front matter 块。 + +### 图片 + +`detailImage(document, destination)` 解析文档中引用的图片:相对于该文档;以 `/` 开头时相对于其文件的根目录;查询和片段被忽略。它返回图片的路径和媒体类型;目标路径超出根目录、格式错误,或指定 PNG、JPEG、GIF、WebP、AVIF 和 SVG 之外的类型时,返回 `undefined`。 + +### 文件来源 + +- `localFiles(root, revision)` 读取一个包目录。实际位置超出该目录的路径按不存在处理。`revision`(例如已安装的版本)成为该来源 `key` 的一部分。 +- `npmFiles(name, version, sources, options)` 按给定顺序从 jsDelivr、unpkg 和 npmmirror 读取一个已发布版本。404 表示该包没有此文件;网络失败或其他状态码时改为请求下一个 CDN。`npmFileSourceOrder(configured, preferred)` 把已配置的优先 CDN 按优先顺序排在最前,其后是其他已配置的 CDN。 +- `fetchBody(url, maxBytes, signal, options)` 读取一个 HTTP 响应体,最多 `maxBytes`,并跟随该 URL 的 origin 内的重定向。 + +每次读取都受调用方 `maxBytes` 的限制,超过时以 `DetailFailure` `too-large` 失败;来源无法访问时以 `network` 失败。 + +### 图片响应 + +`detailImageResponse(outcome, cacheControl)` 生成图片路由的响应:有图片时返回该图片并带 `cacheControl`;没有图片时返回 404;失败时返回该失败的状态码(`too-large` 为 413、`network` 为 502、`denied` 为 403、`git-unavailable` 为 503、`invalid` 为 400),以失败原因作为响应体,并带 `Cache-Control: no-store`。每个响应都带有 `X-Content-Type-Options: nosniff` 和 `Content-Security-Policy: sandbox`,因为否则直接打开的 SVG 会在已通过认证的 API origin 上运行脚本。 + +----- + + +## API + +```ts +import { + DetailFailure, detailImage, detailImageResponse, languageChain, localFiles, NPM_FILE_SOURCES, npmFiles, npmFileSourceOrder, + readPackageDocument, +} from '@deepseek-ai/dsh-plugin-detail' +``` + +`DetailFailureReason` 和 `NpmFileSource` 供其他包的 Remote 方法使用,也从 `@deepseek-ai/dsh-plugin-detail/types` 以仅类型方式导出。 + +`DetailFiles` 是来源接口:`read(path, maxBytes, signal)` 返回文件的字节或 `undefined`,`readme(dir, maxBytes, signal)` 返回目录的 README。仓库来源在 `plugin-market` 中实现该接口。 + +----- + + +## 模型体验 + +无,因为本库为插件页面选择文档,不向模型请求添加任何内容。 + +#### KV Cache 影响 + +无。 + +## 已知限制与延期工作 + + + +- **只支持公共 npm**——文件 CDN 提供公共 npm 注册表中的包;其他注册表中的包没有 npm 文件来源。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/util/plugin-detail/package.json b/packages/util/plugin-detail/package.json new file mode 100644 index 0000000000..db24c51bce --- /dev/null +++ b/packages/util/plugin-detail/package.json @@ -0,0 +1,40 @@ +{ + "name": "@deepseek-ai/dsh-plugin-detail", + "description": "Plugin detail documents: meta.detail or README selection, image paths, and local and npm CDN file sources", + "version": "0.2.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/plugin-detail" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~" + } +} diff --git a/packages/util/plugin-detail/src/document.ts b/packages/util/plugin-detail/src/document.ts new file mode 100644 index 0000000000..2752afd9f2 --- /dev/null +++ b/packages/util/plugin-detail/src/document.ts @@ -0,0 +1,137 @@ +/** + * Plugin detail document selection: the Markdown file a package's locale `meta.detail` names in a display language, + * otherwise the package's README; and the images beside a selected document. + */ +import { posix } from 'node:path' +import { DetailFailure, joinPath, type DetailFiles } from './files.ts' + +const LANGUAGE_ID = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/u + +/** + * The languages a document is chosen in: the language, each shorter prefix, then English. + * @param language - a BCP 47 language id such as `zh-CN`. + * @returns lower-case language ids, most specific first. + * @throws when `language` is not a language id. + */ +export function languageChain(language: string): string[] { + const id = language.toLowerCase() + if (!LANGUAGE_ID.test(id)) throw new Error(`Not a language id: ${language}`) + const parts = id.split('-') + const chain = parts.map((_part, index) => parts.slice(0, parts.length - index).join('-')) + return [...new Set([...chain, 'en'])] +} + +/** + * A locale `meta.detail` value: a `/`-separated Markdown path relative to the package root that cannot leave it. + * @throws {DetailFailure} `invalid` for another value. + */ +function detailPath(value: unknown, label: string): string | undefined { + if (value === undefined) return undefined + const invalid = (rule: string): DetailFailure => new DetailFailure('invalid', `${label} ${rule}`) + if (typeof value !== 'string' || value.trim() === '') throw invalid('must be a non-empty string') + if (value.includes('\\') || /^[A-Za-z][A-Za-z\d+.-]*:/u.test(value) || value.split('/').some(segment => segment === '' || segment === '.' || segment === '..')) { + throw invalid('must be a relative path inside the package') + } + if (!/\.(?:md|markdown)$/iu.test(value)) throw invalid('must name a Markdown file') + return value +} + +/** `meta.detail` of the first `locale/.json` along the chain that declares one. */ +async function localeDetail( + files: DetailFiles, dir: string, languages: readonly string[], maxBytes: number, signal: AbortSignal, +): Promise { + for (const language of languages) { + // Locale files are named by language id: lower case, and also with an upper-case two-letter region. + const names = [...new Set([language, language.replace(/^([a-z]{2,3})-([a-z]{2})$/u, (_match, base: string, region: string) => `${base}-${region.toUpperCase()}`)])] + for (const name of names) { + const path = joinPath(dir, `locale/${name}.json`) + const bytes = await files.read(path, maxBytes, signal) + if (bytes === undefined) continue + let meta: unknown + try { + meta = (JSON.parse(new TextDecoder().decode(bytes)) as { meta?: unknown } | null)?.meta + } catch (error) { + throw new DetailFailure('invalid', `${path}: ${(error as SyntaxError).message}`, { cause: error }) + } + const detail = detailPath(typeof meta === 'object' && meta !== null ? (meta as { detail?: unknown }).detail : undefined, `${path}: meta.detail`) + if (detail !== undefined) return detail + break + } + } + return undefined +} + +/** + * Decode a Markdown document: UTF-8 without a byte order mark, without a leading YAML front matter block, which is file + * metadata rather than description text. + * @param bytes - the file's bytes. + * @returns the Markdown text. + */ +export function markdownOf(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes).replace(/^\uFEFF/u, '').replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/u, '') +} + +/** A package's detail document. */ +export interface PackageDocument { + /** `locale` for the file `meta.detail` names, `readme` for the package's README. */ + readonly origin: 'locale' | 'readme' + /** `/`-separated path of the document inside the files. */ + readonly path: string + /** The document, decoded by {@link markdownOf}. */ + readonly markdown: string +} + +/** + * Read a package's detail document: the Markdown file that `meta.detail` in its `locale/.json` names for the + * first language of the chain that declares one, otherwise its README. + * @param files - the package's files. + * @param dir - package directory inside `files`, `''` for its root. + * @param languages - the chain from {@link languageChain}. + * @param maxBytes - largest accepted locale file and document. + * @param signal - cancellation of the reads. + * @returns the document, or undefined when the package has neither. + * @throws {DetailFailure} `invalid` for a malformed locale file or `meta.detail` or a named file that does not exist, and + * the failures of `files`. + */ +export async function readPackageDocument( + files: DetailFiles, dir: string, languages: readonly string[], maxBytes: number, signal: AbortSignal, +): Promise { + const detail = await localeDetail(files, dir, languages, maxBytes, signal) + if (detail !== undefined) { + const path = joinPath(dir, detail) + const bytes = await files.read(path, maxBytes, signal) + if (bytes === undefined) throw new DetailFailure('invalid', `${path}, named by meta.detail, does not exist`) + return { origin: 'locale', path, markdown: markdownOf(bytes) } + } + const readme = await files.readme(dir, maxBytes, signal) + return readme === undefined ? undefined : { origin: 'readme', path: readme.path, markdown: markdownOf(readme.bytes) } +} + +/** Image file types served beside a document, by lower-case extension. */ +const IMAGE_TYPES = new Map([ + ['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'], + ['.webp', 'image/webp'], ['.avif', 'image/avif'], ['.svg', 'image/svg+xml'], +]) + +/** + * Resolve an image destination a document writes. + * @param document - `/`-separated path of the document inside its files. + * @param authored - the destination as written: relative to the document, or to the root of its files when it starts + * with `/`; a query or fragment is ignored. + * @returns the image's path inside the files and its media type, or undefined for a malformed destination, a path that + * leaves the root, or a type other than PNG, JPEG, GIF, WebP, AVIF, and SVG. + */ +export function detailImage(document: string, authored: string): { path: string; mediaType: string } | undefined { + let decoded: string + try { + decoded = decodeURIComponent(authored.replace(/[?#].*$/su, '')) + } catch (_malformed) { + // A destination with a malformed percent escape names no file. + return undefined + } + if (decoded.includes('\\') || decoded.includes('\0')) return undefined + const path = posix.normalize(decoded.startsWith('/') ? decoded.slice(1) : posix.join(posix.dirname(document), decoded)) + if (path === '.' || path.startsWith('../') || path === '..' || posix.isAbsolute(path)) return undefined + const mediaType = IMAGE_TYPES.get(posix.extname(path).toLowerCase()) + return mediaType === undefined ? undefined : { path, mediaType } +} diff --git a/packages/util/plugin-detail/src/files.ts b/packages/util/plugin-detail/src/files.ts new file mode 100644 index 0000000000..0ace2e2d3f --- /dev/null +++ b/packages/util/plugin-detail/src/files.ts @@ -0,0 +1,283 @@ +/** + * Read-only file sources for plugin detail documents: a local package directory and published npm package versions on + * public file CDNs, with bounded HTTP reads. Sources keep no state between calls. + */ +import { lstat, readdir, readFile, realpath } from 'node:fs/promises' +import { join, relative, sep } from 'node:path' +import type { DetailFailureReason, NpmFileSource } from './types.ts' + + +/** A read that must not fall back: the source exists but refused, failed, or exceeded a limit. */ +export class DetailFailure extends Error { + /** + * @param reason - the failure kind a reader reports. + * @param message - the diagnostic, naming the source. + * @param options - the underlying error. + */ + constructor(readonly reason: DetailFailureReason, message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'DetailFailure' + } +} + +/** One file found by name lookup. */ +export interface FoundFile { + /** `/`-separated path relative to the source root. */ + readonly path: string + readonly bytes: Uint8Array +} + +/** Files below one root, read by `/`-separated relative paths that never start with `/` or contain `.` or `..` segments. */ +export interface DetailFiles { + /** Identity of the files' content: two sources with the same key read the same bytes. */ + readonly key: string + /** + * Read one regular file. + * @param path - path relative to the root. + * @param maxBytes - largest accepted size. + * @param signal - cancellation of the read. + * @returns the file's bytes, or undefined when it does not exist. + * @throws {DetailFailure} when the source fails or the file exceeds `maxBytes`. + */ + read(path: string, maxBytes: number, signal: AbortSignal): Promise + /** + * Read the README of a directory: `README.md`, `README.markdown`, or `README` in any letter case. + * @param dir - directory relative to the root; `''` for the root. + * @param maxBytes - largest accepted size. + * @param signal - cancellation of the read. + * @returns the file, or undefined when the directory has none. + * @throws {DetailFailure} when the source fails or the file exceeds `maxBytes`. + */ + readme(dir: string, maxBytes: number, signal: AbortSignal): Promise +} + +/** README names tried in order where a source cannot list a directory. */ +export const README_NAMES: readonly string[] = ['README.md', 'readme.md', 'Readme.md', 'README.markdown', 'README'] +const README_PATTERN = /^readme(?:\.md|\.markdown)?$/iu + +/** + * Join a directory and a file name into a root-relative path. + * @param dir - directory relative to the root, `''` for the root. + * @param name - path relative to `dir`. + * @returns the joined path. + */ +export function joinPath(dir: string, name: string): string { + return dir === '' ? name : `${dir}/${name}` +} + +/** + * Pick the README entry of a directory listing by the order of {@link README_NAMES}, then by name. + * @param names - file names of the directory. + * @returns the README name, or undefined. + */ +export function readmeName(names: readonly string[]): string | undefined { + const rank = (name: string): number => { + const index = README_NAMES.indexOf(name) + return index === -1 ? README_NAMES.length : index + } + return names.filter(name => README_PATTERN.test(name)).sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : 1))[0] +} + +/** Reads one file: its bytes, or undefined when it is absent. */ +export type ReadFile = (path: string, maxBytes: number, signal: AbortSignal) => Promise + +/** + * Read the first README of `names` that exists, for sources that cannot list a directory. + * @param read - reads one file. + * @param dir - directory relative to the root; `''` for the root. + * @param maxBytes - largest accepted size. + * @param signal - cancellation of the reads. + * @param names - the names to try, in order. + * @returns the file, or undefined when none exists. + * @throws {DetailFailure} what `read` throws. + */ +export async function readmeByName( + read: ReadFile, dir: string, maxBytes: number, signal: AbortSignal, names: readonly string[] = README_NAMES, +): Promise { + for (const name of names) { + const path = joinPath(dir, name) + const bytes = await read(path, maxBytes, signal) + if (bytes !== undefined) return { path, bytes } + } + return undefined +} + +function tooLarge(label: string, maxBytes: number): DetailFailure { + return new DetailFailure('too-large', `${label} exceeds ${String(maxBytes)} bytes`) +} + +/** + * Files of a local package directory. A path whose real location leaves the directory reads as absent. + * @param root - absolute package directory. + * @param revision - the installed version, so a reinstalled package reads as different files. + * @returns the files. + */ +export function localFiles(root: string, revision: string): DetailFiles { + const files: DetailFiles = { + key: `local:${root}@${revision}`, + async read(path, maxBytes) { + let file: string + try { + const base = await realpath(root) + file = await realpath(join(base, ...path.split('/'))) + const inside = relative(base, file) + if (inside === '' || inside === '..' || inside.startsWith(`..${sep}`)) return undefined + const info = await lstat(file) + if (!info.isFile()) return undefined + if (info.size > maxBytes) throw tooLarge(path, maxBytes) + } catch (error) { + if (error instanceof DetailFailure) throw error + // A missing file or directory, or one the process may not read, is a file the package does not provide. + return undefined + } + const bytes = await readFile(file) + /* v8 ignore next -- the file grew between its size check and its read; no test controls that timing */ + if (bytes.byteLength > maxBytes) throw tooLarge(path, maxBytes) + return bytes + }, + async readme(dir, maxBytes, signal) { + let names: string[] + try { + names = (await readdir(join(root, ...dir === '' ? [] : dir.split('/')), { withFileTypes: true })) + .filter(entry => entry.isFile() || entry.isSymbolicLink()).map(entry => entry.name) + } catch (_missing) { + // An absent or unreadable directory has no README. + return undefined + } + const name = readmeName(names) + if (name === undefined) return undefined + const path = joinPath(dir, name) + const bytes = await files.read(path, maxBytes, signal) + return bytes === undefined ? undefined : { path, bytes } + }, + } + return files +} + +/** Request settings of a remote file source. */ +export interface RemoteFileOptions { + readonly timeoutMs: number + /** Cancellation that outlives one read, such as the owning service's disposal. */ + readonly lifetime: AbortSignal +} + +/** One HTTP answer: the body of a 2xx response, or the status of any other. */ +export type FetchedBody = { readonly ok: true; readonly bytes: Uint8Array } | { readonly ok: false; readonly status: number } + +/** Redirects followed within one origin, such as a renamed repository's raw files; a redirect to another origin is not followed. */ +const MAX_REDIRECTS = 3 + +/** + * GET a URL, following redirects within its origin, and read the body up to `maxBytes`. + * @param url - the request URL. + * @param maxBytes - largest accepted body. + * @param signal - cancellation of the read. + * @param options - deadline and service lifetime. + * @returns the body, or the status of a non-2xx response, including a redirect to another origin. + * @throws {DetailFailure} `network` when no response arrives or the body fails, `too-large` over `maxBytes`. + */ +export async function fetchBody(url: string, maxBytes: number, signal: AbortSignal, options: RemoteFileOptions): Promise { + const combined = AbortSignal.any([signal, options.lifetime, AbortSignal.timeout(options.timeoutMs)]) + let response: Response | undefined + let target = url + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + try { + response = await fetch(target, { signal: combined, redirect: 'manual' }) + } catch (error) { + // A cancelled read says nothing about the source, so it keeps the caller's reason. + signal.throwIfAborted() + options.lifetime.throwIfAborted() + throw new DetailFailure('network', `Could not fetch ${target}: ${error instanceof Error ? error.message : String(error)}`, { cause: error }) + } + const location = response.status >= 300 && response.status < 400 ? response.headers.get('location') : null + if (location === null || !URL.canParse(location, target) || new URL(location, target).origin !== new URL(url).origin) break + await response.body?.cancel() + target = new URL(location, target).href + } + // The loop runs at least once. + const answer = response as Response + if (!answer.ok) { + await answer.body?.cancel() + return { ok: false, status: answer.status } + } + if (Number(answer.headers.get('content-length') ?? 0) > maxBytes) { + await answer.body?.cancel() + throw tooLarge(url, maxBytes) + } + const chunks: Uint8Array[] = [] + let size = 0 + try { + for await (const chunk of answer.body ?? []) { + size += chunk.byteLength + if (size > maxBytes) throw tooLarge(url, maxBytes) + chunks.push(chunk) + } + } catch (error) { + if (error instanceof DetailFailure) throw error + throw new DetailFailure('network', `Could not read ${url}: ${error instanceof Error ? error.message : String(error)}`, { cause: error }) + } + return { ok: true, bytes: Buffer.concat(chunks) } +} + +/** Every npm file CDN, in the default order. */ +export const NPM_FILE_SOURCES = ['jsdelivr', 'unpkg', 'npmmirror'] as const satisfies readonly NpmFileSource[] + +/** + * Order the configured npm file CDNs by a caller's preference: the preferred ones that are configured, in the preferred + * order, then the other configured ones in configured order. A preferred CDN that is not configured is not asked. + * @param configured - the configured CDNs in order. + * @param preferred - CDNs to ask first, such as those a Client measured as fastest; names other than CDNs are ignored. + * @returns the CDNs to ask, first to last. + */ +export function npmFileSourceOrder(configured: readonly NpmFileSource[], preferred: readonly string[] = []): NpmFileSource[] { + const first = configured.filter(source => preferred.includes(source)).sort((a, b) => preferred.indexOf(a) - preferred.indexOf(b)) + return [...first, ...configured.filter(source => !first.includes(source))] +} + +/** + * Percent-encode each segment of a `/`-separated path for a URL. + * @param path - the path. + * @returns the encoded path. + */ +export function encodePath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/') +} + +const NPM_FILE_URLS: Record string> = { + jsdelivr: (name, version, path) => `https://cdn.jsdelivr.net/npm/${name}@${version}/${encodePath(path)}`, + unpkg: (name, version, path) => `https://unpkg.com/${name}@${version}/${encodePath(path)}`, + npmmirror: (name, version, path) => `https://registry.npmmirror.com/${name}/${version}/files/${encodePath(path)}`, +} + +/** + * Files of one published npm package version, read from public file CDNs in order. A 404 from a CDN means the package + * does not contain the file; a network failure or another status asks the next CDN. + * @param name - package name. + * @param version - exact version. + * @param sources - the CDNs to ask, first to last; never empty. + * @param options - deadline and service lifetime. + * @returns the files. + */ +export function npmFiles(name: string, version: string, sources: readonly NpmFileSource[], options: RemoteFileOptions): DetailFiles { + const files: DetailFiles = { + key: `npm:${name}@${version}`, + async read(path, maxBytes, signal) { + const failures: string[] = [] + for (const source of sources) { + const url = NPM_FILE_URLS[source](name, version, path) + try { + const body = await fetchBody(url, maxBytes, signal, options) + if (body.ok) return body.bytes + if (body.status === 404) return undefined + failures.push(`${url}: HTTP ${String(body.status)}`) + } catch (error) { + if (!(error instanceof DetailFailure) || error.reason !== 'network') throw error + failures.push(error.message) + } + } + throw new DetailFailure('network', failures.join('; ')) + }, + readme: (dir, maxBytes, signal) => readmeByName((path, max, readSignal) => files.read(path, max, readSignal), dir, maxBytes, signal), + } + return files +} diff --git a/packages/util/plugin-detail/src/index.ts b/packages/util/plugin-detail/src/index.ts new file mode 100644 index 0000000000..e715ca8090 --- /dev/null +++ b/packages/util/plugin-detail/src/index.ts @@ -0,0 +1,13 @@ +/** + * Plugin detail documents without state: language chains, the `meta.detail` or README selection of a package, image + * destinations beside a document, and read-only file sources for local packages and published npm versions. + * @module @deepseek-ai/dsh-plugin-detail + */ + +export { + DetailFailure, encodePath, fetchBody, joinPath, localFiles, NPM_FILE_SOURCES, npmFileSourceOrder, npmFiles, README_NAMES, + readmeByName, readmeName, type DetailFiles, type FetchedBody, type FoundFile, type ReadFile, type RemoteFileOptions, +} from './files.ts' +export { detailImage, languageChain, markdownOf, readPackageDocument, type PackageDocument } from './document.ts' +export { detailImageResponse, type DetailImage } from './response.ts' +export type { DetailFailureReason, NpmFileSource } from './types.ts' diff --git a/packages/util/plugin-detail/src/response.ts b/packages/util/plugin-detail/src/response.ts new file mode 100644 index 0000000000..08cdf2af5d --- /dev/null +++ b/packages/util/plugin-detail/src/response.ts @@ -0,0 +1,37 @@ +/** HTTP responses of Host routes that serve images beside detail documents. */ +import type { DetailFailureReason } from './types.ts' + +/** An image read beside a detail document. */ +export interface DetailImage { + readonly bytes: Uint8Array + readonly mediaType: string +} + +/** HTTP status of each detail failure. */ +const FAILURE_STATUS = { + 'too-large': 413, 'network': 502, 'denied': 403, 'git-unavailable': 503, 'invalid': 400, +} as const satisfies Record + +/** + * The response of an image route: the image, 404 when there is none, or the failure's status with its reason as the + * body. Every response carries `X-Content-Type-Options: nosniff` and a sandbox content security policy, because an SVG + * opened directly would otherwise run scripts on the authenticated API origin; only an image gets `cacheControl`. + * @param outcome - the image, undefined when none exists, or why it cannot be read; `invalid` also answers a malformed request. + * @param cacheControl - the `Cache-Control` value of an image. + * @returns the response. + */ +export function detailImageResponse(outcome: DetailImage | DetailFailureReason | undefined, cacheControl: string): Response { + const [status, body, type] = typeof outcome === 'object' + // A copy into a fresh buffer is a body `Response` accepts whatever buffer the bytes view. + ? [200, new Uint8Array(outcome.bytes), outcome.mediaType] + : outcome === undefined ? [404, 'not found', undefined] : [FAILURE_STATUS[outcome], outcome, undefined] + return new Response(body, { + status, + headers: { + 'Content-Type': type ?? 'text/plain; charset=utf-8', + 'Cache-Control': status === 200 ? cacheControl : 'no-store', + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "sandbox; default-src 'none'; style-src 'unsafe-inline'", + }, + }) +} diff --git a/packages/util/plugin-detail/src/types.ts b/packages/util/plugin-detail/src/types.ts new file mode 100644 index 0000000000..4631b64154 --- /dev/null +++ b/packages/util/plugin-detail/src/types.ts @@ -0,0 +1,12 @@ +/** Plugin detail records that Remote methods and their clients share. */ + +/** + * Why a detail document or one of its images cannot be read: `too-large` beyond a size limit, `network` when a source + * cannot be reached, `denied` when Git refuses the repository with the user's credentials, `git-unavailable` when a + * repository must be cloned and Git cannot run, and `invalid` for a malformed locale file or `meta.detail`, or a named + * file that does not exist. + */ +export type DetailFailureReason = 'too-large' | 'network' | 'denied' | 'git-unavailable' | 'invalid' + +/** A public CDN that serves single files of published npm package versions. */ +export type NpmFileSource = 'jsdelivr' | 'unpkg' | 'npmmirror' diff --git a/packages/util/plugin-detail/tests/document.spec.ts b/packages/util/plugin-detail/tests/document.spec.ts new file mode 100644 index 0000000000..d22719f7de --- /dev/null +++ b/packages/util/plugin-detail/tests/document.spec.ts @@ -0,0 +1,92 @@ +/** Package document selection, language chains, and image destinations. */ +import { expect, it } from 'vitest' +import { detailImage, languageChain, markdownOf, readPackageDocument } from '../src/document.ts' +import { DetailFailure, readmeName, type DetailFiles } from '../src/files.ts' + +const signal = new AbortController().signal + +/** Await a rejection that matches `fields` and whose message contains `message`. */ +async function expectRejection(promise: Promise, fields: object, message: string): Promise { + await expect(promise).rejects.toMatchObject(fields) + await expect(promise).rejects.toThrow(message) +} + +/** In-memory files. */ +function memory(contents: Record): DetailFiles & { reads: string[] } { + const reads: string[] = [] + const read = (path: string, maxBytes: number) => { + reads.push(path) + const value = contents[path] + if (value === undefined) return Promise.resolve(undefined) + if (value.length > maxBytes) return Promise.reject(new DetailFailure('too-large', path)) + return Promise.resolve(new TextEncoder().encode(value)) + } + return { + key: 'memory', reads, read, + async readme(dir, maxBytes) { + const prefix = dir === '' ? '' : `${dir}/` + const name = readmeName(Object.keys(contents).filter(path => path.startsWith(prefix)).map(path => path.slice(prefix.length))) + if (name === undefined) return undefined + return { path: `${prefix}${name}`, bytes: (await read(`${prefix}${name}`, maxBytes)) as Uint8Array } + }, + } +} + +it('derives language chains from a language id', () => { + expect(languageChain('zh-Hans-CN')).toEqual(['zh-hans-cn', 'zh-hans', 'zh', 'en']) + expect(languageChain('en-US')).toEqual(['en-us', 'en']) + expect(languageChain('en')).toEqual(['en']) + expect(() => languageChain('../x')).toThrow('Not a language id') +}) + +it('decodes Markdown without a byte order mark or a leading front matter block', () => { + expect(markdownOf(new TextEncoder().encode('\uFEFF---\ntitle: x\n---\n# A\n\n---\nkept: rule\n---\n'))).toBe('# A\n\n---\nkept: rule\n---\n') + expect(markdownOf(new TextEncoder().encode('---\r\nk: v\r\n---'))).toBe('') + expect(markdownOf(new TextEncoder().encode('# B'))).toBe('# B') +}) + +it('reads the locale document first, falling back through languages and failing on a missing named file', async () => { + const files = memory({ + 'locale/zh.json': JSON.stringify({ meta: { title: '无详情' } }), + 'locale/en.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }), + 'locale/zh-CN.json': JSON.stringify({ meta: { detail: 'guide/详情.md' } }), + 'guide/DETAIL.md': '# Detail', + 'guide/详情.md': '# 详情', + 'README.md': '# Readme', + }) + expect(await readPackageDocument(files, '', ['zh', 'en'], 100, signal)).toEqual({ origin: 'locale', path: 'guide/DETAIL.md', markdown: '# Detail' }) + expect(await readPackageDocument(files, '', languageChain('zh-CN'), 100, signal)).toMatchObject({ origin: 'locale', markdown: '# 详情' }) + expect(files.reads).toContain('locale/zh-cn.json') + await readPackageDocument(files, '', languageChain('zh-Hant'), 100, signal) + expect(files.reads).not.toContain('locale/zh-HANT.json') + const missing = memory({ 'locale/en.json': JSON.stringify({ meta: { detail: 'GONE.md' } }) }) + await expectRejection(readPackageDocument(missing, '', ['en'], 100, signal), { reason: 'invalid' }, 'GONE.md') +}) + +it('reports malformed locale files and invalid detail paths, and falls back to the README without meta.detail', async () => { + const read = (contents: Record) => readPackageDocument(memory(contents), 'pkg', ['en'], 100, signal) + await expectRejection(read({ 'pkg/locale/en.json': '{' }), { reason: 'invalid' }, 'pkg/locale/en.json') + const invalid = [ + ['/README.md', 'must be a relative path inside the package'], ['../x.md', 'must be a relative path inside the package'], + ['guide/./x.md', 'must be a relative path inside the package'], ['guide//x.md', 'must be a relative path inside the package'], + ['guide\\x.md', 'must be a relative path inside the package'], ['https://example.com/x.md', 'must be a relative path inside the package'], + ['README.txt', 'must name a Markdown file'], [' ', 'must be a non-empty string'], [7, 'must be a non-empty string'], + ] as const + for (const [detail, message] of invalid) { + await expectRejection(read({ 'pkg/locale/en.json': JSON.stringify({ meta: { detail } }) }), { reason: 'invalid' }, `meta.detail ${message}`) + } + expect(await read({ 'pkg/locale/en.json': 'null', 'pkg/README': 'plain' })).toEqual({ origin: 'readme', path: 'pkg/README', markdown: 'plain' }) + expect(await read({ 'pkg/locale/en.json': '{"meta":"x"}' })).toBeUndefined() + expect(await read({ 'pkg/locale/en.json': JSON.stringify({ meta: { detail: 'D.markdown' } }), 'pkg/D.markdown': '# D' })) + .toMatchObject({ origin: 'locale', markdown: '# D' }) +}) + +it('resolves image destinations beside a document and refuses other paths', () => { + expect(detailImage('guide/DETAIL.md', 'img/a.png?raw=1#x')).toEqual({ path: 'guide/img/a.png', mediaType: 'image/png' }) + expect(detailImage('guide/DETAIL.md', '/logo.SVG')).toEqual({ path: 'logo.SVG', mediaType: 'image/svg+xml' }) + expect(detailImage('guide/DETAIL.md', './a%20b.webp')).toEqual({ path: 'guide/a b.webp', mediaType: 'image/webp' }) + expect(detailImage('README.md', 'b.jpeg')).toEqual({ path: 'b.jpeg', mediaType: 'image/jpeg' }) + for (const destination of ['../../etc/passwd.png', '..', '/', 'notes.txt', 'img\\a.png', '%E0%A4%A.png', 'a\0.png']) { + expect(detailImage('guide/DETAIL.md', destination)).toBeUndefined() + } +}) diff --git a/packages/util/plugin-detail/tests/files.spec.ts b/packages/util/plugin-detail/tests/files.spec.ts new file mode 100644 index 0000000000..288b4057a7 --- /dev/null +++ b/packages/util/plugin-detail/tests/files.spec.ts @@ -0,0 +1,160 @@ +/** Detail file sources: local package directories, bounded HTTP reads, and npm file CDNs. */ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { DetailFailure, fetchBody, localFiles, npmFileSourceOrder, npmFiles, readmeByName, readmeName } from '../src/files.ts' + +/** Await a rejection that matches `fields` and whose message contains `message`. */ +async function expectRejection(promise: Promise, fields: object, message: string): Promise { + await expect(promise).rejects.toMatchObject(fields) + await expect(promise).rejects.toThrow(message) +} + + +let root: string +const signal = new AbortController().signal +const lifetime = new AbortController().signal +const options = { timeoutMs: 5000, lifetime } + +beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'dsh-plugin-market-detail-files-')) }) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) + vi.unstubAllGlobals() +}) + +function file(path: string, contents: string): void { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), contents) +} + +const text = (bytes: Uint8Array | undefined): string | undefined => bytes === undefined ? undefined : new TextDecoder().decode(bytes) + +/** Answer requests by URL with a fresh response each time; any other URL fails the test. */ +function serve(routes: Record Response)>) { + const fetch = vi.fn((url: string) => { + const route = routes[url] + if (route === undefined) throw new Error(`unexpected fetch ${url}`) + if (route instanceof Error) return Promise.reject(route) + return Promise.resolve(route()) + }) + vi.stubGlobal('fetch', fetch) + return fetch +} + +it('reads package files and README names, and refuses paths whose real location leaves the package', async () => { + file('pkg/README.md', '# Pkg') + file('pkg/docs/readme', 'docs') + file('pkg/big.md', 'x'.repeat(20)) + file('outside.md', 'secret') + symlinkSync(join(root, 'outside.md'), join(root, 'pkg', 'link.md')) + file('escaping/x', '') + symlinkSync(join(root, 'outside.md'), join(root, 'escaping', 'README.md')) + const files = localFiles(join(root, 'pkg'), '1.0.0') + expect(files.key).toBe(`local:${join(root, 'pkg')}@1.0.0`) + expect(text(await files.read('README.md', 100, signal))).toBe('# Pkg') + expect(await files.read('missing.md', 100, signal)).toBeUndefined() + expect(await files.read('docs', 100, signal)).toBeUndefined() + expect(await files.read('link.md', 100, signal)).toBeUndefined() + await expect(files.read('big.md', 10, signal)).rejects.toMatchObject({ reason: 'too-large' }) + expect(await files.readme('', 100, signal)).toMatchObject({ path: 'README.md' }) + expect(await files.readme('docs', 100, signal)).toMatchObject({ path: 'docs/readme' }) + expect(await files.readme('missing', 100, signal)).toBeUndefined() + expect(await localFiles(join(root, 'pkg', 'docs'), '1.0.0').readme('', 100, signal)).toMatchObject({ path: 'readme' }) + expect(await localFiles(join(root, 'empty'), '1.0.0').readme('', 100, signal)).toBeUndefined() + file('noreadme/index.js', '') + expect(await localFiles(join(root, 'noreadme'), '1.0.0').readme('', 100, signal)).toBeUndefined() + expect(await localFiles(join(root, 'escaping'), '1.0.0').readme('', 100, signal)).toBeUndefined() +}) + +it('orders README names', () => { + expect(readmeName(['readme.markdown', 'README', 'Readme.md', 'readme.txt'])).toBe('Readme.md') + expect(readmeName(['readme.MD', 'README.markdown'])).toBe('README.markdown') + expect(readmeName(['readme.MD', 'Readme.MD'])).toBe('Readme.MD') + expect(readmeName(['Readme.MD', 'readme.MD'])).toBe('Readme.MD') + expect(readmeName(['index.md'])).toBeUndefined() +}) + +it('reads bodies under a limit and reports failures', async () => { + serve({ + 'https://a.example/ok': () => new Response('ok'), + 'https://a.example/empty': () => new Response(null), + 'https://a.example/missing': () => new Response('no', { status: 404 }), + 'https://a.example/declared': () => new Response('x', { headers: { 'content-length': '99' } }), + 'https://a.example/long': () => new Response('x'.repeat(20)), + 'https://a.example/down': new Error('refused'), + 'https://a.example/broken': () => new Response(new ReadableStream({ pull(controller) { controller.error(new Error('reset')) } })), + 'https://a.example/odd': () => new Response(new ReadableStream({ pull(controller) { controller.error('reset') } })), + }) + expect(await fetchBody('https://a.example/ok', 10, signal, options)).toEqual({ ok: true, bytes: Buffer.from('ok') }) + expect(await fetchBody('https://a.example/empty', 10, signal, options)).toEqual({ ok: true, bytes: Buffer.alloc(0) }) + expect(await fetchBody('https://a.example/missing', 10, signal, options)).toEqual({ ok: false, status: 404 }) + await expect(fetchBody('https://a.example/declared', 10, signal, options)).rejects.toMatchObject({ reason: 'too-large' }) + await expect(fetchBody('https://a.example/long', 10, signal, options)).rejects.toMatchObject({ reason: 'too-large' }) + await expectRejection(fetchBody('https://a.example/down', 10, signal, options), { reason: 'network' }, 'refused') + await expectRejection(fetchBody('https://a.example/broken', 10, signal, options), { reason: 'network' }, 'reset') + await expectRejection(fetchBody('https://a.example/odd', 10, signal, options), { reason: 'network' }, 'reset') + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- a non-Error rejection keeps its text in the failure + vi.stubGlobal('fetch', vi.fn(() => Promise.reject('plain'))) + await expectRejection(fetchBody('https://a.example/plain', 10, signal, options), { reason: 'network' }, 'plain') +}) + +it('rejects a cancelled read with the cancellation reason instead of a network failure', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('aborted')))) + const cancelled = new AbortController() + cancelled.abort(new Error('closed by caller')) + await expect(fetchBody('https://a.example/x', 10, cancelled.signal, options)).rejects.toThrow('closed by caller') + const disposed = new AbortController() + disposed.abort(new Error('service disposed')) + await expect(fetchBody('https://a.example/x', 10, signal, { ...options, lifetime: disposed.signal })).rejects.toThrow('service disposed') +}) + +it('follows redirects within the origin only', async () => { + serve({ + 'https://a.example/old': () => new Response(null, { status: 301, headers: { location: '/new' } }), + 'https://a.example/new': () => new Response('moved'), + 'https://a.example/away': () => new Response(null, { status: 302, headers: { location: 'https://b.example/x' } }), + 'https://a.example/loop': () => new Response(null, { status: 302, headers: { location: '/loop' } }), + 'https://a.example/bare': () => new Response(null, { status: 302 }), + }) + expect(await fetchBody('https://a.example/old', 10, signal, options)).toEqual({ ok: true, bytes: Buffer.from('moved') }) + expect(await fetchBody('https://a.example/away', 10, signal, options)).toEqual({ ok: false, status: 302 }) + expect(await fetchBody('https://a.example/loop', 10, signal, options)).toEqual({ ok: false, status: 302 }) + expect(await fetchBody('https://a.example/bare', 10, signal, options)).toEqual({ ok: false, status: 302 }) +}) + +it('reads npm files from the CDNs in order, trusting a 404 and asking the next CDN after a failure', async () => { + const fetch = serve({ + 'https://cdn.jsdelivr.net/npm/@a/b@1.0.0/docs/A%20B.md': new Error('offline'), + 'https://unpkg.com/@a/b@1.0.0/docs/A%20B.md': () => new Response('fail', { status: 502 }), + 'https://registry.npmmirror.com/@a/b/1.0.0/files/docs/A%20B.md': () => new Response('# AB'), + 'https://registry.npmmirror.com/@a/b/1.0.0/files/README.md': () => new Response('no', { status: 404 }), + 'https://registry.npmmirror.com/@a/b/1.0.0/files/readme.md': () => new Response('# readme'), + 'https://registry.npmmirror.com/@a/b/1.0.0/files/big.md': () => new Response('x'.repeat(20)), + 'https://cdn.jsdelivr.net/npm/@a/b@1.0.0/down.md': new Error('offline'), + }) + const files = npmFiles('@a/b', '1.0.0', ['jsdelivr', 'unpkg', 'npmmirror'], options) + expect(files.key).toBe('npm:@a/b@1.0.0') + expect(text(await files.read('docs/A B.md', 100, signal))).toBe('# AB') + const mirror = npmFiles('@a/b', '1.0.0', ['npmmirror'], options) + expect(await mirror.readme('', 100, signal)).toMatchObject({ path: 'readme.md' }) + await expect(mirror.read('big.md', 10, signal)).rejects.toMatchObject({ reason: 'too-large' }) + const failure = npmFiles('@a/b', '1.0.0', ['jsdelivr'], options).read('down.md', 10, signal) + await expect(failure).rejects.toBeInstanceOf(DetailFailure) + await expectRejection(failure, { reason: 'network' }, 'offline') + expect(fetch).toHaveBeenCalledTimes(7) +}) + +it('reads the first README name that exists', async () => { + const read = (path: string) => Promise.resolve(path === 'd/readme.md' ? Buffer.from('r') : undefined) + expect(await readmeByName(read, 'd', 10, signal)).toMatchObject({ path: 'd/readme.md' }) + expect(await readmeByName(read, 'd', 10, signal, ['README.md'])).toBeUndefined() +}) + +it('orders the configured npm CDNs by a preference, ignoring CDNs that are not configured', () => { + const all = ['jsdelivr', 'unpkg', 'npmmirror'] as const + expect(npmFileSourceOrder(all, ['npmmirror'])).toEqual(['npmmirror', 'jsdelivr', 'unpkg']) + expect(npmFileSourceOrder(all, ['unpkg', 'npmmirror'])).toEqual(['unpkg', 'npmmirror', 'jsdelivr']) + expect(npmFileSourceOrder(all)).toEqual(['jsdelivr', 'unpkg', 'npmmirror']) + expect(npmFileSourceOrder(['unpkg'], ['npmmirror', 'other'])).toEqual(['unpkg']) +}) diff --git a/packages/util/plugin-detail/tsconfig.json b/packages/util/plugin-detail/tsconfig.json new file mode 100644 index 0000000000..0b174e83f6 --- /dev/null +++ b/packages/util/plugin-detail/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0701c9d0a1..8ca82ea3af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2052,6 +2052,12 @@ importers: '@deepseek-ai/dsh-atomic-write': specifier: workspace:* version: link:../../util/atomic-write + '@deepseek-ai/dsh-plugin-detail': + specifier: workspace:* + version: link:../../util/plugin-detail + '@deepseek-ai/dsh-timeout': + specifier: workspace:* + version: link:../../util/timeout '@deepseek-ai/dsh-util-values': specifier: workspace:* version: link:../../util/values @@ -2086,6 +2092,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand + '@deepseek-ai/dsh-client-connection': + specifier: workspace:* + version: link:../../client/connection '@deepseek-ai/dsh-hmr': specifier: workspace:* version: link:../hmr @@ -2134,9 +2143,18 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:* version: link:../app-boot + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:* + version: link:../../util/atomic-write + '@deepseek-ai/dsh-brand': + specifier: workspace:* + version: link:../../util/brand '@deepseek-ai/dsh-package-manifest': specifier: workspace:* version: link:../../util/package-manifest + '@deepseek-ai/dsh-plugin-detail': + specifier: workspace:* + version: link:../../util/plugin-detail '@deepseek-ai/dsh-plugin-market-index': specifier: workspace:* version: link:../../util/plugin-market-index @@ -2149,6 +2167,12 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + execa: + specifier: ^10.0.1 + version: 10.0.1 + hosted-git-info: + specifier: ^9.0.3 + version: 9.0.3 minisearch: specifier: ^7.2.0 version: 7.2.0 @@ -2168,6 +2192,9 @@ importers: '@deepseek-ai/cosmokit': specifier: link:../../../vendor/cosmokit version: link:../../../vendor/cosmokit + '@deepseek-ai/dsh-client-connection': + specifier: workspace:* + version: link:../../client/connection '@deepseek-ai/dsh-config-editor': specifier: workspace:* version: link:../config-editor @@ -2180,6 +2207,9 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:* version: link:../../settings/settings + '@deepseek-ai/dsh-subprocess': + specifier: workspace:* + version: link:../../subprocess/subprocess '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt @@ -2189,6 +2219,9 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:* version: link:../../typert/protocol + '@types/hosted-git-info': + specifier: ^3.0.5 + version: 3.0.5 '@types/semver': specifier: ^7.8.0 version: 7.8.0 @@ -15220,6 +15253,12 @@ importers: specifier: workspace:~ version: link:../../../vendor/cordis + packages/util/plugin-detail: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + packages/util/plugin-market-index: dependencies: '@deepseek-ai/dsh-package-manifest': diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index 215048297e..3ad1e0ac77 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -100,6 +100,7 @@ const PACKAGE_LIBRARIES: Readonly> = { 'packages/util/native-command': 'Host-side subprocess runner utility.', 'packages/util/output-retention': 'Zero-dependency retention utility.', 'packages/util/package-manifest': 'Shared package manifest declarations with type-only exports.', + 'packages/util/plugin-detail': 'Plugin detail document library.', 'packages/util/plugin-market-index': 'Plugin market index validation library.', 'packages/util/time': 'Zero-dependency time-zone canonicalization utility.', 'packages/util/timeout': 'Zero-dependency timeout utility.', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 6bf4c51bbd..44c305f342 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -309,6 +309,9 @@ export const LINK_MAP: Readonly> = { MarketQuery: 'boot.md', MarketSearchResult: 'boot.md', MarketSort: 'boot.md', + PluginDetail: 'boot.md', + BundleDetail: 'boot.md', + NpmFileSource: 'boot.md', ChangeResult: 'boot.md', InstallBundleOptions: 'boot.md', PluginEntryId: 'boot.md', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 2c313a3dea..925717fb66 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -92,6 +92,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/util/chunked-list': { kind: 'none', reason: 'Immutable collection primitive; registers nothing model-facing.' }, 'packages/util/package-manifest': { kind: 'none', reason: 'Type declarations only; registers nothing model-facing.' }, 'packages/util/lazy-require': { kind: 'none', reason: 'Host module-loading primitive; registers nothing model-facing.' }, + 'packages/util/plugin-detail': { kind: 'none', reason: 'Pure document selection for plugin detail pages; registers nothing model-facing.' }, 'packages/util/plugin-market-index': { kind: 'indirect', reason: 'Pure index validation; the plugin_market tool owns the entries it returns to the model.' }, 'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' }, 'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 2a92208c57..e39c17aa4b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -231,6 +231,7 @@ "@deepseek-ai/dsh-plugin-manager/tools": ["./packages/boot/plugin-manager/src/tools.ts"], "@deepseek-ai/dsh-plugin-market/types": ["./packages/boot/plugin-market/src/types.ts"], "@deepseek-ai/dsh-plugin-market/tools": ["./packages/boot/plugin-market/src/tools.ts"], + "@deepseek-ai/dsh-plugin-detail/types": ["./packages/util/plugin-detail/src/types.ts"], "@deepseek-ai/dsh-tool-cordis/host": ["./packages/extensions/tool-cordis/src/host.ts"], "@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"], "@deepseek-ai/dsh-host-plugin-inventory/types": ["./packages/host/plugin-inventory/src/types.ts"], @@ -441,6 +442,7 @@ "@deepseek-ai/dsh-permission-presets": ["./packages/interaction/permission-presets/src"], "@deepseek-ai/dsh-persona": ["./packages/preset/persona/src"], "@deepseek-ai/dsh-plan-mode": ["./packages/plan/plan-mode/src"], + "@deepseek-ai/dsh-plugin-detail": ["./packages/util/plugin-detail/src"], "@deepseek-ai/dsh-plugin-market": ["./packages/boot/plugin-market/src"], "@deepseek-ai/dsh-plugin-market-index": ["./packages/util/plugin-market-index/src"], "@deepseek-ai/dsh-plugin-package-inventory-deepseek": ["./packages/llm/plugin-package-inventory-deepseek/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index deca327660..3d4f8fbaa0 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -297,6 +297,7 @@ { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, { "path": "./packages/util/package-manifest" }, + { "path": "./packages/util/plugin-detail" }, { "path": "./packages/util/plugin-market-index" }, { "path": "./packages/util/http-proxy" }, { "path": "./packages/util/launch-environment" },